Merge branch 'zed2' into zed2-workspace
This commit is contained in:
@@ -11,6 +11,7 @@ doctest = false
|
||||
[dependencies]
|
||||
gpui = { path = "../gpui" }
|
||||
util = { path = "../util" }
|
||||
language = { path = "../language" }
|
||||
async-trait.workspace = true
|
||||
anyhow.workspace = true
|
||||
futures.workspace = true
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
pub mod completion;
|
||||
pub mod embedding;
|
||||
pub mod models;
|
||||
pub mod templates;
|
||||
|
||||
@@ -53,6 +53,8 @@ pub struct OpenAIRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<RequestMessage>,
|
||||
pub stream: bool,
|
||||
pub stop: Vec<String>,
|
||||
pub temperature: f32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
|
||||
+41
-33
@@ -2,7 +2,7 @@ use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use futures::AsyncReadExt;
|
||||
use gpui::executor::Background;
|
||||
use gpui::serde_json;
|
||||
use gpui::{serde_json, AppContext};
|
||||
use isahc::http::StatusCode;
|
||||
use isahc::prelude::Configurable;
|
||||
use isahc::{AsyncBody, Response};
|
||||
@@ -20,9 +20,11 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tiktoken_rs::{cl100k_base, CoreBPE};
|
||||
use util::http::{HttpClient, Request};
|
||||
use util::ResultExt;
|
||||
|
||||
use crate::completion::OPENAI_API_URL;
|
||||
|
||||
lazy_static! {
|
||||
static ref OPENAI_API_KEY: Option<String> = env::var("OPENAI_API_KEY").ok();
|
||||
static ref OPENAI_BPE_TOKENIZER: CoreBPE = cl100k_base().unwrap();
|
||||
}
|
||||
|
||||
@@ -85,25 +87,6 @@ impl Embedding {
|
||||
}
|
||||
}
|
||||
|
||||
// impl FromSql for Embedding {
|
||||
// fn column_result(value: ValueRef) -> FromSqlResult<Self> {
|
||||
// let bytes = value.as_blob()?;
|
||||
// let embedding: Result<Vec<f32>, Box<bincode::ErrorKind>> = bincode::deserialize(bytes);
|
||||
// if embedding.is_err() {
|
||||
// return Err(rusqlite::types::FromSqlError::Other(embedding.unwrap_err()));
|
||||
// }
|
||||
// Ok(Embedding(embedding.unwrap()))
|
||||
// }
|
||||
// }
|
||||
|
||||
// impl ToSql for Embedding {
|
||||
// fn to_sql(&self) -> rusqlite::Result<ToSqlOutput> {
|
||||
// let bytes = bincode::serialize(&self.0)
|
||||
// .map_err(|err| rusqlite::Error::ToSqlConversionFailure(Box::new(err)))?;
|
||||
// Ok(ToSqlOutput::Owned(rusqlite::types::Value::Blob(bytes)))
|
||||
// }
|
||||
// }
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OpenAIEmbeddings {
|
||||
pub client: Arc<dyn HttpClient>,
|
||||
@@ -139,8 +122,12 @@ struct OpenAIEmbeddingUsage {
|
||||
|
||||
#[async_trait]
|
||||
pub trait EmbeddingProvider: Sync + Send {
|
||||
fn is_authenticated(&self) -> bool;
|
||||
async fn embed_batch(&self, spans: Vec<String>) -> Result<Vec<Embedding>>;
|
||||
fn retrieve_credentials(&self, cx: &AppContext) -> Option<String>;
|
||||
async fn embed_batch(
|
||||
&self,
|
||||
spans: Vec<String>,
|
||||
api_key: Option<String>,
|
||||
) -> Result<Vec<Embedding>>;
|
||||
fn max_tokens_per_batch(&self) -> usize;
|
||||
fn truncate(&self, span: &str) -> (String, usize);
|
||||
fn rate_limit_expiration(&self) -> Option<Instant>;
|
||||
@@ -150,13 +137,17 @@ pub struct DummyEmbeddings {}
|
||||
|
||||
#[async_trait]
|
||||
impl EmbeddingProvider for DummyEmbeddings {
|
||||
fn is_authenticated(&self) -> bool {
|
||||
true
|
||||
fn retrieve_credentials(&self, _cx: &AppContext) -> Option<String> {
|
||||
Some("Dummy API KEY".to_string())
|
||||
}
|
||||
fn rate_limit_expiration(&self) -> Option<Instant> {
|
||||
None
|
||||
}
|
||||
async fn embed_batch(&self, spans: Vec<String>) -> Result<Vec<Embedding>> {
|
||||
async fn embed_batch(
|
||||
&self,
|
||||
spans: Vec<String>,
|
||||
_api_key: Option<String>,
|
||||
) -> Result<Vec<Embedding>> {
|
||||
// 1024 is the OpenAI Embeddings size for ada models.
|
||||
// the model we will likely be starting with.
|
||||
let dummy_vec = Embedding::from(vec![0.32 as f32; 1536]);
|
||||
@@ -255,9 +246,21 @@ impl OpenAIEmbeddings {
|
||||
|
||||
#[async_trait]
|
||||
impl EmbeddingProvider for OpenAIEmbeddings {
|
||||
fn is_authenticated(&self) -> bool {
|
||||
OPENAI_API_KEY.as_ref().is_some()
|
||||
fn retrieve_credentials(&self, cx: &AppContext) -> Option<String> {
|
||||
if let Ok(api_key) = env::var("OPENAI_API_KEY") {
|
||||
Some(api_key)
|
||||
} else if let Some((_, api_key)) = cx
|
||||
.platform()
|
||||
.read_credentials(OPENAI_API_URL)
|
||||
.log_err()
|
||||
.flatten()
|
||||
{
|
||||
String::from_utf8(api_key).log_err()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn max_tokens_per_batch(&self) -> usize {
|
||||
50000
|
||||
}
|
||||
@@ -280,13 +283,17 @@ impl EmbeddingProvider for OpenAIEmbeddings {
|
||||
(output, tokens.len())
|
||||
}
|
||||
|
||||
async fn embed_batch(&self, spans: Vec<String>) -> Result<Vec<Embedding>> {
|
||||
async fn embed_batch(
|
||||
&self,
|
||||
spans: Vec<String>,
|
||||
api_key: Option<String>,
|
||||
) -> Result<Vec<Embedding>> {
|
||||
const BACKOFF_SECONDS: [usize; 4] = [3, 5, 15, 45];
|
||||
const MAX_RETRIES: usize = 4;
|
||||
|
||||
let api_key = OPENAI_API_KEY
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("no api key"))?;
|
||||
let Some(api_key) = api_key else {
|
||||
return Err(anyhow!("no open ai key provided"));
|
||||
};
|
||||
|
||||
let mut request_number = 0;
|
||||
let mut rate_limiting = false;
|
||||
@@ -295,11 +302,12 @@ impl EmbeddingProvider for OpenAIEmbeddings {
|
||||
while request_number < MAX_RETRIES {
|
||||
response = self
|
||||
.send_request(
|
||||
api_key,
|
||||
&api_key,
|
||||
spans.iter().map(|x| &**x).collect(),
|
||||
request_timeout,
|
||||
)
|
||||
.await?;
|
||||
|
||||
request_number += 1;
|
||||
|
||||
match response.status() {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
use anyhow::anyhow;
|
||||
use tiktoken_rs::CoreBPE;
|
||||
use util::ResultExt;
|
||||
|
||||
pub trait LanguageModel {
|
||||
fn name(&self) -> String;
|
||||
fn count_tokens(&self, content: &str) -> anyhow::Result<usize>;
|
||||
fn truncate(&self, content: &str, length: usize) -> anyhow::Result<String>;
|
||||
fn truncate_start(&self, content: &str, length: usize) -> anyhow::Result<String>;
|
||||
fn capacity(&self) -> anyhow::Result<usize>;
|
||||
}
|
||||
|
||||
pub struct OpenAILanguageModel {
|
||||
name: String,
|
||||
bpe: Option<CoreBPE>,
|
||||
}
|
||||
|
||||
impl OpenAILanguageModel {
|
||||
pub fn load(model_name: &str) -> Self {
|
||||
let bpe = tiktoken_rs::get_bpe_from_model(model_name).log_err();
|
||||
OpenAILanguageModel {
|
||||
name: model_name.to_string(),
|
||||
bpe,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LanguageModel for OpenAILanguageModel {
|
||||
fn name(&self) -> String {
|
||||
self.name.clone()
|
||||
}
|
||||
fn count_tokens(&self, content: &str) -> anyhow::Result<usize> {
|
||||
if let Some(bpe) = &self.bpe {
|
||||
anyhow::Ok(bpe.encode_with_special_tokens(content).len())
|
||||
} else {
|
||||
Err(anyhow!("bpe for open ai model was not retrieved"))
|
||||
}
|
||||
}
|
||||
fn truncate(&self, content: &str, length: usize) -> anyhow::Result<String> {
|
||||
if let Some(bpe) = &self.bpe {
|
||||
let tokens = bpe.encode_with_special_tokens(content);
|
||||
if tokens.len() > length {
|
||||
bpe.decode(tokens[..length].to_vec())
|
||||
} else {
|
||||
bpe.decode(tokens)
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!("bpe for open ai model was not retrieved"))
|
||||
}
|
||||
}
|
||||
fn truncate_start(&self, content: &str, length: usize) -> anyhow::Result<String> {
|
||||
if let Some(bpe) = &self.bpe {
|
||||
let tokens = bpe.encode_with_special_tokens(content);
|
||||
if tokens.len() > length {
|
||||
bpe.decode(tokens[length..].to_vec())
|
||||
} else {
|
||||
bpe.decode(tokens)
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!("bpe for open ai model was not retrieved"))
|
||||
}
|
||||
}
|
||||
fn capacity(&self) -> anyhow::Result<usize> {
|
||||
anyhow::Ok(tiktoken_rs::model::get_context_size(&self.name))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use language::BufferSnapshot;
|
||||
use util::ResultExt;
|
||||
|
||||
use crate::models::LanguageModel;
|
||||
use crate::templates::repository_context::PromptCodeSnippet;
|
||||
|
||||
pub(crate) enum PromptFileType {
|
||||
Text,
|
||||
Code,
|
||||
}
|
||||
|
||||
// TODO: Set this up to manage for defaults well
|
||||
pub struct PromptArguments {
|
||||
pub model: Arc<dyn LanguageModel>,
|
||||
pub user_prompt: Option<String>,
|
||||
pub language_name: Option<String>,
|
||||
pub project_name: Option<String>,
|
||||
pub snippets: Vec<PromptCodeSnippet>,
|
||||
pub reserved_tokens: usize,
|
||||
pub buffer: Option<BufferSnapshot>,
|
||||
pub selected_range: Option<Range<usize>>,
|
||||
}
|
||||
|
||||
impl PromptArguments {
|
||||
pub(crate) fn get_file_type(&self) -> PromptFileType {
|
||||
if self
|
||||
.language_name
|
||||
.as_ref()
|
||||
.and_then(|name| Some(!["Markdown", "Plain Text"].contains(&name.as_str())))
|
||||
.unwrap_or(true)
|
||||
{
|
||||
PromptFileType::Code
|
||||
} else {
|
||||
PromptFileType::Text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait PromptTemplate {
|
||||
fn generate(
|
||||
&self,
|
||||
args: &PromptArguments,
|
||||
max_token_length: Option<usize>,
|
||||
) -> anyhow::Result<(String, usize)>;
|
||||
}
|
||||
|
||||
#[repr(i8)]
|
||||
#[derive(PartialEq, Eq, Ord)]
|
||||
pub enum PromptPriority {
|
||||
Mandatory, // Ignores truncation
|
||||
Ordered { order: usize }, // Truncates based on priority
|
||||
}
|
||||
|
||||
impl PartialOrd for PromptPriority {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
match (self, other) {
|
||||
(Self::Mandatory, Self::Mandatory) => Some(std::cmp::Ordering::Equal),
|
||||
(Self::Mandatory, Self::Ordered { .. }) => Some(std::cmp::Ordering::Greater),
|
||||
(Self::Ordered { .. }, Self::Mandatory) => Some(std::cmp::Ordering::Less),
|
||||
(Self::Ordered { order: a }, Self::Ordered { order: b }) => b.partial_cmp(a),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PromptChain {
|
||||
args: PromptArguments,
|
||||
templates: Vec<(PromptPriority, Box<dyn PromptTemplate>)>,
|
||||
}
|
||||
|
||||
impl PromptChain {
|
||||
pub fn new(
|
||||
args: PromptArguments,
|
||||
templates: Vec<(PromptPriority, Box<dyn PromptTemplate>)>,
|
||||
) -> Self {
|
||||
PromptChain { args, templates }
|
||||
}
|
||||
|
||||
pub fn generate(&self, truncate: bool) -> anyhow::Result<(String, usize)> {
|
||||
// Argsort based on Prompt Priority
|
||||
let seperator = "\n";
|
||||
let seperator_tokens = self.args.model.count_tokens(seperator)?;
|
||||
let mut sorted_indices = (0..self.templates.len()).collect::<Vec<_>>();
|
||||
sorted_indices.sort_by_key(|&i| Reverse(&self.templates[i].0));
|
||||
|
||||
// If Truncate
|
||||
let mut tokens_outstanding = if truncate {
|
||||
Some(self.args.model.capacity()? - self.args.reserved_tokens)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut prompts = vec!["".to_string(); sorted_indices.len()];
|
||||
for idx in sorted_indices {
|
||||
let (_, template) = &self.templates[idx];
|
||||
|
||||
if let Some((template_prompt, prompt_token_count)) =
|
||||
template.generate(&self.args, tokens_outstanding).log_err()
|
||||
{
|
||||
if template_prompt != "" {
|
||||
prompts[idx] = template_prompt;
|
||||
|
||||
if let Some(remaining_tokens) = tokens_outstanding {
|
||||
let new_tokens = prompt_token_count + seperator_tokens;
|
||||
tokens_outstanding = if remaining_tokens > new_tokens {
|
||||
Some(remaining_tokens - new_tokens)
|
||||
} else {
|
||||
Some(0)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prompts.retain(|x| x != "");
|
||||
|
||||
let full_prompt = prompts.join(seperator);
|
||||
let total_token_count = self.args.model.count_tokens(&full_prompt)?;
|
||||
anyhow::Ok((prompts.join(seperator), total_token_count))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
pub fn test_prompt_chain() {
|
||||
struct TestPromptTemplate {}
|
||||
impl PromptTemplate for TestPromptTemplate {
|
||||
fn generate(
|
||||
&self,
|
||||
args: &PromptArguments,
|
||||
max_token_length: Option<usize>,
|
||||
) -> anyhow::Result<(String, usize)> {
|
||||
let mut content = "This is a test prompt template".to_string();
|
||||
|
||||
let mut token_count = args.model.count_tokens(&content)?;
|
||||
if let Some(max_token_length) = max_token_length {
|
||||
if token_count > max_token_length {
|
||||
content = args.model.truncate(&content, max_token_length)?;
|
||||
token_count = max_token_length;
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::Ok((content, token_count))
|
||||
}
|
||||
}
|
||||
|
||||
struct TestLowPriorityTemplate {}
|
||||
impl PromptTemplate for TestLowPriorityTemplate {
|
||||
fn generate(
|
||||
&self,
|
||||
args: &PromptArguments,
|
||||
max_token_length: Option<usize>,
|
||||
) -> anyhow::Result<(String, usize)> {
|
||||
let mut content = "This is a low priority test prompt template".to_string();
|
||||
|
||||
let mut token_count = args.model.count_tokens(&content)?;
|
||||
if let Some(max_token_length) = max_token_length {
|
||||
if token_count > max_token_length {
|
||||
content = args.model.truncate(&content, max_token_length)?;
|
||||
token_count = max_token_length;
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::Ok((content, token_count))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DummyLanguageModel {
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
impl LanguageModel for DummyLanguageModel {
|
||||
fn name(&self) -> String {
|
||||
"dummy".to_string()
|
||||
}
|
||||
fn count_tokens(&self, content: &str) -> anyhow::Result<usize> {
|
||||
anyhow::Ok(content.chars().collect::<Vec<char>>().len())
|
||||
}
|
||||
fn truncate(&self, content: &str, length: usize) -> anyhow::Result<String> {
|
||||
anyhow::Ok(
|
||||
content.chars().collect::<Vec<char>>()[..length]
|
||||
.into_iter()
|
||||
.collect::<String>(),
|
||||
)
|
||||
}
|
||||
fn truncate_start(&self, content: &str, length: usize) -> anyhow::Result<String> {
|
||||
anyhow::Ok(
|
||||
content.chars().collect::<Vec<char>>()[length..]
|
||||
.into_iter()
|
||||
.collect::<String>(),
|
||||
)
|
||||
}
|
||||
fn capacity(&self) -> anyhow::Result<usize> {
|
||||
anyhow::Ok(self.capacity)
|
||||
}
|
||||
}
|
||||
|
||||
let model: Arc<dyn LanguageModel> = Arc::new(DummyLanguageModel { capacity: 100 });
|
||||
let args = PromptArguments {
|
||||
model: model.clone(),
|
||||
language_name: None,
|
||||
project_name: None,
|
||||
snippets: Vec::new(),
|
||||
reserved_tokens: 0,
|
||||
buffer: None,
|
||||
selected_range: None,
|
||||
user_prompt: None,
|
||||
};
|
||||
|
||||
let templates: Vec<(PromptPriority, Box<dyn PromptTemplate>)> = vec![
|
||||
(
|
||||
PromptPriority::Ordered { order: 0 },
|
||||
Box::new(TestPromptTemplate {}),
|
||||
),
|
||||
(
|
||||
PromptPriority::Ordered { order: 1 },
|
||||
Box::new(TestLowPriorityTemplate {}),
|
||||
),
|
||||
];
|
||||
let chain = PromptChain::new(args, templates);
|
||||
|
||||
let (prompt, token_count) = chain.generate(false).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
prompt,
|
||||
"This is a test prompt template\nThis is a low priority test prompt template"
|
||||
.to_string()
|
||||
);
|
||||
|
||||
assert_eq!(model.count_tokens(&prompt).unwrap(), token_count);
|
||||
|
||||
// Testing with Truncation Off
|
||||
// Should ignore capacity and return all prompts
|
||||
let model: Arc<dyn LanguageModel> = Arc::new(DummyLanguageModel { capacity: 20 });
|
||||
let args = PromptArguments {
|
||||
model: model.clone(),
|
||||
language_name: None,
|
||||
project_name: None,
|
||||
snippets: Vec::new(),
|
||||
reserved_tokens: 0,
|
||||
buffer: None,
|
||||
selected_range: None,
|
||||
user_prompt: None,
|
||||
};
|
||||
|
||||
let templates: Vec<(PromptPriority, Box<dyn PromptTemplate>)> = vec![
|
||||
(
|
||||
PromptPriority::Ordered { order: 0 },
|
||||
Box::new(TestPromptTemplate {}),
|
||||
),
|
||||
(
|
||||
PromptPriority::Ordered { order: 1 },
|
||||
Box::new(TestLowPriorityTemplate {}),
|
||||
),
|
||||
];
|
||||
let chain = PromptChain::new(args, templates);
|
||||
|
||||
let (prompt, token_count) = chain.generate(false).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
prompt,
|
||||
"This is a test prompt template\nThis is a low priority test prompt template"
|
||||
.to_string()
|
||||
);
|
||||
|
||||
assert_eq!(model.count_tokens(&prompt).unwrap(), token_count);
|
||||
|
||||
// Testing with Truncation Off
|
||||
// Should ignore capacity and return all prompts
|
||||
let capacity = 20;
|
||||
let model: Arc<dyn LanguageModel> = Arc::new(DummyLanguageModel { capacity });
|
||||
let args = PromptArguments {
|
||||
model: model.clone(),
|
||||
language_name: None,
|
||||
project_name: None,
|
||||
snippets: Vec::new(),
|
||||
reserved_tokens: 0,
|
||||
buffer: None,
|
||||
selected_range: None,
|
||||
user_prompt: None,
|
||||
};
|
||||
|
||||
let templates: Vec<(PromptPriority, Box<dyn PromptTemplate>)> = vec![
|
||||
(
|
||||
PromptPriority::Ordered { order: 0 },
|
||||
Box::new(TestPromptTemplate {}),
|
||||
),
|
||||
(
|
||||
PromptPriority::Ordered { order: 1 },
|
||||
Box::new(TestLowPriorityTemplate {}),
|
||||
),
|
||||
(
|
||||
PromptPriority::Ordered { order: 2 },
|
||||
Box::new(TestLowPriorityTemplate {}),
|
||||
),
|
||||
];
|
||||
let chain = PromptChain::new(args, templates);
|
||||
|
||||
let (prompt, token_count) = chain.generate(true).unwrap();
|
||||
|
||||
assert_eq!(prompt, "This is a test promp".to_string());
|
||||
assert_eq!(token_count, capacity);
|
||||
|
||||
// Change Ordering of Prompts Based on Priority
|
||||
let capacity = 120;
|
||||
let reserved_tokens = 10;
|
||||
let model: Arc<dyn LanguageModel> = Arc::new(DummyLanguageModel { capacity });
|
||||
let args = PromptArguments {
|
||||
model: model.clone(),
|
||||
language_name: None,
|
||||
project_name: None,
|
||||
snippets: Vec::new(),
|
||||
reserved_tokens,
|
||||
buffer: None,
|
||||
selected_range: None,
|
||||
user_prompt: None,
|
||||
};
|
||||
let templates: Vec<(PromptPriority, Box<dyn PromptTemplate>)> = vec![
|
||||
(
|
||||
PromptPriority::Mandatory,
|
||||
Box::new(TestLowPriorityTemplate {}),
|
||||
),
|
||||
(
|
||||
PromptPriority::Ordered { order: 0 },
|
||||
Box::new(TestPromptTemplate {}),
|
||||
),
|
||||
(
|
||||
PromptPriority::Ordered { order: 1 },
|
||||
Box::new(TestLowPriorityTemplate {}),
|
||||
),
|
||||
];
|
||||
let chain = PromptChain::new(args, templates);
|
||||
|
||||
let (prompt, token_count) = chain.generate(true).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
prompt,
|
||||
"This is a low priority test prompt template\nThis is a test prompt template\nThis is a low priority test prompt "
|
||||
.to_string()
|
||||
);
|
||||
assert_eq!(token_count, capacity - reserved_tokens);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
use anyhow::anyhow;
|
||||
use language::BufferSnapshot;
|
||||
use language::ToOffset;
|
||||
|
||||
use crate::models::LanguageModel;
|
||||
use crate::templates::base::PromptArguments;
|
||||
use crate::templates::base::PromptTemplate;
|
||||
use std::fmt::Write;
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn retrieve_context(
|
||||
buffer: &BufferSnapshot,
|
||||
selected_range: &Option<Range<usize>>,
|
||||
model: Arc<dyn LanguageModel>,
|
||||
max_token_count: Option<usize>,
|
||||
) -> anyhow::Result<(String, usize, bool)> {
|
||||
let mut prompt = String::new();
|
||||
let mut truncated = false;
|
||||
if let Some(selected_range) = selected_range {
|
||||
let start = selected_range.start.to_offset(buffer);
|
||||
let end = selected_range.end.to_offset(buffer);
|
||||
|
||||
let start_window = buffer.text_for_range(0..start).collect::<String>();
|
||||
|
||||
let mut selected_window = String::new();
|
||||
if start == end {
|
||||
write!(selected_window, "<|START|>").unwrap();
|
||||
} else {
|
||||
write!(selected_window, "<|START|").unwrap();
|
||||
}
|
||||
|
||||
write!(
|
||||
selected_window,
|
||||
"{}",
|
||||
buffer.text_for_range(start..end).collect::<String>()
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
if start != end {
|
||||
write!(selected_window, "|END|>").unwrap();
|
||||
}
|
||||
|
||||
let end_window = buffer.text_for_range(end..buffer.len()).collect::<String>();
|
||||
|
||||
if let Some(max_token_count) = max_token_count {
|
||||
let selected_tokens = model.count_tokens(&selected_window)?;
|
||||
if selected_tokens > max_token_count {
|
||||
return Err(anyhow!(
|
||||
"selected range is greater than model context window, truncation not possible"
|
||||
));
|
||||
};
|
||||
|
||||
let mut remaining_tokens = max_token_count - selected_tokens;
|
||||
let start_window_tokens = model.count_tokens(&start_window)?;
|
||||
let end_window_tokens = model.count_tokens(&end_window)?;
|
||||
let outside_tokens = start_window_tokens + end_window_tokens;
|
||||
if outside_tokens > remaining_tokens {
|
||||
let (start_goal_tokens, end_goal_tokens) =
|
||||
if start_window_tokens < end_window_tokens {
|
||||
let start_goal_tokens = (remaining_tokens / 2).min(start_window_tokens);
|
||||
remaining_tokens -= start_goal_tokens;
|
||||
let end_goal_tokens = remaining_tokens.min(end_window_tokens);
|
||||
(start_goal_tokens, end_goal_tokens)
|
||||
} else {
|
||||
let end_goal_tokens = (remaining_tokens / 2).min(end_window_tokens);
|
||||
remaining_tokens -= end_goal_tokens;
|
||||
let start_goal_tokens = remaining_tokens.min(start_window_tokens);
|
||||
(start_goal_tokens, end_goal_tokens)
|
||||
};
|
||||
|
||||
let truncated_start_window =
|
||||
model.truncate_start(&start_window, start_goal_tokens)?;
|
||||
let truncated_end_window = model.truncate(&end_window, end_goal_tokens)?;
|
||||
writeln!(
|
||||
prompt,
|
||||
"{truncated_start_window}{selected_window}{truncated_end_window}"
|
||||
)
|
||||
.unwrap();
|
||||
truncated = true;
|
||||
} else {
|
||||
writeln!(prompt, "{start_window}{selected_window}{end_window}").unwrap();
|
||||
}
|
||||
} else {
|
||||
// If we dont have a selected range, include entire file.
|
||||
writeln!(prompt, "{}", &buffer.text()).unwrap();
|
||||
|
||||
// Dumb truncation strategy
|
||||
if let Some(max_token_count) = max_token_count {
|
||||
if model.count_tokens(&prompt)? > max_token_count {
|
||||
truncated = true;
|
||||
prompt = model.truncate(&prompt, max_token_count)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let token_count = model.count_tokens(&prompt)?;
|
||||
anyhow::Ok((prompt, token_count, truncated))
|
||||
}
|
||||
|
||||
pub struct FileContext {}
|
||||
|
||||
impl PromptTemplate for FileContext {
|
||||
fn generate(
|
||||
&self,
|
||||
args: &PromptArguments,
|
||||
max_token_length: Option<usize>,
|
||||
) -> anyhow::Result<(String, usize)> {
|
||||
if let Some(buffer) = &args.buffer {
|
||||
let mut prompt = String::new();
|
||||
// Add Initial Preamble
|
||||
// TODO: Do we want to add the path in here?
|
||||
writeln!(
|
||||
prompt,
|
||||
"The file you are currently working on has the following content:"
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let language_name = args
|
||||
.language_name
|
||||
.clone()
|
||||
.unwrap_or("".to_string())
|
||||
.to_lowercase();
|
||||
|
||||
let (context, _, truncated) = retrieve_context(
|
||||
buffer,
|
||||
&args.selected_range,
|
||||
args.model.clone(),
|
||||
max_token_length,
|
||||
)?;
|
||||
writeln!(prompt, "```{language_name}\n{context}\n```").unwrap();
|
||||
|
||||
if truncated {
|
||||
writeln!(prompt, "Note the content has been truncated and only represents a portion of the file.").unwrap();
|
||||
}
|
||||
|
||||
if let Some(selected_range) = &args.selected_range {
|
||||
let start = selected_range.start.to_offset(buffer);
|
||||
let end = selected_range.end.to_offset(buffer);
|
||||
|
||||
if start == end {
|
||||
writeln!(prompt, "In particular, the user's cursor is currently on the '<|START|>' span in the above content, with no text selected.").unwrap();
|
||||
} else {
|
||||
writeln!(prompt, "In particular, the user has selected a section of the text between the '<|START|' and '|END|>' spans.").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// Really dumb truncation strategy
|
||||
if let Some(max_tokens) = max_token_length {
|
||||
prompt = args.model.truncate(&prompt, max_tokens)?;
|
||||
}
|
||||
|
||||
let token_count = args.model.count_tokens(&prompt)?;
|
||||
anyhow::Ok((prompt, token_count))
|
||||
} else {
|
||||
Err(anyhow!("no buffer provided to retrieve file context from"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use crate::templates::base::{PromptArguments, PromptFileType, PromptTemplate};
|
||||
use anyhow::anyhow;
|
||||
use std::fmt::Write;
|
||||
|
||||
pub fn capitalize(s: &str) -> String {
|
||||
let mut c = s.chars();
|
||||
match c.next() {
|
||||
None => String::new(),
|
||||
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GenerateInlineContent {}
|
||||
|
||||
impl PromptTemplate for GenerateInlineContent {
|
||||
fn generate(
|
||||
&self,
|
||||
args: &PromptArguments,
|
||||
max_token_length: Option<usize>,
|
||||
) -> anyhow::Result<(String, usize)> {
|
||||
let Some(user_prompt) = &args.user_prompt else {
|
||||
return Err(anyhow!("user prompt not provided"));
|
||||
};
|
||||
|
||||
let file_type = args.get_file_type();
|
||||
let content_type = match &file_type {
|
||||
PromptFileType::Code => "code",
|
||||
PromptFileType::Text => "text",
|
||||
};
|
||||
|
||||
let mut prompt = String::new();
|
||||
|
||||
if let Some(selected_range) = &args.selected_range {
|
||||
if selected_range.start == selected_range.end {
|
||||
writeln!(
|
||||
prompt,
|
||||
"Assume the cursor is located where the `<|START|>` span is."
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
prompt,
|
||||
"{} can't be replaced, so assume your answer will be inserted at the cursor.",
|
||||
capitalize(content_type)
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
prompt,
|
||||
"Generate {content_type} based on the users prompt: {user_prompt}",
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(prompt, "Modify the user's selected {content_type} based upon the users prompt: '{user_prompt}'").unwrap();
|
||||
writeln!(prompt, "You must reply with only the adjusted {content_type} (within the '<|START|' and '|END|>' spans) not the entire file.").unwrap();
|
||||
writeln!(prompt, "Double check that you only return code and not the '<|START|' and '|END|'> spans").unwrap();
|
||||
}
|
||||
} else {
|
||||
writeln!(
|
||||
prompt,
|
||||
"Generate {content_type} based on the users prompt: {user_prompt}"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
if let Some(language_name) = &args.language_name {
|
||||
writeln!(
|
||||
prompt,
|
||||
"Your answer MUST always and only be valid {}.",
|
||||
language_name
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
writeln!(prompt, "Never make remarks about the output.").unwrap();
|
||||
writeln!(
|
||||
prompt,
|
||||
"Do not return anything else, except the generated {content_type}."
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
match file_type {
|
||||
PromptFileType::Code => {
|
||||
// writeln!(prompt, "Always wrap your code in a Markdown block.").unwrap();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Really dumb truncation strategy
|
||||
if let Some(max_tokens) = max_token_length {
|
||||
prompt = args.model.truncate(&prompt, max_tokens)?;
|
||||
}
|
||||
|
||||
let token_count = args.model.count_tokens(&prompt)?;
|
||||
|
||||
anyhow::Ok((prompt, token_count))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod base;
|
||||
pub mod file_context;
|
||||
pub mod generate;
|
||||
pub mod preamble;
|
||||
pub mod repository_context;
|
||||
@@ -0,0 +1,52 @@
|
||||
use crate::templates::base::{PromptArguments, PromptFileType, PromptTemplate};
|
||||
use std::fmt::Write;
|
||||
|
||||
pub struct EngineerPreamble {}
|
||||
|
||||
impl PromptTemplate for EngineerPreamble {
|
||||
fn generate(
|
||||
&self,
|
||||
args: &PromptArguments,
|
||||
max_token_length: Option<usize>,
|
||||
) -> anyhow::Result<(String, usize)> {
|
||||
let mut prompts = Vec::new();
|
||||
|
||||
match args.get_file_type() {
|
||||
PromptFileType::Code => {
|
||||
prompts.push(format!(
|
||||
"You are an expert {}engineer.",
|
||||
args.language_name.clone().unwrap_or("".to_string()) + " "
|
||||
));
|
||||
}
|
||||
PromptFileType::Text => {
|
||||
prompts.push("You are an expert engineer.".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(project_name) = args.project_name.clone() {
|
||||
prompts.push(format!(
|
||||
"You are currently working inside the '{project_name}' project in code editor Zed."
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(mut remaining_tokens) = max_token_length {
|
||||
let mut prompt = String::new();
|
||||
let mut total_count = 0;
|
||||
for prompt_piece in prompts {
|
||||
let prompt_token_count =
|
||||
args.model.count_tokens(&prompt_piece)? + args.model.count_tokens("\n")?;
|
||||
if remaining_tokens > prompt_token_count {
|
||||
writeln!(prompt, "{prompt_piece}").unwrap();
|
||||
remaining_tokens -= prompt_token_count;
|
||||
total_count += prompt_token_count;
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::Ok((prompt, total_count))
|
||||
} else {
|
||||
let prompt = prompts.join("\n");
|
||||
let token_count = args.model.count_tokens(&prompt)?;
|
||||
anyhow::Ok((prompt, token_count))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use crate::templates::base::{PromptArguments, PromptTemplate};
|
||||
use std::fmt::Write;
|
||||
use std::{ops::Range, path::PathBuf};
|
||||
|
||||
use gpui::{AsyncAppContext, ModelHandle};
|
||||
use language::{Anchor, Buffer};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PromptCodeSnippet {
|
||||
path: Option<PathBuf>,
|
||||
language_name: Option<String>,
|
||||
content: String,
|
||||
}
|
||||
|
||||
impl PromptCodeSnippet {
|
||||
pub fn new(buffer: ModelHandle<Buffer>, range: Range<Anchor>, cx: &AsyncAppContext) -> Self {
|
||||
let (content, language_name, file_path) = buffer.read_with(cx, |buffer, _| {
|
||||
let snapshot = buffer.snapshot();
|
||||
let content = snapshot.text_for_range(range.clone()).collect::<String>();
|
||||
|
||||
let language_name = buffer
|
||||
.language()
|
||||
.and_then(|language| Some(language.name().to_string().to_lowercase()));
|
||||
|
||||
let file_path = buffer
|
||||
.file()
|
||||
.and_then(|file| Some(file.path().to_path_buf()));
|
||||
|
||||
(content, language_name, file_path)
|
||||
});
|
||||
|
||||
PromptCodeSnippet {
|
||||
path: file_path,
|
||||
language_name,
|
||||
content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToString for PromptCodeSnippet {
|
||||
fn to_string(&self) -> String {
|
||||
let path = self
|
||||
.path
|
||||
.as_ref()
|
||||
.and_then(|path| Some(path.to_string_lossy().to_string()))
|
||||
.unwrap_or("".to_string());
|
||||
let language_name = self.language_name.clone().unwrap_or("".to_string());
|
||||
let content = self.content.clone();
|
||||
|
||||
format!("The below code snippet may be relevant from file: {path}\n```{language_name}\n{content}\n```")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RepositoryContext {}
|
||||
|
||||
impl PromptTemplate for RepositoryContext {
|
||||
fn generate(
|
||||
&self,
|
||||
args: &PromptArguments,
|
||||
max_token_length: Option<usize>,
|
||||
) -> anyhow::Result<(String, usize)> {
|
||||
const MAXIMUM_SNIPPET_TOKEN_COUNT: usize = 500;
|
||||
let template = "You are working inside a large repository, here are a few code snippets that may be useful.";
|
||||
let mut prompt = String::new();
|
||||
|
||||
let mut remaining_tokens = max_token_length.clone();
|
||||
let seperator_token_length = args.model.count_tokens("\n")?;
|
||||
for snippet in &args.snippets {
|
||||
let mut snippet_prompt = template.to_string();
|
||||
let content = snippet.to_string();
|
||||
writeln!(snippet_prompt, "{content}").unwrap();
|
||||
|
||||
let token_count = args.model.count_tokens(&snippet_prompt)?;
|
||||
if token_count <= MAXIMUM_SNIPPET_TOKEN_COUNT {
|
||||
if let Some(tokens_left) = remaining_tokens {
|
||||
if tokens_left >= token_count {
|
||||
writeln!(prompt, "{snippet_prompt}").unwrap();
|
||||
remaining_tokens = if tokens_left >= (token_count + seperator_token_length)
|
||||
{
|
||||
Some(tokens_left - token_count - seperator_token_length)
|
||||
} else {
|
||||
Some(0)
|
||||
};
|
||||
}
|
||||
} else {
|
||||
writeln!(prompt, "{snippet_prompt}").unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total_token_count = args.model.count_tokens(&prompt)?;
|
||||
anyhow::Ok((prompt, total_token_count))
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,17 @@ fs = { path = "../fs" }
|
||||
gpui = { path = "../gpui" }
|
||||
language = { path = "../language" }
|
||||
menu = { path = "../menu" }
|
||||
multi_buffer = { path = "../multi_buffer" }
|
||||
search = { path = "../search" }
|
||||
settings = { path = "../settings" }
|
||||
theme = { path = "../theme" }
|
||||
util = { path = "../util" }
|
||||
workspace = { path = "../workspace" }
|
||||
uuid.workspace = true
|
||||
semantic_index = { path = "../semantic_index" }
|
||||
project = { path = "../project" }
|
||||
|
||||
uuid.workspace = true
|
||||
log.workspace = true
|
||||
anyhow.workspace = true
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
futures.workspace = true
|
||||
@@ -36,7 +40,7 @@ schemars.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
smol.workspace = true
|
||||
tiktoken-rs = "0.4"
|
||||
tiktoken-rs = "0.5"
|
||||
|
||||
[dev-dependencies]
|
||||
editor = { path = "../editor", features = ["test-support"] }
|
||||
|
||||
@@ -5,8 +5,11 @@ use crate::{
|
||||
MessageId, MessageMetadata, MessageStatus, Role, SavedConversation, SavedConversationMetadata,
|
||||
SavedMessage,
|
||||
};
|
||||
use ai::completion::{
|
||||
stream_completion, OpenAICompletionProvider, OpenAIRequest, RequestMessage, OPENAI_API_URL,
|
||||
use ai::{
|
||||
completion::{
|
||||
stream_completion, OpenAICompletionProvider, OpenAIRequest, RequestMessage, OPENAI_API_URL,
|
||||
},
|
||||
templates::repository_context::PromptCodeSnippet,
|
||||
};
|
||||
use anyhow::{anyhow, Result};
|
||||
use chrono::{DateTime, Local};
|
||||
@@ -29,13 +32,15 @@ use gpui::{
|
||||
},
|
||||
fonts::HighlightStyle,
|
||||
geometry::vector::{vec2f, Vector2F},
|
||||
platform::{CursorStyle, MouseButton},
|
||||
platform::{CursorStyle, MouseButton, PromptLevel},
|
||||
Action, AnyElement, AppContext, AsyncAppContext, ClipboardItem, Element, Entity, ModelContext,
|
||||
ModelHandle, SizeConstraint, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
|
||||
WindowContext,
|
||||
ModelHandle, SizeConstraint, Subscription, Task, View, ViewContext, ViewHandle,
|
||||
WeakModelHandle, WeakViewHandle, WindowContext,
|
||||
};
|
||||
use language::{language_settings::SoftWrap, Buffer, LanguageRegistry, ToOffset as _};
|
||||
use project::Project;
|
||||
use search::BufferSearchBar;
|
||||
use semantic_index::{SemanticIndex, SemanticIndexStatus};
|
||||
use settings::SettingsStore;
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
@@ -46,7 +51,7 @@ use std::{
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use theme::{
|
||||
components::{action_button::Button, ComponentExt},
|
||||
@@ -72,6 +77,7 @@ actions!(
|
||||
ResetKey,
|
||||
InlineAssist,
|
||||
ToggleIncludeConversation,
|
||||
ToggleRetrieveContext,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -108,6 +114,7 @@ pub fn init(cx: &mut AppContext) {
|
||||
cx.add_action(InlineAssistant::confirm);
|
||||
cx.add_action(InlineAssistant::cancel);
|
||||
cx.add_action(InlineAssistant::toggle_include_conversation);
|
||||
cx.add_action(InlineAssistant::toggle_retrieve_context);
|
||||
cx.add_action(InlineAssistant::move_up);
|
||||
cx.add_action(InlineAssistant::move_down);
|
||||
}
|
||||
@@ -145,6 +152,8 @@ pub struct AssistantPanel {
|
||||
include_conversation_in_next_inline_assist: bool,
|
||||
inline_prompt_history: VecDeque<String>,
|
||||
_watch_saved_conversations: Task<Result<()>>,
|
||||
semantic_index: Option<ModelHandle<SemanticIndex>>,
|
||||
retrieve_context_in_next_inline_assist: bool,
|
||||
}
|
||||
|
||||
impl AssistantPanel {
|
||||
@@ -191,6 +200,9 @@ impl AssistantPanel {
|
||||
toolbar.add_item(cx.add_view(|cx| BufferSearchBar::new(cx)), cx);
|
||||
toolbar
|
||||
});
|
||||
|
||||
let semantic_index = SemanticIndex::global(cx);
|
||||
|
||||
let mut this = Self {
|
||||
workspace: workspace_handle,
|
||||
active_editor_index: Default::default(),
|
||||
@@ -215,6 +227,8 @@ impl AssistantPanel {
|
||||
include_conversation_in_next_inline_assist: false,
|
||||
inline_prompt_history: Default::default(),
|
||||
_watch_saved_conversations,
|
||||
semantic_index,
|
||||
retrieve_context_in_next_inline_assist: false,
|
||||
};
|
||||
|
||||
let mut old_dock_position = this.position(cx);
|
||||
@@ -262,12 +276,19 @@ impl AssistantPanel {
|
||||
return;
|
||||
};
|
||||
|
||||
let project = workspace.project();
|
||||
|
||||
this.update(cx, |assistant, cx| {
|
||||
assistant.new_inline_assist(&active_editor, cx)
|
||||
assistant.new_inline_assist(&active_editor, cx, project)
|
||||
});
|
||||
}
|
||||
|
||||
fn new_inline_assist(&mut self, editor: &ViewHandle<Editor>, cx: &mut ViewContext<Self>) {
|
||||
fn new_inline_assist(
|
||||
&mut self,
|
||||
editor: &ViewHandle<Editor>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
project: &ModelHandle<Project>,
|
||||
) {
|
||||
let api_key = if let Some(api_key) = self.api_key.borrow().clone() {
|
||||
api_key
|
||||
} else {
|
||||
@@ -275,7 +296,7 @@ impl AssistantPanel {
|
||||
};
|
||||
|
||||
let selection = editor.read(cx).selections.newest_anchor().clone();
|
||||
if selection.start.excerpt_id() != selection.end.excerpt_id() {
|
||||
if selection.start.excerpt_id != selection.end.excerpt_id {
|
||||
return;
|
||||
}
|
||||
let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx);
|
||||
@@ -312,6 +333,27 @@ impl AssistantPanel {
|
||||
Codegen::new(editor.read(cx).buffer().clone(), codegen_kind, provider, cx)
|
||||
});
|
||||
|
||||
if let Some(semantic_index) = self.semantic_index.clone() {
|
||||
let project = project.clone();
|
||||
cx.spawn(|_, mut cx| async move {
|
||||
let previously_indexed = semantic_index
|
||||
.update(&mut cx, |index, cx| {
|
||||
index.project_previously_indexed(&project, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if previously_indexed {
|
||||
let _ = semantic_index
|
||||
.update(&mut cx, |index, cx| {
|
||||
index.index_project(project.clone(), cx)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
let measurements = Rc::new(Cell::new(BlockMeasurements::default()));
|
||||
let inline_assistant = cx.add_view(|cx| {
|
||||
let assistant = InlineAssistant::new(
|
||||
@@ -322,6 +364,9 @@ impl AssistantPanel {
|
||||
codegen.clone(),
|
||||
self.workspace.clone(),
|
||||
cx,
|
||||
self.retrieve_context_in_next_inline_assist,
|
||||
self.semantic_index.clone(),
|
||||
project.clone(),
|
||||
);
|
||||
cx.focus_self();
|
||||
assistant
|
||||
@@ -362,6 +407,7 @@ impl AssistantPanel {
|
||||
editor: editor.downgrade(),
|
||||
inline_assistant: Some((block_id, inline_assistant.clone())),
|
||||
codegen: codegen.clone(),
|
||||
project: project.downgrade(),
|
||||
_subscriptions: vec![
|
||||
cx.subscribe(&inline_assistant, Self::handle_inline_assistant_event),
|
||||
cx.subscribe(editor, {
|
||||
@@ -440,8 +486,15 @@ impl AssistantPanel {
|
||||
InlineAssistantEvent::Confirmed {
|
||||
prompt,
|
||||
include_conversation,
|
||||
retrieve_context,
|
||||
} => {
|
||||
self.confirm_inline_assist(assist_id, prompt, *include_conversation, cx);
|
||||
self.confirm_inline_assist(
|
||||
assist_id,
|
||||
prompt,
|
||||
*include_conversation,
|
||||
cx,
|
||||
*retrieve_context,
|
||||
);
|
||||
}
|
||||
InlineAssistantEvent::Canceled => {
|
||||
self.finish_inline_assist(assist_id, true, cx);
|
||||
@@ -454,6 +507,9 @@ impl AssistantPanel {
|
||||
} => {
|
||||
self.include_conversation_in_next_inline_assist = *include_conversation;
|
||||
}
|
||||
InlineAssistantEvent::RetrieveContextToggled { retrieve_context } => {
|
||||
self.retrieve_context_in_next_inline_assist = *retrieve_context
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,6 +588,7 @@ impl AssistantPanel {
|
||||
user_prompt: &str,
|
||||
include_conversation: bool,
|
||||
cx: &mut ViewContext<Self>,
|
||||
retrieve_context: bool,
|
||||
) {
|
||||
let conversation = if include_conversation {
|
||||
self.active_editor()
|
||||
@@ -553,6 +610,20 @@ impl AssistantPanel {
|
||||
return;
|
||||
};
|
||||
|
||||
let project = pending_assist.project.clone();
|
||||
|
||||
let project_name = if let Some(project) = project.upgrade(cx) {
|
||||
Some(
|
||||
project
|
||||
.read(cx)
|
||||
.worktree_root_names(cx)
|
||||
.collect::<Vec<&str>>()
|
||||
.join("/"),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
self.inline_prompt_history
|
||||
.retain(|prompt| prompt != user_prompt);
|
||||
self.inline_prompt_history.push_back(user_prompt.into());
|
||||
@@ -590,13 +661,70 @@ impl AssistantPanel {
|
||||
None
|
||||
};
|
||||
|
||||
let codegen_kind = codegen.read(cx).kind().clone();
|
||||
// Higher Temperature increases the randomness of model outputs.
|
||||
// If Markdown or No Language is Known, increase the randomness for more creative output
|
||||
// If Code, decrease temperature to get more deterministic outputs
|
||||
let temperature = if let Some(language) = language_name.clone() {
|
||||
if language.to_string() != "Markdown".to_string() {
|
||||
0.5
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
let user_prompt = user_prompt.to_string();
|
||||
|
||||
let mut messages = Vec::new();
|
||||
let snippets = if retrieve_context {
|
||||
let Some(project) = project.upgrade(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let search_results = if let Some(semantic_index) = self.semantic_index.clone() {
|
||||
let search_results = semantic_index.update(cx, |this, cx| {
|
||||
this.search_project(project, user_prompt.to_string(), 10, vec![], vec![], cx)
|
||||
});
|
||||
|
||||
cx.background()
|
||||
.spawn(async move { search_results.await.unwrap_or_default() })
|
||||
} else {
|
||||
Task::ready(Vec::new())
|
||||
};
|
||||
|
||||
let snippets = cx.spawn(|_, cx| async move {
|
||||
let mut snippets = Vec::new();
|
||||
for result in search_results.await {
|
||||
snippets.push(PromptCodeSnippet::new(result.buffer, result.range, &cx));
|
||||
}
|
||||
snippets
|
||||
});
|
||||
snippets
|
||||
} else {
|
||||
Task::ready(Vec::new())
|
||||
};
|
||||
|
||||
let mut model = settings::get::<AssistantSettings>(cx)
|
||||
.default_open_ai_model
|
||||
.clone();
|
||||
let model_name = model.full_name();
|
||||
|
||||
let prompt = cx.background().spawn(async move {
|
||||
let snippets = snippets.await;
|
||||
|
||||
let language_name = language_name.as_deref();
|
||||
generate_content_prompt(
|
||||
user_prompt,
|
||||
language_name,
|
||||
buffer,
|
||||
range,
|
||||
snippets,
|
||||
model_name,
|
||||
project_name,
|
||||
)
|
||||
});
|
||||
|
||||
let mut messages = Vec::new();
|
||||
if let Some(conversation) = conversation {
|
||||
let conversation = conversation.read(cx);
|
||||
let buffer = conversation.buffer.read(cx);
|
||||
@@ -608,24 +736,24 @@ impl AssistantPanel {
|
||||
model = conversation.model.clone();
|
||||
}
|
||||
|
||||
let prompt = cx.background().spawn(async move {
|
||||
let language_name = language_name.as_deref();
|
||||
generate_content_prompt(user_prompt, language_name, &buffer, range, codegen_kind)
|
||||
});
|
||||
|
||||
cx.spawn(|_, mut cx| async move {
|
||||
let prompt = prompt.await;
|
||||
// I Don't know if we want to return a ? here.
|
||||
let prompt = prompt.await?;
|
||||
|
||||
messages.push(RequestMessage {
|
||||
role: Role::User,
|
||||
content: prompt,
|
||||
});
|
||||
|
||||
let request = OpenAIRequest {
|
||||
model: model.full_name().into(),
|
||||
messages,
|
||||
stream: true,
|
||||
stop: vec!["|END|>".to_string()],
|
||||
temperature,
|
||||
};
|
||||
codegen.update(&mut cx, |codegen, cx| codegen.start(request, cx));
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
@@ -1514,12 +1642,14 @@ impl Conversation {
|
||||
Role::Assistant => "assistant".into(),
|
||||
Role::System => "system".into(),
|
||||
},
|
||||
content: self
|
||||
.buffer
|
||||
.read(cx)
|
||||
.text_for_range(message.offset_range)
|
||||
.collect(),
|
||||
content: Some(
|
||||
self.buffer
|
||||
.read(cx)
|
||||
.text_for_range(message.offset_range)
|
||||
.collect(),
|
||||
),
|
||||
name: None,
|
||||
function_call: None,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -1613,6 +1743,8 @@ impl Conversation {
|
||||
.map(|message| message.to_open_ai_message(self.buffer.read(cx)))
|
||||
.collect(),
|
||||
stream: true,
|
||||
stop: vec![],
|
||||
temperature: 1.0,
|
||||
};
|
||||
|
||||
let stream = stream_completion(api_key, cx.background().clone(), request);
|
||||
@@ -1897,6 +2029,8 @@ impl Conversation {
|
||||
model: self.model.full_name().to_string(),
|
||||
messages: messages.collect(),
|
||||
stream: true,
|
||||
stop: vec![],
|
||||
temperature: 1.0,
|
||||
};
|
||||
|
||||
let stream = stream_completion(api_key, cx.background().clone(), request);
|
||||
@@ -2638,12 +2772,16 @@ enum InlineAssistantEvent {
|
||||
Confirmed {
|
||||
prompt: String,
|
||||
include_conversation: bool,
|
||||
retrieve_context: bool,
|
||||
},
|
||||
Canceled,
|
||||
Dismissed,
|
||||
IncludeConversationToggled {
|
||||
include_conversation: bool,
|
||||
},
|
||||
RetrieveContextToggled {
|
||||
retrieve_context: bool,
|
||||
},
|
||||
}
|
||||
|
||||
struct InlineAssistant {
|
||||
@@ -2659,6 +2797,11 @@ struct InlineAssistant {
|
||||
pending_prompt: String,
|
||||
codegen: ModelHandle<Codegen>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
retrieve_context: bool,
|
||||
semantic_index: Option<ModelHandle<SemanticIndex>>,
|
||||
semantic_permissioned: Option<bool>,
|
||||
project: WeakModelHandle<Project>,
|
||||
maintain_rate_limit: Option<Task<()>>,
|
||||
}
|
||||
|
||||
impl Entity for InlineAssistant {
|
||||
@@ -2675,51 +2818,65 @@ impl View for InlineAssistant {
|
||||
let theme = theme::current(cx);
|
||||
|
||||
Flex::row()
|
||||
.with_child(
|
||||
Flex::row()
|
||||
.with_child(
|
||||
Button::action(ToggleIncludeConversation)
|
||||
.with_tooltip("Include Conversation", theme.tooltip.clone())
|
||||
.with_children([Flex::row()
|
||||
.with_child(
|
||||
Button::action(ToggleIncludeConversation)
|
||||
.with_tooltip("Include Conversation", theme.tooltip.clone())
|
||||
.with_id(self.id)
|
||||
.with_contents(theme::components::svg::Svg::new("icons/ai.svg"))
|
||||
.toggleable(self.include_conversation)
|
||||
.with_style(theme.assistant.inline.include_conversation.clone())
|
||||
.element()
|
||||
.aligned(),
|
||||
)
|
||||
.with_children(if SemanticIndex::enabled(cx) {
|
||||
Some(
|
||||
Button::action(ToggleRetrieveContext)
|
||||
.with_tooltip("Retrieve Context", theme.tooltip.clone())
|
||||
.with_id(self.id)
|
||||
.with_contents(theme::components::svg::Svg::new("icons/ai.svg"))
|
||||
.toggleable(self.include_conversation)
|
||||
.with_style(theme.assistant.inline.include_conversation.clone())
|
||||
.with_contents(theme::components::svg::Svg::new(
|
||||
"icons/magnifying_glass.svg",
|
||||
))
|
||||
.toggleable(self.retrieve_context)
|
||||
.with_style(theme.assistant.inline.retrieve_context.clone())
|
||||
.element()
|
||||
.aligned(),
|
||||
)
|
||||
.with_children(if let Some(error) = self.codegen.read(cx).error() {
|
||||
Some(
|
||||
Svg::new("icons/error.svg")
|
||||
.with_color(theme.assistant.error_icon.color)
|
||||
.constrained()
|
||||
.with_width(theme.assistant.error_icon.width)
|
||||
.contained()
|
||||
.with_style(theme.assistant.error_icon.container)
|
||||
.with_tooltip::<ErrorIcon>(
|
||||
self.id,
|
||||
error.to_string(),
|
||||
None,
|
||||
theme.tooltip.clone(),
|
||||
cx,
|
||||
)
|
||||
.aligned(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
})
|
||||
.aligned()
|
||||
.constrained()
|
||||
.dynamically({
|
||||
let measurements = self.measurements.clone();
|
||||
move |constraint, _, _| {
|
||||
let measurements = measurements.get();
|
||||
SizeConstraint {
|
||||
min: vec2f(measurements.gutter_width, constraint.min.y()),
|
||||
max: vec2f(measurements.gutter_width, constraint.max.y()),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
})
|
||||
.with_children(if let Some(error) = self.codegen.read(cx).error() {
|
||||
Some(
|
||||
Svg::new("icons/error.svg")
|
||||
.with_color(theme.assistant.error_icon.color)
|
||||
.constrained()
|
||||
.with_width(theme.assistant.error_icon.width)
|
||||
.contained()
|
||||
.with_style(theme.assistant.error_icon.container)
|
||||
.with_tooltip::<ErrorIcon>(
|
||||
self.id,
|
||||
error.to_string(),
|
||||
None,
|
||||
theme.tooltip.clone(),
|
||||
cx,
|
||||
)
|
||||
.aligned(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
})
|
||||
.aligned()
|
||||
.constrained()
|
||||
.dynamically({
|
||||
let measurements = self.measurements.clone();
|
||||
move |constraint, _, _| {
|
||||
let measurements = measurements.get();
|
||||
SizeConstraint {
|
||||
min: vec2f(measurements.gutter_width, constraint.min.y()),
|
||||
max: vec2f(measurements.gutter_width, constraint.max.y()),
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
})])
|
||||
.with_child(Empty::new().constrained().dynamically({
|
||||
let measurements = self.measurements.clone();
|
||||
move |constraint, _, _| {
|
||||
@@ -2742,6 +2899,16 @@ impl View for InlineAssistant {
|
||||
.left()
|
||||
.flex(1., true),
|
||||
)
|
||||
.with_children(if self.retrieve_context {
|
||||
Some(
|
||||
Flex::row()
|
||||
.with_children(self.retrieve_context_status(cx))
|
||||
.flex(1., true)
|
||||
.aligned(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
})
|
||||
.contained()
|
||||
.with_style(theme.assistant.inline.container)
|
||||
.into_any()
|
||||
@@ -2767,6 +2934,9 @@ impl InlineAssistant {
|
||||
codegen: ModelHandle<Codegen>,
|
||||
workspace: WeakViewHandle<Workspace>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
retrieve_context: bool,
|
||||
semantic_index: Option<ModelHandle<SemanticIndex>>,
|
||||
project: ModelHandle<Project>,
|
||||
) -> Self {
|
||||
let prompt_editor = cx.add_view(|cx| {
|
||||
let mut editor = Editor::single_line(
|
||||
@@ -2780,11 +2950,16 @@ impl InlineAssistant {
|
||||
editor.set_placeholder_text(placeholder, cx);
|
||||
editor
|
||||
});
|
||||
let subscriptions = vec![
|
||||
let mut subscriptions = vec![
|
||||
cx.observe(&codegen, Self::handle_codegen_changed),
|
||||
cx.subscribe(&prompt_editor, Self::handle_prompt_editor_events),
|
||||
];
|
||||
Self {
|
||||
|
||||
if let Some(semantic_index) = semantic_index.clone() {
|
||||
subscriptions.push(cx.observe(&semantic_index, Self::semantic_index_changed));
|
||||
}
|
||||
|
||||
let assistant = Self {
|
||||
id,
|
||||
prompt_editor,
|
||||
workspace,
|
||||
@@ -2797,7 +2972,33 @@ impl InlineAssistant {
|
||||
pending_prompt: String::new(),
|
||||
codegen,
|
||||
_subscriptions: subscriptions,
|
||||
retrieve_context,
|
||||
semantic_permissioned: None,
|
||||
semantic_index,
|
||||
project: project.downgrade(),
|
||||
maintain_rate_limit: None,
|
||||
};
|
||||
|
||||
assistant.index_project(cx).log_err();
|
||||
|
||||
assistant
|
||||
}
|
||||
|
||||
fn semantic_permissioned(&self, cx: &mut ViewContext<Self>) -> Task<Result<bool>> {
|
||||
if let Some(value) = self.semantic_permissioned {
|
||||
return Task::ready(Ok(value));
|
||||
}
|
||||
|
||||
let Some(project) = self.project.upgrade(cx) else {
|
||||
return Task::ready(Err(anyhow!("project was dropped")));
|
||||
};
|
||||
|
||||
self.semantic_index
|
||||
.as_ref()
|
||||
.map(|semantic| {
|
||||
semantic.update(cx, |this, cx| this.project_previously_indexed(&project, cx))
|
||||
})
|
||||
.unwrap_or(Task::ready(Ok(false)))
|
||||
}
|
||||
|
||||
fn handle_prompt_editor_events(
|
||||
@@ -2812,6 +3013,37 @@ impl InlineAssistant {
|
||||
}
|
||||
}
|
||||
|
||||
fn semantic_index_changed(
|
||||
&mut self,
|
||||
semantic_index: ModelHandle<SemanticIndex>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let Some(project) = self.project.upgrade(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let status = semantic_index.read(cx).status(&project);
|
||||
match status {
|
||||
SemanticIndexStatus::Indexing {
|
||||
rate_limit_expiry: Some(_),
|
||||
..
|
||||
} => {
|
||||
if self.maintain_rate_limit.is_none() {
|
||||
self.maintain_rate_limit = Some(cx.spawn(|this, mut cx| async move {
|
||||
loop {
|
||||
cx.background().timer(Duration::from_secs(1)).await;
|
||||
this.update(&mut cx, |_, cx| cx.notify()).log_err();
|
||||
}
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ => {
|
||||
self.maintain_rate_limit = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_codegen_changed(&mut self, _: ModelHandle<Codegen>, cx: &mut ViewContext<Self>) {
|
||||
let is_read_only = !self.codegen.read(cx).idle();
|
||||
self.prompt_editor.update(cx, |editor, cx| {
|
||||
@@ -2861,12 +3093,241 @@ impl InlineAssistant {
|
||||
cx.emit(InlineAssistantEvent::Confirmed {
|
||||
prompt,
|
||||
include_conversation: self.include_conversation,
|
||||
retrieve_context: self.retrieve_context,
|
||||
});
|
||||
self.confirmed = true;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_retrieve_context(&mut self, _: &ToggleRetrieveContext, cx: &mut ViewContext<Self>) {
|
||||
let semantic_permissioned = self.semantic_permissioned(cx);
|
||||
|
||||
let Some(project) = self.project.upgrade(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let project_name = project
|
||||
.read(cx)
|
||||
.worktree_root_names(cx)
|
||||
.collect::<Vec<&str>>()
|
||||
.join("/");
|
||||
let is_plural = project_name.chars().filter(|letter| *letter == '/').count() > 0;
|
||||
let prompt_text = format!("Would you like to index the '{}' project{} for context retrieval? This requires sending code to the OpenAI API", project_name,
|
||||
if is_plural {
|
||||
"s"
|
||||
} else {""});
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
// If Necessary prompt user
|
||||
if !semantic_permissioned.await.unwrap_or(false) {
|
||||
let mut answer = this.update(&mut cx, |_, cx| {
|
||||
cx.prompt(
|
||||
PromptLevel::Info,
|
||||
prompt_text.as_str(),
|
||||
&["Continue", "Cancel"],
|
||||
)
|
||||
})?;
|
||||
|
||||
if answer.next().await == Some(0) {
|
||||
this.update(&mut cx, |this, _| {
|
||||
this.semantic_permissioned = Some(true);
|
||||
})?;
|
||||
} else {
|
||||
return anyhow::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// If permissioned, update context appropriately
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.retrieve_context = !this.retrieve_context;
|
||||
|
||||
cx.emit(InlineAssistantEvent::RetrieveContextToggled {
|
||||
retrieve_context: this.retrieve_context,
|
||||
});
|
||||
|
||||
if this.retrieve_context {
|
||||
this.index_project(cx).log_err();
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
fn index_project(&self, cx: &mut ViewContext<Self>) -> anyhow::Result<()> {
|
||||
let Some(project) = self.project.upgrade(cx) else {
|
||||
return Err(anyhow!("project was dropped!"));
|
||||
};
|
||||
|
||||
let semantic_permissioned = self.semantic_permissioned(cx);
|
||||
if let Some(semantic_index) = SemanticIndex::global(cx) {
|
||||
cx.spawn(|_, mut cx| async move {
|
||||
// This has to be updated to accomodate for semantic_permissions
|
||||
if semantic_permissioned.await.unwrap_or(false) {
|
||||
semantic_index
|
||||
.update(&mut cx, |index, cx| index.index_project(project, cx))
|
||||
.await
|
||||
} else {
|
||||
Err(anyhow!("project is not permissioned for semantic indexing"))
|
||||
}
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
anyhow::Ok(())
|
||||
}
|
||||
|
||||
fn retrieve_context_status(
|
||||
&self,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Option<AnyElement<InlineAssistant>> {
|
||||
enum ContextStatusIcon {}
|
||||
|
||||
let Some(project) = self.project.upgrade(cx) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
if let Some(semantic_index) = SemanticIndex::global(cx) {
|
||||
let status = semantic_index.update(cx, |index, _| index.status(&project));
|
||||
let theme = theme::current(cx);
|
||||
match status {
|
||||
SemanticIndexStatus::NotAuthenticated {} => Some(
|
||||
Svg::new("icons/error.svg")
|
||||
.with_color(theme.assistant.error_icon.color)
|
||||
.constrained()
|
||||
.with_width(theme.assistant.error_icon.width)
|
||||
.contained()
|
||||
.with_style(theme.assistant.error_icon.container)
|
||||
.with_tooltip::<ContextStatusIcon>(
|
||||
self.id,
|
||||
"Not Authenticated. Please ensure you have a valid 'OPENAI_API_KEY' in your environment variables.",
|
||||
None,
|
||||
theme.tooltip.clone(),
|
||||
cx,
|
||||
)
|
||||
.aligned()
|
||||
.into_any(),
|
||||
),
|
||||
SemanticIndexStatus::NotIndexed {} => Some(
|
||||
Svg::new("icons/error.svg")
|
||||
.with_color(theme.assistant.inline.context_status.error_icon.color)
|
||||
.constrained()
|
||||
.with_width(theme.assistant.inline.context_status.error_icon.width)
|
||||
.contained()
|
||||
.with_style(theme.assistant.inline.context_status.error_icon.container)
|
||||
.with_tooltip::<ContextStatusIcon>(
|
||||
self.id,
|
||||
"Not Indexed",
|
||||
None,
|
||||
theme.tooltip.clone(),
|
||||
cx,
|
||||
)
|
||||
.aligned()
|
||||
.into_any(),
|
||||
),
|
||||
SemanticIndexStatus::Indexing {
|
||||
remaining_files,
|
||||
rate_limit_expiry,
|
||||
} => {
|
||||
|
||||
let mut status_text = if remaining_files == 0 {
|
||||
"Indexing...".to_string()
|
||||
} else {
|
||||
format!("Remaining files to index: {remaining_files}")
|
||||
};
|
||||
|
||||
if let Some(rate_limit_expiry) = rate_limit_expiry {
|
||||
let remaining_seconds = rate_limit_expiry.duration_since(Instant::now());
|
||||
if remaining_seconds > Duration::from_secs(0) && remaining_files > 0 {
|
||||
write!(
|
||||
status_text,
|
||||
" (rate limit expires in {}s)",
|
||||
remaining_seconds.as_secs()
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
Some(
|
||||
Svg::new("icons/update.svg")
|
||||
.with_color(theme.assistant.inline.context_status.in_progress_icon.color)
|
||||
.constrained()
|
||||
.with_width(theme.assistant.inline.context_status.in_progress_icon.width)
|
||||
.contained()
|
||||
.with_style(theme.assistant.inline.context_status.in_progress_icon.container)
|
||||
.with_tooltip::<ContextStatusIcon>(
|
||||
self.id,
|
||||
status_text,
|
||||
None,
|
||||
theme.tooltip.clone(),
|
||||
cx,
|
||||
)
|
||||
.aligned()
|
||||
.into_any(),
|
||||
)
|
||||
}
|
||||
SemanticIndexStatus::Indexed {} => Some(
|
||||
Svg::new("icons/check.svg")
|
||||
.with_color(theme.assistant.inline.context_status.complete_icon.color)
|
||||
.constrained()
|
||||
.with_width(theme.assistant.inline.context_status.complete_icon.width)
|
||||
.contained()
|
||||
.with_style(theme.assistant.inline.context_status.complete_icon.container)
|
||||
.with_tooltip::<ContextStatusIcon>(
|
||||
self.id,
|
||||
"Index up to date",
|
||||
None,
|
||||
theme.tooltip.clone(),
|
||||
cx,
|
||||
)
|
||||
.aligned()
|
||||
.into_any(),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// fn retrieve_context_status(&self, cx: &mut ViewContext<Self>) -> String {
|
||||
// let project = self.project.clone();
|
||||
// if let Some(semantic_index) = self.semantic_index.clone() {
|
||||
// let status = semantic_index.update(cx, |index, cx| index.status(&project));
|
||||
// return match status {
|
||||
// // This theoretically shouldnt be a valid code path
|
||||
// // As the inline assistant cant be launched without an API key
|
||||
// // We keep it here for safety
|
||||
// semantic_index::SemanticIndexStatus::NotAuthenticated => {
|
||||
// "Not Authenticated!\nPlease ensure you have an `OPENAI_API_KEY` in your environment variables.".to_string()
|
||||
// }
|
||||
// semantic_index::SemanticIndexStatus::Indexed => {
|
||||
// "Indexing Complete!".to_string()
|
||||
// }
|
||||
// semantic_index::SemanticIndexStatus::Indexing { remaining_files, rate_limit_expiry } => {
|
||||
|
||||
// let mut status = format!("Remaining files to index for Context Retrieval: {remaining_files}");
|
||||
|
||||
// if let Some(rate_limit_expiry) = rate_limit_expiry {
|
||||
// let remaining_seconds =
|
||||
// rate_limit_expiry.duration_since(Instant::now());
|
||||
// if remaining_seconds > Duration::from_secs(0) {
|
||||
// write!(status, " (rate limit resets in {}s)", remaining_seconds.as_secs()).unwrap();
|
||||
// }
|
||||
// }
|
||||
// status
|
||||
// }
|
||||
// semantic_index::SemanticIndexStatus::NotIndexed => {
|
||||
// "Not Indexed for Context Retrieval".to_string()
|
||||
// }
|
||||
// };
|
||||
// }
|
||||
|
||||
// "".to_string()
|
||||
// }
|
||||
|
||||
fn toggle_include_conversation(
|
||||
&mut self,
|
||||
_: &ToggleIncludeConversation,
|
||||
@@ -2929,6 +3390,7 @@ struct PendingInlineAssist {
|
||||
inline_assistant: Option<(BlockId, ViewHandle<InlineAssistant>)>,
|
||||
codegen: ModelHandle<Codegen>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
project: WeakModelHandle<Project>,
|
||||
}
|
||||
|
||||
fn merge_ranges(ranges: &mut Vec<Range<Anchor>>, buffer: &MultiBufferSnapshot) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use crate::streaming_diff::{Hunk, StreamingDiff};
|
||||
use ai::completion::{CompletionProvider, OpenAIRequest};
|
||||
use anyhow::Result;
|
||||
use editor::{multi_buffer, Anchor, MultiBuffer, MultiBufferSnapshot, ToOffset, ToPoint};
|
||||
use editor::{Anchor, MultiBuffer, MultiBufferSnapshot, ToOffset, ToPoint};
|
||||
use futures::{channel::mpsc, SinkExt, Stream, StreamExt};
|
||||
use gpui::{Entity, ModelContext, ModelHandle, Task};
|
||||
use language::{Rope, TransactionId};
|
||||
use multi_buffer;
|
||||
use std::{cmp, future, ops::Range, sync::Arc};
|
||||
|
||||
pub enum Event {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
use crate::codegen::CodegenKind;
|
||||
use ai::models::{LanguageModel, OpenAILanguageModel};
|
||||
use ai::templates::base::{PromptArguments, PromptChain, PromptPriority, PromptTemplate};
|
||||
use ai::templates::file_context::FileContext;
|
||||
use ai::templates::generate::GenerateInlineContent;
|
||||
use ai::templates::preamble::EngineerPreamble;
|
||||
use ai::templates::repository_context::{PromptCodeSnippet, RepositoryContext};
|
||||
use language::{BufferSnapshot, OffsetRangeExt, ToOffset};
|
||||
use std::cmp::{self, Reverse};
|
||||
use std::fmt::Write;
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn summarize(buffer: &BufferSnapshot, selected_range: Range<impl ToOffset>) -> String {
|
||||
@@ -118,86 +123,50 @@ fn summarize(buffer: &BufferSnapshot, selected_range: Range<impl ToOffset>) -> S
|
||||
pub fn generate_content_prompt(
|
||||
user_prompt: String,
|
||||
language_name: Option<&str>,
|
||||
buffer: &BufferSnapshot,
|
||||
range: Range<impl ToOffset>,
|
||||
kind: CodegenKind,
|
||||
) -> String {
|
||||
let range = range.to_offset(buffer);
|
||||
let mut prompt = String::new();
|
||||
|
||||
// General Preamble
|
||||
if let Some(language_name) = language_name {
|
||||
writeln!(prompt, "You're an expert {language_name} engineer.\n").unwrap();
|
||||
buffer: BufferSnapshot,
|
||||
range: Range<usize>,
|
||||
search_results: Vec<PromptCodeSnippet>,
|
||||
model: &str,
|
||||
project_name: Option<String>,
|
||||
) -> anyhow::Result<String> {
|
||||
// Using new Prompt Templates
|
||||
let openai_model: Arc<dyn LanguageModel> = Arc::new(OpenAILanguageModel::load(model));
|
||||
let lang_name = if let Some(language_name) = language_name {
|
||||
Some(language_name.to_string())
|
||||
} else {
|
||||
writeln!(prompt, "You're an expert engineer.\n").unwrap();
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
let mut content = String::new();
|
||||
content.extend(buffer.text_for_range(0..range.start));
|
||||
if range.start == range.end {
|
||||
content.push_str("<|START|>");
|
||||
} else {
|
||||
content.push_str("<|START|");
|
||||
}
|
||||
content.extend(buffer.text_for_range(range.clone()));
|
||||
if range.start != range.end {
|
||||
content.push_str("|END|>");
|
||||
}
|
||||
content.extend(buffer.text_for_range(range.end..buffer.len()));
|
||||
let args = PromptArguments {
|
||||
model: openai_model,
|
||||
language_name: lang_name.clone(),
|
||||
project_name,
|
||||
snippets: search_results.clone(),
|
||||
reserved_tokens: 1000,
|
||||
buffer: Some(buffer),
|
||||
selected_range: Some(range),
|
||||
user_prompt: Some(user_prompt.clone()),
|
||||
};
|
||||
|
||||
writeln!(
|
||||
prompt,
|
||||
"The file you are currently working on has the following content:"
|
||||
)
|
||||
.unwrap();
|
||||
if let Some(language_name) = language_name {
|
||||
let language_name = language_name.to_lowercase();
|
||||
writeln!(prompt, "```{language_name}\n{content}\n```").unwrap();
|
||||
} else {
|
||||
writeln!(prompt, "```\n{content}\n```").unwrap();
|
||||
}
|
||||
let templates: Vec<(PromptPriority, Box<dyn PromptTemplate>)> = vec![
|
||||
(PromptPriority::Mandatory, Box::new(EngineerPreamble {})),
|
||||
(
|
||||
PromptPriority::Ordered { order: 1 },
|
||||
Box::new(RepositoryContext {}),
|
||||
),
|
||||
(
|
||||
PromptPriority::Ordered { order: 0 },
|
||||
Box::new(FileContext {}),
|
||||
),
|
||||
(
|
||||
PromptPriority::Mandatory,
|
||||
Box::new(GenerateInlineContent {}),
|
||||
),
|
||||
];
|
||||
let chain = PromptChain::new(args, templates);
|
||||
let (prompt, _) = chain.generate(true)?;
|
||||
|
||||
match kind {
|
||||
CodegenKind::Generate { position: _ } => {
|
||||
writeln!(prompt, "In particular, the user's cursor is current on the '<|START|>' span in the above outline, with no text selected.").unwrap();
|
||||
writeln!(
|
||||
prompt,
|
||||
"Assume the cursor is located where the `<|START|` marker is."
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
prompt,
|
||||
"Text can't be replaced, so assume your answer will be inserted at the cursor."
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
prompt,
|
||||
"Generate text based on the users prompt: {user_prompt}"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
CodegenKind::Transform { range: _ } => {
|
||||
writeln!(prompt, "In particular, the user has selected a section of the text between the '<|START|' and '|END|>' spans.").unwrap();
|
||||
writeln!(
|
||||
prompt,
|
||||
"Modify the users code selected text based upon the users prompt: {user_prompt}"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
prompt,
|
||||
"You MUST reply with only the adjusted code (within the '<|START|' and '|END|>' spans), not the entire file."
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(language_name) = language_name {
|
||||
writeln!(prompt, "Your answer MUST always be valid {language_name}").unwrap();
|
||||
}
|
||||
writeln!(prompt, "Always wrap your response in a Markdown codeblock").unwrap();
|
||||
writeln!(prompt, "Never make remarks about the output.").unwrap();
|
||||
|
||||
prompt
|
||||
anyhow::Ok(prompt)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+99
-8
@@ -10,7 +10,7 @@ use client::{
|
||||
ZED_ALWAYS_ACTIVE,
|
||||
};
|
||||
use collections::HashSet;
|
||||
use futures::{future::Shared, FutureExt};
|
||||
use futures::{channel::oneshot, future::Shared, Future, FutureExt};
|
||||
use gpui::{
|
||||
AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Subscription, Task,
|
||||
WeakModelHandle,
|
||||
@@ -37,10 +37,42 @@ pub struct IncomingCall {
|
||||
pub initial_project: Option<proto::ParticipantProject>,
|
||||
}
|
||||
|
||||
pub struct OneAtATime {
|
||||
cancel: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
impl OneAtATime {
|
||||
/// spawn a task in the given context.
|
||||
/// if another task is spawned before that resolves, or if the OneAtATime itself is dropped, the first task will be cancelled and return Ok(None)
|
||||
/// otherwise you'll see the result of the task.
|
||||
fn spawn<F, Fut, R>(&mut self, cx: &mut AppContext, f: F) -> Task<Result<Option<R>>>
|
||||
where
|
||||
F: 'static + FnOnce(AsyncAppContext) -> Fut,
|
||||
Fut: Future<Output = Result<R>>,
|
||||
R: 'static,
|
||||
{
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.cancel.replace(tx);
|
||||
cx.spawn(|cx| async move {
|
||||
futures::select_biased! {
|
||||
_ = rx.fuse() => Ok(None),
|
||||
result = f(cx).fuse() => result.map(Some),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn running(&self) -> bool {
|
||||
self.cancel
|
||||
.as_ref()
|
||||
.is_some_and(|cancel| !cancel.is_canceled())
|
||||
}
|
||||
}
|
||||
|
||||
/// Singleton global maintaining the user's participation in a room across workspaces.
|
||||
pub struct ActiveCall {
|
||||
room: Option<(ModelHandle<Room>, Vec<Subscription>)>,
|
||||
pending_room_creation: Option<Shared<Task<Result<ModelHandle<Room>, Arc<anyhow::Error>>>>>,
|
||||
_join_debouncer: OneAtATime,
|
||||
location: Option<WeakModelHandle<Project>>,
|
||||
pending_invites: HashSet<u64>,
|
||||
incoming_call: (
|
||||
@@ -69,6 +101,7 @@ impl ActiveCall {
|
||||
pending_invites: Default::default(),
|
||||
incoming_call: watch::channel(),
|
||||
|
||||
_join_debouncer: OneAtATime { cancel: None },
|
||||
_subscriptions: vec![
|
||||
client.add_request_handler(cx.handle(), Self::handle_incoming_call),
|
||||
client.add_message_handler(cx.handle(), Self::handle_call_canceled),
|
||||
@@ -143,6 +176,10 @@ impl ActiveCall {
|
||||
}
|
||||
cx.notify();
|
||||
|
||||
if self._join_debouncer.running() {
|
||||
return Task::ready(Ok(()));
|
||||
}
|
||||
|
||||
let room = if let Some(room) = self.room().cloned() {
|
||||
Some(Task::ready(Ok(room)).shared())
|
||||
} else {
|
||||
@@ -259,11 +296,20 @@ impl ActiveCall {
|
||||
return Task::ready(Err(anyhow!("no incoming call")));
|
||||
};
|
||||
|
||||
let join = Room::join(&call, self.client.clone(), self.user_store.clone(), cx);
|
||||
if self.pending_room_creation.is_some() {
|
||||
return Task::ready(Ok(()));
|
||||
}
|
||||
|
||||
let room_id = call.room_id.clone();
|
||||
let client = self.client.clone();
|
||||
let user_store = self.user_store.clone();
|
||||
let join = self
|
||||
._join_debouncer
|
||||
.spawn(cx, move |cx| Room::join(room_id, client, user_store, cx));
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let room = join.await?;
|
||||
this.update(&mut cx, |this, cx| this.set_room(Some(room.clone()), cx))
|
||||
this.update(&mut cx, |this, cx| this.set_room(room.clone(), cx))
|
||||
.await?;
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.report_call_event("accept incoming", cx)
|
||||
@@ -290,20 +336,28 @@ impl ActiveCall {
|
||||
&mut self,
|
||||
channel_id: u64,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<Result<ModelHandle<Room>>> {
|
||||
) -> Task<Result<Option<ModelHandle<Room>>>> {
|
||||
if let Some(room) = self.room().cloned() {
|
||||
if room.read(cx).channel_id() == Some(channel_id) {
|
||||
return Task::ready(Ok(room));
|
||||
return Task::ready(Ok(Some(room)));
|
||||
} else {
|
||||
room.update(cx, |room, cx| room.clear_state(cx));
|
||||
}
|
||||
}
|
||||
|
||||
let join = Room::join_channel(channel_id, self.client.clone(), self.user_store.clone(), cx);
|
||||
if self.pending_room_creation.is_some() {
|
||||
return Task::ready(Ok(None));
|
||||
}
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let client = self.client.clone();
|
||||
let user_store = self.user_store.clone();
|
||||
let join = self._join_debouncer.spawn(cx, move |cx| async move {
|
||||
Room::join_channel(channel_id, client, user_store, cx).await
|
||||
});
|
||||
|
||||
cx.spawn(move |this, mut cx| async move {
|
||||
let room = join.await?;
|
||||
this.update(&mut cx, |this, cx| this.set_room(Some(room.clone()), cx))
|
||||
this.update(&mut cx, |this, cx| this.set_room(room.clone(), cx))
|
||||
.await?;
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.report_call_event("join channel", cx)
|
||||
@@ -457,3 +511,40 @@ pub fn report_call_event_for_channel(
|
||||
};
|
||||
telemetry.report_clickhouse_event(event, telemetry_settings);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use gpui::TestAppContext;
|
||||
|
||||
use crate::OneAtATime;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_one_at_a_time(cx: &mut TestAppContext) {
|
||||
let mut one_at_a_time = OneAtATime { cancel: None };
|
||||
|
||||
assert_eq!(
|
||||
cx.update(|cx| one_at_a_time.spawn(cx, |_| async { Ok(1) }))
|
||||
.await
|
||||
.unwrap(),
|
||||
Some(1)
|
||||
);
|
||||
|
||||
let (a, b) = cx.update(|cx| {
|
||||
(
|
||||
one_at_a_time.spawn(cx, |_| async {
|
||||
assert!(false);
|
||||
Ok(2)
|
||||
}),
|
||||
one_at_a_time.spawn(cx, |_| async { Ok(3) }),
|
||||
)
|
||||
});
|
||||
|
||||
assert_eq!(a.await.unwrap(), None);
|
||||
assert_eq!(b.await.unwrap(), Some(3));
|
||||
|
||||
let promise = cx.update(|cx| one_at_a_time.spawn(cx, |_| async { Ok(4) }));
|
||||
drop(one_at_a_time);
|
||||
|
||||
assert_eq!(promise.await.unwrap(), None);
|
||||
}
|
||||
}
|
||||
|
||||
+39
-37
@@ -1,7 +1,6 @@
|
||||
use crate::{
|
||||
call_settings::CallSettings,
|
||||
participant::{LocalParticipant, ParticipantLocation, RemoteParticipant, RemoteVideoTrack},
|
||||
IncomingCall,
|
||||
};
|
||||
use anyhow::{anyhow, Result};
|
||||
use audio::{Audio, Sound};
|
||||
@@ -55,7 +54,7 @@ pub enum Event {
|
||||
|
||||
pub struct Room {
|
||||
id: u64,
|
||||
channel_id: Option<u64>,
|
||||
pub channel_id: Option<u64>,
|
||||
live_kit: Option<LiveKitRoom>,
|
||||
status: RoomStatus,
|
||||
shared_projects: HashSet<WeakModelHandle<Project>>,
|
||||
@@ -122,6 +121,10 @@ impl Room {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_publish(&self) -> bool {
|
||||
self.live_kit.as_ref().is_some_and(|room| room.can_publish)
|
||||
}
|
||||
|
||||
fn new(
|
||||
id: u64,
|
||||
channel_id: Option<u64>,
|
||||
@@ -181,20 +184,23 @@ impl Room {
|
||||
});
|
||||
|
||||
let connect = room.connect(&connection_info.server_url, &connection_info.token);
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
connect.await?;
|
||||
if connection_info.can_publish {
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
connect.await?;
|
||||
|
||||
if !cx.read(Self::mute_on_join) {
|
||||
this.update(&mut cx, |this, cx| this.share_microphone(cx))
|
||||
.await?;
|
||||
}
|
||||
if !cx.read(Self::mute_on_join) {
|
||||
this.update(&mut cx, |this, cx| this.share_microphone(cx))
|
||||
.await?;
|
||||
}
|
||||
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
Some(LiveKitRoom {
|
||||
room,
|
||||
can_publish: connection_info.can_publish,
|
||||
screen_track: LocalTrack::None,
|
||||
microphone_track: LocalTrack::None,
|
||||
next_publish_id: 0,
|
||||
@@ -284,37 +290,32 @@ impl Room {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn join_channel(
|
||||
pub(crate) async fn join_channel(
|
||||
channel_id: u64,
|
||||
client: Arc<Client>,
|
||||
user_store: ModelHandle<UserStore>,
|
||||
cx: &mut AppContext,
|
||||
) -> Task<Result<ModelHandle<Self>>> {
|
||||
cx.spawn(|cx| async move {
|
||||
Self::from_join_response(
|
||||
client.request(proto::JoinChannel { channel_id }).await?,
|
||||
client,
|
||||
user_store,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
cx: AsyncAppContext,
|
||||
) -> Result<ModelHandle<Self>> {
|
||||
Self::from_join_response(
|
||||
client.request(proto::JoinChannel { channel_id }).await?,
|
||||
client,
|
||||
user_store,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn join(
|
||||
call: &IncomingCall,
|
||||
pub(crate) async fn join(
|
||||
room_id: u64,
|
||||
client: Arc<Client>,
|
||||
user_store: ModelHandle<UserStore>,
|
||||
cx: &mut AppContext,
|
||||
) -> Task<Result<ModelHandle<Self>>> {
|
||||
let id = call.room_id;
|
||||
cx.spawn(|cx| async move {
|
||||
Self::from_join_response(
|
||||
client.request(proto::JoinRoom { id }).await?,
|
||||
client,
|
||||
user_store,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
cx: AsyncAppContext,
|
||||
) -> Result<ModelHandle<Self>> {
|
||||
Self::from_join_response(
|
||||
client.request(proto::JoinRoom { id: room_id }).await?,
|
||||
client,
|
||||
user_store,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn mute_on_join(cx: &AppContext) -> bool {
|
||||
@@ -1251,7 +1252,7 @@ impl Room {
|
||||
.read_with(&cx, |this, _| {
|
||||
this.live_kit
|
||||
.as_ref()
|
||||
.map(|live_kit| live_kit.room.publish_audio_track(&track))
|
||||
.map(|live_kit| live_kit.room.publish_audio_track(track))
|
||||
})
|
||||
.ok_or_else(|| anyhow!("live-kit was not initialized"))?
|
||||
.await
|
||||
@@ -1337,7 +1338,7 @@ impl Room {
|
||||
.read_with(&cx, |this, _| {
|
||||
this.live_kit
|
||||
.as_ref()
|
||||
.map(|live_kit| live_kit.room.publish_video_track(&track))
|
||||
.map(|live_kit| live_kit.room.publish_video_track(track))
|
||||
})
|
||||
.ok_or_else(|| anyhow!("live-kit was not initialized"))?
|
||||
.await
|
||||
@@ -1498,6 +1499,7 @@ struct LiveKitRoom {
|
||||
deafened: bool,
|
||||
speaking: bool,
|
||||
next_publish_id: usize,
|
||||
can_publish: bool,
|
||||
_maintain_room: Task<()>,
|
||||
_maintain_tracks: [Task<()>; 2],
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use client2::ParticipantIndex;
|
||||
use client2::{proto, User};
|
||||
use collections::HashMap;
|
||||
use gpui2::WeakHandle;
|
||||
pub use live_kit_client::Frame;
|
||||
use live_kit_client::RemoteAudioTrack;
|
||||
use project2::Project;
|
||||
use std::{fmt, sync::Arc};
|
||||
|
||||
@@ -47,8 +45,8 @@ pub struct RemoteParticipant {
|
||||
pub participant_index: ParticipantIndex,
|
||||
pub muted: bool,
|
||||
pub speaking: bool,
|
||||
pub video_tracks: HashMap<live_kit_client::Sid, Arc<RemoteVideoTrack>>,
|
||||
pub audio_tracks: HashMap<live_kit_client::Sid, Arc<RemoteAudioTrack>>,
|
||||
// pub video_tracks: HashMap<live_kit_client::Sid, Arc<RemoteVideoTrack>>,
|
||||
// pub audio_tracks: HashMap<live_kit_client::Sid, Arc<RemoteAudioTrack>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -56,6 +54,10 @@ pub struct RemoteVideoTrack {
|
||||
pub(crate) live_kit_track: Arc<live_kit_client::RemoteVideoTrack>,
|
||||
}
|
||||
|
||||
unsafe impl Send for RemoteVideoTrack {}
|
||||
// todo!("remove this sync because it's not legit")
|
||||
unsafe impl Sync for RemoteVideoTrack {}
|
||||
|
||||
impl fmt::Debug for RemoteVideoTrack {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("RemoteVideoTrack").finish()
|
||||
|
||||
+463
-446
File diff suppressed because it is too large
Load Diff
@@ -7,10 +7,11 @@ use gpui::{AppContext, ModelHandle};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use channel_buffer::{ChannelBuffer, ChannelBufferEvent, ACKNOWLEDGE_DEBOUNCE_INTERVAL};
|
||||
pub use channel_chat::{ChannelChat, ChannelChatEvent, ChannelMessage, ChannelMessageId};
|
||||
pub use channel_store::{
|
||||
Channel, ChannelData, ChannelEvent, ChannelId, ChannelMembership, ChannelPath, ChannelStore,
|
||||
pub use channel_chat::{
|
||||
mentions_to_proto, ChannelChat, ChannelChatEvent, ChannelMessage, ChannelMessageId,
|
||||
MessageParams,
|
||||
};
|
||||
pub use channel_store::{Channel, ChannelEvent, ChannelId, ChannelMembership, ChannelStore};
|
||||
|
||||
#[cfg(test)]
|
||||
mod channel_store_tests;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::Channel;
|
||||
use crate::{Channel, ChannelId, ChannelStore};
|
||||
use anyhow::Result;
|
||||
use client::{Client, Collaborator, UserStore};
|
||||
use collections::HashMap;
|
||||
@@ -19,10 +19,11 @@ pub(crate) fn init(client: &Arc<Client>) {
|
||||
}
|
||||
|
||||
pub struct ChannelBuffer {
|
||||
pub(crate) channel: Arc<Channel>,
|
||||
pub channel_id: ChannelId,
|
||||
connected: bool,
|
||||
collaborators: HashMap<PeerId, Collaborator>,
|
||||
user_store: ModelHandle<UserStore>,
|
||||
channel_store: ModelHandle<ChannelStore>,
|
||||
buffer: ModelHandle<language::Buffer>,
|
||||
buffer_epoch: u64,
|
||||
client: Arc<Client>,
|
||||
@@ -34,6 +35,7 @@ pub enum ChannelBufferEvent {
|
||||
CollaboratorsChanged,
|
||||
Disconnected,
|
||||
BufferEdited,
|
||||
ChannelChanged,
|
||||
}
|
||||
|
||||
impl Entity for ChannelBuffer {
|
||||
@@ -46,7 +48,7 @@ impl Entity for ChannelBuffer {
|
||||
}
|
||||
self.client
|
||||
.send(proto::LeaveChannelBuffer {
|
||||
channel_id: self.channel.id,
|
||||
channel_id: self.channel_id,
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
@@ -58,6 +60,7 @@ impl ChannelBuffer {
|
||||
channel: Arc<Channel>,
|
||||
client: Arc<Client>,
|
||||
user_store: ModelHandle<UserStore>,
|
||||
channel_store: ModelHandle<ChannelStore>,
|
||||
mut cx: AsyncAppContext,
|
||||
) -> Result<ModelHandle<Self>> {
|
||||
let response = client
|
||||
@@ -90,9 +93,10 @@ impl ChannelBuffer {
|
||||
connected: true,
|
||||
collaborators: Default::default(),
|
||||
acknowledge_task: None,
|
||||
channel,
|
||||
channel_id: channel.id,
|
||||
subscription: Some(subscription.set_model(&cx.handle(), &mut cx.to_async())),
|
||||
user_store,
|
||||
channel_store,
|
||||
};
|
||||
this.replace_collaborators(response.collaborators, cx);
|
||||
this
|
||||
@@ -179,7 +183,7 @@ impl ChannelBuffer {
|
||||
let operation = language::proto::serialize_operation(operation);
|
||||
self.client
|
||||
.send(proto::UpdateChannelBuffer {
|
||||
channel_id: self.channel.id,
|
||||
channel_id: self.channel_id,
|
||||
operations: vec![operation],
|
||||
})
|
||||
.log_err();
|
||||
@@ -223,12 +227,15 @@ impl ChannelBuffer {
|
||||
&self.collaborators
|
||||
}
|
||||
|
||||
pub fn channel(&self) -> Arc<Channel> {
|
||||
self.channel.clone()
|
||||
pub fn channel(&self, cx: &AppContext) -> Option<Arc<Channel>> {
|
||||
self.channel_store
|
||||
.read(cx)
|
||||
.channel_for_id(self.channel_id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn disconnect(&mut self, cx: &mut ModelContext<Self>) {
|
||||
log::info!("channel buffer {} disconnected", self.channel.id);
|
||||
log::info!("channel buffer {} disconnected", self.channel_id);
|
||||
if self.connected {
|
||||
self.connected = false;
|
||||
self.subscription.take();
|
||||
@@ -237,6 +244,11 @@ impl ChannelBuffer {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn channel_changed(&mut self, cx: &mut ModelContext<Self>) {
|
||||
cx.emit(ChannelBufferEvent::ChannelChanged);
|
||||
cx.notify()
|
||||
}
|
||||
|
||||
pub fn is_connected(&self) -> bool {
|
||||
self.connected
|
||||
}
|
||||
|
||||
@@ -3,19 +3,25 @@ use anyhow::{anyhow, Result};
|
||||
use client::{
|
||||
proto,
|
||||
user::{User, UserStore},
|
||||
Client, Subscription, TypedEnvelope,
|
||||
Client, Subscription, TypedEnvelope, UserId,
|
||||
};
|
||||
use futures::lock::Mutex;
|
||||
use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Task};
|
||||
use rand::prelude::*;
|
||||
use std::{collections::HashSet, mem, ops::Range, sync::Arc};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
mem,
|
||||
ops::{ControlFlow, Range},
|
||||
sync::Arc,
|
||||
};
|
||||
use sum_tree::{Bias, SumTree};
|
||||
use time::OffsetDateTime;
|
||||
use util::{post_inc, ResultExt as _, TryFutureExt};
|
||||
|
||||
pub struct ChannelChat {
|
||||
channel: Arc<Channel>,
|
||||
pub channel_id: ChannelId,
|
||||
messages: SumTree<ChannelMessage>,
|
||||
acknowledged_message_ids: HashSet<u64>,
|
||||
channel_store: ModelHandle<ChannelStore>,
|
||||
loaded_all_messages: bool,
|
||||
last_acknowledged_id: Option<u64>,
|
||||
@@ -27,6 +33,12 @@ pub struct ChannelChat {
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct MessageParams {
|
||||
pub text: String,
|
||||
pub mentions: Vec<(Range<usize>, UserId)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ChannelMessage {
|
||||
pub id: ChannelMessageId,
|
||||
@@ -34,6 +46,7 @@ pub struct ChannelMessage {
|
||||
pub timestamp: OffsetDateTime,
|
||||
pub sender: Arc<User>,
|
||||
pub nonce: u128,
|
||||
pub mentions: Vec<(Range<usize>, UserId)>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
@@ -74,7 +87,7 @@ impl Entity for ChannelChat {
|
||||
fn release(&mut self, _: &mut AppContext) {
|
||||
self.rpc
|
||||
.send(proto::LeaveChannelChat {
|
||||
channel_id: self.channel.id,
|
||||
channel_id: self.channel_id,
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
@@ -99,12 +112,13 @@ impl ChannelChat {
|
||||
|
||||
Ok(cx.add_model(|cx| {
|
||||
let mut this = Self {
|
||||
channel,
|
||||
channel_id: channel.id,
|
||||
user_store,
|
||||
channel_store,
|
||||
rpc: client,
|
||||
outgoing_messages_lock: Default::default(),
|
||||
messages: Default::default(),
|
||||
acknowledged_message_ids: Default::default(),
|
||||
loaded_all_messages,
|
||||
next_pending_message_id: 0,
|
||||
last_acknowledged_id: None,
|
||||
@@ -116,16 +130,23 @@ impl ChannelChat {
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn channel(&self) -> &Arc<Channel> {
|
||||
&self.channel
|
||||
pub fn channel(&self, cx: &AppContext) -> 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,
|
||||
body: String,
|
||||
message: MessageParams,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Result<Task<Result<()>>> {
|
||||
if body.is_empty() {
|
||||
) -> Result<Task<Result<u64>>> {
|
||||
if message.text.is_empty() {
|
||||
Err(anyhow!("message body can't be empty"))?;
|
||||
}
|
||||
|
||||
@@ -135,16 +156,17 @@ impl ChannelChat {
|
||||
.current_user()
|
||||
.ok_or_else(|| anyhow!("current_user is not present"))?;
|
||||
|
||||
let channel_id = self.channel.id;
|
||||
let channel_id = self.channel_id;
|
||||
let pending_id = ChannelMessageId::Pending(post_inc(&mut self.next_pending_message_id));
|
||||
let nonce = self.rng.gen();
|
||||
self.insert_messages(
|
||||
SumTree::from_item(
|
||||
ChannelMessage {
|
||||
id: pending_id,
|
||||
body: body.clone(),
|
||||
body: message.text.clone(),
|
||||
sender: current_user,
|
||||
timestamp: OffsetDateTime::now_utc(),
|
||||
mentions: message.mentions.clone(),
|
||||
nonce,
|
||||
},
|
||||
&(),
|
||||
@@ -158,27 +180,25 @@ impl ChannelChat {
|
||||
let outgoing_message_guard = outgoing_messages_lock.lock().await;
|
||||
let request = rpc.request(proto::SendChannelMessage {
|
||||
channel_id,
|
||||
body,
|
||||
body: message.text,
|
||||
nonce: Some(nonce.into()),
|
||||
mentions: mentions_to_proto(&message.mentions),
|
||||
});
|
||||
let response = request.await?;
|
||||
drop(outgoing_message_guard);
|
||||
let message = ChannelMessage::from_proto(
|
||||
response.message.ok_or_else(|| anyhow!("invalid message"))?,
|
||||
&user_store,
|
||||
&mut cx,
|
||||
)
|
||||
.await?;
|
||||
let response = response.message.ok_or_else(|| anyhow!("invalid message"))?;
|
||||
let id = response.id;
|
||||
let message = ChannelMessage::from_proto(response, &user_store, &mut cx).await?;
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.insert_messages(SumTree::from_item(message, &()), cx);
|
||||
Ok(())
|
||||
Ok(id)
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn remove_message(&mut self, id: u64, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
|
||||
let response = self.rpc.request(proto::RemoveChannelMessage {
|
||||
channel_id: self.channel.id,
|
||||
channel_id: self.channel_id,
|
||||
message_id: id,
|
||||
});
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
@@ -191,41 +211,76 @@ impl ChannelChat {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_more_messages(&mut self, cx: &mut ModelContext<Self>) -> bool {
|
||||
if !self.loaded_all_messages {
|
||||
let rpc = self.rpc.clone();
|
||||
let user_store = self.user_store.clone();
|
||||
let channel_id = self.channel.id;
|
||||
if let Some(before_message_id) =
|
||||
self.messages.first().and_then(|message| match message.id {
|
||||
ChannelMessageId::Saved(id) => Some(id),
|
||||
ChannelMessageId::Pending(_) => None,
|
||||
})
|
||||
{
|
||||
cx.spawn(|this, mut cx| {
|
||||
async move {
|
||||
let response = rpc
|
||||
.request(proto::GetChannelMessages {
|
||||
channel_id,
|
||||
before_message_id,
|
||||
})
|
||||
.await?;
|
||||
let loaded_all_messages = response.done;
|
||||
let messages =
|
||||
messages_from_proto(response.messages, &user_store, &mut cx).await?;
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.loaded_all_messages = loaded_all_messages;
|
||||
this.insert_messages(messages, cx);
|
||||
});
|
||||
anyhow::Ok(())
|
||||
pub fn load_more_messages(&mut self, cx: &mut ModelContext<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(|this, mut cx| {
|
||||
async move {
|
||||
let response = rpc
|
||||
.request(proto::GetChannelMessages {
|
||||
channel_id,
|
||||
before_message_id,
|
||||
})
|
||||
.await?;
|
||||
let loaded_all_messages = response.done;
|
||||
let messages = messages_from_proto(response.messages, &user_store, &mut cx).await?;
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.loaded_all_messages = loaded_all_messages;
|
||||
this.insert_messages(messages, cx);
|
||||
});
|
||||
anyhow::Ok(())
|
||||
}
|
||||
.log_err()
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn first_loaded_message_id(&mut self) -> Option<u64> {
|
||||
self.messages.first().and_then(|message| match message.id {
|
||||
ChannelMessageId::Saved(id) => Some(id),
|
||||
ChannelMessageId::Pending(_) => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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: ModelHandle<Self>,
|
||||
message_id: u64,
|
||||
mut cx: AsyncAppContext,
|
||||
) -> Option<usize> {
|
||||
loop {
|
||||
let step = chat.update(&mut cx, |chat, cx| {
|
||||
if let Some(first_id) = chat.first_loaded_message_id() {
|
||||
if first_id <= message_id {
|
||||
let mut cursor = chat.messages.cursor::<(ChannelMessageId, Count)>();
|
||||
let message_id = ChannelMessageId::Saved(message_id);
|
||||
cursor.seek(&message_id, Bias::Left, &());
|
||||
return ControlFlow::Break(
|
||||
if cursor
|
||||
.item()
|
||||
.map_or(false, |message| message.id == message_id)
|
||||
{
|
||||
Some(cursor.start().1 .0)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
);
|
||||
}
|
||||
.log_err()
|
||||
})
|
||||
.detach();
|
||||
return true;
|
||||
}
|
||||
ControlFlow::Continue(chat.load_more_messages(cx))
|
||||
});
|
||||
match step {
|
||||
ControlFlow::Break(ix) => return ix,
|
||||
ControlFlow::Continue(task) => task?.await?,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn acknowledge_last_message(&mut self, cx: &mut ModelContext<Self>) {
|
||||
@@ -236,13 +291,13 @@ impl ChannelChat {
|
||||
{
|
||||
self.rpc
|
||||
.send(proto::AckChannelMessage {
|
||||
channel_id: self.channel.id,
|
||||
channel_id: self.channel_id,
|
||||
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);
|
||||
store.acknowledge_message_id(self.channel_id, latest_message_id, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -251,7 +306,7 @@ impl ChannelChat {
|
||||
pub fn rejoin(&mut self, cx: &mut ModelContext<Self>) {
|
||||
let user_store = self.user_store.clone();
|
||||
let rpc = self.rpc.clone();
|
||||
let channel_id = self.channel.id;
|
||||
let channel_id = self.channel_id;
|
||||
cx.spawn(|this, mut cx| {
|
||||
async move {
|
||||
let response = rpc.request(proto::JoinChannelChat { channel_id }).await?;
|
||||
@@ -284,6 +339,7 @@ impl ChannelChat {
|
||||
let request = rpc.request(proto::SendChannelMessage {
|
||||
channel_id,
|
||||
body: pending_message.body,
|
||||
mentions: mentions_to_proto(&pending_message.mentions),
|
||||
nonce: Some(pending_message.nonce.into()),
|
||||
});
|
||||
let response = request.await?;
|
||||
@@ -319,6 +375,17 @@ impl ChannelChat {
|
||||
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,
|
||||
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, &());
|
||||
@@ -348,7 +415,7 @@ impl ChannelChat {
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.insert_messages(SumTree::from_item(message, &()), cx);
|
||||
cx.emit(ChannelChatEvent::NewMessage {
|
||||
channel_id: this.channel.id,
|
||||
channel_id: this.channel_id,
|
||||
message_id,
|
||||
})
|
||||
});
|
||||
@@ -451,22 +518,7 @@ async fn messages_from_proto(
|
||||
user_store: &ModelHandle<UserStore>,
|
||||
cx: &mut AsyncAppContext,
|
||||
) -> Result<SumTree<ChannelMessage>> {
|
||||
let unique_user_ids = proto_messages
|
||||
.iter()
|
||||
.map(|m| m.sender_id)
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
user_store
|
||||
.update(cx, |user_store, cx| {
|
||||
user_store.get_users(unique_user_ids, cx)
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut messages = Vec::with_capacity(proto_messages.len());
|
||||
for message in proto_messages {
|
||||
messages.push(ChannelMessage::from_proto(message, user_store, cx).await?);
|
||||
}
|
||||
let messages = ChannelMessage::from_proto_vec(proto_messages, user_store, cx).await?;
|
||||
let mut result = SumTree::new();
|
||||
result.extend(messages, &());
|
||||
Ok(result)
|
||||
@@ -486,6 +538,14 @@ impl ChannelMessage {
|
||||
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
|
||||
@@ -498,6 +558,43 @@ impl ChannelMessage {
|
||||
pub fn is_pending(&self) -> bool {
|
||||
matches!(self.id, ChannelMessageId::Pending(_))
|
||||
}
|
||||
|
||||
pub async fn from_proto_vec(
|
||||
proto_messages: Vec<proto::ChannelMessage>,
|
||||
user_store: &ModelHandle<UserStore>,
|
||||
cx: &mut AsyncAppContext,
|
||||
) -> 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 as u64,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl sum_tree::Item for ChannelMessage {
|
||||
@@ -538,3 +635,12 @@ impl<'a> sum_tree::Dimension<'a, ChannelMessageSummary> for Count {
|
||||
self.0 += summary.count;
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a str> for MessageParams {
|
||||
fn from(value: &'a str) -> Self {
|
||||
Self {
|
||||
text: value.into(),
|
||||
mentions: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+156
-184
@@ -1,6 +1,6 @@
|
||||
mod channel_index;
|
||||
|
||||
use crate::{channel_buffer::ChannelBuffer, channel_chat::ChannelChat};
|
||||
use crate::{channel_buffer::ChannelBuffer, channel_chat::ChannelChat, ChannelMessage};
|
||||
use anyhow::{anyhow, Result};
|
||||
use channel_index::ChannelIndex;
|
||||
use client::{Client, Subscription, User, UserId, UserStore};
|
||||
@@ -9,11 +9,10 @@ use db::RELEASE_CHANNEL;
|
||||
use futures::{channel::mpsc, future::Shared, Future, FutureExt, StreamExt};
|
||||
use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Task, WeakModelHandle};
|
||||
use rpc::{
|
||||
proto::{self, ChannelEdge, ChannelPermission},
|
||||
proto::{self, ChannelVisibility},
|
||||
TypedEnvelope,
|
||||
};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::{borrow::Cow, hash::Hash, mem, ops::Deref, sync::Arc, time::Duration};
|
||||
use std::{mem, sync::Arc, time::Duration};
|
||||
use util::ResultExt;
|
||||
|
||||
pub fn init(client: &Arc<Client>, user_store: ModelHandle<UserStore>, cx: &mut AppContext) {
|
||||
@@ -27,10 +26,9 @@ pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
pub type ChannelId = u64;
|
||||
|
||||
pub struct ChannelStore {
|
||||
channel_index: ChannelIndex,
|
||||
pub channel_index: ChannelIndex,
|
||||
channel_invitations: Vec<Arc<Channel>>,
|
||||
channel_participants: HashMap<ChannelId, Vec<Arc<User>>>,
|
||||
channels_with_admin_privileges: HashSet<ChannelId>,
|
||||
outgoing_invites: HashSet<(ChannelId, UserId)>,
|
||||
update_channels_tx: mpsc::UnboundedSender<proto::UpdateChannels>,
|
||||
opened_buffers: HashMap<ChannelId, OpenedModelHandle<ChannelBuffer>>,
|
||||
@@ -43,14 +41,15 @@ pub struct ChannelStore {
|
||||
_update_channels: Task<()>,
|
||||
}
|
||||
|
||||
pub type ChannelData = (Channel, ChannelPath);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Channel {
|
||||
pub id: ChannelId,
|
||||
pub name: String,
|
||||
pub visibility: proto::ChannelVisibility,
|
||||
pub role: proto::ChannelRole,
|
||||
pub unseen_note_version: Option<(u64, clock::Global)>,
|
||||
pub unseen_message_id: Option<u64>,
|
||||
pub parent_path: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
@@ -71,15 +70,41 @@ impl Channel {
|
||||
|
||||
slug.trim_matches(|c| c == '-').to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize)]
|
||||
pub struct ChannelPath(Arc<[ChannelId]>);
|
||||
pub fn can_edit_notes(&self) -> bool {
|
||||
self.role == proto::ChannelRole::Member || self.role == proto::ChannelRole::Admin
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ChannelMembership {
|
||||
pub user: Arc<User>,
|
||||
pub kind: proto::channel_member::Kind,
|
||||
pub admin: bool,
|
||||
pub role: proto::ChannelRole,
|
||||
}
|
||||
impl ChannelMembership {
|
||||
pub fn sort_key(&self) -> MembershipSortKey {
|
||||
MembershipSortKey {
|
||||
role_order: match self.role {
|
||||
proto::ChannelRole::Admin => 0,
|
||||
proto::ChannelRole::Member => 1,
|
||||
proto::ChannelRole::Banned => 2,
|
||||
proto::ChannelRole::Guest => 3,
|
||||
},
|
||||
kind_order: match self.kind {
|
||||
proto::channel_member::Kind::Member => 0,
|
||||
proto::channel_member::Kind::AncestorMember => 1,
|
||||
proto::channel_member::Kind::Invitee => 2,
|
||||
},
|
||||
username_order: self.user.github_login.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialOrd, Ord, PartialEq, Eq)]
|
||||
pub struct MembershipSortKey<'a> {
|
||||
role_order: u8,
|
||||
kind_order: u8,
|
||||
username_order: &'a str,
|
||||
}
|
||||
|
||||
pub enum ChannelEvent {
|
||||
@@ -127,9 +152,6 @@ impl ChannelStore {
|
||||
this.update(&mut cx, |this, cx| this.handle_disconnect(true, cx));
|
||||
}
|
||||
}
|
||||
if status.is_connected() {
|
||||
} else {
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
});
|
||||
@@ -138,7 +160,6 @@ impl ChannelStore {
|
||||
channel_invitations: Vec::default(),
|
||||
channel_index: ChannelIndex::default(),
|
||||
channel_participants: Default::default(),
|
||||
channels_with_admin_privileges: Default::default(),
|
||||
outgoing_invites: Default::default(),
|
||||
opened_buffers: Default::default(),
|
||||
opened_chats: Default::default(),
|
||||
@@ -167,16 +188,6 @@ impl ChannelStore {
|
||||
self.client.clone()
|
||||
}
|
||||
|
||||
pub fn has_children(&self, channel_id: ChannelId) -> bool {
|
||||
self.channel_index.iter().any(|path| {
|
||||
if let Some(ix) = path.iter().position(|id| *id == channel_id) {
|
||||
path.len() > ix + 1
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the number of unique channels in the store
|
||||
pub fn channel_count(&self) -> usize {
|
||||
self.channel_index.by_id().len()
|
||||
@@ -196,26 +207,31 @@ impl ChannelStore {
|
||||
}
|
||||
|
||||
/// Iterate over all entries in the channel DAG
|
||||
pub fn channel_dag_entries(&self) -> impl '_ + Iterator<Item = (usize, &Arc<Channel>)> {
|
||||
self.channel_index.iter().map(move |path| {
|
||||
let id = path.last().unwrap();
|
||||
let channel = self.channel_for_id(*id).unwrap();
|
||||
(path.len() - 1, channel)
|
||||
})
|
||||
pub fn ordered_channels(&self) -> impl '_ + Iterator<Item = (usize, &Arc<Channel>)> {
|
||||
self.channel_index
|
||||
.ordered_channels()
|
||||
.iter()
|
||||
.filter_map(move |id| {
|
||||
let channel = self.channel_index.by_id().get(id)?;
|
||||
Some((channel.parent_path.len(), channel))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn channel_dag_entry_at(&self, ix: usize) -> Option<(&Arc<Channel>, &ChannelPath)> {
|
||||
let path = self.channel_index.get(ix)?;
|
||||
let id = path.last().unwrap();
|
||||
let channel = self.channel_for_id(*id).unwrap();
|
||||
|
||||
Some((channel, path))
|
||||
pub fn channel_at_index(&self, ix: usize) -> Option<&Arc<Channel>> {
|
||||
let channel_id = self.channel_index.ordered_channels().get(ix)?;
|
||||
self.channel_index.by_id().get(channel_id)
|
||||
}
|
||||
|
||||
pub fn channel_at(&self, ix: usize) -> Option<&Arc<Channel>> {
|
||||
self.channel_index.by_id().values().nth(ix)
|
||||
}
|
||||
|
||||
pub fn has_channel_invitation(&self, channel_id: ChannelId) -> bool {
|
||||
self.channel_invitations
|
||||
.iter()
|
||||
.any(|channel| channel.id == channel_id)
|
||||
}
|
||||
|
||||
pub fn channel_invitations(&self) -> &[Arc<Channel>] {
|
||||
&self.channel_invitations
|
||||
}
|
||||
@@ -240,14 +256,42 @@ impl ChannelStore {
|
||||
) -> Task<Result<ModelHandle<ChannelBuffer>>> {
|
||||
let client = self.client.clone();
|
||||
let user_store = self.user_store.clone();
|
||||
let channel_store = cx.handle();
|
||||
self.open_channel_resource(
|
||||
channel_id,
|
||||
|this| &mut this.opened_buffers,
|
||||
|channel, cx| ChannelBuffer::new(channel, client, user_store, cx),
|
||||
|channel, cx| ChannelBuffer::new(channel, client, user_store, channel_store, cx),
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn fetch_channel_messages(
|
||||
&self,
|
||||
message_ids: Vec<u64>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<Result<Vec<ChannelMessage>>> {
|
||||
let request = if message_ids.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
self.client
|
||||
.request(proto::GetChannelMessagesById { message_ids }),
|
||||
)
|
||||
};
|
||||
cx.spawn_weak(|this, mut cx| async move {
|
||||
if let Some(request) = request {
|
||||
let response = request.await?;
|
||||
let this = this
|
||||
.upgrade(&cx)
|
||||
.ok_or_else(|| anyhow!("channel store dropped"))?;
|
||||
let user_store = this.read_with(&cx, |this, _| this.user_store.clone());
|
||||
ChannelMessage::from_proto_vec(response.messages, &user_store, &mut cx).await
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn has_channel_buffer_changed(&self, channel_id: ChannelId) -> Option<bool> {
|
||||
self.channel_index
|
||||
.by_id()
|
||||
@@ -393,16 +437,11 @@ impl ChannelStore {
|
||||
.spawn(async move { task.await.map_err(|error| anyhow!("{}", error)) })
|
||||
}
|
||||
|
||||
pub fn is_user_admin(&self, channel_id: ChannelId) -> bool {
|
||||
self.channel_index.iter().any(|path| {
|
||||
if let Some(ix) = path.iter().position(|id| *id == channel_id) {
|
||||
path[..=ix]
|
||||
.iter()
|
||||
.any(|id| self.channels_with_admin_privileges.contains(id))
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
pub fn is_channel_admin(&self, channel_id: ChannelId) -> bool {
|
||||
let Some(channel) = self.channel_for_id(channel_id) else {
|
||||
return false;
|
||||
};
|
||||
channel.role == proto::ChannelRole::Admin
|
||||
}
|
||||
|
||||
pub fn channel_participants(&self, channel_id: ChannelId) -> &[Arc<User>] {
|
||||
@@ -429,24 +468,19 @@ impl ChannelStore {
|
||||
.ok_or_else(|| anyhow!("missing channel in response"))?;
|
||||
let channel_id = channel.id;
|
||||
|
||||
let parent_edge = if let Some(parent_id) = parent_id {
|
||||
vec![ChannelEdge {
|
||||
channel_id: channel.id,
|
||||
parent_id,
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
// let parent_edge = if let Some(parent_id) = parent_id {
|
||||
// vec![ChannelEdge {
|
||||
// channel_id: channel.id,
|
||||
// parent_id,
|
||||
// }]
|
||||
// } else {
|
||||
// vec![]
|
||||
// };
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
let task = this.update_channels(
|
||||
proto::UpdateChannels {
|
||||
channels: vec![channel],
|
||||
insert_edge: parent_edge,
|
||||
channel_permissions: vec![ChannelPermission {
|
||||
channel_id,
|
||||
is_admin: true,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
@@ -464,52 +498,34 @@ impl ChannelStore {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn link_channel(
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
to: ChannelId,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
let client = self.client.clone();
|
||||
cx.spawn(|_, _| async move {
|
||||
let _ = client
|
||||
.request(proto::LinkChannel { channel_id, to })
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn unlink_channel(
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
from: ChannelId,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
let client = self.client.clone();
|
||||
cx.spawn(|_, _| async move {
|
||||
let _ = client
|
||||
.request(proto::UnlinkChannel { channel_id, from })
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn move_channel(
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
from: ChannelId,
|
||||
to: ChannelId,
|
||||
to: Option<ChannelId>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
let client = self.client.clone();
|
||||
cx.spawn(|_, _| async move {
|
||||
let _ = client
|
||||
.request(proto::MoveChannel {
|
||||
.request(proto::MoveChannel { channel_id, to })
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_channel_visibility(
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
visibility: ChannelVisibility,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
let client = self.client.clone();
|
||||
cx.spawn(|_, _| async move {
|
||||
let _ = client
|
||||
.request(proto::SetChannelVisibility {
|
||||
channel_id,
|
||||
from,
|
||||
to,
|
||||
visibility: visibility.into(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -521,7 +537,7 @@ impl ChannelStore {
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
user_id: UserId,
|
||||
admin: bool,
|
||||
role: proto::ChannelRole,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
if !self.outgoing_invites.insert((channel_id, user_id)) {
|
||||
@@ -535,7 +551,7 @@ impl ChannelStore {
|
||||
.request(proto::InviteChannelMember {
|
||||
channel_id,
|
||||
user_id,
|
||||
admin,
|
||||
role: role.into(),
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -579,11 +595,11 @@ impl ChannelStore {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_member_admin(
|
||||
pub fn set_member_role(
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
user_id: UserId,
|
||||
admin: bool,
|
||||
role: proto::ChannelRole,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
if !self.outgoing_invites.insert((channel_id, user_id)) {
|
||||
@@ -594,10 +610,10 @@ impl ChannelStore {
|
||||
let client = self.client.clone();
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let result = client
|
||||
.request(proto::SetChannelMemberAdmin {
|
||||
.request(proto::SetChannelMemberRole {
|
||||
channel_id,
|
||||
user_id,
|
||||
admin,
|
||||
role: role.into(),
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -649,14 +665,15 @@ impl ChannelStore {
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
accept: bool,
|
||||
) -> impl Future<Output = Result<()>> {
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
let client = self.client.clone();
|
||||
async move {
|
||||
cx.background().spawn(async move {
|
||||
client
|
||||
.request(proto::RespondToChannelInvite { channel_id, accept })
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_channel_member_details(
|
||||
@@ -685,8 +702,8 @@ impl ChannelStore {
|
||||
.filter_map(|(user, member)| {
|
||||
Some(ChannelMembership {
|
||||
user,
|
||||
admin: member.admin,
|
||||
kind: proto::channel_member::Kind::from_i32(member.kind)?,
|
||||
role: member.role(),
|
||||
kind: member.kind(),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
@@ -724,6 +741,11 @@ impl ChannelStore {
|
||||
}
|
||||
|
||||
fn handle_connect(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
|
||||
self.channel_index.clear();
|
||||
self.channel_invitations.clear();
|
||||
self.channel_participants.clear();
|
||||
self.channel_index.clear();
|
||||
self.outgoing_invites.clear();
|
||||
self.disconnect_channel_buffers_task.take();
|
||||
|
||||
for chat in self.opened_chats.values() {
|
||||
@@ -743,7 +765,7 @@ impl ChannelStore {
|
||||
let channel_buffer = buffer.read(cx);
|
||||
let buffer = channel_buffer.buffer().read(cx);
|
||||
buffer_versions.push(proto::ChannelBufferVersion {
|
||||
channel_id: channel_buffer.channel().id,
|
||||
channel_id: channel_buffer.channel_id,
|
||||
epoch: channel_buffer.epoch(),
|
||||
version: language::proto::serialize_version(&buffer.version()),
|
||||
});
|
||||
@@ -770,13 +792,13 @@ impl ChannelStore {
|
||||
};
|
||||
|
||||
channel_buffer.update(cx, |channel_buffer, cx| {
|
||||
let channel_id = channel_buffer.channel().id;
|
||||
let channel_id = channel_buffer.channel_id;
|
||||
if let Some(remote_buffer) = response
|
||||
.buffers
|
||||
.iter_mut()
|
||||
.find(|buffer| buffer.channel_id == channel_id)
|
||||
{
|
||||
let channel_id = channel_buffer.channel().id;
|
||||
let channel_id = channel_buffer.channel_id;
|
||||
let remote_version =
|
||||
language::proto::deserialize_version(&remote_buffer.version);
|
||||
|
||||
@@ -833,12 +855,6 @@ impl ChannelStore {
|
||||
}
|
||||
|
||||
fn handle_disconnect(&mut self, wait_for_reconnect: bool, cx: &mut ModelContext<Self>) {
|
||||
self.channel_index.clear();
|
||||
self.channel_invitations.clear();
|
||||
self.channel_participants.clear();
|
||||
self.channels_with_admin_privileges.clear();
|
||||
self.channel_index.clear();
|
||||
self.outgoing_invites.clear();
|
||||
cx.notify();
|
||||
|
||||
self.disconnect_channel_buffers_task.get_or_insert_with(|| {
|
||||
@@ -881,9 +897,12 @@ impl ChannelStore {
|
||||
ix,
|
||||
Arc::new(Channel {
|
||||
id: channel.id,
|
||||
visibility: channel.visibility(),
|
||||
role: channel.role(),
|
||||
name: channel.name,
|
||||
unseen_note_version: None,
|
||||
unseen_message_id: None,
|
||||
parent_path: channel.parent_path,
|
||||
}),
|
||||
),
|
||||
}
|
||||
@@ -891,8 +910,6 @@ impl ChannelStore {
|
||||
|
||||
let channels_changed = !payload.channels.is_empty()
|
||||
|| !payload.delete_channels.is_empty()
|
||||
|| !payload.insert_edge.is_empty()
|
||||
|| !payload.delete_edge.is_empty()
|
||||
|| !payload.unseen_channel_messages.is_empty()
|
||||
|| !payload.unseen_channel_buffer_changes.is_empty();
|
||||
|
||||
@@ -900,12 +917,17 @@ impl ChannelStore {
|
||||
if !payload.delete_channels.is_empty() {
|
||||
self.channel_index.delete_channels(&payload.delete_channels);
|
||||
self.channel_participants
|
||||
.retain(|channel_id, _| !payload.delete_channels.contains(channel_id));
|
||||
self.channels_with_admin_privileges
|
||||
.retain(|channel_id| !payload.delete_channels.contains(channel_id));
|
||||
.retain(|channel_id, _| !&payload.delete_channels.contains(channel_id));
|
||||
|
||||
for channel_id in &payload.delete_channels {
|
||||
let channel_id = *channel_id;
|
||||
if payload
|
||||
.channels
|
||||
.iter()
|
||||
.any(|channel| channel.id == channel_id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(OpenedModelHandle::Open(buffer)) =
|
||||
self.opened_buffers.remove(&channel_id)
|
||||
{
|
||||
@@ -918,7 +940,16 @@ impl ChannelStore {
|
||||
|
||||
let mut index = self.channel_index.bulk_insert();
|
||||
for channel in payload.channels {
|
||||
index.insert(channel)
|
||||
let id = channel.id;
|
||||
let channel_changed = index.insert(channel);
|
||||
|
||||
if channel_changed {
|
||||
if let Some(OpenedModelHandle::Open(buffer)) = self.opened_buffers.get(&id) {
|
||||
if let Some(buffer) = buffer.upgrade(cx) {
|
||||
buffer.update(cx, ChannelBuffer::channel_changed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for unseen_buffer_change in payload.unseen_channel_buffer_changes {
|
||||
@@ -936,24 +967,6 @@ impl ChannelStore {
|
||||
unseen_channel_message.message_id,
|
||||
);
|
||||
}
|
||||
|
||||
for edge in payload.insert_edge {
|
||||
index.insert_edge(edge.channel_id, edge.parent_id);
|
||||
}
|
||||
|
||||
for edge in payload.delete_edge {
|
||||
index.delete_edge(edge.parent_id, edge.channel_id);
|
||||
}
|
||||
}
|
||||
|
||||
for permission in payload.channel_permissions {
|
||||
if permission.is_admin {
|
||||
self.channels_with_admin_privileges
|
||||
.insert(permission.channel_id);
|
||||
} else {
|
||||
self.channels_with_admin_privileges
|
||||
.remove(&permission.channel_id);
|
||||
}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
@@ -1002,44 +1015,3 @@ impl ChannelStore {
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for ChannelPath {
|
||||
type Target = [ChannelId];
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl ChannelPath {
|
||||
pub fn new(path: Arc<[ChannelId]>) -> Self {
|
||||
debug_assert!(path.len() >= 1);
|
||||
Self(path)
|
||||
}
|
||||
|
||||
pub fn parent_id(&self) -> Option<ChannelId> {
|
||||
self.0.len().checked_sub(2).map(|i| self.0[i])
|
||||
}
|
||||
|
||||
pub fn channel_id(&self) -> ChannelId {
|
||||
self.0[self.0.len() - 1]
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ChannelPath> for Cow<'static, ChannelPath> {
|
||||
fn from(value: ChannelPath) -> Self {
|
||||
Cow::Owned(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a ChannelPath> for Cow<'a, ChannelPath> {
|
||||
fn from(value: &'a ChannelPath) -> Self {
|
||||
Cow::Borrowed(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ChannelPath {
|
||||
fn default() -> Self {
|
||||
ChannelPath(Arc::from([]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
use std::{ops::Deref, sync::Arc};
|
||||
|
||||
use crate::{Channel, ChannelId};
|
||||
use collections::BTreeMap;
|
||||
use rpc::proto;
|
||||
|
||||
use super::ChannelPath;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct ChannelIndex {
|
||||
paths: Vec<ChannelPath>,
|
||||
channels_ordered: Vec<ChannelId>,
|
||||
channels_by_id: BTreeMap<ChannelId, Arc<Channel>>,
|
||||
}
|
||||
|
||||
@@ -17,8 +14,12 @@ impl ChannelIndex {
|
||||
&self.channels_by_id
|
||||
}
|
||||
|
||||
pub fn ordered_channels(&self) -> &[ChannelId] {
|
||||
&self.channels_ordered
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.paths.clear();
|
||||
self.channels_ordered.clear();
|
||||
self.channels_by_id.clear();
|
||||
}
|
||||
|
||||
@@ -26,15 +27,13 @@ impl ChannelIndex {
|
||||
pub fn delete_channels(&mut self, channels: &[ChannelId]) {
|
||||
self.channels_by_id
|
||||
.retain(|channel_id, _| !channels.contains(channel_id));
|
||||
self.paths.retain(|path| {
|
||||
path.iter()
|
||||
.all(|channel_id| self.channels_by_id.contains_key(channel_id))
|
||||
});
|
||||
self.channels_ordered
|
||||
.retain(|channel_id| !channels.contains(channel_id));
|
||||
}
|
||||
|
||||
pub fn bulk_insert(&mut self) -> ChannelPathsInsertGuard {
|
||||
ChannelPathsInsertGuard {
|
||||
paths: &mut self.paths,
|
||||
channels_ordered: &mut self.channels_ordered,
|
||||
channels_by_id: &mut self.channels_by_id,
|
||||
}
|
||||
}
|
||||
@@ -77,42 +76,15 @@ impl ChannelIndex {
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for ChannelIndex {
|
||||
type Target = [ChannelPath];
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.paths
|
||||
}
|
||||
}
|
||||
|
||||
/// A guard for ensuring that the paths index maintains its sort and uniqueness
|
||||
/// invariants after a series of insertions
|
||||
#[derive(Debug)]
|
||||
pub struct ChannelPathsInsertGuard<'a> {
|
||||
paths: &'a mut Vec<ChannelPath>,
|
||||
channels_ordered: &'a mut Vec<ChannelId>,
|
||||
channels_by_id: &'a mut BTreeMap<ChannelId, Arc<Channel>>,
|
||||
}
|
||||
|
||||
impl<'a> ChannelPathsInsertGuard<'a> {
|
||||
/// Remove the given edge from this index. This will not remove the channel.
|
||||
/// If this operation would result in a dangling edge, re-insert it.
|
||||
pub fn delete_edge(&mut self, parent_id: ChannelId, channel_id: ChannelId) {
|
||||
self.paths.retain(|path| {
|
||||
!path
|
||||
.windows(2)
|
||||
.any(|window| window == [parent_id, channel_id])
|
||||
});
|
||||
|
||||
// Ensure that there is at least one channel path in the index
|
||||
if !self
|
||||
.paths
|
||||
.iter()
|
||||
.any(|path| path.iter().any(|id| id == &channel_id))
|
||||
{
|
||||
self.insert_root(channel_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn note_changed(&mut self, channel_id: ChannelId, epoch: u64, version: &clock::Global) {
|
||||
insert_note_changed(&mut self.channels_by_id, channel_id, epoch, &version);
|
||||
}
|
||||
@@ -121,91 +93,65 @@ impl<'a> ChannelPathsInsertGuard<'a> {
|
||||
insert_new_message(&mut self.channels_by_id, channel_id, message_id)
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, channel_proto: proto::Channel) {
|
||||
pub fn insert(&mut self, channel_proto: proto::Channel) -> bool {
|
||||
let mut ret = false;
|
||||
if let Some(existing_channel) = self.channels_by_id.get_mut(&channel_proto.id) {
|
||||
Arc::make_mut(existing_channel).name = channel_proto.name;
|
||||
let existing_channel = Arc::make_mut(existing_channel);
|
||||
|
||||
ret = existing_channel.visibility != channel_proto.visibility()
|
||||
|| existing_channel.role != channel_proto.role()
|
||||
|| existing_channel.name != channel_proto.name;
|
||||
|
||||
existing_channel.visibility = channel_proto.visibility();
|
||||
existing_channel.role = channel_proto.role();
|
||||
existing_channel.name = channel_proto.name;
|
||||
} else {
|
||||
self.channels_by_id.insert(
|
||||
channel_proto.id,
|
||||
Arc::new(Channel {
|
||||
id: channel_proto.id,
|
||||
visibility: channel_proto.visibility(),
|
||||
role: channel_proto.role(),
|
||||
name: channel_proto.name,
|
||||
unseen_note_version: None,
|
||||
unseen_message_id: None,
|
||||
parent_path: channel_proto.parent_path,
|
||||
}),
|
||||
);
|
||||
self.insert_root(channel_proto.id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert_edge(&mut self, channel_id: ChannelId, parent_id: ChannelId) {
|
||||
let mut parents = Vec::new();
|
||||
let mut descendants = Vec::new();
|
||||
let mut ixs_to_remove = Vec::new();
|
||||
|
||||
for (ix, path) in self.paths.iter().enumerate() {
|
||||
if path
|
||||
.windows(2)
|
||||
.any(|window| window[0] == parent_id && window[1] == channel_id)
|
||||
{
|
||||
// We already have this edge in the index
|
||||
return;
|
||||
}
|
||||
if path.ends_with(&[parent_id]) {
|
||||
parents.push(path);
|
||||
} else if let Some(position) = path.iter().position(|id| id == &channel_id) {
|
||||
if position == 0 {
|
||||
ixs_to_remove.push(ix);
|
||||
}
|
||||
descendants.push(path.split_at(position).1);
|
||||
}
|
||||
}
|
||||
|
||||
let mut new_paths = Vec::new();
|
||||
for parent in parents.iter() {
|
||||
if descendants.is_empty() {
|
||||
let mut new_path = Vec::with_capacity(parent.len() + 1);
|
||||
new_path.extend_from_slice(parent);
|
||||
new_path.push(channel_id);
|
||||
new_paths.push(ChannelPath::new(new_path.into()));
|
||||
} else {
|
||||
for descendant in descendants.iter() {
|
||||
let mut new_path = Vec::with_capacity(parent.len() + descendant.len());
|
||||
new_path.extend_from_slice(parent);
|
||||
new_path.extend_from_slice(descendant);
|
||||
new_paths.push(ChannelPath::new(new_path.into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for ix in ixs_to_remove.into_iter().rev() {
|
||||
self.paths.swap_remove(ix);
|
||||
}
|
||||
self.paths.extend(new_paths)
|
||||
ret
|
||||
}
|
||||
|
||||
fn insert_root(&mut self, channel_id: ChannelId) {
|
||||
self.paths.push(ChannelPath::new(Arc::from([channel_id])));
|
||||
self.channels_ordered.push(channel_id);
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Drop for ChannelPathsInsertGuard<'a> {
|
||||
fn drop(&mut self) {
|
||||
self.paths.sort_by(|a, b| {
|
||||
let a = channel_path_sorting_key(a, &self.channels_by_id);
|
||||
let b = channel_path_sorting_key(b, &self.channels_by_id);
|
||||
self.channels_ordered.sort_by(|a, b| {
|
||||
let a = channel_path_sorting_key(*a, &self.channels_by_id);
|
||||
let b = channel_path_sorting_key(*b, &self.channels_by_id);
|
||||
a.cmp(b)
|
||||
});
|
||||
self.paths.dedup();
|
||||
self.channels_ordered.dedup();
|
||||
}
|
||||
}
|
||||
|
||||
fn channel_path_sorting_key<'a>(
|
||||
path: &'a [ChannelId],
|
||||
id: ChannelId,
|
||||
channels_by_id: &'a BTreeMap<ChannelId, Arc<Channel>>,
|
||||
) -> impl 'a + Iterator<Item = Option<&'a str>> {
|
||||
path.iter()
|
||||
.map(|id| Some(channels_by_id.get(id)?.name.as_str()))
|
||||
) -> impl Iterator<Item = &str> {
|
||||
let (parent_path, name) = channels_by_id
|
||||
.get(&id)
|
||||
.map_or((&[] as &[_], None), |channel| {
|
||||
(channel.parent_path.as_slice(), Some(channel.name.as_str()))
|
||||
});
|
||||
parent_path
|
||||
.iter()
|
||||
.filter_map(|id| Some(channels_by_id.get(id)?.name.as_str()))
|
||||
.chain(name)
|
||||
}
|
||||
|
||||
fn insert_note_changed(
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::channel_chat::ChannelChatEvent;
|
||||
use super::*;
|
||||
use client::{test::FakeServer, Client, UserStore};
|
||||
use gpui::{AppContext, ModelHandle, TestAppContext};
|
||||
use rpc::proto;
|
||||
use rpc::proto::{self};
|
||||
use settings::SettingsStore;
|
||||
use util::http::FakeHttpClient;
|
||||
|
||||
@@ -18,16 +18,18 @@ fn test_update_channels(cx: &mut AppContext) {
|
||||
proto::Channel {
|
||||
id: 1,
|
||||
name: "b".to_string(),
|
||||
visibility: proto::ChannelVisibility::Members as i32,
|
||||
role: proto::ChannelRole::Admin.into(),
|
||||
parent_path: Vec::new(),
|
||||
},
|
||||
proto::Channel {
|
||||
id: 2,
|
||||
name: "a".to_string(),
|
||||
visibility: proto::ChannelVisibility::Members as i32,
|
||||
role: proto::ChannelRole::Member.into(),
|
||||
parent_path: Vec::new(),
|
||||
},
|
||||
],
|
||||
channel_permissions: vec![proto::ChannelPermission {
|
||||
channel_id: 1,
|
||||
is_admin: true,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
@@ -36,8 +38,8 @@ fn test_update_channels(cx: &mut AppContext) {
|
||||
&channel_store,
|
||||
&[
|
||||
//
|
||||
(0, "a".to_string(), false),
|
||||
(0, "b".to_string(), true),
|
||||
(0, "a".to_string(), proto::ChannelRole::Member),
|
||||
(0, "b".to_string(), proto::ChannelRole::Admin),
|
||||
],
|
||||
cx,
|
||||
);
|
||||
@@ -49,20 +51,16 @@ fn test_update_channels(cx: &mut AppContext) {
|
||||
proto::Channel {
|
||||
id: 3,
|
||||
name: "x".to_string(),
|
||||
visibility: proto::ChannelVisibility::Members as i32,
|
||||
role: proto::ChannelRole::Admin.into(),
|
||||
parent_path: vec![1],
|
||||
},
|
||||
proto::Channel {
|
||||
id: 4,
|
||||
name: "y".to_string(),
|
||||
},
|
||||
],
|
||||
insert_edge: vec![
|
||||
proto::ChannelEdge {
|
||||
parent_id: 1,
|
||||
channel_id: 3,
|
||||
},
|
||||
proto::ChannelEdge {
|
||||
parent_id: 2,
|
||||
channel_id: 4,
|
||||
visibility: proto::ChannelVisibility::Members as i32,
|
||||
role: proto::ChannelRole::Member.into(),
|
||||
parent_path: vec![2],
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
@@ -72,10 +70,10 @@ fn test_update_channels(cx: &mut AppContext) {
|
||||
assert_channels(
|
||||
&channel_store,
|
||||
&[
|
||||
(0, "a".to_string(), false),
|
||||
(1, "y".to_string(), false),
|
||||
(0, "b".to_string(), true),
|
||||
(1, "x".to_string(), true),
|
||||
(0, "a".to_string(), proto::ChannelRole::Member),
|
||||
(1, "y".to_string(), proto::ChannelRole::Member),
|
||||
(0, "b".to_string(), proto::ChannelRole::Admin),
|
||||
(1, "x".to_string(), proto::ChannelRole::Admin),
|
||||
],
|
||||
cx,
|
||||
);
|
||||
@@ -92,30 +90,25 @@ fn test_dangling_channel_paths(cx: &mut AppContext) {
|
||||
proto::Channel {
|
||||
id: 0,
|
||||
name: "a".to_string(),
|
||||
visibility: proto::ChannelVisibility::Members as i32,
|
||||
role: proto::ChannelRole::Admin.into(),
|
||||
parent_path: vec![],
|
||||
},
|
||||
proto::Channel {
|
||||
id: 1,
|
||||
name: "b".to_string(),
|
||||
visibility: proto::ChannelVisibility::Members as i32,
|
||||
role: proto::ChannelRole::Admin.into(),
|
||||
parent_path: vec![0],
|
||||
},
|
||||
proto::Channel {
|
||||
id: 2,
|
||||
name: "c".to_string(),
|
||||
visibility: proto::ChannelVisibility::Members as i32,
|
||||
role: proto::ChannelRole::Admin.into(),
|
||||
parent_path: vec![0, 1],
|
||||
},
|
||||
],
|
||||
insert_edge: vec![
|
||||
proto::ChannelEdge {
|
||||
parent_id: 0,
|
||||
channel_id: 1,
|
||||
},
|
||||
proto::ChannelEdge {
|
||||
parent_id: 1,
|
||||
channel_id: 2,
|
||||
},
|
||||
],
|
||||
channel_permissions: vec![proto::ChannelPermission {
|
||||
channel_id: 0,
|
||||
is_admin: true,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
@@ -125,9 +118,9 @@ fn test_dangling_channel_paths(cx: &mut AppContext) {
|
||||
&channel_store,
|
||||
&[
|
||||
//
|
||||
(0, "a".to_string(), true),
|
||||
(1, "b".to_string(), true),
|
||||
(2, "c".to_string(), true),
|
||||
(0, "a".to_string(), proto::ChannelRole::Admin),
|
||||
(1, "b".to_string(), proto::ChannelRole::Admin),
|
||||
(2, "c".to_string(), proto::ChannelRole::Admin),
|
||||
],
|
||||
cx,
|
||||
);
|
||||
@@ -142,7 +135,11 @@ fn test_dangling_channel_paths(cx: &mut AppContext) {
|
||||
);
|
||||
|
||||
// Make sure that the 1/2/3 path is gone
|
||||
assert_channels(&channel_store, &[(0, "a".to_string(), true)], cx);
|
||||
assert_channels(
|
||||
&channel_store,
|
||||
&[(0, "a".to_string(), proto::ChannelRole::Admin)],
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
@@ -158,12 +155,19 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
|
||||
channels: vec![proto::Channel {
|
||||
id: channel_id,
|
||||
name: "the-channel".to_string(),
|
||||
visibility: proto::ChannelVisibility::Members as i32,
|
||||
role: proto::ChannelRole::Member.into(),
|
||||
parent_path: vec![],
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
cx.foreground().run_until_parked();
|
||||
cx.read(|cx| {
|
||||
assert_channels(&channel_store, &[(0, "the-channel".to_string(), false)], cx);
|
||||
assert_channels(
|
||||
&channel_store,
|
||||
&[(0, "the-channel".to_string(), proto::ChannelRole::Member)],
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
let get_users = server.receive::<proto::GetUsers>().await.unwrap();
|
||||
@@ -181,7 +185,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
|
||||
|
||||
// Join a channel and populate its existing messages.
|
||||
let channel = channel_store.update(cx, |store, cx| {
|
||||
let channel_id = store.channel_dag_entries().next().unwrap().1.id;
|
||||
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();
|
||||
@@ -194,6 +198,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
|
||||
body: "a".into(),
|
||||
timestamp: 1000,
|
||||
sender_id: 5,
|
||||
mentions: vec![],
|
||||
nonce: Some(1.into()),
|
||||
},
|
||||
proto::ChannelMessage {
|
||||
@@ -201,6 +206,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
|
||||
body: "b".into(),
|
||||
timestamp: 1001,
|
||||
sender_id: 6,
|
||||
mentions: vec![],
|
||||
nonce: Some(2.into()),
|
||||
},
|
||||
],
|
||||
@@ -247,6 +253,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
|
||||
body: "c".into(),
|
||||
timestamp: 1002,
|
||||
sender_id: 7,
|
||||
mentions: vec![],
|
||||
nonce: Some(3.into()),
|
||||
}),
|
||||
});
|
||||
@@ -284,7 +291,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
|
||||
|
||||
// Scroll up to view older messages.
|
||||
channel.update(cx, |channel, cx| {
|
||||
assert!(channel.load_more_messages(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);
|
||||
@@ -300,6 +307,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
|
||||
timestamp: 998,
|
||||
sender_id: 5,
|
||||
nonce: Some(4.into()),
|
||||
mentions: vec![],
|
||||
},
|
||||
proto::ChannelMessage {
|
||||
id: 9,
|
||||
@@ -307,6 +315,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
|
||||
timestamp: 999,
|
||||
sender_id: 6,
|
||||
nonce: Some(5.into()),
|
||||
mentions: vec![],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -358,19 +367,13 @@ fn update_channels(
|
||||
#[track_caller]
|
||||
fn assert_channels(
|
||||
channel_store: &ModelHandle<ChannelStore>,
|
||||
expected_channels: &[(usize, String, bool)],
|
||||
expected_channels: &[(usize, String, proto::ChannelRole)],
|
||||
cx: &AppContext,
|
||||
) {
|
||||
let actual = channel_store.read_with(cx, |store, _| {
|
||||
store
|
||||
.channel_dag_entries()
|
||||
.map(|(depth, channel)| {
|
||||
(
|
||||
depth,
|
||||
channel.name.to_string(),
|
||||
store.is_user_admin(channel.id),
|
||||
)
|
||||
})
|
||||
.ordered_channels()
|
||||
.map(|(depth, channel)| (depth, channel.name.to_string(), channel.role))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
assert_eq!(actual, expected_channels);
|
||||
|
||||
@@ -4,7 +4,9 @@ use lazy_static::lazy_static;
|
||||
use parking_lot::Mutex;
|
||||
use serde::Serialize;
|
||||
use std::{env, io::Write, mem, path::PathBuf, sync::Arc, time::Duration};
|
||||
use sysinfo::{Pid, PidExt, ProcessExt, System, SystemExt};
|
||||
use sysinfo::{
|
||||
CpuRefreshKind, Pid, PidExt, ProcessExt, ProcessRefreshKind, RefreshKind, System, SystemExt,
|
||||
};
|
||||
use tempfile::NamedTempFile;
|
||||
use util::http::HttpClient;
|
||||
use util::{channel::ReleaseChannel, TryFutureExt};
|
||||
@@ -166,8 +168,16 @@ impl Telemetry {
|
||||
|
||||
let this = self.clone();
|
||||
cx.spawn(|mut cx| async move {
|
||||
let mut system = System::new_all();
|
||||
system.refresh_all();
|
||||
// Avoiding calling `System::new_all()`, as there have been crashes related to it
|
||||
let refresh_kind = RefreshKind::new()
|
||||
.with_memory() // For memory usage
|
||||
.with_processes(ProcessRefreshKind::everything()) // For process usage
|
||||
.with_cpu(CpuRefreshKind::everything()); // For core count
|
||||
|
||||
let mut system = System::new_with_specifics(refresh_kind);
|
||||
|
||||
// Avoiding calling `refresh_all()`, just update what we need
|
||||
system.refresh_specifics(refresh_kind);
|
||||
|
||||
loop {
|
||||
// Waiting some amount of time before the first query is important to get a reasonable value
|
||||
@@ -175,8 +185,7 @@ impl Telemetry {
|
||||
const DURATION_BETWEEN_SYSTEM_EVENTS: Duration = Duration::from_secs(60);
|
||||
smol::Timer::after(DURATION_BETWEEN_SYSTEM_EVENTS).await;
|
||||
|
||||
system.refresh_memory();
|
||||
system.refresh_processes();
|
||||
system.refresh_specifics(refresh_kind);
|
||||
|
||||
let current_process = Pid::from_u32(std::process::id());
|
||||
let Some(process) = system.processes().get(¤t_process) else {
|
||||
|
||||
+16
-25
@@ -293,21 +293,19 @@ impl UserStore {
|
||||
// No need to paralellize here
|
||||
let mut updated_contacts = Vec::new();
|
||||
for contact in message.contacts {
|
||||
let should_notify = contact.should_notify;
|
||||
updated_contacts.push((
|
||||
Arc::new(Contact::from_proto(contact, &this, &mut cx).await?),
|
||||
should_notify,
|
||||
updated_contacts.push(Arc::new(
|
||||
Contact::from_proto(contact, &this, &mut cx).await?,
|
||||
));
|
||||
}
|
||||
|
||||
let mut incoming_requests = Vec::new();
|
||||
for request in message.incoming_requests {
|
||||
incoming_requests.push({
|
||||
let user = this
|
||||
.update(&mut cx, |this, cx| this.get_user(request.requester_id, cx))
|
||||
.await?;
|
||||
(user, request.should_notify)
|
||||
});
|
||||
incoming_requests.push(
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.get_user(request.requester_id, cx)
|
||||
})
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
let mut outgoing_requests = Vec::new();
|
||||
@@ -330,13 +328,7 @@ impl UserStore {
|
||||
this.contacts
|
||||
.retain(|contact| !removed_contacts.contains(&contact.user.id));
|
||||
// Update existing contacts and insert new ones
|
||||
for (updated_contact, should_notify) in updated_contacts {
|
||||
if should_notify {
|
||||
cx.emit(Event::Contact {
|
||||
user: updated_contact.user.clone(),
|
||||
kind: ContactEventKind::Accepted,
|
||||
});
|
||||
}
|
||||
for updated_contact in updated_contacts {
|
||||
match this.contacts.binary_search_by_key(
|
||||
&&updated_contact.user.github_login,
|
||||
|contact| &contact.user.github_login,
|
||||
@@ -359,14 +351,7 @@ impl UserStore {
|
||||
}
|
||||
});
|
||||
// Update existing incoming requests and insert new ones
|
||||
for (user, should_notify) in incoming_requests {
|
||||
if should_notify {
|
||||
cx.emit(Event::Contact {
|
||||
user: user.clone(),
|
||||
kind: ContactEventKind::Requested,
|
||||
});
|
||||
}
|
||||
|
||||
for user in incoming_requests {
|
||||
match this
|
||||
.incoming_contact_requests
|
||||
.binary_search_by_key(&&user.github_login, |contact| {
|
||||
@@ -415,6 +400,12 @@ impl UserStore {
|
||||
&self.incoming_contact_requests
|
||||
}
|
||||
|
||||
pub fn has_incoming_contact_request(&self, user_id: u64) -> bool {
|
||||
self.incoming_contact_requests
|
||||
.iter()
|
||||
.any(|user| user.id == user_id)
|
||||
}
|
||||
|
||||
pub fn outgoing_contact_requests(&self) -> &[Arc<User>] {
|
||||
&self.outgoing_contact_requests
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ pub struct PendingEntitySubscription<T: 'static> {
|
||||
|
||||
impl<T> PendingEntitySubscription<T>
|
||||
where
|
||||
T: 'static + Send + Sync,
|
||||
T: 'static + Send,
|
||||
{
|
||||
pub fn set_model(mut self, model: &Handle<T>, cx: &mut AsyncAppContext) -> Subscription {
|
||||
self.consumed = true;
|
||||
@@ -529,7 +529,7 @@ impl Client {
|
||||
remote_id: u64,
|
||||
) -> Result<PendingEntitySubscription<T>>
|
||||
where
|
||||
T: 'static + Send + Sync,
|
||||
T: 'static + Send,
|
||||
{
|
||||
let id = (TypeId::of::<T>(), remote_id);
|
||||
|
||||
@@ -557,7 +557,7 @@ impl Client {
|
||||
) -> Subscription
|
||||
where
|
||||
M: EnvelopedMessage,
|
||||
E: 'static + Send + Sync,
|
||||
E: 'static + Send,
|
||||
H: 'static + Send + Sync + Fn(Handle<E>, TypedEnvelope<M>, Arc<Self>, AsyncAppContext) -> F,
|
||||
F: 'static + Future<Output = Result<()>> + Send,
|
||||
{
|
||||
@@ -599,7 +599,7 @@ impl Client {
|
||||
) -> Subscription
|
||||
where
|
||||
M: RequestMessage,
|
||||
E: 'static + Send + Sync,
|
||||
E: 'static + Send,
|
||||
H: 'static + Send + Sync + Fn(Handle<E>, TypedEnvelope<M>, Arc<Self>, AsyncAppContext) -> F,
|
||||
F: 'static + Future<Output = Result<M::Response>> + Send,
|
||||
{
|
||||
@@ -615,7 +615,7 @@ impl Client {
|
||||
pub fn add_model_message_handler<M, E, H, F>(self: &Arc<Self>, handler: H)
|
||||
where
|
||||
M: EntityMessage,
|
||||
E: 'static + Send + Sync,
|
||||
E: 'static + Send,
|
||||
H: 'static + Send + Sync + Fn(Handle<E>, TypedEnvelope<M>, Arc<Self>, AsyncAppContext) -> F,
|
||||
F: 'static + Future<Output = Result<()>> + Send,
|
||||
{
|
||||
@@ -627,7 +627,7 @@ impl Client {
|
||||
fn add_entity_message_handler<M, E, H, F>(self: &Arc<Self>, handler: H)
|
||||
where
|
||||
M: EntityMessage,
|
||||
E: 'static + Send + Sync,
|
||||
E: 'static + Send,
|
||||
H: 'static + Send + Sync + Fn(AnyHandle, TypedEnvelope<M>, Arc<Self>, AsyncAppContext) -> F,
|
||||
F: 'static + Future<Output = Result<()>> + Send,
|
||||
{
|
||||
@@ -666,7 +666,7 @@ impl Client {
|
||||
pub fn add_model_request_handler<M, E, H, F>(self: &Arc<Self>, handler: H)
|
||||
where
|
||||
M: EntityMessage + RequestMessage,
|
||||
E: 'static + Send + Sync,
|
||||
E: 'static + Send,
|
||||
H: 'static + Send + Sync + Fn(Handle<E>, TypedEnvelope<M>, Arc<Self>, AsyncAppContext) -> F,
|
||||
F: 'static + Future<Output = Result<M::Response>> + Send,
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathan@zed.dev>"]
|
||||
default-run = "collab"
|
||||
edition = "2021"
|
||||
name = "collab"
|
||||
version = "0.24.0"
|
||||
version = "0.27.0"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
@@ -73,6 +73,7 @@ git = { path = "../git", features = ["test-support"] }
|
||||
live_kit_client = { path = "../live_kit_client", features = ["test-support"] }
|
||||
lsp = { path = "../lsp", features = ["test-support"] }
|
||||
node_runtime = { path = "../node_runtime" }
|
||||
notifications = { path = "../notifications", features = ["test-support"] }
|
||||
project = { path = "../project", features = ["test-support"] }
|
||||
rpc = { path = "../rpc", features = ["test-support"] }
|
||||
settings = { path = "../settings", features = ["test-support"] }
|
||||
|
||||
@@ -44,7 +44,7 @@ CREATE UNIQUE INDEX "index_rooms_on_channel_id" ON "rooms" ("channel_id");
|
||||
|
||||
CREATE TABLE "projects" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"room_id" INTEGER REFERENCES rooms (id) NOT NULL,
|
||||
"room_id" INTEGER REFERENCES rooms (id) ON DELETE CASCADE NOT NULL,
|
||||
"host_user_id" INTEGER REFERENCES users (id) NOT NULL,
|
||||
"host_connection_id" INTEGER,
|
||||
"host_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE CASCADE,
|
||||
@@ -192,9 +192,13 @@ CREATE INDEX "index_followers_on_room_id" ON "followers" ("room_id");
|
||||
CREATE TABLE "channels" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"name" VARCHAR NOT NULL,
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT now
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"visibility" VARCHAR NOT NULL,
|
||||
"parent_path" TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX "index_channels_on_parent_path" ON "channels" ("parent_path");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "channel_chat_participants" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"user_id" INTEGER NOT NULL REFERENCES users (id),
|
||||
@@ -213,19 +217,22 @@ CREATE TABLE IF NOT EXISTS "channel_messages" (
|
||||
"nonce" BLOB NOT NULL
|
||||
);
|
||||
CREATE INDEX "index_channel_messages_on_channel_id" ON "channel_messages" ("channel_id");
|
||||
CREATE UNIQUE INDEX "index_channel_messages_on_nonce" ON "channel_messages" ("nonce");
|
||||
CREATE UNIQUE INDEX "index_channel_messages_on_sender_id_nonce" ON "channel_messages" ("sender_id", "nonce");
|
||||
|
||||
CREATE TABLE "channel_paths" (
|
||||
"id_path" TEXT NOT NULL PRIMARY KEY,
|
||||
"channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE
|
||||
CREATE TABLE "channel_message_mentions" (
|
||||
"message_id" INTEGER NOT NULL REFERENCES channel_messages (id) ON DELETE CASCADE,
|
||||
"start_offset" INTEGER NOT NULL,
|
||||
"end_offset" INTEGER NOT NULL,
|
||||
"user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
PRIMARY KEY(message_id, start_offset)
|
||||
);
|
||||
CREATE INDEX "index_channel_paths_on_channel_id" ON "channel_paths" ("channel_id");
|
||||
|
||||
CREATE TABLE "channel_members" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
|
||||
"user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
"admin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"role" VARCHAR,
|
||||
"accepted" BOOLEAN NOT NULL DEFAULT false,
|
||||
"updated_at" TIMESTAMP NOT NULL DEFAULT now
|
||||
);
|
||||
@@ -312,3 +319,26 @@ CREATE TABLE IF NOT EXISTS "observed_channel_messages" (
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "index_observed_channel_messages_user_and_channel_id" ON "observed_channel_messages" ("user_id", "channel_id");
|
||||
|
||||
CREATE TABLE "notification_kinds" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"name" VARCHAR NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "index_notification_kinds_on_name" ON "notification_kinds" ("name");
|
||||
|
||||
CREATE TABLE "notifications" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"created_at" TIMESTAMP NOT NULL default CURRENT_TIMESTAMP,
|
||||
"recipient_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
"kind" INTEGER NOT NULL REFERENCES notification_kinds (id),
|
||||
"entity_id" INTEGER,
|
||||
"content" TEXT,
|
||||
"is_read" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
"response" BOOLEAN
|
||||
);
|
||||
|
||||
CREATE INDEX
|
||||
"index_notifications_on_recipient_id_is_read_kind_entity_id"
|
||||
ON "notifications"
|
||||
("recipient_id", "is_read", "kind", "entity_id");
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE "notification_kinds" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"name" VARCHAR NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "index_notification_kinds_on_name" ON "notification_kinds" ("name");
|
||||
|
||||
CREATE TABLE notifications (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"recipient_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
"kind" INTEGER NOT NULL REFERENCES notification_kinds (id),
|
||||
"entity_id" INTEGER,
|
||||
"content" TEXT,
|
||||
"is_read" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
"response" BOOLEAN
|
||||
);
|
||||
|
||||
CREATE INDEX
|
||||
"index_notifications_on_recipient_id_is_read_kind_entity_id"
|
||||
ON "notifications"
|
||||
("recipient_id", "is_read", "kind", "entity_id");
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE channel_members ADD COLUMN role TEXT;
|
||||
UPDATE channel_members SET role = CASE WHEN admin THEN 'admin' ELSE 'member' END;
|
||||
|
||||
ALTER TABLE channels ADD COLUMN visibility TEXT NOT NULL DEFAULT 'members';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Add migration script here
|
||||
|
||||
ALTER TABLE projects
|
||||
DROP CONSTRAINT projects_room_id_fkey,
|
||||
ADD CONSTRAINT projects_room_id_fkey
|
||||
FOREIGN KEY (room_id)
|
||||
REFERENCES rooms (id)
|
||||
ON DELETE CASCADE;
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "channel_message_mentions" (
|
||||
"message_id" INTEGER NOT NULL REFERENCES channel_messages (id) ON DELETE CASCADE,
|
||||
"start_offset" INTEGER NOT NULL,
|
||||
"end_offset" INTEGER NOT NULL,
|
||||
"user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
PRIMARY KEY(message_id, start_offset)
|
||||
);
|
||||
|
||||
-- We use 'on conflict update' with this index, so it should be per-user.
|
||||
CREATE UNIQUE INDEX "index_channel_messages_on_sender_id_nonce" ON "channel_messages" ("sender_id", "nonce");
|
||||
DROP INDEX "index_channel_messages_on_nonce";
|
||||
@@ -0,0 +1,12 @@
|
||||
ALTER TABLE channels ADD COLUMN parent_path TEXT;
|
||||
|
||||
UPDATE channels
|
||||
SET parent_path = substr(
|
||||
channel_paths.id_path,
|
||||
2,
|
||||
length(channel_paths.id_path) - length('/' || channel_paths.channel_id::text || '/')
|
||||
)
|
||||
FROM channel_paths
|
||||
WHERE channel_paths.channel_id = channels.id;
|
||||
|
||||
CREATE INDEX "index_channels_on_parent_path" ON "channels" ("parent_path");
|
||||
@@ -71,7 +71,6 @@ async fn main() {
|
||||
db::NewUserParams {
|
||||
github_login: github_user.login,
|
||||
github_user_id: github_user.id,
|
||||
invite_count: 5,
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
+123
-19
@@ -20,7 +20,7 @@ use rpc::{
|
||||
};
|
||||
use sea_orm::{
|
||||
entity::prelude::*,
|
||||
sea_query::{Alias, Expr, OnConflict, Query},
|
||||
sea_query::{Alias, Expr, OnConflict},
|
||||
ActiveValue, Condition, ConnectionTrait, DatabaseConnection, DatabaseTransaction, DbErr,
|
||||
FromQueryResult, IntoActiveModel, IsolationLevel, JoinType, QueryOrder, QuerySelect, Statement,
|
||||
TransactionTrait,
|
||||
@@ -47,14 +47,14 @@ pub use ids::*;
|
||||
pub use sea_orm::ConnectOptions;
|
||||
pub use tables::user::Model as User;
|
||||
|
||||
use self::queries::channels::ChannelGraph;
|
||||
|
||||
pub struct Database {
|
||||
options: ConnectOptions,
|
||||
pool: DatabaseConnection,
|
||||
rooms: DashMap<RoomId, Arc<Mutex<()>>>,
|
||||
rng: Mutex<StdRng>,
|
||||
executor: Executor,
|
||||
notification_kinds_by_id: HashMap<NotificationKindId, &'static str>,
|
||||
notification_kinds_by_name: HashMap<String, NotificationKindId>,
|
||||
#[cfg(test)]
|
||||
runtime: Option<tokio::runtime::Runtime>,
|
||||
}
|
||||
@@ -69,6 +69,8 @@ impl Database {
|
||||
pool: sea_orm::Database::connect(options).await?,
|
||||
rooms: DashMap::with_capacity(16384),
|
||||
rng: Mutex::new(StdRng::seed_from_u64(0)),
|
||||
notification_kinds_by_id: HashMap::default(),
|
||||
notification_kinds_by_name: HashMap::default(),
|
||||
executor,
|
||||
#[cfg(test)]
|
||||
runtime: None,
|
||||
@@ -121,6 +123,11 @@ impl Database {
|
||||
Ok(new_migrations)
|
||||
}
|
||||
|
||||
pub async fn initialize_static_data(&mut self) -> Result<()> {
|
||||
self.initialize_notification_kinds().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn transaction<F, Fut, T>(&self, f: F) -> Result<T>
|
||||
where
|
||||
F: Send + Fn(TransactionHandle) -> Fut,
|
||||
@@ -361,18 +368,9 @@ impl<T> RoomGuard<T> {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Contact {
|
||||
Accepted {
|
||||
user_id: UserId,
|
||||
should_notify: bool,
|
||||
busy: bool,
|
||||
},
|
||||
Outgoing {
|
||||
user_id: UserId,
|
||||
},
|
||||
Incoming {
|
||||
user_id: UserId,
|
||||
should_notify: bool,
|
||||
},
|
||||
Accepted { user_id: UserId, busy: bool },
|
||||
Outgoing { user_id: UserId },
|
||||
Incoming { user_id: UserId },
|
||||
}
|
||||
|
||||
impl Contact {
|
||||
@@ -385,6 +383,15 @@ impl Contact {
|
||||
}
|
||||
}
|
||||
|
||||
pub type NotificationBatch = Vec<(UserId, proto::Notification)>;
|
||||
|
||||
pub struct CreatedChannelMessage {
|
||||
pub message_id: MessageId,
|
||||
pub participant_connection_ids: Vec<ConnectionId>,
|
||||
pub channel_members: Vec<UserId>,
|
||||
pub notifications: NotificationBatch,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, FromQueryResult, Serialize, Deserialize)]
|
||||
pub struct Invite {
|
||||
pub email_address: String,
|
||||
@@ -417,7 +424,6 @@ pub struct WaitlistSummary {
|
||||
pub struct NewUserParams {
|
||||
pub github_login: String,
|
||||
pub github_user_id: i32,
|
||||
pub invite_count: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -428,17 +434,115 @@ pub struct NewUserResult {
|
||||
pub signup_device_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(FromQueryResult, Debug, PartialEq, Eq, Hash)]
|
||||
#[derive(Debug)]
|
||||
pub struct MoveChannelResult {
|
||||
pub participants_to_update: HashMap<UserId, ChannelsForUser>,
|
||||
pub participants_to_remove: HashSet<UserId>,
|
||||
pub moved_channels: HashSet<ChannelId>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RenameChannelResult {
|
||||
pub channel: Channel,
|
||||
pub participants_to_update: HashMap<UserId, Channel>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CreateChannelResult {
|
||||
pub channel: Channel,
|
||||
pub participants_to_update: Vec<(UserId, ChannelsForUser)>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SetChannelVisibilityResult {
|
||||
pub participants_to_update: HashMap<UserId, ChannelsForUser>,
|
||||
pub participants_to_remove: HashSet<UserId>,
|
||||
pub channels_to_remove: Vec<ChannelId>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MembershipUpdated {
|
||||
pub channel_id: ChannelId,
|
||||
pub new_channels: ChannelsForUser,
|
||||
pub removed_channels: Vec<ChannelId>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SetMemberRoleResult {
|
||||
InviteUpdated(Channel),
|
||||
MembershipUpdated(MembershipUpdated),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InviteMemberResult {
|
||||
pub channel: Channel,
|
||||
pub notifications: NotificationBatch,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RespondToChannelInvite {
|
||||
pub membership_update: Option<MembershipUpdated>,
|
||||
pub notifications: NotificationBatch,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RemoveChannelMemberResult {
|
||||
pub membership_update: MembershipUpdated,
|
||||
pub notification_id: Option<NotificationId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Hash)]
|
||||
pub struct Channel {
|
||||
pub id: ChannelId,
|
||||
pub name: String,
|
||||
pub visibility: ChannelVisibility,
|
||||
pub role: ChannelRole,
|
||||
pub parent_path: Vec<ChannelId>,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
fn from_model(value: channel::Model, role: ChannelRole) -> Self {
|
||||
Channel {
|
||||
id: value.id,
|
||||
visibility: value.visibility,
|
||||
name: value.clone().name,
|
||||
role,
|
||||
parent_path: value.ancestors().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_proto(&self) -> proto::Channel {
|
||||
proto::Channel {
|
||||
id: self.id.to_proto(),
|
||||
name: self.name.clone(),
|
||||
visibility: self.visibility.into(),
|
||||
role: self.role.into(),
|
||||
parent_path: self.parent_path.iter().map(|c| c.to_proto()).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Hash)]
|
||||
pub struct ChannelMember {
|
||||
pub role: ChannelRole,
|
||||
pub user_id: UserId,
|
||||
pub kind: proto::channel_member::Kind,
|
||||
}
|
||||
|
||||
impl ChannelMember {
|
||||
pub fn to_proto(&self) -> proto::ChannelMember {
|
||||
proto::ChannelMember {
|
||||
role: self.role.into(),
|
||||
user_id: self.user_id.to_proto(),
|
||||
kind: self.kind.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct ChannelsForUser {
|
||||
pub channels: ChannelGraph,
|
||||
pub channels: Vec<Channel>,
|
||||
pub channel_participants: HashMap<ChannelId, Vec<UserId>>,
|
||||
pub channels_with_admin_privileges: HashSet<ChannelId>,
|
||||
pub unseen_buffer_changes: Vec<proto::UnseenChannelBufferChange>,
|
||||
pub channel_messages: Vec<proto::UnseenChannelMessage>,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::Result;
|
||||
use rpc::proto;
|
||||
use sea_orm::{entity::prelude::*, DbErr};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -80,3 +81,119 @@ id_type!(SignupId);
|
||||
id_type!(UserId);
|
||||
id_type!(ChannelBufferCollaboratorId);
|
||||
id_type!(FlagId);
|
||||
id_type!(NotificationId);
|
||||
id_type!(NotificationKindId);
|
||||
|
||||
#[derive(Eq, PartialEq, Copy, Clone, Debug, EnumIter, DeriveActiveEnum, Default, Hash)]
|
||||
#[sea_orm(rs_type = "String", db_type = "String(None)")]
|
||||
pub enum ChannelRole {
|
||||
#[sea_orm(string_value = "admin")]
|
||||
Admin,
|
||||
#[sea_orm(string_value = "member")]
|
||||
#[default]
|
||||
Member,
|
||||
#[sea_orm(string_value = "guest")]
|
||||
Guest,
|
||||
#[sea_orm(string_value = "banned")]
|
||||
Banned,
|
||||
}
|
||||
|
||||
impl ChannelRole {
|
||||
pub fn should_override(&self, other: Self) -> bool {
|
||||
use ChannelRole::*;
|
||||
match self {
|
||||
Admin => matches!(other, Member | Banned | Guest),
|
||||
Member => matches!(other, Banned | Guest),
|
||||
Banned => matches!(other, Guest),
|
||||
Guest => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn max(&self, other: Self) -> Self {
|
||||
if self.should_override(other) {
|
||||
*self
|
||||
} else {
|
||||
other
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_see_all_descendants(&self) -> bool {
|
||||
use ChannelRole::*;
|
||||
match self {
|
||||
Admin | Member => true,
|
||||
Guest | Banned => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_only_see_public_descendants(&self) -> bool {
|
||||
use ChannelRole::*;
|
||||
match self {
|
||||
Guest => true,
|
||||
Admin | Member | Banned => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<proto::ChannelRole> for ChannelRole {
|
||||
fn from(value: proto::ChannelRole) -> Self {
|
||||
match value {
|
||||
proto::ChannelRole::Admin => ChannelRole::Admin,
|
||||
proto::ChannelRole::Member => ChannelRole::Member,
|
||||
proto::ChannelRole::Guest => ChannelRole::Guest,
|
||||
proto::ChannelRole::Banned => ChannelRole::Banned,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<proto::ChannelRole> for ChannelRole {
|
||||
fn into(self) -> proto::ChannelRole {
|
||||
match self {
|
||||
ChannelRole::Admin => proto::ChannelRole::Admin,
|
||||
ChannelRole::Member => proto::ChannelRole::Member,
|
||||
ChannelRole::Guest => proto::ChannelRole::Guest,
|
||||
ChannelRole::Banned => proto::ChannelRole::Banned,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<i32> for ChannelRole {
|
||||
fn into(self) -> i32 {
|
||||
let proto: proto::ChannelRole = self.into();
|
||||
proto.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Eq, PartialEq, Copy, Clone, Debug, EnumIter, DeriveActiveEnum, Default, Hash)]
|
||||
#[sea_orm(rs_type = "String", db_type = "String(None)")]
|
||||
pub enum ChannelVisibility {
|
||||
#[sea_orm(string_value = "public")]
|
||||
Public,
|
||||
#[sea_orm(string_value = "members")]
|
||||
#[default]
|
||||
Members,
|
||||
}
|
||||
|
||||
impl From<proto::ChannelVisibility> for ChannelVisibility {
|
||||
fn from(value: proto::ChannelVisibility) -> Self {
|
||||
match value {
|
||||
proto::ChannelVisibility::Public => ChannelVisibility::Public,
|
||||
proto::ChannelVisibility::Members => ChannelVisibility::Members,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<proto::ChannelVisibility> for ChannelVisibility {
|
||||
fn into(self) -> proto::ChannelVisibility {
|
||||
match self {
|
||||
ChannelVisibility::Public => proto::ChannelVisibility::Public,
|
||||
ChannelVisibility::Members => proto::ChannelVisibility::Members,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<i32> for ChannelVisibility {
|
||||
fn into(self) -> i32 {
|
||||
let proto: proto::ChannelVisibility = self.into();
|
||||
proto.into()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod buffers;
|
||||
pub mod channels;
|
||||
pub mod contacts;
|
||||
pub mod messages;
|
||||
pub mod notifications;
|
||||
pub mod projects;
|
||||
pub mod rooms;
|
||||
pub mod servers;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use sea_orm::sea_query::Query;
|
||||
|
||||
impl Database {
|
||||
pub async fn create_access_token(
|
||||
|
||||
@@ -16,7 +16,8 @@ impl Database {
|
||||
connection: ConnectionId,
|
||||
) -> Result<proto::JoinChannelBufferResponse> {
|
||||
self.transaction(|tx| async move {
|
||||
self.check_user_is_channel_member(channel_id, user_id, &tx)
|
||||
let channel = self.get_channel_internal(channel_id, &*tx).await?;
|
||||
self.check_user_is_channel_participant(&channel, user_id, &tx)
|
||||
.await?;
|
||||
|
||||
let buffer = channel::Model {
|
||||
@@ -129,9 +130,11 @@ impl Database {
|
||||
self.transaction(|tx| async move {
|
||||
let mut results = Vec::new();
|
||||
for client_buffer in buffers {
|
||||
let channel_id = ChannelId::from_proto(client_buffer.channel_id);
|
||||
let channel = self
|
||||
.get_channel_internal(ChannelId::from_proto(client_buffer.channel_id), &*tx)
|
||||
.await?;
|
||||
if self
|
||||
.check_user_is_channel_member(channel_id, user_id, &*tx)
|
||||
.check_user_is_channel_participant(&channel, user_id, &*tx)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
@@ -139,9 +142,9 @@ impl Database {
|
||||
continue;
|
||||
}
|
||||
|
||||
let buffer = self.get_channel_buffer(channel_id, &*tx).await?;
|
||||
let buffer = self.get_channel_buffer(channel.id, &*tx).await?;
|
||||
let mut collaborators = channel_buffer_collaborator::Entity::find()
|
||||
.filter(channel_buffer_collaborator::Column::ChannelId.eq(channel_id))
|
||||
.filter(channel_buffer_collaborator::Column::ChannelId.eq(channel.id))
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
|
||||
@@ -439,7 +442,8 @@ impl Database {
|
||||
Vec<proto::VectorClockEntry>,
|
||||
)> {
|
||||
self.transaction(move |tx| async move {
|
||||
self.check_user_is_channel_member(channel_id, user, &*tx)
|
||||
let channel = self.get_channel_internal(channel_id, &*tx).await?;
|
||||
self.check_user_is_channel_member(&channel, user, &*tx)
|
||||
.await?;
|
||||
|
||||
let buffer = buffer::Entity::find()
|
||||
@@ -482,7 +486,7 @@ impl Database {
|
||||
)
|
||||
.await?;
|
||||
|
||||
channel_members = self.get_channel_members_internal(channel_id, &*tx).await?;
|
||||
channel_members = self.get_channel_participants(&channel, &*tx).await?;
|
||||
let collaborators = self
|
||||
.get_channel_buffer_collaborators_internal(channel_id, &*tx)
|
||||
.await?;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,6 @@ impl Database {
|
||||
user_id_b: UserId,
|
||||
a_to_b: bool,
|
||||
accepted: bool,
|
||||
should_notify: bool,
|
||||
user_a_busy: bool,
|
||||
user_b_busy: bool,
|
||||
}
|
||||
@@ -53,7 +52,6 @@ impl Database {
|
||||
if db_contact.accepted {
|
||||
contacts.push(Contact::Accepted {
|
||||
user_id: db_contact.user_id_b,
|
||||
should_notify: db_contact.should_notify && db_contact.a_to_b,
|
||||
busy: db_contact.user_b_busy,
|
||||
});
|
||||
} else if db_contact.a_to_b {
|
||||
@@ -63,19 +61,16 @@ impl Database {
|
||||
} else {
|
||||
contacts.push(Contact::Incoming {
|
||||
user_id: db_contact.user_id_b,
|
||||
should_notify: db_contact.should_notify,
|
||||
});
|
||||
}
|
||||
} else if db_contact.accepted {
|
||||
contacts.push(Contact::Accepted {
|
||||
user_id: db_contact.user_id_a,
|
||||
should_notify: db_contact.should_notify && !db_contact.a_to_b,
|
||||
busy: db_contact.user_a_busy,
|
||||
});
|
||||
} else if db_contact.a_to_b {
|
||||
contacts.push(Contact::Incoming {
|
||||
user_id: db_contact.user_id_a,
|
||||
should_notify: db_contact.should_notify,
|
||||
});
|
||||
} else {
|
||||
contacts.push(Contact::Outgoing {
|
||||
@@ -124,7 +119,11 @@ impl Database {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn send_contact_request(&self, sender_id: UserId, receiver_id: UserId) -> Result<()> {
|
||||
pub async fn send_contact_request(
|
||||
&self,
|
||||
sender_id: UserId,
|
||||
receiver_id: UserId,
|
||||
) -> Result<NotificationBatch> {
|
||||
self.transaction(|tx| async move {
|
||||
let (id_a, id_b, a_to_b) = if sender_id < receiver_id {
|
||||
(sender_id, receiver_id, true)
|
||||
@@ -161,11 +160,22 @@ impl Database {
|
||||
.exec_without_returning(&*tx)
|
||||
.await?;
|
||||
|
||||
if rows_affected == 1 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("contact already requested"))?
|
||||
if rows_affected == 0 {
|
||||
Err(anyhow!("contact already requested"))?;
|
||||
}
|
||||
|
||||
Ok(self
|
||||
.create_notification(
|
||||
receiver_id,
|
||||
rpc::Notification::ContactRequest {
|
||||
sender_id: sender_id.to_proto(),
|
||||
},
|
||||
true,
|
||||
&*tx,
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect())
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -179,7 +189,11 @@ impl Database {
|
||||
///
|
||||
/// * `requester_id` - The user that initiates this request
|
||||
/// * `responder_id` - The user that will be removed
|
||||
pub async fn remove_contact(&self, requester_id: UserId, responder_id: UserId) -> Result<bool> {
|
||||
pub async fn remove_contact(
|
||||
&self,
|
||||
requester_id: UserId,
|
||||
responder_id: UserId,
|
||||
) -> Result<(bool, Option<NotificationId>)> {
|
||||
self.transaction(|tx| async move {
|
||||
let (id_a, id_b) = if responder_id < requester_id {
|
||||
(responder_id, requester_id)
|
||||
@@ -198,7 +212,21 @@ impl Database {
|
||||
.ok_or_else(|| anyhow!("no such contact"))?;
|
||||
|
||||
contact::Entity::delete_by_id(contact.id).exec(&*tx).await?;
|
||||
Ok(contact.accepted)
|
||||
|
||||
let mut deleted_notification_id = None;
|
||||
if !contact.accepted {
|
||||
deleted_notification_id = self
|
||||
.remove_notification(
|
||||
responder_id,
|
||||
rpc::Notification::ContactRequest {
|
||||
sender_id: requester_id.to_proto(),
|
||||
},
|
||||
&*tx,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok((contact.accepted, deleted_notification_id))
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -249,7 +277,7 @@ impl Database {
|
||||
responder_id: UserId,
|
||||
requester_id: UserId,
|
||||
accept: bool,
|
||||
) -> Result<()> {
|
||||
) -> Result<NotificationBatch> {
|
||||
self.transaction(|tx| async move {
|
||||
let (id_a, id_b, a_to_b) = if responder_id < requester_id {
|
||||
(responder_id, requester_id, false)
|
||||
@@ -287,11 +315,38 @@ impl Database {
|
||||
result.rows_affected
|
||||
};
|
||||
|
||||
if rows_affected == 1 {
|
||||
Ok(())
|
||||
} else {
|
||||
if rows_affected == 0 {
|
||||
Err(anyhow!("no such contact request"))?
|
||||
}
|
||||
|
||||
let mut notifications = Vec::new();
|
||||
notifications.extend(
|
||||
self.mark_notification_as_read_with_response(
|
||||
responder_id,
|
||||
&rpc::Notification::ContactRequest {
|
||||
sender_id: requester_id.to_proto(),
|
||||
},
|
||||
accept,
|
||||
&*tx,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
|
||||
if accept {
|
||||
notifications.extend(
|
||||
self.create_notification(
|
||||
requester_id,
|
||||
rpc::Notification::ContactRequestAccepted {
|
||||
responder_id: responder_id.to_proto(),
|
||||
},
|
||||
true,
|
||||
&*tx,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(notifications)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::*;
|
||||
use rpc::Notification;
|
||||
use sea_orm::TryInsertResult;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
impl Database {
|
||||
@@ -9,7 +11,8 @@ impl Database {
|
||||
user_id: UserId,
|
||||
) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
self.check_user_is_channel_member(channel_id, user_id, &*tx)
|
||||
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,
|
||||
@@ -77,7 +80,8 @@ impl Database {
|
||||
before_message_id: Option<MessageId>,
|
||||
) -> Result<Vec<proto::ChannelMessage>> {
|
||||
self.transaction(|tx| async move {
|
||||
self.check_user_is_channel_member(channel_id, user_id, &*tx)
|
||||
let channel = self.get_channel_internal(channel_id, &*tx).await?;
|
||||
self.check_user_is_channel_participant(&channel, user_id, &*tx)
|
||||
.await?;
|
||||
|
||||
let mut condition =
|
||||
@@ -87,33 +91,103 @@ impl Database {
|
||||
condition = condition.add(channel_message::Column::Id.lt(before_message_id));
|
||||
}
|
||||
|
||||
let mut rows = channel_message::Entity::find()
|
||||
let rows = channel_message::Entity::find()
|
||||
.filter(condition)
|
||||
.order_by_desc(channel_message::Column::Id)
|
||||
.limit(count as u64)
|
||||
.stream(&*tx)
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
|
||||
let mut messages = Vec::new();
|
||||
while let Some(row) = rows.next().await {
|
||||
let row = row?;
|
||||
self.load_channel_messages(rows, &*tx).await
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
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();
|
||||
messages.push(proto::ChannelMessage {
|
||||
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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
})
|
||||
.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;
|
||||
}
|
||||
}
|
||||
drop(rows);
|
||||
messages.reverse();
|
||||
Ok(messages)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
pub async fn create_channel_message(
|
||||
@@ -121,10 +195,15 @@ impl Database {
|
||||
channel_id: ChannelId,
|
||||
user_id: UserId,
|
||||
body: &str,
|
||||
mentions: &[proto::ChatMention],
|
||||
timestamp: OffsetDateTime,
|
||||
nonce: u128,
|
||||
) -> Result<(MessageId, Vec<ConnectionId>, Vec<UserId>)> {
|
||||
) -> 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)
|
||||
@@ -150,7 +229,7 @@ impl Database {
|
||||
let timestamp = timestamp.to_offset(time::UtcOffset::UTC);
|
||||
let timestamp = time::PrimitiveDateTime::new(timestamp.date(), timestamp.time());
|
||||
|
||||
let message = channel_message::Entity::insert(channel_message::ActiveModel {
|
||||
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()),
|
||||
@@ -159,35 +238,85 @@ impl Database {
|
||||
id: ActiveValue::NotSet,
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::column(channel_message::Column::Nonce)
|
||||
.update_column(channel_message::Column::Nonce)
|
||||
.to_owned(),
|
||||
OnConflict::columns([
|
||||
channel_message::Column::SenderId,
|
||||
channel_message::Column::Nonce,
|
||||
])
|
||||
.do_nothing()
|
||||
.to_owned(),
|
||||
)
|
||||
.do_nothing()
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
#[derive(Debug, Clone, Copy, EnumIter, DeriveColumn)]
|
||||
enum QueryConnectionId {
|
||||
ConnectionId,
|
||||
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 = 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<_>>();
|
||||
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?
|
||||
.ok_or_else(|| anyhow!("failed to insert message"))?
|
||||
.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Observe this message for the sender
|
||||
self.observe_channel_message_internal(
|
||||
channel_id,
|
||||
user_id,
|
||||
message.last_insert_id,
|
||||
&*tx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut channel_members = self.get_channel_members_internal(channel_id, &*tx).await?;
|
||||
let mut channel_members = self.get_channel_participants(&channel, &*tx).await?;
|
||||
channel_members.retain(|member| !participant_user_ids.contains(member));
|
||||
|
||||
Ok((
|
||||
message.last_insert_id,
|
||||
Ok(CreatedChannelMessage {
|
||||
message_id,
|
||||
participant_connection_ids,
|
||||
channel_members,
|
||||
))
|
||||
notifications,
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -197,11 +326,24 @@ impl Database {
|
||||
channel_id: ChannelId,
|
||||
user_id: UserId,
|
||||
message_id: MessageId,
|
||||
) -> Result<()> {
|
||||
) -> Result<NotificationBatch> {
|
||||
self.transaction(|tx| async move {
|
||||
self.observe_channel_message_internal(channel_id, user_id, message_id, &*tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
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
|
||||
}
|
||||
@@ -337,8 +479,23 @@ impl Database {
|
||||
.filter(channel_message::Column::SenderId.eq(user_id))
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected == 0 {
|
||||
Err(anyhow!("no such message"))?;
|
||||
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"))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(participant_connection_ids)
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
use super::*;
|
||||
use rpc::Notification;
|
||||
|
||||
impl Database {
|
||||
pub async fn initialize_notification_kinds(&mut self) -> Result<()> {
|
||||
notification_kind::Entity::insert_many(Notification::all_variant_names().iter().map(
|
||||
|kind| notification_kind::ActiveModel {
|
||||
name: ActiveValue::Set(kind.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
))
|
||||
.on_conflict(OnConflict::new().do_nothing().to_owned())
|
||||
.exec_without_returning(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut rows = notification_kind::Entity::find().stream(&self.pool).await?;
|
||||
while let Some(row) = rows.next().await {
|
||||
let row = row?;
|
||||
self.notification_kinds_by_name.insert(row.name, row.id);
|
||||
}
|
||||
|
||||
for name in Notification::all_variant_names() {
|
||||
if let Some(id) = self.notification_kinds_by_name.get(*name).copied() {
|
||||
self.notification_kinds_by_id.insert(id, name);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_notifications(
|
||||
&self,
|
||||
recipient_id: UserId,
|
||||
limit: usize,
|
||||
before_id: Option<NotificationId>,
|
||||
) -> Result<Vec<proto::Notification>> {
|
||||
self.transaction(|tx| async move {
|
||||
let mut result = Vec::new();
|
||||
let mut condition =
|
||||
Condition::all().add(notification::Column::RecipientId.eq(recipient_id));
|
||||
|
||||
if let Some(before_id) = before_id {
|
||||
condition = condition.add(notification::Column::Id.lt(before_id));
|
||||
}
|
||||
|
||||
let mut rows = notification::Entity::find()
|
||||
.filter(condition)
|
||||
.order_by_desc(notification::Column::Id)
|
||||
.limit(limit as u64)
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
while let Some(row) = rows.next().await {
|
||||
let row = row?;
|
||||
let kind = row.kind;
|
||||
if let Some(proto) = model_to_proto(self, row) {
|
||||
result.push(proto);
|
||||
} else {
|
||||
log::warn!("unknown notification kind {:?}", kind);
|
||||
}
|
||||
}
|
||||
result.reverse();
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Create a notification. If `avoid_duplicates` is set to true, then avoid
|
||||
/// creating a new notification if the given recipient already has an
|
||||
/// unread notification with the given kind and entity id.
|
||||
pub async fn create_notification(
|
||||
&self,
|
||||
recipient_id: UserId,
|
||||
notification: Notification,
|
||||
avoid_duplicates: bool,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Option<(UserId, proto::Notification)>> {
|
||||
if avoid_duplicates {
|
||||
if self
|
||||
.find_notification(recipient_id, ¬ification, tx)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
let proto = notification.to_proto();
|
||||
let kind = notification_kind_from_proto(self, &proto)?;
|
||||
let model = notification::ActiveModel {
|
||||
recipient_id: ActiveValue::Set(recipient_id),
|
||||
kind: ActiveValue::Set(kind),
|
||||
entity_id: ActiveValue::Set(proto.entity_id.map(|id| id as i32)),
|
||||
content: ActiveValue::Set(proto.content.clone()),
|
||||
..Default::default()
|
||||
}
|
||||
.save(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok(Some((
|
||||
recipient_id,
|
||||
proto::Notification {
|
||||
id: model.id.as_ref().to_proto(),
|
||||
kind: proto.kind,
|
||||
timestamp: model.created_at.as_ref().assume_utc().unix_timestamp() as u64,
|
||||
is_read: false,
|
||||
response: None,
|
||||
content: proto.content,
|
||||
entity_id: proto.entity_id,
|
||||
},
|
||||
)))
|
||||
}
|
||||
|
||||
/// Remove an unread notification with the given recipient, kind and
|
||||
/// entity id.
|
||||
pub async fn remove_notification(
|
||||
&self,
|
||||
recipient_id: UserId,
|
||||
notification: Notification,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Option<NotificationId>> {
|
||||
let id = self
|
||||
.find_notification(recipient_id, ¬ification, tx)
|
||||
.await?;
|
||||
if let Some(id) = id {
|
||||
notification::Entity::delete_by_id(id).exec(tx).await?;
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Populate the response for the notification with the given kind and
|
||||
/// entity id.
|
||||
pub async fn mark_notification_as_read_with_response(
|
||||
&self,
|
||||
recipient_id: UserId,
|
||||
notification: &Notification,
|
||||
response: bool,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Option<(UserId, proto::Notification)>> {
|
||||
self.mark_notification_as_read_internal(recipient_id, notification, Some(response), tx)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn mark_notification_as_read(
|
||||
&self,
|
||||
recipient_id: UserId,
|
||||
notification: &Notification,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Option<(UserId, proto::Notification)>> {
|
||||
self.mark_notification_as_read_internal(recipient_id, notification, None, tx)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn mark_notification_as_read_by_id(
|
||||
&self,
|
||||
recipient_id: UserId,
|
||||
notification_id: NotificationId,
|
||||
) -> Result<NotificationBatch> {
|
||||
self.transaction(|tx| async move {
|
||||
let row = notification::Entity::update(notification::ActiveModel {
|
||||
id: ActiveValue::Unchanged(notification_id),
|
||||
recipient_id: ActiveValue::Unchanged(recipient_id),
|
||||
is_read: ActiveValue::Set(true),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
Ok(model_to_proto(self, row)
|
||||
.map(|notification| (recipient_id, notification))
|
||||
.into_iter()
|
||||
.collect())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn mark_notification_as_read_internal(
|
||||
&self,
|
||||
recipient_id: UserId,
|
||||
notification: &Notification,
|
||||
response: Option<bool>,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Option<(UserId, proto::Notification)>> {
|
||||
if let Some(id) = self
|
||||
.find_notification(recipient_id, notification, &*tx)
|
||||
.await?
|
||||
{
|
||||
let row = notification::Entity::update(notification::ActiveModel {
|
||||
id: ActiveValue::Unchanged(id),
|
||||
recipient_id: ActiveValue::Unchanged(recipient_id),
|
||||
is_read: ActiveValue::Set(true),
|
||||
response: if let Some(response) = response {
|
||||
ActiveValue::Set(Some(response))
|
||||
} else {
|
||||
ActiveValue::NotSet
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.exec(tx)
|
||||
.await?;
|
||||
Ok(model_to_proto(self, row).map(|notification| (recipient_id, notification)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Find an unread notification by its recipient, kind and entity id.
|
||||
async fn find_notification(
|
||||
&self,
|
||||
recipient_id: UserId,
|
||||
notification: &Notification,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Option<NotificationId>> {
|
||||
let proto = notification.to_proto();
|
||||
let kind = notification_kind_from_proto(self, &proto)?;
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
||||
enum QueryIds {
|
||||
Id,
|
||||
}
|
||||
|
||||
Ok(notification::Entity::find()
|
||||
.select_only()
|
||||
.column(notification::Column::Id)
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(notification::Column::RecipientId.eq(recipient_id))
|
||||
.add(notification::Column::IsRead.eq(false))
|
||||
.add(notification::Column::Kind.eq(kind))
|
||||
.add(if proto.entity_id.is_some() {
|
||||
notification::Column::EntityId.eq(proto.entity_id)
|
||||
} else {
|
||||
notification::Column::EntityId.is_null()
|
||||
}),
|
||||
)
|
||||
.into_values::<_, QueryIds>()
|
||||
.one(&*tx)
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
|
||||
fn model_to_proto(this: &Database, row: notification::Model) -> Option<proto::Notification> {
|
||||
let kind = this.notification_kinds_by_id.get(&row.kind)?;
|
||||
Some(proto::Notification {
|
||||
id: row.id.to_proto(),
|
||||
kind: kind.to_string(),
|
||||
timestamp: row.created_at.assume_utc().unix_timestamp() as u64,
|
||||
is_read: row.is_read,
|
||||
response: row.response,
|
||||
content: row.content,
|
||||
entity_id: row.entity_id.map(|id| id as u64),
|
||||
})
|
||||
}
|
||||
|
||||
fn notification_kind_from_proto(
|
||||
this: &Database,
|
||||
proto: &proto::Notification,
|
||||
) -> Result<NotificationKindId> {
|
||||
Ok(this
|
||||
.notification_kinds_by_name
|
||||
.get(&proto.kind)
|
||||
.copied()
|
||||
.ok_or_else(|| anyhow!("invalid notification kind {:?}", proto.kind))?)
|
||||
}
|
||||
@@ -50,10 +50,10 @@ impl Database {
|
||||
.map(|participant| participant.user_id),
|
||||
);
|
||||
|
||||
let (channel_id, room) = self.get_channel_room(room_id, &tx).await?;
|
||||
let (channel, room) = self.get_channel_room(room_id, &tx).await?;
|
||||
let channel_members;
|
||||
if let Some(channel_id) = channel_id {
|
||||
channel_members = self.get_channel_members_internal(channel_id, &tx).await?;
|
||||
if let Some(channel) = &channel {
|
||||
channel_members = self.get_channel_participants(channel, &tx).await?;
|
||||
} else {
|
||||
channel_members = Vec::new();
|
||||
|
||||
@@ -69,7 +69,7 @@ impl Database {
|
||||
|
||||
Ok(RefreshedRoom {
|
||||
room,
|
||||
channel_id,
|
||||
channel_id: channel.map(|channel| channel.id),
|
||||
channel_members,
|
||||
stale_participant_user_ids,
|
||||
canceled_calls_to_user_ids,
|
||||
@@ -298,98 +298,137 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
||||
enum QueryParticipantIndices {
|
||||
ParticipantIndex,
|
||||
if channel_id.is_some() {
|
||||
Err(anyhow!("tried to join channel call directly"))?
|
||||
}
|
||||
let existing_participant_indices: Vec<i32> = room_participant::Entity::find()
|
||||
.filter(
|
||||
room_participant::Column::RoomId
|
||||
.eq(room_id)
|
||||
.and(room_participant::Column::ParticipantIndex.is_not_null()),
|
||||
)
|
||||
.select_only()
|
||||
.column(room_participant::Column::ParticipantIndex)
|
||||
.into_values::<_, QueryParticipantIndices>()
|
||||
.all(&*tx)
|
||||
|
||||
let participant_index = self
|
||||
.get_next_participant_index_internal(room_id, &*tx)
|
||||
.await?;
|
||||
|
||||
let mut participant_index = 0;
|
||||
while existing_participant_indices.contains(&participant_index) {
|
||||
participant_index += 1;
|
||||
}
|
||||
|
||||
if let Some(channel_id) = channel_id {
|
||||
self.check_user_is_channel_member(channel_id, user_id, &*tx)
|
||||
.await?;
|
||||
|
||||
room_participant::Entity::insert_many([room_participant::ActiveModel {
|
||||
room_id: ActiveValue::set(room_id),
|
||||
user_id: ActiveValue::set(user_id),
|
||||
let result = room_participant::Entity::update_many()
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(room_participant::Column::RoomId.eq(room_id))
|
||||
.add(room_participant::Column::UserId.eq(user_id))
|
||||
.add(room_participant::Column::AnsweringConnectionId.is_null()),
|
||||
)
|
||||
.set(room_participant::ActiveModel {
|
||||
participant_index: ActiveValue::Set(Some(participant_index)),
|
||||
answering_connection_id: ActiveValue::set(Some(connection.id as i32)),
|
||||
answering_connection_server_id: ActiveValue::set(Some(ServerId(
|
||||
connection.owner_id as i32,
|
||||
))),
|
||||
answering_connection_lost: ActiveValue::set(false),
|
||||
calling_user_id: ActiveValue::set(user_id),
|
||||
calling_connection_id: ActiveValue::set(connection.id as i32),
|
||||
calling_connection_server_id: ActiveValue::set(Some(ServerId(
|
||||
connection.owner_id as i32,
|
||||
))),
|
||||
participant_index: ActiveValue::Set(Some(participant_index)),
|
||||
..Default::default()
|
||||
}])
|
||||
.on_conflict(
|
||||
OnConflict::columns([room_participant::Column::UserId])
|
||||
.update_columns([
|
||||
room_participant::Column::AnsweringConnectionId,
|
||||
room_participant::Column::AnsweringConnectionServerId,
|
||||
room_participant::Column::AnsweringConnectionLost,
|
||||
room_participant::Column::ParticipantIndex,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
})
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
} else {
|
||||
let result = room_participant::Entity::update_many()
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(room_participant::Column::RoomId.eq(room_id))
|
||||
.add(room_participant::Column::UserId.eq(user_id))
|
||||
.add(room_participant::Column::AnsweringConnectionId.is_null()),
|
||||
)
|
||||
.set(room_participant::ActiveModel {
|
||||
participant_index: ActiveValue::Set(Some(participant_index)),
|
||||
answering_connection_id: ActiveValue::set(Some(connection.id as i32)),
|
||||
answering_connection_server_id: ActiveValue::set(Some(ServerId(
|
||||
connection.owner_id as i32,
|
||||
))),
|
||||
answering_connection_lost: ActiveValue::set(false),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
if result.rows_affected == 0 {
|
||||
Err(anyhow!("room does not exist or was already joined"))?;
|
||||
}
|
||||
if result.rows_affected == 0 {
|
||||
Err(anyhow!("room does not exist or was already joined"))?;
|
||||
}
|
||||
|
||||
let room = self.get_room(room_id, &tx).await?;
|
||||
let channel_members = if let Some(channel_id) = channel_id {
|
||||
self.get_channel_members_internal(channel_id, &tx).await?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok(JoinRoom {
|
||||
room,
|
||||
channel_id,
|
||||
channel_members,
|
||||
channel_id: None,
|
||||
channel_members: vec![],
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_next_participant_index_internal(
|
||||
&self,
|
||||
room_id: RoomId,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<i32> {
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
||||
enum QueryParticipantIndices {
|
||||
ParticipantIndex,
|
||||
}
|
||||
let existing_participant_indices: Vec<i32> = room_participant::Entity::find()
|
||||
.filter(
|
||||
room_participant::Column::RoomId
|
||||
.eq(room_id)
|
||||
.and(room_participant::Column::ParticipantIndex.is_not_null()),
|
||||
)
|
||||
.select_only()
|
||||
.column(room_participant::Column::ParticipantIndex)
|
||||
.into_values::<_, QueryParticipantIndices>()
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
|
||||
let mut participant_index = 0;
|
||||
while existing_participant_indices.contains(&participant_index) {
|
||||
participant_index += 1;
|
||||
}
|
||||
|
||||
Ok(participant_index)
|
||||
}
|
||||
|
||||
pub async fn channel_id_for_room(&self, room_id: RoomId) -> Result<Option<ChannelId>> {
|
||||
self.transaction(|tx| async move {
|
||||
let room: Option<room::Model> = room::Entity::find()
|
||||
.filter(room::Column::Id.eq(room_id))
|
||||
.one(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok(room.and_then(|room| room.channel_id))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn join_channel_room_internal(
|
||||
&self,
|
||||
room_id: RoomId,
|
||||
user_id: UserId,
|
||||
connection: ConnectionId,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<JoinRoom> {
|
||||
let participant_index = self
|
||||
.get_next_participant_index_internal(room_id, &*tx)
|
||||
.await?;
|
||||
|
||||
room_participant::Entity::insert_many([room_participant::ActiveModel {
|
||||
room_id: ActiveValue::set(room_id),
|
||||
user_id: ActiveValue::set(user_id),
|
||||
answering_connection_id: ActiveValue::set(Some(connection.id as i32)),
|
||||
answering_connection_server_id: ActiveValue::set(Some(ServerId(
|
||||
connection.owner_id as i32,
|
||||
))),
|
||||
answering_connection_lost: ActiveValue::set(false),
|
||||
calling_user_id: ActiveValue::set(user_id),
|
||||
calling_connection_id: ActiveValue::set(connection.id as i32),
|
||||
calling_connection_server_id: ActiveValue::set(Some(ServerId(
|
||||
connection.owner_id as i32,
|
||||
))),
|
||||
participant_index: ActiveValue::Set(Some(participant_index)),
|
||||
..Default::default()
|
||||
}])
|
||||
.on_conflict(
|
||||
OnConflict::columns([room_participant::Column::UserId])
|
||||
.update_columns([
|
||||
room_participant::Column::AnsweringConnectionId,
|
||||
room_participant::Column::AnsweringConnectionServerId,
|
||||
room_participant::Column::AnsweringConnectionLost,
|
||||
room_participant::Column::ParticipantIndex,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
let (channel, room) = self.get_channel_room(room_id, &tx).await?;
|
||||
let channel = channel.ok_or_else(|| anyhow!("no channel for room"))?;
|
||||
let channel_members = self.get_channel_participants(&channel, &*tx).await?;
|
||||
Ok(JoinRoom {
|
||||
room,
|
||||
channel_id: Some(channel.id),
|
||||
channel_members,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn rejoin_room(
|
||||
&self,
|
||||
rejoin_room: proto::RejoinRoom,
|
||||
@@ -679,16 +718,16 @@ impl Database {
|
||||
});
|
||||
}
|
||||
|
||||
let (channel_id, room) = self.get_channel_room(room_id, &tx).await?;
|
||||
let channel_members = if let Some(channel_id) = channel_id {
|
||||
self.get_channel_members_internal(channel_id, &tx).await?
|
||||
let (channel, room) = self.get_channel_room(room_id, &tx).await?;
|
||||
let channel_members = if let Some(channel) = &channel {
|
||||
self.get_channel_participants(&channel, &tx).await?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(RejoinedRoom {
|
||||
room,
|
||||
channel_id,
|
||||
channel_id: channel.map(|channel| channel.id),
|
||||
channel_members,
|
||||
rejoined_projects,
|
||||
reshared_projects,
|
||||
@@ -830,7 +869,7 @@ impl Database {
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
let (channel_id, room) = self.get_channel_room(room_id, &tx).await?;
|
||||
let (channel, room) = self.get_channel_room(room_id, &tx).await?;
|
||||
let deleted = if room.participants.is_empty() {
|
||||
let result = room::Entity::delete_by_id(room_id).exec(&*tx).await?;
|
||||
result.rows_affected > 0
|
||||
@@ -838,14 +877,14 @@ impl Database {
|
||||
false
|
||||
};
|
||||
|
||||
let channel_members = if let Some(channel_id) = channel_id {
|
||||
self.get_channel_members_internal(channel_id, &tx).await?
|
||||
let channel_members = if let Some(channel) = &channel {
|
||||
self.get_channel_participants(channel, &tx).await?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let left_room = LeftRoom {
|
||||
room,
|
||||
channel_id,
|
||||
channel_id: channel.map(|channel| channel.id),
|
||||
channel_members,
|
||||
left_projects,
|
||||
canceled_calls_to_user_ids,
|
||||
@@ -1033,7 +1072,7 @@ impl Database {
|
||||
&self,
|
||||
room_id: RoomId,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<(Option<ChannelId>, proto::Room)> {
|
||||
) -> Result<(Option<channel::Model>, proto::Room)> {
|
||||
let db_room = room::Entity::find_by_id(room_id)
|
||||
.one(tx)
|
||||
.await?
|
||||
@@ -1142,9 +1181,16 @@ impl Database {
|
||||
project_id: db_follower.project_id.to_proto(),
|
||||
});
|
||||
}
|
||||
drop(db_followers);
|
||||
|
||||
let channel = if let Some(channel_id) = db_room.channel_id {
|
||||
Some(self.get_channel_internal(channel_id, &*tx).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok((
|
||||
db_room.channel_id,
|
||||
channel,
|
||||
proto::Room {
|
||||
id: db_room.id.to_proto(),
|
||||
live_kit_room: db_room.live_kit_room,
|
||||
|
||||
@@ -7,11 +7,13 @@ pub mod channel_buffer_collaborator;
|
||||
pub mod channel_chat_participant;
|
||||
pub mod channel_member;
|
||||
pub mod channel_message;
|
||||
pub mod channel_path;
|
||||
pub mod channel_message_mention;
|
||||
pub mod contact;
|
||||
pub mod feature_flag;
|
||||
pub mod follower;
|
||||
pub mod language_server;
|
||||
pub mod notification;
|
||||
pub mod notification_kind;
|
||||
pub mod observed_buffer_edits;
|
||||
pub mod observed_channel_messages;
|
||||
pub mod project;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::db::ChannelId;
|
||||
use crate::db::{ChannelId, ChannelVisibility};
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
|
||||
@@ -7,6 +7,29 @@ pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: ChannelId,
|
||||
pub name: String,
|
||||
pub visibility: ChannelVisibility,
|
||||
pub parent_path: String,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn parent_id(&self) -> Option<ChannelId> {
|
||||
self.ancestors().last()
|
||||
}
|
||||
|
||||
pub fn ancestors(&self) -> impl Iterator<Item = ChannelId> + '_ {
|
||||
self.parent_path
|
||||
.trim_end_matches('/')
|
||||
.split('/')
|
||||
.filter_map(|id| Some(ChannelId::from_proto(id.parse().ok()?)))
|
||||
}
|
||||
|
||||
pub fn ancestors_including_self(&self) -> impl Iterator<Item = ChannelId> + '_ {
|
||||
self.ancestors().chain(Some(self.id))
|
||||
}
|
||||
|
||||
pub fn path(&self) -> String {
|
||||
format!("{}{}/", self.parent_path, self.id)
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::db::{channel_member, ChannelId, ChannelMemberId, UserId};
|
||||
use crate::db::{channel_member, ChannelId, ChannelMemberId, ChannelRole, UserId};
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "channel_members")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
@@ -9,7 +9,7 @@ pub struct Model {
|
||||
pub channel_id: ChannelId,
|
||||
pub user_id: UserId,
|
||||
pub accepted: bool,
|
||||
pub admin: bool,
|
||||
pub role: ChannelRole,
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
use crate::db::{MessageId, UserId};
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "channel_message_mentions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub message_id: MessageId,
|
||||
#[sea_orm(primary_key)]
|
||||
pub start_offset: i32,
|
||||
pub end_offset: i32,
|
||||
pub user_id: UserId,
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::channel_message::Entity",
|
||||
from = "Column::MessageId",
|
||||
to = "super::channel_message::Column::Id"
|
||||
)]
|
||||
Message,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::user::Column::Id"
|
||||
)]
|
||||
MentionedUser,
|
||||
}
|
||||
|
||||
impl Related<super::channel::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Message.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::MentionedUser.def()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use crate::db::{NotificationId, NotificationKindId, UserId};
|
||||
use sea_orm::entity::prelude::*;
|
||||
use time::PrimitiveDateTime;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "notifications")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: NotificationId,
|
||||
pub created_at: PrimitiveDateTime,
|
||||
pub recipient_id: UserId,
|
||||
pub kind: NotificationKindId,
|
||||
pub entity_id: Option<i32>,
|
||||
pub content: String,
|
||||
pub is_read: bool,
|
||||
pub response: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::RecipientId",
|
||||
to = "super::user::Column::Id"
|
||||
)]
|
||||
Recipient,
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
+7
-7
@@ -1,15 +1,15 @@
|
||||
use crate::db::ChannelId;
|
||||
use crate::db::NotificationKindId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "channel_paths")]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "notification_kinds")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id_path: String,
|
||||
pub channel_id: ChannelId,
|
||||
pub id: NotificationKindId,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -7,10 +7,12 @@ mod message_tests;
|
||||
use super::*;
|
||||
use gpui::executor::Background;
|
||||
use parking_lot::Mutex;
|
||||
use rpc::proto::ChannelEdge;
|
||||
use sea_orm::ConnectionTrait;
|
||||
use sqlx::migrate::MigrateDatabase;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{
|
||||
atomic::{AtomicI32, AtomicU32, Ordering::SeqCst},
|
||||
Arc,
|
||||
};
|
||||
|
||||
const TEST_RELEASE_CHANNEL: &'static str = "test";
|
||||
|
||||
@@ -31,7 +33,7 @@ impl TestDb {
|
||||
let mut db = runtime.block_on(async {
|
||||
let mut options = ConnectOptions::new(url);
|
||||
options.max_connections(5);
|
||||
let db = Database::new(options, Executor::Deterministic(background))
|
||||
let mut db = Database::new(options, Executor::Deterministic(background))
|
||||
.await
|
||||
.unwrap();
|
||||
let sql = include_str!(concat!(
|
||||
@@ -45,6 +47,7 @@ impl TestDb {
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
db.initialize_notification_kinds().await.unwrap();
|
||||
db
|
||||
});
|
||||
|
||||
@@ -79,11 +82,12 @@ impl TestDb {
|
||||
options
|
||||
.max_connections(5)
|
||||
.idle_timeout(Duration::from_secs(0));
|
||||
let db = Database::new(options, Executor::Deterministic(background))
|
||||
let mut db = Database::new(options, Executor::Deterministic(background))
|
||||
.await
|
||||
.unwrap();
|
||||
let migrations_path = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations");
|
||||
db.migrate(Path::new(migrations_path), false).await.unwrap();
|
||||
db.initialize_notification_kinds().await.unwrap();
|
||||
db
|
||||
});
|
||||
|
||||
@@ -148,26 +152,39 @@ impl Drop for TestDb {
|
||||
}
|
||||
}
|
||||
|
||||
/// The second tuples are (channel_id, parent)
|
||||
fn graph(channels: &[(ChannelId, &'static str)], edges: &[(ChannelId, ChannelId)]) -> ChannelGraph {
|
||||
let mut graph = ChannelGraph {
|
||||
channels: vec![],
|
||||
edges: vec![],
|
||||
};
|
||||
|
||||
for (id, name) in channels {
|
||||
graph.channels.push(Channel {
|
||||
fn channel_tree(channels: &[(ChannelId, &[ChannelId], &'static str, ChannelRole)]) -> Vec<Channel> {
|
||||
channels
|
||||
.iter()
|
||||
.map(|(id, parent_path, name, role)| Channel {
|
||||
id: *id,
|
||||
name: name.to_string(),
|
||||
visibility: ChannelVisibility::Members,
|
||||
role: *role,
|
||||
parent_path: parent_path.to_vec(),
|
||||
})
|
||||
}
|
||||
.collect()
|
||||
}
|
||||
|
||||
static GITHUB_USER_ID: AtomicI32 = AtomicI32::new(5);
|
||||
|
||||
async fn new_test_user(db: &Arc<Database>, email: &str) -> UserId {
|
||||
db.create_user(
|
||||
email,
|
||||
false,
|
||||
NewUserParams {
|
||||
github_login: email[0..email.find("@").unwrap()].to_string(),
|
||||
github_user_id: GITHUB_USER_ID.fetch_add(1, SeqCst),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.user_id
|
||||
}
|
||||
|
||||
for (channel, parent) in edges {
|
||||
graph.edges.push(ChannelEdge {
|
||||
channel_id: channel.to_proto(),
|
||||
parent_id: parent.to_proto(),
|
||||
})
|
||||
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,
|
||||
}
|
||||
|
||||
graph
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ async fn test_channel_buffers(db: &Arc<Database>) {
|
||||
NewUserParams {
|
||||
github_login: "user_a".into(),
|
||||
github_user_id: 101,
|
||||
invite_count: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -30,7 +29,6 @@ async fn test_channel_buffers(db: &Arc<Database>) {
|
||||
NewUserParams {
|
||||
github_login: "user_b".into(),
|
||||
github_user_id: 102,
|
||||
invite_count: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -45,7 +43,6 @@ async fn test_channel_buffers(db: &Arc<Database>) {
|
||||
NewUserParams {
|
||||
github_login: "user_c".into(),
|
||||
github_user_id: 102,
|
||||
invite_count: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -56,7 +53,7 @@ async fn test_channel_buffers(db: &Arc<Database>) {
|
||||
|
||||
let zed_id = db.create_root_channel("zed", a_id).await.unwrap();
|
||||
|
||||
db.invite_channel_member(zed_id, b_id, a_id, false)
|
||||
db.invite_channel_member(zed_id, b_id, a_id, ChannelRole::Member)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -178,7 +175,6 @@ async fn test_channel_buffers_last_operations(db: &Database) {
|
||||
NewUserParams {
|
||||
github_login: "user_a".into(),
|
||||
github_user_id: 101,
|
||||
invite_count: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -191,7 +187,6 @@ async fn test_channel_buffers_last_operations(db: &Database) {
|
||||
NewUserParams {
|
||||
github_login: "user_b".into(),
|
||||
github_user_id: 102,
|
||||
invite_count: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -211,7 +206,7 @@ async fn test_channel_buffers_last_operations(db: &Database) {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
db.invite_channel_member(channel, observer_id, user_id, false)
|
||||
db.invite_channel_member(channel, observer_id, user_id, ChannelRole::Member)
|
||||
.await
|
||||
.unwrap();
|
||||
db.respond_to_channel_invite(channel, observer_id, true)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,6 @@ async fn test_get_users(db: &Arc<Database>) {
|
||||
NewUserParams {
|
||||
github_login: format!("user{i}"),
|
||||
github_user_id: i,
|
||||
invite_count: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -88,7 +87,6 @@ async fn test_get_or_create_user_by_github_account(db: &Arc<Database>) {
|
||||
NewUserParams {
|
||||
github_login: "login1".into(),
|
||||
github_user_id: 101,
|
||||
invite_count: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -101,7 +99,6 @@ async fn test_get_or_create_user_by_github_account(db: &Arc<Database>) {
|
||||
NewUserParams {
|
||||
github_login: "login2".into(),
|
||||
github_user_id: 102,
|
||||
invite_count: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -156,7 +153,6 @@ async fn test_create_access_tokens(db: &Arc<Database>) {
|
||||
NewUserParams {
|
||||
github_login: "u1".into(),
|
||||
github_user_id: 1,
|
||||
invite_count: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -238,7 +234,6 @@ async fn test_add_contacts(db: &Arc<Database>) {
|
||||
NewUserParams {
|
||||
github_login: format!("user{i}"),
|
||||
github_user_id: i,
|
||||
invite_count: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -264,10 +259,7 @@ async fn test_add_contacts(db: &Arc<Database>) {
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_contacts(user_2).await.unwrap(),
|
||||
&[Contact::Incoming {
|
||||
user_id: user_1,
|
||||
should_notify: true
|
||||
}]
|
||||
&[Contact::Incoming { user_id: user_1 }]
|
||||
);
|
||||
|
||||
// User 2 dismisses the contact request notification without accepting or rejecting.
|
||||
@@ -280,10 +272,7 @@ async fn test_add_contacts(db: &Arc<Database>) {
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
db.get_contacts(user_2).await.unwrap(),
|
||||
&[Contact::Incoming {
|
||||
user_id: user_1,
|
||||
should_notify: false
|
||||
}]
|
||||
&[Contact::Incoming { user_id: user_1 }]
|
||||
);
|
||||
|
||||
// User can't accept their own contact request
|
||||
@@ -299,7 +288,6 @@ async fn test_add_contacts(db: &Arc<Database>) {
|
||||
db.get_contacts(user_1).await.unwrap(),
|
||||
&[Contact::Accepted {
|
||||
user_id: user_2,
|
||||
should_notify: true,
|
||||
busy: false,
|
||||
}],
|
||||
);
|
||||
@@ -309,7 +297,6 @@ async fn test_add_contacts(db: &Arc<Database>) {
|
||||
db.get_contacts(user_2).await.unwrap(),
|
||||
&[Contact::Accepted {
|
||||
user_id: user_1,
|
||||
should_notify: false,
|
||||
busy: false,
|
||||
}]
|
||||
);
|
||||
@@ -326,7 +313,6 @@ async fn test_add_contacts(db: &Arc<Database>) {
|
||||
db.get_contacts(user_1).await.unwrap(),
|
||||
&[Contact::Accepted {
|
||||
user_id: user_2,
|
||||
should_notify: true,
|
||||
busy: false,
|
||||
}]
|
||||
);
|
||||
@@ -339,7 +325,6 @@ async fn test_add_contacts(db: &Arc<Database>) {
|
||||
db.get_contacts(user_1).await.unwrap(),
|
||||
&[Contact::Accepted {
|
||||
user_id: user_2,
|
||||
should_notify: false,
|
||||
busy: false,
|
||||
}]
|
||||
);
|
||||
@@ -353,12 +338,10 @@ async fn test_add_contacts(db: &Arc<Database>) {
|
||||
&[
|
||||
Contact::Accepted {
|
||||
user_id: user_2,
|
||||
should_notify: false,
|
||||
busy: false,
|
||||
},
|
||||
Contact::Accepted {
|
||||
user_id: user_3,
|
||||
should_notify: false,
|
||||
busy: false,
|
||||
}
|
||||
]
|
||||
@@ -367,7 +350,6 @@ async fn test_add_contacts(db: &Arc<Database>) {
|
||||
db.get_contacts(user_3).await.unwrap(),
|
||||
&[Contact::Accepted {
|
||||
user_id: user_1,
|
||||
should_notify: false,
|
||||
busy: false,
|
||||
}],
|
||||
);
|
||||
@@ -383,7 +365,6 @@ async fn test_add_contacts(db: &Arc<Database>) {
|
||||
db.get_contacts(user_2).await.unwrap(),
|
||||
&[Contact::Accepted {
|
||||
user_id: user_1,
|
||||
should_notify: false,
|
||||
busy: false,
|
||||
}]
|
||||
);
|
||||
@@ -391,7 +372,6 @@ async fn test_add_contacts(db: &Arc<Database>) {
|
||||
db.get_contacts(user_3).await.unwrap(),
|
||||
&[Contact::Accepted {
|
||||
user_id: user_1,
|
||||
should_notify: false,
|
||||
busy: false,
|
||||
}],
|
||||
);
|
||||
@@ -415,7 +395,6 @@ async fn test_metrics_id(db: &Arc<Database>) {
|
||||
NewUserParams {
|
||||
github_login: "person1".into(),
|
||||
github_user_id: 101,
|
||||
invite_count: 5,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -431,7 +410,6 @@ async fn test_metrics_id(db: &Arc<Database>) {
|
||||
NewUserParams {
|
||||
github_login: "person2".into(),
|
||||
github_user_id: 102,
|
||||
invite_count: 5,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -460,7 +438,6 @@ async fn test_project_count(db: &Arc<Database>) {
|
||||
NewUserParams {
|
||||
github_login: "admin".into(),
|
||||
github_user_id: 0,
|
||||
invite_count: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -472,7 +449,6 @@ async fn test_project_count(db: &Arc<Database>) {
|
||||
NewUserParams {
|
||||
github_login: "user".into(),
|
||||
github_user_id: 1,
|
||||
invite_count: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -554,7 +530,6 @@ async fn test_fuzzy_search_users() {
|
||||
NewUserParams {
|
||||
github_login: github_login.into(),
|
||||
github_user_id: i as i32,
|
||||
invite_count: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -596,7 +571,6 @@ async fn test_non_matching_release_channels(db: &Arc<Database>) {
|
||||
NewUserParams {
|
||||
github_login: "admin".into(),
|
||||
github_user_id: 0,
|
||||
invite_count: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -608,7 +582,6 @@ async fn test_non_matching_release_channels(db: &Arc<Database>) {
|
||||
NewUserParams {
|
||||
github_login: "user".into(),
|
||||
github_user_id: 1,
|
||||
invite_count: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -18,7 +18,6 @@ async fn test_get_user_flags(db: &Arc<Database>) {
|
||||
NewUserParams {
|
||||
github_login: format!("user1"),
|
||||
github_user_id: 1,
|
||||
invite_count: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -32,7 +31,6 @@ async fn test_get_user_flags(db: &Arc<Database>) {
|
||||
NewUserParams {
|
||||
github_login: format!("user2"),
|
||||
github_user_id: 2,
|
||||
invite_count: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use super::new_test_user;
|
||||
use crate::{
|
||||
db::{Database, MessageId, NewUserParams},
|
||||
db::{ChannelRole, Database, MessageId},
|
||||
test_both_dbs,
|
||||
};
|
||||
use channel::mentions_to_proto;
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
@@ -12,39 +14,38 @@ test_both_dbs!(
|
||||
);
|
||||
|
||||
async fn test_channel_message_retrieval(db: &Arc<Database>) {
|
||||
let user = db
|
||||
.create_user(
|
||||
"user@example.com",
|
||||
false,
|
||||
NewUserParams {
|
||||
github_login: "user".into(),
|
||||
github_user_id: 1,
|
||||
invite_count: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.user_id;
|
||||
let channel = db.create_channel("channel", None, user).await.unwrap();
|
||||
let user = new_test_user(db, "user@example.com").await;
|
||||
let result = db.create_channel("channel", None, user).await.unwrap();
|
||||
|
||||
let owner_id = db.create_server("test").await.unwrap().0 as u32;
|
||||
db.join_channel_chat(channel, rpc::ConnectionId { owner_id, id: 0 }, user)
|
||||
.await
|
||||
.unwrap();
|
||||
db.join_channel_chat(
|
||||
result.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, user, &i.to_string(), OffsetDateTime::now_utc(), i)
|
||||
.await
|
||||
.unwrap()
|
||||
.0
|
||||
.to_proto(),
|
||||
db.create_channel_message(
|
||||
result.channel.id,
|
||||
user,
|
||||
&i.to_string(),
|
||||
&[],
|
||||
OffsetDateTime::now_utc(),
|
||||
i,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.message_id
|
||||
.to_proto(),
|
||||
);
|
||||
}
|
||||
|
||||
let messages = db
|
||||
.get_channel_messages(channel, user, 3, None)
|
||||
.get_channel_messages(result.channel.id, user, 3, None)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
@@ -54,7 +55,7 @@ async fn test_channel_message_retrieval(db: &Arc<Database>) {
|
||||
|
||||
let messages = db
|
||||
.get_channel_messages(
|
||||
channel,
|
||||
result.channel.id,
|
||||
user,
|
||||
4,
|
||||
Some(MessageId::from_proto(all_messages[6])),
|
||||
@@ -74,99 +75,154 @@ test_both_dbs!(
|
||||
);
|
||||
|
||||
async fn test_channel_message_nonces(db: &Arc<Database>) {
|
||||
let user = db
|
||||
.create_user(
|
||||
"user@example.com",
|
||||
false,
|
||||
NewUserParams {
|
||||
github_login: "user".into(),
|
||||
github_user_id: 1,
|
||||
invite_count: 0,
|
||||
},
|
||||
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 re-use 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,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.user_id;
|
||||
let channel = db.create_channel("channel", None, user).await.unwrap();
|
||||
.message_id;
|
||||
let id2 = db
|
||||
.create_channel_message(
|
||||
channel,
|
||||
user_a,
|
||||
"hello, fellow users",
|
||||
&mentions_to_proto(&[]),
|
||||
OffsetDateTime::now_utc(),
|
||||
200,
|
||||
)
|
||||
.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,
|
||||
)
|
||||
.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,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.message_id;
|
||||
|
||||
let owner_id = db.create_server("test").await.unwrap().0 as u32;
|
||||
// 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,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.message_id;
|
||||
|
||||
db.join_channel_chat(channel, rpc::ConnectionId { owner_id, id: 0 }, user)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(id1, id2);
|
||||
assert_eq!(id1, id3);
|
||||
assert_eq!(id2, id4);
|
||||
assert_ne!(id5, id1);
|
||||
|
||||
let msg1_id = db
|
||||
.create_channel_message(channel, user, "1", OffsetDateTime::now_utc(), 1)
|
||||
let messages = db
|
||||
.get_channel_messages(channel, user_a, 5, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let msg2_id = db
|
||||
.create_channel_message(channel, user, "2", OffsetDateTime::now_utc(), 2)
|
||||
.await
|
||||
.unwrap();
|
||||
let msg3_id = db
|
||||
.create_channel_message(channel, user, "3", OffsetDateTime::now_utc(), 1)
|
||||
.await
|
||||
.unwrap();
|
||||
let msg4_id = db
|
||||
.create_channel_message(channel, user, "4", OffsetDateTime::now_utc(), 2)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(msg1_id, msg2_id);
|
||||
assert_eq!(msg1_id, msg3_id);
|
||||
assert_eq!(msg2_id, msg4_id);
|
||||
.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_channel_message_new_notification,
|
||||
test_channel_message_new_notification_postgres,
|
||||
test_channel_message_new_notification_sqlite
|
||||
test_unseen_channel_messages,
|
||||
test_unseen_channel_messages_postgres,
|
||||
test_unseen_channel_messages_sqlite
|
||||
);
|
||||
|
||||
async fn test_channel_message_new_notification(db: &Arc<Database>) {
|
||||
let user = db
|
||||
.create_user(
|
||||
"user_a@example.com",
|
||||
false,
|
||||
NewUserParams {
|
||||
github_login: "user_a".into(),
|
||||
github_user_id: 1,
|
||||
invite_count: 0,
|
||||
},
|
||||
)
|
||||
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()
|
||||
.user_id;
|
||||
let observer = db
|
||||
.create_user(
|
||||
"user_b@example.com",
|
||||
false,
|
||||
NewUserParams {
|
||||
github_login: "user_b".into(),
|
||||
github_user_id: 1,
|
||||
invite_count: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.user_id;
|
||||
|
||||
let channel_1 = db.create_channel("channel", None, user).await.unwrap();
|
||||
|
||||
let channel_2 = db.create_channel("channel-2", None, user).await.unwrap();
|
||||
|
||||
db.invite_channel_member(channel_1, observer, user, false)
|
||||
.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.invite_channel_member(channel_2, observer, user, false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
db.respond_to_channel_invite(channel_2, observer, true)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -179,28 +235,31 @@ async fn test_channel_message_new_notification(db: &Arc<Database>) {
|
||||
.unwrap();
|
||||
|
||||
let _ = db
|
||||
.create_channel_message(channel_1, user, "1_1", OffsetDateTime::now_utc(), 1)
|
||||
.create_channel_message(channel_1, user, "1_1", &[], OffsetDateTime::now_utc(), 1)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (second_message, _, _) = db
|
||||
.create_channel_message(channel_1, user, "1_2", OffsetDateTime::now_utc(), 2)
|
||||
let second_message = db
|
||||
.create_channel_message(channel_1, user, "1_2", &[], OffsetDateTime::now_utc(), 2)
|
||||
.await
|
||||
.unwrap();
|
||||
.unwrap()
|
||||
.message_id;
|
||||
|
||||
let (third_message, _, _) = db
|
||||
.create_channel_message(channel_1, user, "1_3", OffsetDateTime::now_utc(), 3)
|
||||
let third_message = db
|
||||
.create_channel_message(channel_1, user, "1_3", &[], OffsetDateTime::now_utc(), 3)
|
||||
.await
|
||||
.unwrap();
|
||||
.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)
|
||||
let fourth_message = db
|
||||
.create_channel_message(channel_2, user, "2_1", &[], OffsetDateTime::now_utc(), 4)
|
||||
.await
|
||||
.unwrap();
|
||||
.unwrap()
|
||||
.message_id;
|
||||
|
||||
// Check that observer has new messages
|
||||
let unseen_messages = db
|
||||
@@ -295,3 +354,101 @@ async fn test_channel_message_new_notification(db: &Arc<Database>) {
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
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()
|
||||
.channel
|
||||
.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,
|
||||
)
|
||||
.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,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_channel_message(
|
||||
channel,
|
||||
user_a,
|
||||
"umm",
|
||||
&mentions_to_proto(&[]),
|
||||
OffsetDateTime::now_utc(),
|
||||
3,
|
||||
)
|
||||
.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,
|
||||
)
|
||||
.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())]),
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,7 +119,9 @@ impl AppState {
|
||||
pub async fn new(config: Config) -> Result<Arc<Self>> {
|
||||
let mut db_options = db::ConnectOptions::new(config.database_url.clone());
|
||||
db_options.max_connections(config.database_max_connections);
|
||||
let db = Database::new(db_options, Executor::Production).await?;
|
||||
let mut db = Database::new(db_options, Executor::Production).await?;
|
||||
db.initialize_notification_kinds().await?;
|
||||
|
||||
let live_kit_client = if let Some(((server, key), secret)) = config
|
||||
.live_kit_server
|
||||
.as_ref()
|
||||
|
||||
+457
-349
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ mod channel_message_tests;
|
||||
mod channel_tests;
|
||||
mod following_tests;
|
||||
mod integration_tests;
|
||||
mod notification_tests;
|
||||
mod random_channel_buffer_tests;
|
||||
mod random_project_collaboration_tests;
|
||||
mod randomized_test_helpers;
|
||||
@@ -39,3 +40,7 @@ fn room_participants(room: &ModelHandle<Room>, cx: &mut TestAppContext) -> RoomP
|
||||
RoomParticipants { remote, pending }
|
||||
})
|
||||
}
|
||||
|
||||
fn channel_id(room: &ModelHandle<Room>, cx: &mut TestAppContext) -> Option<u64> {
|
||||
cx.read(|cx| room.read(cx).channel_id())
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::{
|
||||
tests::TestServer,
|
||||
};
|
||||
use call::ActiveCall;
|
||||
use channel::{Channel, ACKNOWLEDGE_DEBOUNCE_INTERVAL};
|
||||
use channel::ACKNOWLEDGE_DEBOUNCE_INTERVAL;
|
||||
use client::ParticipantIndex;
|
||||
use client::{Collaborator, UserId};
|
||||
use collab_ui::channel_view::ChannelView;
|
||||
@@ -407,11 +407,8 @@ async fn test_channel_buffer_disconnect(
|
||||
server.disconnect_client(client_a.peer_id().unwrap());
|
||||
deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
|
||||
|
||||
channel_buffer_a.update(cx_a, |buffer, _| {
|
||||
assert_eq!(
|
||||
buffer.channel().as_ref(),
|
||||
&channel(channel_id, "the-channel")
|
||||
);
|
||||
channel_buffer_a.update(cx_a, |buffer, cx| {
|
||||
assert_eq!(buffer.channel(cx).unwrap().name, "the-channel");
|
||||
assert!(!buffer.is_connected());
|
||||
});
|
||||
|
||||
@@ -432,24 +429,12 @@ async fn test_channel_buffer_disconnect(
|
||||
deterministic.run_until_parked();
|
||||
|
||||
// Channel buffer observed the deletion
|
||||
channel_buffer_b.update(cx_b, |buffer, _| {
|
||||
assert_eq!(
|
||||
buffer.channel().as_ref(),
|
||||
&channel(channel_id, "the-channel")
|
||||
);
|
||||
channel_buffer_b.update(cx_b, |buffer, cx| {
|
||||
assert!(buffer.channel(cx).is_none());
|
||||
assert!(!buffer.is_connected());
|
||||
});
|
||||
}
|
||||
|
||||
fn channel(id: u64, name: &'static str) -> Channel {
|
||||
Channel {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
unseen_note_version: None,
|
||||
unseen_message_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_rejoin_channel_buffer(
|
||||
deterministic: Arc<Deterministic>,
|
||||
@@ -694,7 +679,7 @@ async fn test_following_to_channel_notes_without_a_shared_project(
|
||||
.await
|
||||
.unwrap();
|
||||
channel_view_1_a.update(cx_a, |notes, cx| {
|
||||
assert_eq!(notes.channel(cx).name, "channel-1");
|
||||
assert_eq!(notes.channel(cx).unwrap().name, "channel-1");
|
||||
notes.editor.update(cx, |editor, cx| {
|
||||
editor.insert("Hello from A.", cx);
|
||||
editor.change_selections(None, cx, |selections| {
|
||||
@@ -726,7 +711,7 @@ async fn test_following_to_channel_notes_without_a_shared_project(
|
||||
.expect("active item is not a channel view")
|
||||
});
|
||||
channel_view_1_b.read_with(cx_b, |notes, cx| {
|
||||
assert_eq!(notes.channel(cx).name, "channel-1");
|
||||
assert_eq!(notes.channel(cx).unwrap().name, "channel-1");
|
||||
let editor = notes.editor.read(cx);
|
||||
assert_eq!(editor.text(cx), "Hello from A.");
|
||||
assert_eq!(editor.selections.ranges::<usize>(cx), &[3..4]);
|
||||
@@ -738,7 +723,7 @@ async fn test_following_to_channel_notes_without_a_shared_project(
|
||||
.await
|
||||
.unwrap();
|
||||
channel_view_2_a.read_with(cx_a, |notes, cx| {
|
||||
assert_eq!(notes.channel(cx).name, "channel-2");
|
||||
assert_eq!(notes.channel(cx).unwrap().name, "channel-2");
|
||||
});
|
||||
|
||||
// Client B is taken to the notes for channel 2.
|
||||
@@ -755,7 +740,7 @@ async fn test_following_to_channel_notes_without_a_shared_project(
|
||||
.expect("active item is not a channel view")
|
||||
});
|
||||
channel_view_2_b.read_with(cx_b, |notes, cx| {
|
||||
assert_eq!(notes.channel(cx).name, "channel-2");
|
||||
assert_eq!(notes.channel(cx).unwrap().name, "channel-2");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer};
|
||||
use channel::{ChannelChat, ChannelMessageId};
|
||||
use channel::{ChannelChat, ChannelMessageId, MessageParams};
|
||||
use collab_ui::chat_panel::ChatPanel;
|
||||
use gpui::{executor::Deterministic, BorrowAppContext, ModelHandle, TestAppContext};
|
||||
use rpc::Notification;
|
||||
use std::sync::Arc;
|
||||
use workspace::dock::Panel;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_basic_channel_messages(
|
||||
deterministic: Arc<Deterministic>,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
mut cx_a: &mut TestAppContext,
|
||||
mut cx_b: &mut TestAppContext,
|
||||
mut cx_c: &mut TestAppContext,
|
||||
) {
|
||||
deterministic.forbid_parking();
|
||||
let mut server = TestServer::start(&deterministic).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
let client_c = server.create_client(cx_c, "user_c").await;
|
||||
|
||||
let channel_id = server
|
||||
.make_channel(
|
||||
"the-channel",
|
||||
None,
|
||||
(&client_a, cx_a),
|
||||
&mut [(&client_b, cx_b)],
|
||||
&mut [(&client_b, cx_b), (&client_c, cx_c)],
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -36,8 +39,17 @@ async fn test_basic_channel_messages(
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| c.send_message("one".into(), cx).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())],
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
channel_chat_a
|
||||
@@ -52,15 +64,55 @@ async fn test_basic_channel_messages(
|
||||
.unwrap();
|
||||
|
||||
deterministic.run_until_parked();
|
||||
channel_chat_a.update(cx_a, |c, _| {
|
||||
|
||||
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!(
|
||||
c.messages()
|
||||
.iter()
|
||||
.map(|m| m.body.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["one", "two", "three"]
|
||||
store.notification_at(0).unwrap().notification,
|
||||
Notification::ChannelMessageMention {
|
||||
message_id,
|
||||
sender_id: client_a.id(),
|
||||
channel_id,
|
||||
}
|
||||
);
|
||||
})
|
||||
assert_eq!(
|
||||
store.notification_at(1).unwrap().notification,
|
||||
Notification::ChannelInvitation {
|
||||
channel_id,
|
||||
channel_name: "the-channel".to_string(),
|
||||
inviter_id: client_a.id()
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
@@ -280,7 +332,7 @@ async fn test_channel_message_changes(
|
||||
chat_panel_b
|
||||
.update(cx_b, |chat_panel, cx| {
|
||||
chat_panel.set_active(true, cx);
|
||||
chat_panel.select_channel(channel_id, cx)
|
||||
chat_panel.select_channel(channel_id, None, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer};
|
||||
use call::ActiveCall;
|
||||
use collab_ui::project_shared_notification::ProjectSharedNotification;
|
||||
use collab_ui::notifications::project_shared_notification::ProjectSharedNotification;
|
||||
use editor::{Editor, ExcerptRange, MultiBuffer};
|
||||
use gpui::{executor::Deterministic, geometry::vector::vec2f, TestAppContext, ViewHandle};
|
||||
use live_kit_client::MacOSDisplay;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
rpc::{CLEANUP_TIMEOUT, RECONNECT_TIMEOUT},
|
||||
tests::{room_participants, RoomParticipants, TestClient, TestServer},
|
||||
tests::{channel_id, room_participants, RoomParticipants, TestClient, TestServer},
|
||||
};
|
||||
use call::{room, ActiveCall, ParticipantLocation, Room};
|
||||
use client::{User, RECEIVE_TIMEOUT};
|
||||
@@ -15,8 +15,8 @@ use gpui::{executor::Deterministic, test::EmptyView, AppContext, ModelHandle, Te
|
||||
use indoc::indoc;
|
||||
use language::{
|
||||
language_settings::{AllLanguageSettings, Formatter, InlayHintSettings},
|
||||
tree_sitter_rust, Anchor, BundledFormatter, Diagnostic, DiagnosticEntry, FakeLspAdapter,
|
||||
Language, LanguageConfig, LineEnding, OffsetRangeExt, Point, Rope,
|
||||
tree_sitter_rust, Anchor, Diagnostic, DiagnosticEntry, FakeLspAdapter, Language,
|
||||
LanguageConfig, LineEnding, OffsetRangeExt, Point, Rope,
|
||||
};
|
||||
use live_kit_client::MacOSDisplay;
|
||||
use lsp::LanguageServerId;
|
||||
@@ -469,6 +469,119 @@ async fn test_calling_multiple_users_simultaneously(
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test(iterations = 10)]
|
||||
async fn test_joining_channels_and_calling_multiple_users_simultaneously(
|
||||
deterministic: Arc<Deterministic>,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
cx_c: &mut TestAppContext,
|
||||
) {
|
||||
deterministic.forbid_parking();
|
||||
let mut server = TestServer::start(&deterministic).await;
|
||||
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
let client_c = server.create_client(cx_c, "user_c").await;
|
||||
server
|
||||
.make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
|
||||
.await;
|
||||
|
||||
let channel_1 = server
|
||||
.make_channel(
|
||||
"channel1",
|
||||
None,
|
||||
(&client_a, cx_a),
|
||||
&mut [(&client_b, cx_b), (&client_c, cx_c)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let channel_2 = server
|
||||
.make_channel(
|
||||
"channel2",
|
||||
None,
|
||||
(&client_a, cx_a),
|
||||
&mut [(&client_b, cx_b), (&client_c, cx_c)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let active_call_a = cx_a.read(ActiveCall::global);
|
||||
|
||||
// Simultaneously join channel 1 and then channel 2
|
||||
active_call_a
|
||||
.update(cx_a, |call, cx| call.join_channel(channel_1, cx))
|
||||
.detach();
|
||||
let join_channel_2 = active_call_a.update(cx_a, |call, cx| call.join_channel(channel_2, cx));
|
||||
|
||||
join_channel_2.await.unwrap();
|
||||
|
||||
let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
|
||||
deterministic.run_until_parked();
|
||||
|
||||
assert_eq!(channel_id(&room_a, cx_a), Some(channel_2));
|
||||
|
||||
// Leave the room
|
||||
active_call_a
|
||||
.update(cx_a, |call, cx| {
|
||||
let hang_up = call.hang_up(cx);
|
||||
hang_up
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Initiating invites and then joining a channel should fail gracefully
|
||||
let b_invite = active_call_a.update(cx_a, |call, cx| {
|
||||
call.invite(client_b.user_id().unwrap(), None, cx)
|
||||
});
|
||||
let c_invite = active_call_a.update(cx_a, |call, cx| {
|
||||
call.invite(client_c.user_id().unwrap(), None, cx)
|
||||
});
|
||||
|
||||
let join_channel = active_call_a.update(cx_a, |call, cx| call.join_channel(channel_1, cx));
|
||||
|
||||
b_invite.await.unwrap();
|
||||
c_invite.await.unwrap();
|
||||
join_channel.await.unwrap();
|
||||
|
||||
let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
|
||||
deterministic.run_until_parked();
|
||||
|
||||
assert_eq!(
|
||||
room_participants(&room_a, cx_a),
|
||||
RoomParticipants {
|
||||
remote: Default::default(),
|
||||
pending: vec!["user_b".to_string(), "user_c".to_string()]
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(channel_id(&room_a, cx_a), None);
|
||||
|
||||
// Leave the room
|
||||
active_call_a
|
||||
.update(cx_a, |call, cx| {
|
||||
let hang_up = call.hang_up(cx);
|
||||
hang_up
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Simultaneously join channel 1 and call user B and user C from client A.
|
||||
let join_channel = active_call_a.update(cx_a, |call, cx| call.join_channel(channel_1, cx));
|
||||
|
||||
let b_invite = active_call_a.update(cx_a, |call, cx| {
|
||||
call.invite(client_b.user_id().unwrap(), None, cx)
|
||||
});
|
||||
let c_invite = active_call_a.update(cx_a, |call, cx| {
|
||||
call.invite(client_c.user_id().unwrap(), None, cx)
|
||||
});
|
||||
|
||||
join_channel.await.unwrap();
|
||||
b_invite.await.unwrap();
|
||||
c_invite.await.unwrap();
|
||||
|
||||
active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
|
||||
deterministic.run_until_parked();
|
||||
}
|
||||
|
||||
#[gpui::test(iterations = 10)]
|
||||
async fn test_room_uniqueness(
|
||||
deterministic: Arc<Deterministic>,
|
||||
@@ -4530,6 +4643,7 @@ async fn test_prettier_formatting_buffer(
|
||||
LanguageConfig {
|
||||
name: "Rust".into(),
|
||||
path_suffixes: vec!["rs".to_string()],
|
||||
prettier_parser_name: Some("test_parser".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
Some(tree_sitter_rust::language()),
|
||||
@@ -4537,10 +4651,7 @@ async fn test_prettier_formatting_buffer(
|
||||
let test_plugin = "test_plugin";
|
||||
let mut fake_language_servers = language
|
||||
.set_fake_lsp_adapter(Arc::new(FakeLspAdapter {
|
||||
enabled_formatters: vec![BundledFormatter::Prettier {
|
||||
parser_name: Some("test_parser"),
|
||||
plugin_names: vec![test_plugin],
|
||||
}],
|
||||
prettier_plugins: vec![test_plugin],
|
||||
..Default::default()
|
||||
}))
|
||||
.await;
|
||||
@@ -4557,11 +4668,7 @@ async fn test_prettier_formatting_buffer(
|
||||
.insert_tree(&directory, json!({ "a.rs": buffer_text }))
|
||||
.await;
|
||||
let (project_a, worktree_id) = client_a.build_local_project(&directory, cx_a).await;
|
||||
let prettier_format_suffix = project_a.update(cx_a, |project, _| {
|
||||
let suffix = project.enable_test_prettier(&[test_plugin]);
|
||||
project.languages().add(language);
|
||||
suffix
|
||||
});
|
||||
let prettier_format_suffix = project::TEST_PRETTIER_FORMAT_SUFFIX;
|
||||
let buffer_a = cx_a
|
||||
.background()
|
||||
.spawn(project_a.update(cx_a, |p, cx| p.open_buffer((worktree_id, "a.rs"), cx)))
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
use crate::tests::TestServer;
|
||||
use gpui::{executor::Deterministic, TestAppContext};
|
||||
use notifications::NotificationEvent;
|
||||
use parking_lot::Mutex;
|
||||
use rpc::{proto, Notification};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_notifications(
|
||||
deterministic: Arc<Deterministic>,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
) {
|
||||
deterministic.forbid_parking();
|
||||
let mut server = TestServer::start(&deterministic).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
|
||||
let notification_events_a = Arc::new(Mutex::new(Vec::new()));
|
||||
let notification_events_b = Arc::new(Mutex::new(Vec::new()));
|
||||
client_a.notification_store().update(cx_a, |_, cx| {
|
||||
let events = notification_events_a.clone();
|
||||
cx.subscribe(&cx.handle(), move |_, _, event, _| {
|
||||
events.lock().push(event.clone());
|
||||
})
|
||||
.detach()
|
||||
});
|
||||
client_b.notification_store().update(cx_b, |_, cx| {
|
||||
let events = notification_events_b.clone();
|
||||
cx.subscribe(&cx.handle(), move |_, _, event, _| {
|
||||
events.lock().push(event.clone());
|
||||
})
|
||||
.detach()
|
||||
});
|
||||
|
||||
// Client A sends a contact request to client B.
|
||||
client_a
|
||||
.user_store()
|
||||
.update(cx_a, |store, cx| store.request_contact(client_b.id(), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Client B receives a contact request notification and responds to the
|
||||
// request, accepting it.
|
||||
deterministic.run_until_parked();
|
||||
client_b.notification_store().update(cx_b, |store, cx| {
|
||||
assert_eq!(store.notification_count(), 1);
|
||||
assert_eq!(store.unread_notification_count(), 1);
|
||||
|
||||
let entry = store.notification_at(0).unwrap();
|
||||
assert_eq!(
|
||||
entry.notification,
|
||||
Notification::ContactRequest {
|
||||
sender_id: client_a.id()
|
||||
}
|
||||
);
|
||||
assert!(!entry.is_read);
|
||||
assert_eq!(
|
||||
¬ification_events_b.lock()[0..],
|
||||
&[
|
||||
NotificationEvent::NewNotification {
|
||||
entry: entry.clone(),
|
||||
},
|
||||
NotificationEvent::NotificationsUpdated {
|
||||
old_range: 0..0,
|
||||
new_count: 1
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
store.respond_to_notification(entry.notification.clone(), true, cx);
|
||||
});
|
||||
|
||||
// Client B sees the notification is now read, and that they responded.
|
||||
deterministic.run_until_parked();
|
||||
client_b.notification_store().read_with(cx_b, |store, _| {
|
||||
assert_eq!(store.notification_count(), 1);
|
||||
assert_eq!(store.unread_notification_count(), 0);
|
||||
|
||||
let entry = store.notification_at(0).unwrap();
|
||||
assert!(entry.is_read);
|
||||
assert_eq!(entry.response, Some(true));
|
||||
assert_eq!(
|
||||
¬ification_events_b.lock()[2..],
|
||||
&[
|
||||
NotificationEvent::NotificationRead {
|
||||
entry: entry.clone(),
|
||||
},
|
||||
NotificationEvent::NotificationsUpdated {
|
||||
old_range: 0..1,
|
||||
new_count: 1
|
||||
}
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
// Client A receives a notification that client B accepted their request.
|
||||
client_a.notification_store().read_with(cx_a, |store, _| {
|
||||
assert_eq!(store.notification_count(), 1);
|
||||
assert_eq!(store.unread_notification_count(), 1);
|
||||
|
||||
let entry = store.notification_at(0).unwrap();
|
||||
assert_eq!(
|
||||
entry.notification,
|
||||
Notification::ContactRequestAccepted {
|
||||
responder_id: client_b.id()
|
||||
}
|
||||
);
|
||||
assert!(!entry.is_read);
|
||||
});
|
||||
|
||||
// Client A creates a channel and invites client B to be a member.
|
||||
let channel_id = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| {
|
||||
store.create_channel("the-channel", None, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| {
|
||||
store.invite_member(channel_id, client_b.id(), proto::ChannelRole::Member, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Client B receives a channel invitation notification and responds to the
|
||||
// invitation, accepting it.
|
||||
deterministic.run_until_parked();
|
||||
client_b.notification_store().update(cx_b, |store, cx| {
|
||||
assert_eq!(store.notification_count(), 2);
|
||||
assert_eq!(store.unread_notification_count(), 1);
|
||||
|
||||
let entry = store.notification_at(0).unwrap();
|
||||
assert_eq!(
|
||||
entry.notification,
|
||||
Notification::ChannelInvitation {
|
||||
channel_id,
|
||||
channel_name: "the-channel".to_string(),
|
||||
inviter_id: client_a.id()
|
||||
}
|
||||
);
|
||||
assert!(!entry.is_read);
|
||||
|
||||
store.respond_to_notification(entry.notification.clone(), true, cx);
|
||||
});
|
||||
|
||||
// Client B sees the notification is now read, and that they responded.
|
||||
deterministic.run_until_parked();
|
||||
client_b.notification_store().read_with(cx_b, |store, _| {
|
||||
assert_eq!(store.notification_count(), 2);
|
||||
assert_eq!(store.unread_notification_count(), 0);
|
||||
|
||||
let entry = store.notification_at(0).unwrap();
|
||||
assert!(entry.is_read);
|
||||
assert_eq!(entry.response, Some(true));
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
use crate::db::ChannelRole;
|
||||
|
||||
use super::{run_randomized_test, RandomizedTest, TestClient, TestError, TestServer, UserTestPlan};
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
@@ -46,11 +48,11 @@ impl RandomizedTest for RandomChannelBufferTest {
|
||||
let db = &server.app_state.db;
|
||||
for ix in 0..CHANNEL_COUNT {
|
||||
let id = db
|
||||
.create_channel(&format!("channel-{ix}"), None, users[0].user_id)
|
||||
.create_root_channel(&format!("channel-{ix}"), users[0].user_id)
|
||||
.await
|
||||
.unwrap();
|
||||
for user in &users[1..] {
|
||||
db.invite_channel_member(id, user.user_id, users[0].user_id, false)
|
||||
db.invite_channel_member(id, user.user_id, users[0].user_id, ChannelRole::Member)
|
||||
.await
|
||||
.unwrap();
|
||||
db.respond_to_channel_invite(id, user.user_id, true)
|
||||
@@ -81,7 +83,7 @@ impl RandomizedTest for RandomChannelBufferTest {
|
||||
match rng.gen_range(0..100_u32) {
|
||||
0..=29 => {
|
||||
let channel_name = client.channel_store().read_with(cx, |store, cx| {
|
||||
store.channel_dag_entries().find_map(|(_, channel)| {
|
||||
store.ordered_channels().find_map(|(_, channel)| {
|
||||
if store.has_open_channel_buffer(channel.id, cx) {
|
||||
None
|
||||
} else {
|
||||
@@ -96,15 +98,16 @@ impl RandomizedTest for RandomChannelBufferTest {
|
||||
|
||||
30..=40 => {
|
||||
if let Some(buffer) = channel_buffers.iter().choose(rng) {
|
||||
let channel_name = buffer.read_with(cx, |b, _| b.channel().name.clone());
|
||||
let channel_name =
|
||||
buffer.read_with(cx, |b, cx| b.channel(cx).unwrap().name.clone());
|
||||
break ChannelBufferOperation::LeaveChannelNotes { channel_name };
|
||||
}
|
||||
}
|
||||
|
||||
_ => {
|
||||
if let Some(buffer) = channel_buffers.iter().choose(rng) {
|
||||
break buffer.read_with(cx, |b, _| {
|
||||
let channel_name = b.channel().name.clone();
|
||||
break buffer.read_with(cx, |b, cx| {
|
||||
let channel_name = b.channel(cx).unwrap().name.clone();
|
||||
let edits = b
|
||||
.buffer()
|
||||
.read_with(cx, |buffer, _| buffer.get_random_edits(rng, 3));
|
||||
@@ -128,7 +131,7 @@ impl RandomizedTest for RandomChannelBufferTest {
|
||||
ChannelBufferOperation::JoinChannelNotes { channel_name } => {
|
||||
let buffer = client.channel_store().update(cx, |store, cx| {
|
||||
let channel_id = store
|
||||
.channel_dag_entries()
|
||||
.ordered_channels()
|
||||
.find(|(_, c)| c.name == channel_name)
|
||||
.unwrap()
|
||||
.1
|
||||
@@ -151,7 +154,7 @@ impl RandomizedTest for RandomChannelBufferTest {
|
||||
let buffer = cx.update(|cx| {
|
||||
let mut left_buffer = Err(TestError::Inapplicable);
|
||||
client.channel_buffers().retain(|buffer| {
|
||||
if buffer.read(cx).channel().name == channel_name {
|
||||
if buffer.read(cx).channel(cx).unwrap().name == channel_name {
|
||||
left_buffer = Ok(buffer.clone());
|
||||
false
|
||||
} else {
|
||||
@@ -177,7 +180,9 @@ impl RandomizedTest for RandomChannelBufferTest {
|
||||
client
|
||||
.channel_buffers()
|
||||
.iter()
|
||||
.find(|buffer| buffer.read(cx).channel().name == channel_name)
|
||||
.find(|buffer| {
|
||||
buffer.read(cx).channel(cx).unwrap().name == channel_name
|
||||
})
|
||||
.cloned()
|
||||
})
|
||||
.ok_or_else(|| TestError::Inapplicable)?;
|
||||
@@ -248,7 +253,7 @@ impl RandomizedTest for RandomChannelBufferTest {
|
||||
if let Some(channel_buffer) = client
|
||||
.channel_buffers()
|
||||
.iter()
|
||||
.find(|b| b.read(cx).channel().id == channel_id.to_proto())
|
||||
.find(|b| b.read(cx).channel_id == channel_id.to_proto())
|
||||
{
|
||||
let channel_buffer = channel_buffer.read(cx);
|
||||
|
||||
|
||||
@@ -208,8 +208,7 @@ impl<T: RandomizedTest> TestPlan<T> {
|
||||
false,
|
||||
NewUserParams {
|
||||
github_login: username.clone(),
|
||||
github_user_id: (ix + 1) as i32,
|
||||
invite_count: 0,
|
||||
github_user_id: ix as i32,
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -16,9 +16,10 @@ use futures::{channel::oneshot, StreamExt as _};
|
||||
use gpui::{executor::Deterministic, ModelHandle, Task, TestAppContext, WindowHandle};
|
||||
use language::LanguageRegistry;
|
||||
use node_runtime::FakeNodeRuntime;
|
||||
use notifications::NotificationStore;
|
||||
use parking_lot::Mutex;
|
||||
use project::{Project, WorktreeId};
|
||||
use rpc::RECEIVE_TIMEOUT;
|
||||
use rpc::{proto::ChannelRole, RECEIVE_TIMEOUT};
|
||||
use settings::SettingsStore;
|
||||
use std::{
|
||||
cell::{Ref, RefCell, RefMut},
|
||||
@@ -46,6 +47,7 @@ pub struct TestClient {
|
||||
pub username: String,
|
||||
pub app_state: Arc<workspace::AppState>,
|
||||
channel_store: ModelHandle<ChannelStore>,
|
||||
notification_store: ModelHandle<NotificationStore>,
|
||||
state: RefCell<TestClientState>,
|
||||
}
|
||||
|
||||
@@ -138,7 +140,6 @@ impl TestServer {
|
||||
NewUserParams {
|
||||
github_login: name.into(),
|
||||
github_user_id: 0,
|
||||
invite_count: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -231,7 +232,8 @@ impl TestServer {
|
||||
workspace::init(app_state.clone(), cx);
|
||||
audio::init((), cx);
|
||||
call::init(client.clone(), user_store.clone(), cx);
|
||||
channel::init(&client, user_store, cx);
|
||||
channel::init(&client, user_store.clone(), cx);
|
||||
notifications::init(client.clone(), user_store, cx);
|
||||
});
|
||||
|
||||
client
|
||||
@@ -243,6 +245,7 @@ impl TestServer {
|
||||
app_state,
|
||||
username: name.to_string(),
|
||||
channel_store: cx.read(ChannelStore::global).clone(),
|
||||
notification_store: cx.read(NotificationStore::global).clone(),
|
||||
state: Default::default(),
|
||||
};
|
||||
client.wait_for_current_user(cx).await;
|
||||
@@ -327,7 +330,7 @@ impl TestServer {
|
||||
channel_store.invite_member(
|
||||
channel_id,
|
||||
member_client.user_id().unwrap(),
|
||||
false,
|
||||
ChannelRole::Member,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
@@ -338,8 +341,8 @@ impl TestServer {
|
||||
|
||||
member_cx
|
||||
.read(ChannelStore::global)
|
||||
.update(*member_cx, |channels, _| {
|
||||
channels.respond_to_channel_invite(channel_id, true)
|
||||
.update(*member_cx, |channels, cx| {
|
||||
channels.respond_to_channel_invite(channel_id, true, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -448,6 +451,10 @@ impl TestClient {
|
||||
&self.channel_store
|
||||
}
|
||||
|
||||
pub fn notification_store(&self) -> &ModelHandle<NotificationStore> {
|
||||
&self.notification_store
|
||||
}
|
||||
|
||||
pub fn user_store(&self) -> &ModelHandle<UserStore> {
|
||||
&self.app_state.user_store
|
||||
}
|
||||
@@ -604,33 +611,6 @@ impl TestClient {
|
||||
) -> WindowHandle<Workspace> {
|
||||
cx.add_window(|cx| Workspace::new(0, project.clone(), self.app_state.clone(), cx))
|
||||
}
|
||||
|
||||
pub async fn add_admin_to_channel(
|
||||
&self,
|
||||
user: (&TestClient, &mut TestAppContext),
|
||||
channel: u64,
|
||||
cx_self: &mut TestAppContext,
|
||||
) {
|
||||
let (other_client, other_cx) = user;
|
||||
|
||||
cx_self
|
||||
.read(ChannelStore::global)
|
||||
.update(cx_self, |channel_store, cx| {
|
||||
channel_store.invite_member(channel, other_client.user_id().unwrap(), true, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx_self.foreground().run_until_parked();
|
||||
|
||||
other_cx
|
||||
.read(ChannelStore::global)
|
||||
.update(other_cx, |channel_store, _| {
|
||||
channel_store.respond_to_channel_invite(channel, true)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestClient {
|
||||
|
||||
@@ -37,10 +37,12 @@ fuzzy = { path = "../fuzzy" }
|
||||
gpui = { path = "../gpui" }
|
||||
language = { path = "../language" }
|
||||
menu = { path = "../menu" }
|
||||
notifications = { path = "../notifications" }
|
||||
rich_text = { path = "../rich_text" }
|
||||
picker = { path = "../picker" }
|
||||
project = { path = "../project" }
|
||||
recent_projects = {path = "../recent_projects"}
|
||||
recent_projects = { path = "../recent_projects" }
|
||||
rpc = { path = "../rpc" }
|
||||
settings = { path = "../settings" }
|
||||
feature_flags = {path = "../feature_flags"}
|
||||
theme = { path = "../theme" }
|
||||
@@ -52,12 +54,14 @@ zed-actions = {path = "../zed-actions"}
|
||||
|
||||
anyhow.workspace = true
|
||||
futures.workspace = true
|
||||
lazy_static.workspace = true
|
||||
log.workspace = true
|
||||
schemars.workspace = true
|
||||
postage.workspace = true
|
||||
serde.workspace = true
|
||||
serde_derive.workspace = true
|
||||
time.workspace = true
|
||||
smallvec.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
call = { path = "../call", features = ["test-support"] }
|
||||
@@ -65,7 +69,12 @@ client = { path = "../client", features = ["test-support"] }
|
||||
collections = { path = "../collections", features = ["test-support"] }
|
||||
editor = { path = "../editor", features = ["test-support"] }
|
||||
gpui = { path = "../gpui", features = ["test-support"] }
|
||||
notifications = { path = "../notifications", features = ["test-support"] }
|
||||
project = { path = "../project", features = ["test-support"] }
|
||||
rpc = { path = "../rpc", features = ["test-support"] }
|
||||
settings = { path = "../settings", features = ["test-support"] }
|
||||
util = { path = "../util", features = ["test-support"] }
|
||||
workspace = { path = "../workspace", features = ["test-support"] }
|
||||
|
||||
pretty_assertions.workspace = true
|
||||
tree-sitter-markdown.workspace = true
|
||||
|
||||
@@ -15,13 +15,14 @@ use gpui::{
|
||||
ViewContext, ViewHandle,
|
||||
};
|
||||
use project::Project;
|
||||
use smallvec::SmallVec;
|
||||
use std::{
|
||||
any::{Any, TypeId},
|
||||
sync::Arc,
|
||||
};
|
||||
use util::ResultExt;
|
||||
use workspace::{
|
||||
item::{FollowableItem, Item, ItemHandle},
|
||||
item::{FollowableItem, Item, ItemEvent, ItemHandle},
|
||||
register_followable_item,
|
||||
searchable::SearchableItemHandle,
|
||||
ItemNavHistory, Pane, SaveIntent, ViewId, Workspace, WorkspaceId,
|
||||
@@ -140,6 +141,12 @@ impl ChannelView {
|
||||
editor.set_collaboration_hub(Box::new(ChannelBufferCollaborationHub(
|
||||
channel_buffer.clone(),
|
||||
)));
|
||||
editor.set_read_only(
|
||||
!channel_buffer
|
||||
.read(cx)
|
||||
.channel(cx)
|
||||
.is_some_and(|c| c.can_edit_notes()),
|
||||
);
|
||||
editor
|
||||
});
|
||||
let _editor_event_subscription = cx.subscribe(&editor, |_, _, e, cx| cx.emit(e.clone()));
|
||||
@@ -157,8 +164,8 @@ impl ChannelView {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn channel(&self, cx: &AppContext) -> Arc<Channel> {
|
||||
self.channel_buffer.read(cx).channel()
|
||||
pub fn channel(&self, cx: &AppContext) -> Option<Arc<Channel>> {
|
||||
self.channel_buffer.read(cx).channel(cx)
|
||||
}
|
||||
|
||||
fn handle_channel_buffer_event(
|
||||
@@ -172,6 +179,13 @@ impl ChannelView {
|
||||
editor.set_read_only(true);
|
||||
cx.notify();
|
||||
}),
|
||||
ChannelBufferEvent::ChannelChanged => {
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.set_read_only(!self.channel(cx).is_some_and(|c| c.can_edit_notes()));
|
||||
cx.emit(editor::Event::TitleChanged);
|
||||
cx.notify()
|
||||
});
|
||||
}
|
||||
ChannelBufferEvent::BufferEdited => {
|
||||
if cx.is_self_focused() || self.editor.is_focused(cx) {
|
||||
self.acknowledge_buffer_version(cx);
|
||||
@@ -179,7 +193,7 @@ impl ChannelView {
|
||||
self.channel_store.update(cx, |store, cx| {
|
||||
let channel_buffer = self.channel_buffer.read(cx);
|
||||
store.notes_changed(
|
||||
channel_buffer.channel().id,
|
||||
channel_buffer.channel_id,
|
||||
channel_buffer.epoch(),
|
||||
&channel_buffer.buffer().read(cx).version(),
|
||||
cx,
|
||||
@@ -187,7 +201,7 @@ impl ChannelView {
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
ChannelBufferEvent::CollaboratorsChanged => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +209,7 @@ impl ChannelView {
|
||||
self.channel_store.update(cx, |store, cx| {
|
||||
let channel_buffer = self.channel_buffer.read(cx);
|
||||
store.acknowledge_notes_version(
|
||||
channel_buffer.channel().id,
|
||||
channel_buffer.channel_id,
|
||||
channel_buffer.epoch(),
|
||||
&channel_buffer.buffer().read(cx).version(),
|
||||
cx,
|
||||
@@ -250,11 +264,17 @@ impl Item for ChannelView {
|
||||
style: &theme::Tab,
|
||||
cx: &gpui::AppContext,
|
||||
) -> AnyElement<V> {
|
||||
let channel_name = &self.channel_buffer.read(cx).channel().name;
|
||||
let label = if self.channel_buffer.read(cx).is_connected() {
|
||||
format!("#{}", channel_name)
|
||||
let label = if let Some(channel) = self.channel(cx) {
|
||||
match (
|
||||
channel.can_edit_notes(),
|
||||
self.channel_buffer.read(cx).is_connected(),
|
||||
) {
|
||||
(true, true) => format!("#{}", channel.name),
|
||||
(false, true) => format!("#{} (read-only)", channel.name),
|
||||
(_, false) => format!("#{} (disconnected)", channel.name),
|
||||
}
|
||||
} else {
|
||||
format!("#{} (disconnected)", channel_name)
|
||||
format!("channel notes (disconnected)")
|
||||
};
|
||||
Label::new(label, style.label.to_owned()).into_any()
|
||||
}
|
||||
@@ -298,6 +318,10 @@ impl Item for ChannelView {
|
||||
fn pixel_position_of_cursor(&self, cx: &AppContext) -> Option<Vector2F> {
|
||||
self.editor.read(cx).pixel_position_of_cursor(cx)
|
||||
}
|
||||
|
||||
fn to_item_events(event: &Self::Event) -> SmallVec<[ItemEvent; 2]> {
|
||||
editor::Editor::to_item_events(event)
|
||||
}
|
||||
}
|
||||
|
||||
impl FollowableItem for ChannelView {
|
||||
@@ -313,7 +337,7 @@ impl FollowableItem for ChannelView {
|
||||
|
||||
Some(proto::view::Variant::ChannelView(
|
||||
proto::view::ChannelView {
|
||||
channel_id: channel_buffer.channel().id,
|
||||
channel_id: channel_buffer.channel_id,
|
||||
editor: if let Some(proto::view::Variant::Editor(proto)) =
|
||||
self.editor.read(cx).to_state_proto(cx)
|
||||
{
|
||||
|
||||
+226
-128
@@ -1,4 +1,6 @@
|
||||
use crate::{channel_view::ChannelView, ChatPanelSettings};
|
||||
use crate::{
|
||||
channel_view::ChannelView, is_channels_feature_enabled, render_avatar, ChatPanelSettings,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use call::ActiveCall;
|
||||
use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore};
|
||||
@@ -6,18 +8,18 @@ use client::Client;
|
||||
use collections::HashMap;
|
||||
use db::kvp::KEY_VALUE_STORE;
|
||||
use editor::Editor;
|
||||
use feature_flags::{ChannelsAlpha, FeatureFlagAppExt};
|
||||
use gpui::{
|
||||
actions,
|
||||
elements::*,
|
||||
platform::{CursorStyle, MouseButton},
|
||||
serde_json,
|
||||
views::{ItemType, Select, SelectStyle},
|
||||
AnyViewHandle, AppContext, AsyncAppContext, Entity, ImageData, ModelHandle, Subscription, Task,
|
||||
View, ViewContext, ViewHandle, WeakViewHandle,
|
||||
AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Subscription, Task, View,
|
||||
ViewContext, ViewHandle, WeakViewHandle,
|
||||
};
|
||||
use language::{language_settings::SoftWrap, LanguageRegistry};
|
||||
use language::LanguageRegistry;
|
||||
use menu::Confirm;
|
||||
use message_editor::MessageEditor;
|
||||
use project::Fs;
|
||||
use rich_text::RichText;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -31,6 +33,8 @@ use workspace::{
|
||||
Workspace,
|
||||
};
|
||||
|
||||
mod message_editor;
|
||||
|
||||
const MESSAGE_LOADING_THRESHOLD: usize = 50;
|
||||
const CHAT_PANEL_KEY: &'static str = "ChatPanel";
|
||||
|
||||
@@ -40,7 +44,7 @@ pub struct ChatPanel {
|
||||
languages: Arc<LanguageRegistry>,
|
||||
active_chat: Option<(ModelHandle<ChannelChat>, Subscription)>,
|
||||
message_list: ListState<ChatPanel>,
|
||||
input_editor: ViewHandle<Editor>,
|
||||
input_editor: ViewHandle<MessageEditor>,
|
||||
channel_select: ViewHandle<Select>,
|
||||
local_timezone: UtcOffset,
|
||||
fs: Arc<dyn Fs>,
|
||||
@@ -49,6 +53,7 @@ pub struct ChatPanel {
|
||||
pending_serialization: Task<Option<()>>,
|
||||
subscriptions: Vec<gpui::Subscription>,
|
||||
workspace: WeakViewHandle<Workspace>,
|
||||
is_scrolled_to_bottom: bool,
|
||||
has_focus: bool,
|
||||
markdown_data: HashMap<ChannelMessageId, RichText>,
|
||||
}
|
||||
@@ -85,13 +90,18 @@ impl ChatPanel {
|
||||
let languages = workspace.app_state().languages.clone();
|
||||
|
||||
let input_editor = cx.add_view(|cx| {
|
||||
let mut editor = Editor::auto_height(
|
||||
4,
|
||||
Some(Arc::new(|theme| theme.chat_panel.input_editor.clone())),
|
||||
MessageEditor::new(
|
||||
languages.clone(),
|
||||
channel_store.clone(),
|
||||
cx.add_view(|cx| {
|
||||
Editor::auto_height(
|
||||
4,
|
||||
Some(Arc::new(|theme| theme.chat_panel.input_editor.clone())),
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
|
||||
editor
|
||||
)
|
||||
});
|
||||
|
||||
let workspace_handle = workspace.weak_handle();
|
||||
@@ -121,13 +131,14 @@ impl ChatPanel {
|
||||
});
|
||||
|
||||
let mut message_list =
|
||||
ListState::<Self>::new(0, Orientation::Bottom, 1000., move |this, ix, cx| {
|
||||
ListState::<Self>::new(0, Orientation::Bottom, 10., move |this, ix, cx| {
|
||||
this.render_message(ix, cx)
|
||||
});
|
||||
message_list.set_scroll_handler(|visible_range, this, cx| {
|
||||
message_list.set_scroll_handler(|visible_range, count, this, cx| {
|
||||
if visible_range.start < MESSAGE_LOADING_THRESHOLD {
|
||||
this.load_more_messages(&LoadMoreMessages, cx);
|
||||
}
|
||||
this.is_scrolled_to_bottom = visible_range.end == count;
|
||||
});
|
||||
|
||||
cx.add_view(|cx| {
|
||||
@@ -136,7 +147,6 @@ impl ChatPanel {
|
||||
client,
|
||||
channel_store,
|
||||
languages,
|
||||
|
||||
active_chat: Default::default(),
|
||||
pending_serialization: Task::ready(None),
|
||||
message_list,
|
||||
@@ -146,6 +156,7 @@ impl ChatPanel {
|
||||
has_focus: false,
|
||||
subscriptions: Vec::new(),
|
||||
workspace: workspace_handle,
|
||||
is_scrolled_to_bottom: true,
|
||||
active: false,
|
||||
width: None,
|
||||
markdown_data: Default::default(),
|
||||
@@ -179,35 +190,20 @@ impl ChatPanel {
|
||||
.channel_at(selected_ix)
|
||||
.map(|e| e.id);
|
||||
if let Some(selected_channel_id) = selected_channel_id {
|
||||
this.select_channel(selected_channel_id, cx)
|
||||
this.select_channel(selected_channel_id, None, cx)
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
let markdown = this.languages.language_for_name("Markdown");
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let markdown = markdown.await?;
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.input_editor.update(cx, |editor, cx| {
|
||||
editor.buffer().update(cx, |multi_buffer, cx| {
|
||||
multi_buffer
|
||||
.as_singleton()
|
||||
.unwrap()
|
||||
.update(cx, |buffer, cx| buffer.set_language(Some(markdown), cx))
|
||||
})
|
||||
})
|
||||
})?;
|
||||
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
|
||||
this
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_scrolled_to_bottom(&self) -> bool {
|
||||
self.is_scrolled_to_bottom
|
||||
}
|
||||
|
||||
pub fn active_chat(&self) -> Option<ModelHandle<ChannelChat>> {
|
||||
self.active_chat.as_ref().map(|(chat, _)| chat.clone())
|
||||
}
|
||||
@@ -267,20 +263,22 @@ impl ChatPanel {
|
||||
|
||||
fn set_active_chat(&mut self, chat: ModelHandle<ChannelChat>, cx: &mut ViewContext<Self>) {
|
||||
if self.active_chat.as_ref().map(|e| &e.0) != Some(&chat) {
|
||||
let id = chat.read(cx).channel().id;
|
||||
let channel_id = chat.read(cx).channel_id;
|
||||
{
|
||||
self.markdown_data.clear();
|
||||
let chat = chat.read(cx);
|
||||
self.message_list.reset(chat.message_count());
|
||||
let placeholder = format!("Message #{}", chat.channel().name);
|
||||
self.input_editor.update(cx, move |editor, cx| {
|
||||
editor.set_placeholder_text(placeholder, cx);
|
||||
|
||||
let channel_name = chat.channel(cx).map(|channel| channel.name.clone());
|
||||
self.input_editor.update(cx, |editor, cx| {
|
||||
editor.set_channel(channel_id, channel_name, cx);
|
||||
});
|
||||
}
|
||||
};
|
||||
let subscription = cx.subscribe(&chat, Self::channel_did_change);
|
||||
self.active_chat = Some((chat, subscription));
|
||||
self.acknowledge_last_message(cx);
|
||||
self.channel_select.update(cx, |select, cx| {
|
||||
if let Some(ix) = self.channel_store.read(cx).index_of_channel(id) {
|
||||
if let Some(ix) = self.channel_store.read(cx).index_of_channel(channel_id) {
|
||||
select.set_selected_index(ix, cx);
|
||||
}
|
||||
});
|
||||
@@ -319,7 +317,7 @@ impl ChatPanel {
|
||||
}
|
||||
|
||||
fn acknowledge_last_message(&mut self, cx: &mut ViewContext<'_, '_, ChatPanel>) {
|
||||
if self.active {
|
||||
if self.active && self.is_scrolled_to_bottom {
|
||||
if let Some((chat, _)) = &self.active_chat {
|
||||
chat.update(cx, |chat, cx| {
|
||||
chat.acknowledge_last_message(cx);
|
||||
@@ -355,28 +353,48 @@ impl ChatPanel {
|
||||
}
|
||||
|
||||
fn render_message(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
|
||||
let (message, is_continuation, is_last) = {
|
||||
let active_chat = self.active_chat.as_ref().unwrap().0.read(cx);
|
||||
let last_message = active_chat.message(ix.saturating_sub(1));
|
||||
let this_message = active_chat.message(ix);
|
||||
let is_continuation = last_message.id != this_message.id
|
||||
&& this_message.sender.id == last_message.sender.id;
|
||||
let (message, is_continuation, is_last, is_admin) = self
|
||||
.active_chat
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.0
|
||||
.update(cx, |active_chat, cx| {
|
||||
let is_admin = self
|
||||
.channel_store
|
||||
.read(cx)
|
||||
.is_channel_admin(active_chat.channel_id);
|
||||
|
||||
(
|
||||
active_chat.message(ix).clone(),
|
||||
is_continuation,
|
||||
active_chat.message_count() == ix + 1,
|
||||
)
|
||||
};
|
||||
let last_message = active_chat.message(ix.saturating_sub(1));
|
||||
let this_message = active_chat.message(ix).clone();
|
||||
let is_continuation = last_message.id != this_message.id
|
||||
&& this_message.sender.id == last_message.sender.id;
|
||||
|
||||
if let ChannelMessageId::Saved(id) = this_message.id {
|
||||
if this_message
|
||||
.mentions
|
||||
.iter()
|
||||
.any(|(_, user_id)| Some(*user_id) == self.client.user_id())
|
||||
{
|
||||
active_chat.acknowledge_message(id);
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
this_message,
|
||||
is_continuation,
|
||||
active_chat.message_count() == ix + 1,
|
||||
is_admin,
|
||||
)
|
||||
});
|
||||
|
||||
let is_pending = message.is_pending();
|
||||
let text = self
|
||||
.markdown_data
|
||||
.entry(message.id)
|
||||
.or_insert_with(|| rich_text::render_markdown(message.body, &self.languages, None));
|
||||
let theme = theme::current(cx);
|
||||
let text = self.markdown_data.entry(message.id).or_insert_with(|| {
|
||||
Self::render_markdown_with_mentions(&self.languages, self.client.id(), &message)
|
||||
});
|
||||
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let theme = theme::current(cx);
|
||||
|
||||
let style = if is_pending {
|
||||
&theme.chat_panel.pending_message
|
||||
} else if is_continuation {
|
||||
@@ -386,23 +404,23 @@ impl ChatPanel {
|
||||
};
|
||||
|
||||
let belongs_to_user = Some(message.sender.id) == self.client.user_id();
|
||||
let message_id_to_remove =
|
||||
if let (ChannelMessageId::Saved(id), true) = (message.id, belongs_to_user) {
|
||||
Some(id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let message_id_to_remove = if let (ChannelMessageId::Saved(id), true) =
|
||||
(message.id, belongs_to_user || is_admin)
|
||||
{
|
||||
Some(id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
enum MessageBackgroundHighlight {}
|
||||
MouseEventHandler::new::<MessageBackgroundHighlight, _>(ix, cx, |state, cx| {
|
||||
let container = style.container.style_for(state);
|
||||
let container = style.style_for(state);
|
||||
if is_continuation {
|
||||
Flex::row()
|
||||
.with_child(
|
||||
text.element(
|
||||
theme.editor.syntax.clone(),
|
||||
style.body.clone(),
|
||||
theme.editor.document_highlight_read_background,
|
||||
theme.chat_panel.rich_text.clone(),
|
||||
cx,
|
||||
)
|
||||
.flex(1., true),
|
||||
@@ -424,15 +442,16 @@ impl ChatPanel {
|
||||
Flex::row()
|
||||
.with_child(render_avatar(
|
||||
message.sender.avatar.clone(),
|
||||
&theme,
|
||||
&theme.chat_panel.avatar,
|
||||
theme.chat_panel.avatar_container,
|
||||
))
|
||||
.with_child(
|
||||
Label::new(
|
||||
message.sender.github_login.clone(),
|
||||
style.sender.text.clone(),
|
||||
theme.chat_panel.message_sender.text.clone(),
|
||||
)
|
||||
.contained()
|
||||
.with_style(style.sender.container),
|
||||
.with_style(theme.chat_panel.message_sender.container),
|
||||
)
|
||||
.with_child(
|
||||
Label::new(
|
||||
@@ -441,10 +460,10 @@ impl ChatPanel {
|
||||
now,
|
||||
self.local_timezone,
|
||||
),
|
||||
style.timestamp.text.clone(),
|
||||
theme.chat_panel.message_timestamp.text.clone(),
|
||||
)
|
||||
.contained()
|
||||
.with_style(style.timestamp.container),
|
||||
.with_style(theme.chat_panel.message_timestamp.container),
|
||||
)
|
||||
.align_children_center()
|
||||
.flex(1., true),
|
||||
@@ -457,8 +476,7 @@ impl ChatPanel {
|
||||
.with_child(
|
||||
text.element(
|
||||
theme.editor.syntax.clone(),
|
||||
style.body.clone(),
|
||||
theme.editor.document_highlight_read_background,
|
||||
theme.chat_panel.rich_text.clone(),
|
||||
cx,
|
||||
)
|
||||
.flex(1., true),
|
||||
@@ -479,6 +497,23 @@ impl ChatPanel {
|
||||
.into_any()
|
||||
}
|
||||
|
||||
fn render_markdown_with_mentions(
|
||||
language_registry: &Arc<LanguageRegistry>,
|
||||
current_user_id: u64,
|
||||
message: &channel::ChannelMessage,
|
||||
) -> RichText {
|
||||
let mentions = message
|
||||
.mentions
|
||||
.iter()
|
||||
.map(|(range, user_id)| rich_text::Mention {
|
||||
range: range.clone(),
|
||||
is_self_mention: *user_id == current_user_id,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
rich_text::render_markdown(message.body.clone(), &mentions, language_registry, None)
|
||||
}
|
||||
|
||||
fn render_input_box(&self, theme: &Arc<Theme>, cx: &AppContext) -> AnyElement<Self> {
|
||||
ChildView::new(&self.input_editor, cx)
|
||||
.contained()
|
||||
@@ -604,14 +639,12 @@ impl ChatPanel {
|
||||
|
||||
fn send(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
|
||||
if let Some((chat, _)) = self.active_chat.as_ref() {
|
||||
let body = self.input_editor.update(cx, |editor, cx| {
|
||||
let body = editor.text(cx);
|
||||
editor.clear(cx);
|
||||
body
|
||||
});
|
||||
let message = self
|
||||
.input_editor
|
||||
.update(cx, |editor, cx| editor.take_message(cx));
|
||||
|
||||
if let Some(task) = chat
|
||||
.update(cx, |chat, cx| chat.send_message(body, cx))
|
||||
.update(cx, |chat, cx| chat.send_message(message, cx))
|
||||
.log_err()
|
||||
{
|
||||
task.detach();
|
||||
@@ -628,7 +661,9 @@ impl ChatPanel {
|
||||
fn load_more_messages(&mut self, _: &LoadMoreMessages, cx: &mut ViewContext<Self>) {
|
||||
if let Some((chat, _)) = self.active_chat.as_ref() {
|
||||
chat.update(cx, |channel, cx| {
|
||||
channel.load_more_messages(cx);
|
||||
if let Some(task) = channel.load_more_messages(cx) {
|
||||
task.detach();
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -636,29 +671,52 @@ impl ChatPanel {
|
||||
pub fn select_channel(
|
||||
&mut self,
|
||||
selected_channel_id: u64,
|
||||
scroll_to_message_id: Option<u64>,
|
||||
cx: &mut ViewContext<ChatPanel>,
|
||||
) -> Task<Result<()>> {
|
||||
if let Some((chat, _)) = &self.active_chat {
|
||||
if chat.read(cx).channel().id == selected_channel_id {
|
||||
return Task::ready(Ok(()));
|
||||
}
|
||||
}
|
||||
let open_chat = self
|
||||
.active_chat
|
||||
.as_ref()
|
||||
.and_then(|(chat, _)| {
|
||||
(chat.read(cx).channel_id == selected_channel_id)
|
||||
.then(|| Task::ready(anyhow::Ok(chat.clone())))
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
self.channel_store.update(cx, |store, cx| {
|
||||
store.open_channel_chat(selected_channel_id, cx)
|
||||
})
|
||||
});
|
||||
|
||||
let open_chat = self.channel_store.update(cx, |store, cx| {
|
||||
store.open_channel_chat(selected_channel_id, cx)
|
||||
});
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let chat = open_chat.await?;
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.markdown_data = Default::default();
|
||||
this.set_active_chat(chat, cx);
|
||||
})
|
||||
this.set_active_chat(chat.clone(), cx);
|
||||
})?;
|
||||
|
||||
if let Some(message_id) = scroll_to_message_id {
|
||||
if let Some(item_ix) =
|
||||
ChannelChat::load_history_since_message(chat.clone(), message_id, cx.clone())
|
||||
.await
|
||||
{
|
||||
this.update(&mut cx, |this, cx| {
|
||||
if this.active_chat.as_ref().map_or(false, |(c, _)| *c == chat) {
|
||||
this.message_list.scroll_to(ListOffset {
|
||||
item_ix,
|
||||
offset_in_item: 0.,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn open_notes(&mut self, _: &OpenChannelNotes, cx: &mut ViewContext<Self>) {
|
||||
if let Some((chat, _)) = &self.active_chat {
|
||||
let channel_id = chat.read(cx).channel().id;
|
||||
let channel_id = chat.read(cx).channel_id;
|
||||
if let Some(workspace) = self.workspace.upgrade(cx) {
|
||||
ChannelView::open(channel_id, workspace, cx).detach();
|
||||
}
|
||||
@@ -667,7 +725,7 @@ impl ChatPanel {
|
||||
|
||||
fn join_call(&mut self, _: &JoinCall, cx: &mut ViewContext<Self>) {
|
||||
if let Some((chat, _)) = &self.active_chat {
|
||||
let channel_id = chat.read(cx).channel().id;
|
||||
let channel_id = chat.read(cx).channel_id;
|
||||
ActiveCall::global(cx)
|
||||
.update(cx, |call, cx| call.join_channel(channel_id, cx))
|
||||
.detach_and_log_err(cx);
|
||||
@@ -675,32 +733,6 @@ impl ChatPanel {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_avatar(avatar: Option<Arc<ImageData>>, theme: &Arc<Theme>) -> AnyElement<ChatPanel> {
|
||||
let avatar_style = theme.chat_panel.avatar;
|
||||
|
||||
avatar
|
||||
.map(|avatar| {
|
||||
Image::from_data(avatar)
|
||||
.with_style(avatar_style.image)
|
||||
.aligned()
|
||||
.contained()
|
||||
.with_corner_radius(avatar_style.outer_corner_radius)
|
||||
.constrained()
|
||||
.with_width(avatar_style.outer_width)
|
||||
.with_height(avatar_style.outer_width)
|
||||
.into_any()
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
Empty::new()
|
||||
.constrained()
|
||||
.with_width(avatar_style.outer_width)
|
||||
.into_any()
|
||||
})
|
||||
.contained()
|
||||
.with_style(theme.chat_panel.avatar_container)
|
||||
.into_any()
|
||||
}
|
||||
|
||||
fn render_remove(
|
||||
message_id_to_remove: Option<u64>,
|
||||
cx: &mut ViewContext<'_, '_, ChatPanel>,
|
||||
@@ -771,7 +803,8 @@ impl View for ChatPanel {
|
||||
*self.client.status().borrow(),
|
||||
client::Status::Connected { .. }
|
||||
) {
|
||||
cx.focus(&self.input_editor);
|
||||
let editor = self.input_editor.read(cx).editor.clone();
|
||||
cx.focus(&editor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -810,14 +843,14 @@ impl Panel for ChatPanel {
|
||||
self.active = active;
|
||||
if active {
|
||||
self.acknowledge_last_message(cx);
|
||||
if !is_chat_feature_enabled(cx) {
|
||||
if !is_channels_feature_enabled(cx) {
|
||||
cx.emit(Event::Dismissed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
|
||||
(settings::get::<ChatPanelSettings>(cx).button && is_chat_feature_enabled(cx))
|
||||
(settings::get::<ChatPanelSettings>(cx).button && is_channels_feature_enabled(cx))
|
||||
.then(|| "icons/conversations.svg")
|
||||
}
|
||||
|
||||
@@ -842,10 +875,6 @@ impl Panel for ChatPanel {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_chat_feature_enabled(cx: &gpui::WindowContext<'_>) -> bool {
|
||||
cx.is_staff() || cx.has_flag::<ChannelsAlpha>()
|
||||
}
|
||||
|
||||
fn format_timestamp(
|
||||
mut timestamp: OffsetDateTime,
|
||||
mut now: OffsetDateTime,
|
||||
@@ -883,3 +912,72 @@ fn render_icon_button<V: View>(style: &IconButton, svg_path: &'static str) -> im
|
||||
.contained()
|
||||
.with_style(style.container)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::fonts::HighlightStyle;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rich_text::{BackgroundKind, Highlight, RenderedRegion};
|
||||
use util::test::marked_text_ranges;
|
||||
|
||||
#[gpui::test]
|
||||
fn test_render_markdown_with_mentions() {
|
||||
let language_registry = Arc::new(LanguageRegistry::test());
|
||||
let (body, ranges) = marked_text_ranges("*hi*, «@abc», let's **call** «@fgh»", false);
|
||||
let message = channel::ChannelMessage {
|
||||
id: ChannelMessageId::Saved(0),
|
||||
body,
|
||||
timestamp: OffsetDateTime::now_utc(),
|
||||
sender: Arc::new(client::User {
|
||||
github_login: "fgh".into(),
|
||||
avatar: None,
|
||||
id: 103,
|
||||
}),
|
||||
nonce: 5,
|
||||
mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)],
|
||||
};
|
||||
|
||||
let message = ChatPanel::render_markdown_with_mentions(&language_registry, 102, &message);
|
||||
|
||||
// Note that the "'" was replaced with ’ due to smart punctuation.
|
||||
let (body, ranges) = marked_text_ranges("«hi», «@abc», let’s «call» «@fgh»", false);
|
||||
assert_eq!(message.text, body);
|
||||
assert_eq!(
|
||||
message.highlights,
|
||||
vec![
|
||||
(
|
||||
ranges[0].clone(),
|
||||
HighlightStyle {
|
||||
italic: Some(true),
|
||||
..Default::default()
|
||||
}
|
||||
.into()
|
||||
),
|
||||
(ranges[1].clone(), Highlight::Mention),
|
||||
(
|
||||
ranges[2].clone(),
|
||||
HighlightStyle {
|
||||
weight: Some(gpui::fonts::Weight::BOLD),
|
||||
..Default::default()
|
||||
}
|
||||
.into()
|
||||
),
|
||||
(ranges[3].clone(), Highlight::SelfMention)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
message.regions,
|
||||
vec![
|
||||
RenderedRegion {
|
||||
background_kind: Some(BackgroundKind::Mention),
|
||||
link_url: None
|
||||
},
|
||||
RenderedRegion {
|
||||
background_kind: Some(BackgroundKind::SelfMention),
|
||||
link_url: None
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
use channel::{ChannelId, ChannelMembership, ChannelStore, MessageParams};
|
||||
use client::UserId;
|
||||
use collections::HashMap;
|
||||
use editor::{AnchorRangeExt, Editor};
|
||||
use gpui::{
|
||||
elements::ChildView, AnyElement, AsyncAppContext, Element, Entity, ModelHandle, Task, View,
|
||||
ViewContext, ViewHandle, WeakViewHandle,
|
||||
};
|
||||
use language::{language_settings::SoftWrap, Buffer, BufferSnapshot, LanguageRegistry};
|
||||
use lazy_static::lazy_static;
|
||||
use project::search::SearchQuery;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50);
|
||||
|
||||
lazy_static! {
|
||||
static ref MENTIONS_SEARCH: SearchQuery = SearchQuery::regex(
|
||||
"@[-_\\w]+",
|
||||
false,
|
||||
false,
|
||||
Default::default(),
|
||||
Default::default()
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub struct MessageEditor {
|
||||
pub editor: ViewHandle<Editor>,
|
||||
channel_store: ModelHandle<ChannelStore>,
|
||||
users: HashMap<String, UserId>,
|
||||
mentions: Vec<UserId>,
|
||||
mentions_task: Option<Task<()>>,
|
||||
channel_id: Option<ChannelId>,
|
||||
}
|
||||
|
||||
impl MessageEditor {
|
||||
pub fn new(
|
||||
language_registry: Arc<LanguageRegistry>,
|
||||
channel_store: ModelHandle<ChannelStore>,
|
||||
editor: ViewHandle<Editor>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
|
||||
});
|
||||
|
||||
let buffer = editor
|
||||
.read(cx)
|
||||
.buffer()
|
||||
.read(cx)
|
||||
.as_singleton()
|
||||
.expect("message editor must be singleton");
|
||||
|
||||
cx.subscribe(&buffer, Self::on_buffer_event).detach();
|
||||
|
||||
let markdown = language_registry.language_for_name("Markdown");
|
||||
cx.app_context()
|
||||
.spawn(|mut cx| async move {
|
||||
let markdown = markdown.await?;
|
||||
buffer.update(&mut cx, |buffer, cx| {
|
||||
buffer.set_language(Some(markdown), cx)
|
||||
});
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
|
||||
Self {
|
||||
editor,
|
||||
channel_store,
|
||||
users: HashMap::default(),
|
||||
channel_id: None,
|
||||
mentions: Vec::new(),
|
||||
mentions_task: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_channel(
|
||||
&mut self,
|
||||
channel_id: u64,
|
||||
channel_name: Option<String>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
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(format!("Message Channel"), cx);
|
||||
}
|
||||
});
|
||||
self.channel_id = Some(channel_id);
|
||||
self.refresh_users(cx);
|
||||
}
|
||||
|
||||
pub fn refresh_users(&mut self, cx: &mut ViewContext<Self>) {
|
||||
if let Some(channel_id) = self.channel_id {
|
||||
let members = self.channel_store.update(cx, |store, cx| {
|
||||
store.get_channel_member_details(channel_id, cx)
|
||||
});
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let members = members.await?;
|
||||
this.update(&mut cx, |this, cx| this.set_members(members, cx))?;
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_members(&mut self, members: Vec<ChannelMembership>, _: &mut ViewContext<Self>) {
|
||||
self.users.clear();
|
||||
self.users.extend(
|
||||
members
|
||||
.into_iter()
|
||||
.map(|member| (member.user.github_login.clone(), member.user.id)),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn take_message(&mut self, cx: &mut ViewContext<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(cx);
|
||||
self.mentions.clear();
|
||||
|
||||
MessageParams { text, mentions }
|
||||
})
|
||||
}
|
||||
|
||||
fn on_buffer_event(
|
||||
&mut self,
|
||||
buffer: ModelHandle<Buffer>,
|
||||
event: &language::Event,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let language::Event::Reparsed | language::Event::Edited = event {
|
||||
let buffer = buffer.read(cx).snapshot();
|
||||
self.mentions_task = Some(cx.spawn(|this, cx| async move {
|
||||
cx.background().timer(MENTIONS_DEBOUNCE_INTERVAL).await;
|
||||
Self::find_mentions(this, buffer, cx).await;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async fn find_mentions(
|
||||
this: WeakViewHandle<MessageEditor>,
|
||||
buffer: BufferSnapshot,
|
||||
mut cx: AsyncAppContext,
|
||||
) {
|
||||
let (buffer, ranges) = cx
|
||||
.background()
|
||||
.spawn(async move {
|
||||
let ranges = MENTIONS_SEARCH.search(&buffer, None).await;
|
||||
(buffer, ranges)
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update(&mut 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("@") {
|
||||
if let Some(user_id) = this.users.get(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,
|
||||
theme::current(cx).chat_panel.rich_text.mention_highlight,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
this.mentions = mentioned_user_ids;
|
||||
this.mentions_task.take();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for MessageEditor {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for MessageEditor {
|
||||
fn render(&mut self, cx: &mut ViewContext<'_, '_, Self>) -> AnyElement<Self> {
|
||||
ChildView::new(&self.editor, cx).into_any()
|
||||
}
|
||||
|
||||
fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
|
||||
if cx.is_self_focused() {
|
||||
cx.focus(&self.editor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use client::{Client, User, UserStore};
|
||||
use gpui::{TestAppContext, WindowHandle};
|
||||
use language::{Language, LanguageConfig};
|
||||
use rpc::proto;
|
||||
use settings::SettingsStore;
|
||||
use util::{http::FakeHttpClient, test::marked_text_ranges};
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_message_editor(cx: &mut TestAppContext) {
|
||||
let editor = init_test(cx);
|
||||
let editor = editor.root(cx);
|
||||
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.set_members(
|
||||
vec![
|
||||
ChannelMembership {
|
||||
user: Arc::new(User {
|
||||
github_login: "a-b".into(),
|
||||
id: 101,
|
||||
avatar: None,
|
||||
}),
|
||||
kind: proto::channel_member::Kind::Member,
|
||||
role: proto::ChannelRole::Member,
|
||||
},
|
||||
ChannelMembership {
|
||||
user: Arc::new(User {
|
||||
github_login: "C_D".into(),
|
||||
id: 102,
|
||||
avatar: None,
|
||||
}),
|
||||
kind: proto::channel_member::Kind::Member,
|
||||
role: proto::ChannelRole::Member,
|
||||
},
|
||||
],
|
||||
cx,
|
||||
);
|
||||
|
||||
editor.editor.update(cx, |editor, cx| {
|
||||
editor.set_text("Hello, @a-b! Have you met @C_D?", cx)
|
||||
});
|
||||
});
|
||||
|
||||
cx.foreground().advance_clock(MENTIONS_DEBOUNCE_INTERVAL);
|
||||
|
||||
editor.update(cx, |editor, cx| {
|
||||
let (text, ranges) = marked_text_ranges("Hello, «@a-b»! Have you met «@C_D»?", false);
|
||||
assert_eq!(
|
||||
editor.take_message(cx),
|
||||
MessageParams {
|
||||
text,
|
||||
mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)],
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn init_test(cx: &mut TestAppContext) -> WindowHandle<MessageEditor> {
|
||||
cx.foreground().forbid_parking();
|
||||
|
||||
cx.update(|cx| {
|
||||
let http = FakeHttpClient::with_404_response();
|
||||
let client = Client::new(http.clone(), cx);
|
||||
let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx));
|
||||
cx.set_global(SettingsStore::test(cx));
|
||||
theme::init((), cx);
|
||||
language::init(cx);
|
||||
editor::init(cx);
|
||||
client::init(&client, cx);
|
||||
channel::init(&client, user_store, cx);
|
||||
});
|
||||
|
||||
let language_registry = Arc::new(LanguageRegistry::test());
|
||||
language_registry.add(Arc::new(Language::new(
|
||||
LanguageConfig {
|
||||
name: "Markdown".into(),
|
||||
..Default::default()
|
||||
},
|
||||
Some(tree_sitter_markdown::language()),
|
||||
)));
|
||||
|
||||
let editor = cx.add_window(|cx| {
|
||||
MessageEditor::new(
|
||||
language_registry,
|
||||
ChannelStore::global(cx),
|
||||
cx.add_view(|cx| Editor::auto_height(4, None, cx)),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.foreground().run_until_parked();
|
||||
editor
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,16 @@
|
||||
use channel::{ChannelId, ChannelMembership, ChannelStore};
|
||||
use client::{proto, User, UserId, UserStore};
|
||||
use client::{
|
||||
proto::{self, ChannelRole, ChannelVisibility},
|
||||
User, UserId, UserStore,
|
||||
};
|
||||
use context_menu::{ContextMenu, ContextMenuItem};
|
||||
use fuzzy::{match_strings, StringMatchCandidate};
|
||||
use gpui::{
|
||||
actions,
|
||||
elements::*,
|
||||
platform::{CursorStyle, MouseButton},
|
||||
AppContext, Entity, ModelHandle, MouseState, Task, View, ViewContext, ViewHandle,
|
||||
AppContext, ClipboardItem, Entity, ModelHandle, MouseState, Task, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
use picker::{Picker, PickerDelegate, PickerEvent};
|
||||
use std::sync::Arc;
|
||||
@@ -96,11 +100,14 @@ impl ChannelModal {
|
||||
let channel_id = self.channel_id;
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
if mode == Mode::ManageMembers {
|
||||
let members = channel_store
|
||||
let mut members = channel_store
|
||||
.update(&mut cx, |channel_store, cx| {
|
||||
channel_store.get_channel_member_details(channel_id, cx)
|
||||
})
|
||||
.await?;
|
||||
|
||||
members.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.picker
|
||||
.update(cx, |picker, _| picker.delegate_mut().members = members);
|
||||
@@ -182,6 +189,81 @@ impl View for ChannelModal {
|
||||
.into_any()
|
||||
}
|
||||
|
||||
fn render_visibility(
|
||||
channel_id: ChannelId,
|
||||
visibility: ChannelVisibility,
|
||||
theme: &theme::TabbedModal,
|
||||
cx: &mut ViewContext<ChannelModal>,
|
||||
) -> AnyElement<ChannelModal> {
|
||||
enum TogglePublic {}
|
||||
|
||||
if visibility == ChannelVisibility::Members {
|
||||
return Flex::row()
|
||||
.with_child(
|
||||
MouseEventHandler::new::<TogglePublic, _>(0, cx, move |state, _| {
|
||||
let style = theme.visibility_toggle.style_for(state);
|
||||
Label::new(format!("{}", "Public access: OFF"), style.text.clone())
|
||||
.contained()
|
||||
.with_style(style.container.clone())
|
||||
})
|
||||
.on_click(MouseButton::Left, move |_, this, cx| {
|
||||
this.channel_store
|
||||
.update(cx, |channel_store, cx| {
|
||||
channel_store.set_channel_visibility(
|
||||
channel_id,
|
||||
ChannelVisibility::Public,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand),
|
||||
)
|
||||
.into_any();
|
||||
}
|
||||
|
||||
Flex::row()
|
||||
.with_child(
|
||||
MouseEventHandler::new::<TogglePublic, _>(0, cx, move |state, _| {
|
||||
let style = theme.visibility_toggle.style_for(state);
|
||||
Label::new(format!("{}", "Public access: ON"), style.text.clone())
|
||||
.contained()
|
||||
.with_style(style.container.clone())
|
||||
})
|
||||
.on_click(MouseButton::Left, move |_, this, cx| {
|
||||
this.channel_store
|
||||
.update(cx, |channel_store, cx| {
|
||||
channel_store.set_channel_visibility(
|
||||
channel_id,
|
||||
ChannelVisibility::Members,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand),
|
||||
)
|
||||
.with_spacing(14.0)
|
||||
.with_child(
|
||||
MouseEventHandler::new::<TogglePublic, _>(1, cx, move |state, _| {
|
||||
let style = theme.channel_link.style_for(state);
|
||||
Label::new(format!("{}", "copy link"), style.text.clone())
|
||||
.contained()
|
||||
.with_style(style.container.clone())
|
||||
})
|
||||
.on_click(MouseButton::Left, move |_, this, cx| {
|
||||
if let Some(channel) =
|
||||
this.channel_store.read(cx).channel_for_id(channel_id)
|
||||
{
|
||||
let item = ClipboardItem::new(channel.link());
|
||||
cx.write_to_clipboard(item);
|
||||
}
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand),
|
||||
)
|
||||
.into_any()
|
||||
}
|
||||
|
||||
Flex::column()
|
||||
.with_child(
|
||||
Flex::column()
|
||||
@@ -190,6 +272,7 @@ impl View for ChannelModal {
|
||||
.contained()
|
||||
.with_style(theme.title.container.clone()),
|
||||
)
|
||||
.with_child(render_visibility(channel.id, channel.visibility, theme, cx))
|
||||
.with_child(Flex::row().with_children([
|
||||
render_mode_button::<InviteMembers>(
|
||||
Mode::InviteMembers,
|
||||
@@ -343,9 +426,11 @@ impl PickerDelegate for ChannelModalDelegate {
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
|
||||
if let Some((selected_user, admin)) = self.user_at_index(self.selected_index) {
|
||||
if let Some((selected_user, role)) = self.user_at_index(self.selected_index) {
|
||||
match self.mode {
|
||||
Mode::ManageMembers => self.show_context_menu(admin.unwrap_or(false), cx),
|
||||
Mode::ManageMembers => {
|
||||
self.show_context_menu(role.unwrap_or(ChannelRole::Member), cx)
|
||||
}
|
||||
Mode::InviteMembers => match self.member_status(selected_user.id, cx) {
|
||||
Some(proto::channel_member::Kind::Invitee) => {
|
||||
self.remove_selected_member(cx);
|
||||
@@ -373,7 +458,7 @@ impl PickerDelegate for ChannelModalDelegate {
|
||||
let full_theme = &theme::current(cx);
|
||||
let theme = &full_theme.collab_panel.channel_modal;
|
||||
let tabbed_modal = &full_theme.collab_panel.tabbed_modal;
|
||||
let (user, admin) = self.user_at_index(ix).unwrap();
|
||||
let (user, role) = self.user_at_index(ix).unwrap();
|
||||
let request_status = self.member_status(user.id, cx);
|
||||
|
||||
let style = tabbed_modal
|
||||
@@ -409,15 +494,25 @@ impl PickerDelegate for ChannelModalDelegate {
|
||||
},
|
||||
)
|
||||
})
|
||||
.with_children(admin.and_then(|admin| {
|
||||
(in_manage && admin).then(|| {
|
||||
.with_children(if in_manage && role == Some(ChannelRole::Admin) {
|
||||
Some(
|
||||
Label::new("Admin", theme.member_tag.text.clone())
|
||||
.contained()
|
||||
.with_style(theme.member_tag.container)
|
||||
.aligned()
|
||||
.left()
|
||||
})
|
||||
}))
|
||||
.left(),
|
||||
)
|
||||
} else if in_manage && role == Some(ChannelRole::Guest) {
|
||||
Some(
|
||||
Label::new("Guest", theme.member_tag.text.clone())
|
||||
.contained()
|
||||
.with_style(theme.member_tag.container)
|
||||
.aligned()
|
||||
.left(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
})
|
||||
.with_children({
|
||||
let svg = match self.mode {
|
||||
Mode::ManageMembers => Some(
|
||||
@@ -502,13 +597,13 @@ impl ChannelModalDelegate {
|
||||
})
|
||||
}
|
||||
|
||||
fn user_at_index(&self, ix: usize) -> Option<(Arc<User>, Option<bool>)> {
|
||||
fn user_at_index(&self, ix: usize) -> Option<(Arc<User>, Option<ChannelRole>)> {
|
||||
match self.mode {
|
||||
Mode::ManageMembers => self.matching_member_indices.get(ix).and_then(|ix| {
|
||||
let channel_membership = self.members.get(*ix)?;
|
||||
Some((
|
||||
channel_membership.user.clone(),
|
||||
Some(channel_membership.admin),
|
||||
Some(channel_membership.role),
|
||||
))
|
||||
}),
|
||||
Mode::InviteMembers => Some((self.matching_users.get(ix).cloned()?, None)),
|
||||
@@ -516,17 +611,21 @@ impl ChannelModalDelegate {
|
||||
}
|
||||
|
||||
fn toggle_selected_member_admin(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<()> {
|
||||
let (user, admin) = self.user_at_index(self.selected_index)?;
|
||||
let admin = !admin.unwrap_or(false);
|
||||
let (user, role) = self.user_at_index(self.selected_index)?;
|
||||
let new_role = if role == Some(ChannelRole::Admin) {
|
||||
ChannelRole::Member
|
||||
} else {
|
||||
ChannelRole::Admin
|
||||
};
|
||||
let update = self.channel_store.update(cx, |store, cx| {
|
||||
store.set_member_admin(self.channel_id, user.id, admin, cx)
|
||||
store.set_member_role(self.channel_id, user.id, new_role, cx)
|
||||
});
|
||||
cx.spawn(|picker, mut cx| async move {
|
||||
update.await?;
|
||||
picker.update(&mut cx, |picker, cx| {
|
||||
let this = picker.delegate_mut();
|
||||
if let Some(member) = this.members.iter_mut().find(|m| m.user.id == user.id) {
|
||||
member.admin = admin;
|
||||
member.role = new_role;
|
||||
}
|
||||
cx.focus_self();
|
||||
cx.notify();
|
||||
@@ -572,25 +671,30 @@ impl ChannelModalDelegate {
|
||||
|
||||
fn invite_member(&mut self, user: Arc<User>, cx: &mut ViewContext<Picker<Self>>) {
|
||||
let invite_member = self.channel_store.update(cx, |store, cx| {
|
||||
store.invite_member(self.channel_id, user.id, false, cx)
|
||||
store.invite_member(self.channel_id, user.id, ChannelRole::Member, cx)
|
||||
});
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
invite_member.await?;
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.delegate_mut().members.push(ChannelMembership {
|
||||
let new_member = ChannelMembership {
|
||||
user,
|
||||
kind: proto::channel_member::Kind::Invitee,
|
||||
admin: false,
|
||||
});
|
||||
role: ChannelRole::Member,
|
||||
};
|
||||
let members = &mut this.delegate_mut().members;
|
||||
match members.binary_search_by_key(&new_member.sort_key(), |k| k.sort_key()) {
|
||||
Ok(ix) | Err(ix) => members.insert(ix, new_member),
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
fn show_context_menu(&mut self, user_is_admin: bool, cx: &mut ViewContext<Picker<Self>>) {
|
||||
fn show_context_menu(&mut self, role: ChannelRole, cx: &mut ViewContext<Picker<Self>>) {
|
||||
self.context_menu.update(cx, |context_menu, cx| {
|
||||
context_menu.show(
|
||||
Default::default(),
|
||||
@@ -598,7 +702,7 @@ impl ChannelModalDelegate {
|
||||
vec![
|
||||
ContextMenuItem::action("Remove", RemoveMember),
|
||||
ContextMenuItem::action(
|
||||
if user_is_admin {
|
||||
if role == ChannelRole::Admin {
|
||||
"Make non-admin"
|
||||
} else {
|
||||
"Make admin"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use crate::{
|
||||
contact_notification::ContactNotification, face_pile::FacePile, toggle_deafen, toggle_mute,
|
||||
toggle_screen_sharing, LeaveCall, ToggleDeafen, ToggleMute, ToggleScreenSharing,
|
||||
face_pile::FacePile, toggle_deafen, toggle_mute, toggle_screen_sharing, LeaveCall,
|
||||
ToggleDeafen, ToggleMute, ToggleScreenSharing,
|
||||
};
|
||||
use auto_update::AutoUpdateStatus;
|
||||
use call::{ActiveCall, ParticipantLocation, Room};
|
||||
use client::{proto::PeerId, Client, ContactEventKind, SignIn, SignOut, User, UserStore};
|
||||
use client::{proto::PeerId, Client, SignIn, SignOut, User, UserStore};
|
||||
use clock::ReplicaId;
|
||||
use context_menu::{ContextMenu, ContextMenuItem};
|
||||
use gpui::{
|
||||
@@ -88,8 +88,10 @@ impl View for CollabTitlebarItem {
|
||||
.zip(peer_id)
|
||||
.zip(ActiveCall::global(cx).read(cx).room().cloned())
|
||||
{
|
||||
right_container
|
||||
.add_children(self.render_in_call_share_unshare_button(&workspace, &theme, cx));
|
||||
if room.read(cx).can_publish() {
|
||||
right_container
|
||||
.add_children(self.render_in_call_share_unshare_button(&workspace, &theme, cx));
|
||||
}
|
||||
right_container.add_child(self.render_leave_call(&theme, cx));
|
||||
let muted = room.read(cx).is_muted(cx);
|
||||
let speaking = room.read(cx).is_speaking();
|
||||
@@ -97,9 +99,14 @@ impl View for CollabTitlebarItem {
|
||||
self.render_current_user(&workspace, &theme, &user, peer_id, muted, speaking, cx),
|
||||
);
|
||||
left_container.add_children(self.render_collaborators(&workspace, &theme, &room, cx));
|
||||
right_container.add_child(self.render_toggle_mute(&theme, &room, cx));
|
||||
if room.read(cx).can_publish() {
|
||||
right_container.add_child(self.render_toggle_mute(&theme, &room, cx));
|
||||
}
|
||||
right_container.add_child(self.render_toggle_deafen(&theme, &room, cx));
|
||||
right_container.add_child(self.render_toggle_screen_sharing_button(&theme, &room, cx));
|
||||
if room.read(cx).can_publish() {
|
||||
right_container
|
||||
.add_child(self.render_toggle_screen_sharing_button(&theme, &room, cx));
|
||||
}
|
||||
}
|
||||
|
||||
let status = workspace.read(cx).client().status();
|
||||
@@ -151,28 +158,6 @@ impl CollabTitlebarItem {
|
||||
this.window_activation_changed(active, cx)
|
||||
}));
|
||||
subscriptions.push(cx.observe(&user_store, |_, _, cx| cx.notify()));
|
||||
subscriptions.push(
|
||||
cx.subscribe(&user_store, move |this, user_store, event, cx| {
|
||||
if let Some(workspace) = this.workspace.upgrade(cx) {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
if let client::Event::Contact { user, kind } = event {
|
||||
if let ContactEventKind::Requested | ContactEventKind::Accepted = kind {
|
||||
workspace.show_notification(user.id as usize, cx, |cx| {
|
||||
cx.add_view(|cx| {
|
||||
ContactNotification::new(
|
||||
user.clone(),
|
||||
*kind,
|
||||
user_store,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
workspace: workspace.weak_handle(),
|
||||
@@ -488,7 +473,11 @@ impl CollabTitlebarItem {
|
||||
pub fn toggle_vcs_menu(&mut self, _: &ToggleVcsMenu, cx: &mut ViewContext<Self>) {
|
||||
if self.branch_popover.take().is_none() {
|
||||
if let Some(workspace) = self.workspace.upgrade(cx) {
|
||||
let view = cx.add_view(|cx| build_branch_list(workspace, cx));
|
||||
let Some(view) =
|
||||
cx.add_option_view(|cx| build_branch_list(workspace, cx).log_err())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
cx.subscribe(&view, |this, _, event, cx| {
|
||||
match event {
|
||||
PickerEvent::Dismiss => {
|
||||
|
||||
@@ -2,30 +2,32 @@ pub mod channel_view;
|
||||
pub mod chat_panel;
|
||||
pub mod collab_panel;
|
||||
mod collab_titlebar_item;
|
||||
mod contact_notification;
|
||||
mod face_pile;
|
||||
mod incoming_call_notification;
|
||||
mod notifications;
|
||||
pub mod notification_panel;
|
||||
pub mod notifications;
|
||||
mod panel_settings;
|
||||
pub mod project_shared_notification;
|
||||
mod sharing_status_indicator;
|
||||
|
||||
use call::{report_call_event_for_room, ActiveCall, Room};
|
||||
use feature_flags::{ChannelsAlpha, FeatureFlagAppExt};
|
||||
use gpui::{
|
||||
actions,
|
||||
elements::{ContainerStyle, Empty, Image},
|
||||
geometry::{
|
||||
rect::RectF,
|
||||
vector::{vec2f, Vector2F},
|
||||
},
|
||||
platform::{Screen, WindowBounds, WindowKind, WindowOptions},
|
||||
AppContext, Task,
|
||||
AnyElement, AppContext, Element, ImageData, Task,
|
||||
};
|
||||
use std::{rc::Rc, sync::Arc};
|
||||
use theme::AvatarStyle;
|
||||
use util::ResultExt;
|
||||
use workspace::AppState;
|
||||
|
||||
pub use collab_titlebar_item::CollabTitlebarItem;
|
||||
pub use panel_settings::{ChatPanelSettings, CollaborationPanelSettings};
|
||||
pub use panel_settings::{
|
||||
ChatPanelSettings, CollaborationPanelSettings, NotificationPanelSettings,
|
||||
};
|
||||
|
||||
actions!(
|
||||
collab,
|
||||
@@ -35,14 +37,13 @@ actions!(
|
||||
pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
|
||||
settings::register::<CollaborationPanelSettings>(cx);
|
||||
settings::register::<ChatPanelSettings>(cx);
|
||||
settings::register::<NotificationPanelSettings>(cx);
|
||||
|
||||
vcs_menu::init(cx);
|
||||
collab_titlebar_item::init(cx);
|
||||
collab_panel::init(cx);
|
||||
chat_panel::init(cx);
|
||||
incoming_call_notification::init(&app_state, cx);
|
||||
project_shared_notification::init(&app_state, cx);
|
||||
sharing_status_indicator::init(cx);
|
||||
notifications::init(&app_state, cx);
|
||||
|
||||
cx.add_global_action(toggle_screen_sharing);
|
||||
cx.add_global_action(toggle_mute);
|
||||
@@ -130,3 +131,35 @@ fn notification_window_options(
|
||||
screen: Some(screen),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_avatar<T: 'static>(
|
||||
avatar: Option<Arc<ImageData>>,
|
||||
avatar_style: &AvatarStyle,
|
||||
container: ContainerStyle,
|
||||
) -> AnyElement<T> {
|
||||
avatar
|
||||
.map(|avatar| {
|
||||
Image::from_data(avatar)
|
||||
.with_style(avatar_style.image)
|
||||
.aligned()
|
||||
.contained()
|
||||
.with_corner_radius(avatar_style.outer_corner_radius)
|
||||
.constrained()
|
||||
.with_width(avatar_style.outer_width)
|
||||
.with_height(avatar_style.outer_width)
|
||||
.into_any()
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
Empty::new()
|
||||
.constrained()
|
||||
.with_width(avatar_style.outer_width)
|
||||
.into_any()
|
||||
})
|
||||
.contained()
|
||||
.with_style(container)
|
||||
.into_any()
|
||||
}
|
||||
|
||||
fn is_channels_feature_enabled(cx: &gpui::WindowContext<'_>) -> bool {
|
||||
cx.is_staff() || cx.has_flag::<ChannelsAlpha>()
|
||||
}
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::notifications::render_user_notification;
|
||||
use client::{ContactEventKind, User, UserStore};
|
||||
use gpui::{elements::*, Entity, ModelHandle, View, ViewContext};
|
||||
use workspace::notifications::Notification;
|
||||
|
||||
pub struct ContactNotification {
|
||||
user_store: ModelHandle<UserStore>,
|
||||
user: Arc<User>,
|
||||
kind: client::ContactEventKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct Dismiss(u64);
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct RespondToContactRequest {
|
||||
pub user_id: u64,
|
||||
pub accept: bool,
|
||||
}
|
||||
|
||||
pub enum Event {
|
||||
Dismiss,
|
||||
}
|
||||
|
||||
impl Entity for ContactNotification {
|
||||
type Event = Event;
|
||||
}
|
||||
|
||||
impl View for ContactNotification {
|
||||
fn ui_name() -> &'static str {
|
||||
"ContactNotification"
|
||||
}
|
||||
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
|
||||
match self.kind {
|
||||
ContactEventKind::Requested => render_user_notification(
|
||||
self.user.clone(),
|
||||
"wants to add you as a contact",
|
||||
Some("They won't be alerted if you decline."),
|
||||
|notification, cx| notification.dismiss(cx),
|
||||
vec![
|
||||
(
|
||||
"Decline",
|
||||
Box::new(|notification, cx| {
|
||||
notification.respond_to_contact_request(false, cx)
|
||||
}),
|
||||
),
|
||||
(
|
||||
"Accept",
|
||||
Box::new(|notification, cx| {
|
||||
notification.respond_to_contact_request(true, cx)
|
||||
}),
|
||||
),
|
||||
],
|
||||
cx,
|
||||
),
|
||||
ContactEventKind::Accepted => render_user_notification(
|
||||
self.user.clone(),
|
||||
"accepted your contact request",
|
||||
None,
|
||||
|notification, cx| notification.dismiss(cx),
|
||||
vec![],
|
||||
cx,
|
||||
),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Notification for ContactNotification {
|
||||
fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool {
|
||||
matches!(event, Event::Dismiss)
|
||||
}
|
||||
}
|
||||
|
||||
impl ContactNotification {
|
||||
pub fn new(
|
||||
user: Arc<User>,
|
||||
kind: client::ContactEventKind,
|
||||
user_store: ModelHandle<UserStore>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
cx.subscribe(&user_store, move |this, _, event, cx| {
|
||||
if let client::Event::Contact {
|
||||
kind: ContactEventKind::Cancelled,
|
||||
user,
|
||||
} = event
|
||||
{
|
||||
if user.id == this.user.id {
|
||||
cx.emit(Event::Dismiss);
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
user,
|
||||
kind,
|
||||
user_store,
|
||||
}
|
||||
}
|
||||
|
||||
fn dismiss(&mut self, cx: &mut ViewContext<Self>) {
|
||||
self.user_store.update(cx, |store, cx| {
|
||||
store
|
||||
.dismiss_contact_request(self.user.id, cx)
|
||||
.detach_and_log_err(cx);
|
||||
});
|
||||
cx.emit(Event::Dismiss);
|
||||
}
|
||||
|
||||
fn respond_to_contact_request(&mut self, accept: bool, cx: &mut ViewContext<Self>) {
|
||||
self.user_store
|
||||
.update(cx, |store, cx| {
|
||||
store.respond_to_contact_request(self.user.id, accept, cx)
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,884 @@
|
||||
use crate::{chat_panel::ChatPanel, render_avatar, NotificationPanelSettings};
|
||||
use anyhow::Result;
|
||||
use channel::ChannelStore;
|
||||
use client::{Client, Notification, User, UserStore};
|
||||
use collections::HashMap;
|
||||
use db::kvp::KEY_VALUE_STORE;
|
||||
use futures::StreamExt;
|
||||
use gpui::{
|
||||
actions,
|
||||
elements::*,
|
||||
platform::{CursorStyle, MouseButton},
|
||||
serde_json, AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Task, View,
|
||||
ViewContext, ViewHandle, WeakViewHandle, WindowContext,
|
||||
};
|
||||
use notifications::{NotificationEntry, NotificationEvent, NotificationStore};
|
||||
use project::Fs;
|
||||
use rpc::proto;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::SettingsStore;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use theme::{ui, Theme};
|
||||
use time::{OffsetDateTime, UtcOffset};
|
||||
use util::{ResultExt, TryFutureExt};
|
||||
use workspace::{
|
||||
dock::{DockPosition, Panel},
|
||||
Workspace,
|
||||
};
|
||||
|
||||
const LOADING_THRESHOLD: usize = 30;
|
||||
const MARK_AS_READ_DELAY: Duration = Duration::from_secs(1);
|
||||
const TOAST_DURATION: Duration = Duration::from_secs(5);
|
||||
const NOTIFICATION_PANEL_KEY: &'static str = "NotificationPanel";
|
||||
|
||||
pub struct NotificationPanel {
|
||||
client: Arc<Client>,
|
||||
user_store: ModelHandle<UserStore>,
|
||||
channel_store: ModelHandle<ChannelStore>,
|
||||
notification_store: ModelHandle<NotificationStore>,
|
||||
fs: Arc<dyn Fs>,
|
||||
width: Option<f32>,
|
||||
active: bool,
|
||||
notification_list: ListState<Self>,
|
||||
pending_serialization: Task<Option<()>>,
|
||||
subscriptions: Vec<gpui::Subscription>,
|
||||
workspace: WeakViewHandle<Workspace>,
|
||||
current_notification_toast: Option<(u64, Task<()>)>,
|
||||
local_timezone: UtcOffset,
|
||||
has_focus: bool,
|
||||
mark_as_read_tasks: HashMap<u64, Task<Result<()>>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct SerializedNotificationPanel {
|
||||
width: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Event {
|
||||
DockPositionChanged,
|
||||
Focus,
|
||||
Dismissed,
|
||||
}
|
||||
|
||||
pub struct NotificationPresenter {
|
||||
pub actor: Option<Arc<client::User>>,
|
||||
pub text: String,
|
||||
pub icon: &'static str,
|
||||
pub needs_response: bool,
|
||||
pub can_navigate: bool,
|
||||
}
|
||||
|
||||
actions!(notification_panel, [ToggleFocus]);
|
||||
|
||||
pub fn init(_cx: &mut AppContext) {}
|
||||
|
||||
impl NotificationPanel {
|
||||
pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
|
||||
let fs = workspace.app_state().fs.clone();
|
||||
let client = workspace.app_state().client.clone();
|
||||
let user_store = workspace.app_state().user_store.clone();
|
||||
let workspace_handle = workspace.weak_handle();
|
||||
|
||||
cx.add_view(|cx| {
|
||||
let mut status = client.status();
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
while let Some(_) = status.next().await {
|
||||
if this
|
||||
.update(&mut cx, |_, cx| {
|
||||
cx.notify();
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
let mut notification_list =
|
||||
ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
|
||||
this.render_notification(ix, cx)
|
||||
.unwrap_or_else(|| Empty::new().into_any())
|
||||
});
|
||||
notification_list.set_scroll_handler(|visible_range, count, this, cx| {
|
||||
if count.saturating_sub(visible_range.end) < LOADING_THRESHOLD {
|
||||
if let Some(task) = this
|
||||
.notification_store
|
||||
.update(cx, |store, cx| store.load_more_notifications(false, cx))
|
||||
{
|
||||
task.detach();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut this = Self {
|
||||
fs,
|
||||
client,
|
||||
user_store,
|
||||
local_timezone: cx.platform().local_timezone(),
|
||||
channel_store: ChannelStore::global(cx),
|
||||
notification_store: NotificationStore::global(cx),
|
||||
notification_list,
|
||||
pending_serialization: Task::ready(None),
|
||||
workspace: workspace_handle,
|
||||
has_focus: false,
|
||||
current_notification_toast: None,
|
||||
subscriptions: Vec::new(),
|
||||
active: false,
|
||||
mark_as_read_tasks: HashMap::default(),
|
||||
width: None,
|
||||
};
|
||||
|
||||
let mut old_dock_position = this.position(cx);
|
||||
this.subscriptions.extend([
|
||||
cx.observe(&this.notification_store, |_, _, cx| cx.notify()),
|
||||
cx.subscribe(&this.notification_store, Self::on_notification_event),
|
||||
cx.observe_global::<SettingsStore, _>(move |this: &mut Self, cx| {
|
||||
let new_dock_position = this.position(cx);
|
||||
if new_dock_position != old_dock_position {
|
||||
old_dock_position = new_dock_position;
|
||||
cx.emit(Event::DockPositionChanged);
|
||||
}
|
||||
cx.notify();
|
||||
}),
|
||||
]);
|
||||
this
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load(
|
||||
workspace: WeakViewHandle<Workspace>,
|
||||
cx: AsyncAppContext,
|
||||
) -> Task<Result<ViewHandle<Self>>> {
|
||||
cx.spawn(|mut cx| async move {
|
||||
let serialized_panel = if let Some(panel) = cx
|
||||
.background()
|
||||
.spawn(async move { KEY_VALUE_STORE.read_kvp(NOTIFICATION_PANEL_KEY) })
|
||||
.await
|
||||
.log_err()
|
||||
.flatten()
|
||||
{
|
||||
Some(serde_json::from_str::<SerializedNotificationPanel>(&panel)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
workspace.update(&mut cx, |workspace, cx| {
|
||||
let panel = Self::new(workspace, cx);
|
||||
if let Some(serialized_panel) = serialized_panel {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.width = serialized_panel.width;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
panel
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn serialize(&mut self, cx: &mut ViewContext<Self>) {
|
||||
let width = self.width;
|
||||
self.pending_serialization = cx.background().spawn(
|
||||
async move {
|
||||
KEY_VALUE_STORE
|
||||
.write_kvp(
|
||||
NOTIFICATION_PANEL_KEY.into(),
|
||||
serde_json::to_string(&SerializedNotificationPanel { width })?,
|
||||
)
|
||||
.await?;
|
||||
anyhow::Ok(())
|
||||
}
|
||||
.log_err(),
|
||||
);
|
||||
}
|
||||
|
||||
fn render_notification(
|
||||
&mut self,
|
||||
ix: usize,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Option<AnyElement<Self>> {
|
||||
let entry = self.notification_store.read(cx).notification_at(ix)?;
|
||||
let notification_id = entry.id;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let timestamp = entry.timestamp;
|
||||
let NotificationPresenter {
|
||||
actor,
|
||||
text,
|
||||
needs_response,
|
||||
can_navigate,
|
||||
..
|
||||
} = self.present_notification(entry, cx)?;
|
||||
|
||||
let theme = theme::current(cx);
|
||||
let style = &theme.notification_panel;
|
||||
let response = entry.response;
|
||||
let notification = entry.notification.clone();
|
||||
|
||||
let message_style = if entry.is_read {
|
||||
style.read_text.clone()
|
||||
} else {
|
||||
style.unread_text.clone()
|
||||
};
|
||||
|
||||
if self.active && !entry.is_read {
|
||||
self.did_render_notification(notification_id, ¬ification, cx);
|
||||
}
|
||||
|
||||
enum Decline {}
|
||||
enum Accept {}
|
||||
|
||||
Some(
|
||||
MouseEventHandler::new::<NotificationEntry, _>(ix, cx, |_, cx| {
|
||||
let container = message_style.container;
|
||||
|
||||
Flex::row()
|
||||
.with_children(actor.map(|actor| {
|
||||
render_avatar(actor.avatar.clone(), &style.avatar, style.avatar_container)
|
||||
}))
|
||||
.with_child(
|
||||
Flex::column()
|
||||
.with_child(Text::new(text, message_style.text.clone()))
|
||||
.with_child(
|
||||
Flex::row()
|
||||
.with_child(
|
||||
Label::new(
|
||||
format_timestamp(timestamp, now, self.local_timezone),
|
||||
style.timestamp.text.clone(),
|
||||
)
|
||||
.contained()
|
||||
.with_style(style.timestamp.container),
|
||||
)
|
||||
.with_children(if let Some(is_accepted) = response {
|
||||
Some(
|
||||
Label::new(
|
||||
if is_accepted {
|
||||
"You accepted"
|
||||
} else {
|
||||
"You declined"
|
||||
},
|
||||
style.read_text.text.clone(),
|
||||
)
|
||||
.flex_float()
|
||||
.into_any(),
|
||||
)
|
||||
} else if needs_response {
|
||||
Some(
|
||||
Flex::row()
|
||||
.with_children([
|
||||
MouseEventHandler::new::<Decline, _>(
|
||||
ix,
|
||||
cx,
|
||||
|state, _| {
|
||||
let button =
|
||||
style.button.style_for(state);
|
||||
Label::new(
|
||||
"Decline",
|
||||
button.text.clone(),
|
||||
)
|
||||
.contained()
|
||||
.with_style(button.container)
|
||||
},
|
||||
)
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.on_click(MouseButton::Left, {
|
||||
let notification = notification.clone();
|
||||
move |_, view, cx| {
|
||||
view.respond_to_notification(
|
||||
notification.clone(),
|
||||
false,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}),
|
||||
MouseEventHandler::new::<Accept, _>(
|
||||
ix,
|
||||
cx,
|
||||
|state, _| {
|
||||
let button =
|
||||
style.button.style_for(state);
|
||||
Label::new(
|
||||
"Accept",
|
||||
button.text.clone(),
|
||||
)
|
||||
.contained()
|
||||
.with_style(button.container)
|
||||
},
|
||||
)
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.on_click(MouseButton::Left, {
|
||||
let notification = notification.clone();
|
||||
move |_, view, cx| {
|
||||
view.respond_to_notification(
|
||||
notification.clone(),
|
||||
true,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}),
|
||||
])
|
||||
.flex_float()
|
||||
.into_any(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}),
|
||||
)
|
||||
.flex(1.0, true),
|
||||
)
|
||||
.contained()
|
||||
.with_style(container)
|
||||
.into_any()
|
||||
})
|
||||
.with_cursor_style(if can_navigate {
|
||||
CursorStyle::PointingHand
|
||||
} else {
|
||||
CursorStyle::default()
|
||||
})
|
||||
.on_click(MouseButton::Left, {
|
||||
let notification = notification.clone();
|
||||
move |_, this, cx| this.did_click_notification(¬ification, cx)
|
||||
})
|
||||
.into_any(),
|
||||
)
|
||||
}
|
||||
|
||||
fn present_notification(
|
||||
&self,
|
||||
entry: &NotificationEntry,
|
||||
cx: &AppContext,
|
||||
) -> Option<NotificationPresenter> {
|
||||
let user_store = self.user_store.read(cx);
|
||||
let channel_store = self.channel_store.read(cx);
|
||||
match entry.notification {
|
||||
Notification::ContactRequest { sender_id } => {
|
||||
let requester = user_store.get_cached_user(sender_id)?;
|
||||
Some(NotificationPresenter {
|
||||
icon: "icons/plus.svg",
|
||||
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 } => {
|
||||
let responder = user_store.get_cached_user(responder_id)?;
|
||||
Some(NotificationPresenter {
|
||||
icon: "icons/plus.svg",
|
||||
text: format!("{} accepted your contact invite", responder.github_login),
|
||||
needs_response: false,
|
||||
actor: Some(responder),
|
||||
can_navigate: false,
|
||||
})
|
||||
}
|
||||
Notification::ChannelInvitation {
|
||||
ref channel_name,
|
||||
channel_id,
|
||||
inviter_id,
|
||||
} => {
|
||||
let inviter = user_store.get_cached_user(inviter_id)?;
|
||||
Some(NotificationPresenter {
|
||||
icon: "icons/hash.svg",
|
||||
text: format!(
|
||||
"{} invited you to join the #{channel_name} channel",
|
||||
inviter.github_login
|
||||
),
|
||||
needs_response: channel_store.has_channel_invitation(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(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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn did_render_notification(
|
||||
&mut self,
|
||||
notification_id: u64,
|
||||
notification: &Notification,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let should_mark_as_read = match notification {
|
||||
Notification::ContactRequestAccepted { .. } => true,
|
||||
Notification::ContactRequest { .. }
|
||||
| Notification::ChannelInvitation { .. }
|
||||
| Notification::ChannelMessageMention { .. } => false,
|
||||
};
|
||||
|
||||
if should_mark_as_read {
|
||||
self.mark_as_read_tasks
|
||||
.entry(notification_id)
|
||||
.or_insert_with(|| {
|
||||
let client = self.client.clone();
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
cx.background().timer(MARK_AS_READ_DELAY).await;
|
||||
client
|
||||
.request(proto::MarkNotificationRead { notification_id })
|
||||
.await?;
|
||||
this.update(&mut cx, |this, _| {
|
||||
this.mark_as_read_tasks.remove(¬ification_id);
|
||||
})?;
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn did_click_notification(&mut self, notification: &Notification, cx: &mut ViewContext<Self>) {
|
||||
if let Notification::ChannelMessageMention {
|
||||
message_id,
|
||||
channel_id,
|
||||
..
|
||||
} = notification.clone()
|
||||
{
|
||||
if let Some(workspace) = self.workspace.upgrade(cx) {
|
||||
cx.app_context().defer(move |cx| {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel
|
||||
.select_channel(channel_id, Some(message_id), cx)
|
||||
.detach_and_log_err(cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_showing_notification(&self, notification: &Notification, cx: &AppContext) -> bool {
|
||||
if let Notification::ChannelMessageMention { channel_id, .. } = ¬ification {
|
||||
if let Some(workspace) = self.workspace.upgrade(cx) {
|
||||
return workspace
|
||||
.read_with(cx, |workspace, cx| {
|
||||
if let Some(panel) = workspace.panel::<ChatPanel>(cx) {
|
||||
return panel.read_with(cx, |panel, cx| {
|
||||
panel.is_scrolled_to_bottom()
|
||||
&& panel.active_chat().map_or(false, |chat| {
|
||||
chat.read(cx).channel_id == *channel_id
|
||||
})
|
||||
});
|
||||
}
|
||||
false
|
||||
})
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn render_sign_in_prompt(
|
||||
&self,
|
||||
theme: &Arc<Theme>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> AnyElement<Self> {
|
||||
enum SignInPromptLabel {}
|
||||
|
||||
MouseEventHandler::new::<SignInPromptLabel, _>(0, cx, |mouse_state, _| {
|
||||
Label::new(
|
||||
"Sign in to view your notifications".to_string(),
|
||||
theme
|
||||
.chat_panel
|
||||
.sign_in_prompt
|
||||
.style_for(mouse_state)
|
||||
.clone(),
|
||||
)
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.on_click(MouseButton::Left, move |_, this, cx| {
|
||||
let client = this.client.clone();
|
||||
cx.spawn(|_, cx| async move {
|
||||
client.authenticate_and_connect(true, &cx).log_err().await;
|
||||
})
|
||||
.detach();
|
||||
})
|
||||
.aligned()
|
||||
.into_any()
|
||||
}
|
||||
|
||||
fn render_empty_state(
|
||||
&self,
|
||||
theme: &Arc<Theme>,
|
||||
_cx: &mut ViewContext<Self>,
|
||||
) -> AnyElement<Self> {
|
||||
Label::new(
|
||||
"You have no notifications".to_string(),
|
||||
theme.chat_panel.sign_in_prompt.default.clone(),
|
||||
)
|
||||
.aligned()
|
||||
.into_any()
|
||||
}
|
||||
|
||||
fn on_notification_event(
|
||||
&mut self,
|
||||
_: ModelHandle<NotificationStore>,
|
||||
event: &NotificationEvent,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
NotificationEvent::NewNotification { entry } => self.add_toast(entry, cx),
|
||||
NotificationEvent::NotificationRemoved { entry }
|
||||
| NotificationEvent::NotificationRead { entry } => self.remove_toast(entry.id, cx),
|
||||
NotificationEvent::NotificationsUpdated {
|
||||
old_range,
|
||||
new_count,
|
||||
} => {
|
||||
self.notification_list.splice(old_range.clone(), *new_count);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext<Self>) {
|
||||
if self.is_showing_notification(&entry.notification, cx) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(NotificationPresenter { actor, text, .. }) = self.present_notification(entry, cx)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let notification_id = entry.id;
|
||||
self.current_notification_toast = Some((
|
||||
notification_id,
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
cx.background().timer(TOAST_DURATION).await;
|
||||
this.update(&mut cx, |this, cx| this.remove_toast(notification_id, cx))
|
||||
.ok();
|
||||
}),
|
||||
));
|
||||
|
||||
self.workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace.dismiss_notification::<NotificationToast>(0, cx);
|
||||
workspace.show_notification(0, cx, |cx| {
|
||||
let workspace = cx.weak_handle();
|
||||
cx.add_view(|_| NotificationToast {
|
||||
notification_id,
|
||||
actor,
|
||||
text,
|
||||
workspace,
|
||||
})
|
||||
})
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn remove_toast(&mut self, notification_id: u64, cx: &mut ViewContext<Self>) {
|
||||
if let Some((current_id, _)) = &self.current_notification_toast {
|
||||
if *current_id == notification_id {
|
||||
self.current_notification_toast.take();
|
||||
self.workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace.dismiss_notification::<NotificationToast>(0, cx)
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn respond_to_notification(
|
||||
&mut self,
|
||||
notification: Notification,
|
||||
response: bool,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.notification_store.update(cx, |store, cx| {
|
||||
store.respond_to_notification(notification, response, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for NotificationPanel {
|
||||
type Event = Event;
|
||||
}
|
||||
|
||||
impl View for NotificationPanel {
|
||||
fn ui_name() -> &'static str {
|
||||
"NotificationPanel"
|
||||
}
|
||||
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
|
||||
let theme = theme::current(cx);
|
||||
let style = &theme.notification_panel;
|
||||
let element = if self.client.user_id().is_none() {
|
||||
self.render_sign_in_prompt(&theme, cx)
|
||||
} else if self.notification_list.item_count() == 0 {
|
||||
self.render_empty_state(&theme, cx)
|
||||
} else {
|
||||
Flex::column()
|
||||
.with_child(
|
||||
Flex::row()
|
||||
.with_child(Label::new("Notifications", style.title.text.clone()))
|
||||
.with_child(ui::svg(&style.title_icon).flex_float())
|
||||
.align_children_center()
|
||||
.contained()
|
||||
.with_style(style.title.container)
|
||||
.constrained()
|
||||
.with_height(style.title_height),
|
||||
)
|
||||
.with_child(
|
||||
List::new(self.notification_list.clone())
|
||||
.contained()
|
||||
.with_style(style.list)
|
||||
.flex(1., true),
|
||||
)
|
||||
.into_any()
|
||||
};
|
||||
element
|
||||
.contained()
|
||||
.with_style(style.container)
|
||||
.constrained()
|
||||
.with_min_width(150.)
|
||||
.into_any()
|
||||
}
|
||||
|
||||
fn focus_in(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
|
||||
self.has_focus = true;
|
||||
}
|
||||
|
||||
fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
|
||||
self.has_focus = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for NotificationPanel {
|
||||
fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
|
||||
settings::get::<NotificationPanelSettings>(cx).dock
|
||||
}
|
||||
|
||||
fn position_is_valid(&self, position: DockPosition) -> bool {
|
||||
matches!(position, DockPosition::Left | DockPosition::Right)
|
||||
}
|
||||
|
||||
fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
|
||||
settings::update_settings_file::<NotificationPanelSettings>(
|
||||
self.fs.clone(),
|
||||
cx,
|
||||
move |settings| settings.dock = Some(position),
|
||||
);
|
||||
}
|
||||
|
||||
fn size(&self, cx: &gpui::WindowContext) -> f32 {
|
||||
self.width
|
||||
.unwrap_or_else(|| settings::get::<NotificationPanelSettings>(cx).default_width)
|
||||
}
|
||||
|
||||
fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
|
||||
self.width = size;
|
||||
self.serialize(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
|
||||
self.active = active;
|
||||
if self.notification_store.read(cx).notification_count() == 0 {
|
||||
cx.emit(Event::Dismissed);
|
||||
}
|
||||
}
|
||||
|
||||
fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
|
||||
(settings::get::<NotificationPanelSettings>(cx).button
|
||||
&& self.notification_store.read(cx).notification_count() > 0)
|
||||
.then(|| "icons/bell.svg")
|
||||
}
|
||||
|
||||
fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
|
||||
(
|
||||
"Notification Panel".to_string(),
|
||||
Some(Box::new(ToggleFocus)),
|
||||
)
|
||||
}
|
||||
|
||||
fn icon_label(&self, cx: &WindowContext) -> Option<String> {
|
||||
let count = self.notification_store.read(cx).unread_notification_count();
|
||||
if count == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(count.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn should_change_position_on_event(event: &Self::Event) -> bool {
|
||||
matches!(event, Event::DockPositionChanged)
|
||||
}
|
||||
|
||||
fn should_close_on_event(event: &Self::Event) -> bool {
|
||||
matches!(event, Event::Dismissed)
|
||||
}
|
||||
|
||||
fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
|
||||
self.has_focus
|
||||
}
|
||||
|
||||
fn is_focus_event(event: &Self::Event) -> bool {
|
||||
matches!(event, Event::Focus)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NotificationToast {
|
||||
notification_id: u64,
|
||||
actor: Option<Arc<User>>,
|
||||
text: String,
|
||||
workspace: WeakViewHandle<Workspace>,
|
||||
}
|
||||
|
||||
pub enum ToastEvent {
|
||||
Dismiss,
|
||||
}
|
||||
|
||||
impl NotificationToast {
|
||||
fn focus_notification_panel(&self, cx: &mut AppContext) {
|
||||
let workspace = self.workspace.clone();
|
||||
let notification_id = self.notification_id;
|
||||
cx.defer(move |cx| {
|
||||
workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
if let Some(panel) = workspace.focus_panel::<NotificationPanel>(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, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for NotificationToast {
|
||||
type Event = ToastEvent;
|
||||
}
|
||||
|
||||
impl View for NotificationToast {
|
||||
fn ui_name() -> &'static str {
|
||||
"ContactNotification"
|
||||
}
|
||||
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
|
||||
let user = self.actor.clone();
|
||||
let theme = theme::current(cx).clone();
|
||||
let theme = &theme.contact_notification;
|
||||
|
||||
MouseEventHandler::new::<Self, _>(0, cx, |_, cx| {
|
||||
Flex::row()
|
||||
.with_children(user.and_then(|user| {
|
||||
Some(
|
||||
Image::from_data(user.avatar.clone()?)
|
||||
.with_style(theme.header_avatar)
|
||||
.aligned()
|
||||
.constrained()
|
||||
.with_height(
|
||||
cx.font_cache()
|
||||
.line_height(theme.header_message.text.font_size),
|
||||
)
|
||||
.aligned()
|
||||
.top(),
|
||||
)
|
||||
}))
|
||||
.with_child(
|
||||
Text::new(self.text.clone(), theme.header_message.text.clone())
|
||||
.contained()
|
||||
.with_style(theme.header_message.container)
|
||||
.aligned()
|
||||
.top()
|
||||
.left()
|
||||
.flex(1., true),
|
||||
)
|
||||
.with_child(
|
||||
MouseEventHandler::new::<ToastEvent, _>(0, cx, |state, _| {
|
||||
let style = theme.dismiss_button.style_for(state);
|
||||
Svg::new("icons/x.svg")
|
||||
.with_color(style.color)
|
||||
.constrained()
|
||||
.with_width(style.icon_width)
|
||||
.aligned()
|
||||
.contained()
|
||||
.with_style(style.container)
|
||||
.constrained()
|
||||
.with_width(style.button_width)
|
||||
.with_height(style.button_width)
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.with_padding(Padding::uniform(5.))
|
||||
.on_click(MouseButton::Left, move |_, _, cx| {
|
||||
cx.emit(ToastEvent::Dismiss)
|
||||
})
|
||||
.aligned()
|
||||
.constrained()
|
||||
.with_height(
|
||||
cx.font_cache()
|
||||
.line_height(theme.header_message.text.font_size),
|
||||
)
|
||||
.aligned()
|
||||
.top()
|
||||
.flex_float(),
|
||||
)
|
||||
.contained()
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.on_click(MouseButton::Left, move |_, this, cx| {
|
||||
this.focus_notification_panel(cx);
|
||||
cx.emit(ToastEvent::Dismiss);
|
||||
})
|
||||
.into_any()
|
||||
}
|
||||
}
|
||||
|
||||
impl workspace::notifications::Notification for NotificationToast {
|
||||
fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool {
|
||||
matches!(event, ToastEvent::Dismiss)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_timestamp(
|
||||
mut timestamp: OffsetDateTime,
|
||||
mut now: OffsetDateTime,
|
||||
local_timezone: UtcOffset,
|
||||
) -> String {
|
||||
timestamp = timestamp.to_offset(local_timezone);
|
||||
now = now.to_offset(local_timezone);
|
||||
|
||||
let today = now.date();
|
||||
let date = timestamp.date();
|
||||
if date == today {
|
||||
let difference = now - timestamp;
|
||||
if difference >= Duration::from_secs(3600) {
|
||||
format!("{}h", difference.whole_seconds() / 3600)
|
||||
} else if difference >= Duration::from_secs(60) {
|
||||
format!("{}m", difference.whole_seconds() / 60)
|
||||
} else {
|
||||
"just now".to_string()
|
||||
}
|
||||
} else if date.next_day() == Some(today) {
|
||||
format!("yesterday")
|
||||
} else {
|
||||
format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year())
|
||||
}
|
||||
}
|
||||
@@ -1,110 +1,11 @@
|
||||
use client::User;
|
||||
use gpui::{
|
||||
elements::*,
|
||||
platform::{CursorStyle, MouseButton},
|
||||
AnyElement, Element, ViewContext,
|
||||
};
|
||||
use gpui::AppContext;
|
||||
use std::sync::Arc;
|
||||
use workspace::AppState;
|
||||
|
||||
enum Dismiss {}
|
||||
enum Button {}
|
||||
pub mod incoming_call_notification;
|
||||
pub mod project_shared_notification;
|
||||
|
||||
pub fn render_user_notification<F, V: 'static>(
|
||||
user: Arc<User>,
|
||||
title: &'static str,
|
||||
body: Option<&'static str>,
|
||||
on_dismiss: F,
|
||||
buttons: Vec<(&'static str, Box<dyn Fn(&mut V, &mut ViewContext<V>)>)>,
|
||||
cx: &mut ViewContext<V>,
|
||||
) -> AnyElement<V>
|
||||
where
|
||||
F: 'static + Fn(&mut V, &mut ViewContext<V>),
|
||||
{
|
||||
let theme = theme::current(cx).clone();
|
||||
let theme = &theme.contact_notification;
|
||||
|
||||
Flex::column()
|
||||
.with_child(
|
||||
Flex::row()
|
||||
.with_children(user.avatar.clone().map(|avatar| {
|
||||
Image::from_data(avatar)
|
||||
.with_style(theme.header_avatar)
|
||||
.aligned()
|
||||
.constrained()
|
||||
.with_height(
|
||||
cx.font_cache()
|
||||
.line_height(theme.header_message.text.font_size),
|
||||
)
|
||||
.aligned()
|
||||
.top()
|
||||
}))
|
||||
.with_child(
|
||||
Text::new(
|
||||
format!("{} {}", user.github_login, title),
|
||||
theme.header_message.text.clone(),
|
||||
)
|
||||
.contained()
|
||||
.with_style(theme.header_message.container)
|
||||
.aligned()
|
||||
.top()
|
||||
.left()
|
||||
.flex(1., true),
|
||||
)
|
||||
.with_child(
|
||||
MouseEventHandler::new::<Dismiss, _>(user.id as usize, cx, |state, _| {
|
||||
let style = theme.dismiss_button.style_for(state);
|
||||
Svg::new("icons/x.svg")
|
||||
.with_color(style.color)
|
||||
.constrained()
|
||||
.with_width(style.icon_width)
|
||||
.aligned()
|
||||
.contained()
|
||||
.with_style(style.container)
|
||||
.constrained()
|
||||
.with_width(style.button_width)
|
||||
.with_height(style.button_width)
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.with_padding(Padding::uniform(5.))
|
||||
.on_click(MouseButton::Left, move |_, view, cx| on_dismiss(view, cx))
|
||||
.aligned()
|
||||
.constrained()
|
||||
.with_height(
|
||||
cx.font_cache()
|
||||
.line_height(theme.header_message.text.font_size),
|
||||
)
|
||||
.aligned()
|
||||
.top()
|
||||
.flex_float(),
|
||||
)
|
||||
.into_any_named("contact notification header"),
|
||||
)
|
||||
.with_children(body.map(|body| {
|
||||
Label::new(body, theme.body_message.text.clone())
|
||||
.contained()
|
||||
.with_style(theme.body_message.container)
|
||||
}))
|
||||
.with_children(if buttons.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
Flex::row()
|
||||
.with_children(buttons.into_iter().enumerate().map(
|
||||
|(ix, (message, handler))| {
|
||||
MouseEventHandler::new::<Button, _>(ix, cx, |state, _| {
|
||||
let button = theme.button.style_for(state);
|
||||
Label::new(message, button.text.clone())
|
||||
.contained()
|
||||
.with_style(button.container)
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.on_click(MouseButton::Left, move |_, view, cx| handler(view, cx))
|
||||
},
|
||||
))
|
||||
.aligned()
|
||||
.right(),
|
||||
)
|
||||
})
|
||||
.contained()
|
||||
.into_any()
|
||||
pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
|
||||
incoming_call_notification::init(app_state, cx);
|
||||
project_shared_notification::init(app_state, cx);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,13 @@ pub struct ChatPanelSettings {
|
||||
pub default_width: f32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct NotificationPanelSettings {
|
||||
pub button: bool,
|
||||
pub dock: DockPosition,
|
||||
pub default_width: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
|
||||
pub struct PanelSettingsContent {
|
||||
pub button: Option<bool>,
|
||||
@@ -27,9 +34,7 @@ pub struct PanelSettingsContent {
|
||||
|
||||
impl Setting for CollaborationPanelSettings {
|
||||
const KEY: Option<&'static str> = Some("collaboration_panel");
|
||||
|
||||
type FileContent = PanelSettingsContent;
|
||||
|
||||
fn load(
|
||||
default_value: &Self::FileContent,
|
||||
user_values: &[&Self::FileContent],
|
||||
@@ -41,9 +46,19 @@ impl Setting for CollaborationPanelSettings {
|
||||
|
||||
impl Setting for ChatPanelSettings {
|
||||
const KEY: Option<&'static str> = Some("chat_panel");
|
||||
|
||||
type FileContent = PanelSettingsContent;
|
||||
fn load(
|
||||
default_value: &Self::FileContent,
|
||||
user_values: &[&Self::FileContent],
|
||||
_: &gpui::AppContext,
|
||||
) -> anyhow::Result<Self> {
|
||||
Self::load_via_json_merge(default_value, user_values)
|
||||
}
|
||||
}
|
||||
|
||||
impl Setting for NotificationPanelSettings {
|
||||
const KEY: Option<&'static str> = Some("notification_panel");
|
||||
type FileContent = PanelSettingsContent;
|
||||
fn load(
|
||||
default_value: &Self::FileContent,
|
||||
user_values: &[&Self::FileContent],
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
use crate::toggle_screen_sharing;
|
||||
use call::ActiveCall;
|
||||
use gpui::{
|
||||
color::Color,
|
||||
elements::{MouseEventHandler, Svg},
|
||||
platform::{Appearance, MouseButton},
|
||||
AnyElement, AppContext, Element, Entity, View, ViewContext,
|
||||
};
|
||||
use workspace::WorkspaceSettings;
|
||||
|
||||
pub fn init(cx: &mut AppContext) {
|
||||
let active_call = ActiveCall::global(cx);
|
||||
|
||||
let mut status_indicator = None;
|
||||
cx.observe(&active_call, move |call, cx| {
|
||||
if let Some(room) = call.read(cx).room() {
|
||||
if room.read(cx).is_screen_sharing() {
|
||||
if status_indicator.is_none()
|
||||
&& settings::get::<WorkspaceSettings>(cx).show_call_status_icon
|
||||
{
|
||||
status_indicator = Some(cx.add_status_bar_item(|_| SharingStatusIndicator));
|
||||
}
|
||||
} else if let Some(window) = status_indicator.take() {
|
||||
window.update(cx, |cx| cx.remove_window());
|
||||
}
|
||||
} else if let Some(window) = status_indicator.take() {
|
||||
window.update(cx, |cx| cx.remove_window());
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub struct SharingStatusIndicator;
|
||||
|
||||
impl Entity for SharingStatusIndicator {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for SharingStatusIndicator {
|
||||
fn ui_name() -> &'static str {
|
||||
"SharingStatusIndicator"
|
||||
}
|
||||
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
|
||||
let color = match cx.window_appearance() {
|
||||
Appearance::Light | Appearance::VibrantLight => Color::black(),
|
||||
Appearance::Dark | Appearance::VibrantDark => Color::white(),
|
||||
};
|
||||
|
||||
MouseEventHandler::new::<Self, _>(0, cx, |_, _| {
|
||||
Svg::new("icons/desktop.svg")
|
||||
.with_color(color)
|
||||
.constrained()
|
||||
.with_width(18.)
|
||||
.aligned()
|
||||
})
|
||||
.on_click(MouseButton::Left, |_, _, cx| {
|
||||
toggle_screen_sharing(&Default::default(), cx)
|
||||
})
|
||||
.into_any()
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ settings = { path = "../settings" }
|
||||
util = { path = "../util" }
|
||||
theme = { path = "../theme" }
|
||||
workspace = { path = "../workspace" }
|
||||
zed-actions = { path = "../zed-actions" }
|
||||
|
||||
[dev-dependencies]
|
||||
gpui = { path = "../gpui", features = ["test-support"] }
|
||||
|
||||
@@ -6,8 +6,12 @@ use gpui::{
|
||||
};
|
||||
use picker::{Picker, PickerDelegate, PickerEvent};
|
||||
use std::cmp::{self, Reverse};
|
||||
use util::ResultExt;
|
||||
use util::{
|
||||
channel::{parse_zed_link, ReleaseChannel, RELEASE_CHANNEL},
|
||||
ResultExt,
|
||||
};
|
||||
use workspace::Workspace;
|
||||
use zed_actions::OpenZedURL;
|
||||
|
||||
pub fn init(cx: &mut AppContext) {
|
||||
cx.add_action(toggle_command_palette);
|
||||
@@ -167,13 +171,22 @@ impl PickerDelegate for CommandPaletteDelegate {
|
||||
)
|
||||
.await
|
||||
};
|
||||
let intercept_result = cx.read(|cx| {
|
||||
let mut intercept_result = cx.read(|cx| {
|
||||
if cx.has_global::<CommandPaletteInterceptor>() {
|
||||
cx.global::<CommandPaletteInterceptor>()(&query, cx)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if *RELEASE_CHANNEL == ReleaseChannel::Dev {
|
||||
if parse_zed_link(&query).is_some() {
|
||||
intercept_result = Some(CommandInterceptResult {
|
||||
action: OpenZedURL { url: query.clone() }.boxed_clone(),
|
||||
string: query.clone(),
|
||||
positions: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
if let Some(CommandInterceptResult {
|
||||
action,
|
||||
string,
|
||||
|
||||
@@ -36,6 +36,7 @@ serde.workspace = true
|
||||
serde_derive.workspace = true
|
||||
smol.workspace = true
|
||||
futures.workspace = true
|
||||
parking_lot.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
clock = { path = "../clock" }
|
||||
|
||||
@@ -16,6 +16,7 @@ use language::{
|
||||
};
|
||||
use lsp::{LanguageServer, LanguageServerBinary, LanguageServerId};
|
||||
use node_runtime::NodeRuntime;
|
||||
use parking_lot::Mutex;
|
||||
use request::StatusNotification;
|
||||
use settings::SettingsStore;
|
||||
use smol::{fs, io::BufReader, stream::StreamExt};
|
||||
@@ -387,8 +388,15 @@ impl Copilot {
|
||||
path: node_path,
|
||||
arguments,
|
||||
};
|
||||
let server =
|
||||
LanguageServer::new(new_server_id, binary, Path::new("/"), None, cx.clone())?;
|
||||
|
||||
let server = LanguageServer::new(
|
||||
Arc::new(Mutex::new(None)),
|
||||
new_server_id,
|
||||
binary,
|
||||
Path::new("/"),
|
||||
None,
|
||||
cx.clone(),
|
||||
)?;
|
||||
|
||||
server
|
||||
.on_notification::<StatusNotification, _>(
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use util::channel::ReleaseChannel;
|
||||
use util::{async_iife, ResultExt};
|
||||
use util::{async_maybe, ResultExt};
|
||||
|
||||
const CONNECTION_INITIALIZE_QUERY: &'static str = sql!(
|
||||
PRAGMA foreign_keys=TRUE;
|
||||
@@ -57,7 +57,7 @@ pub async fn open_db<M: Migrator + 'static>(
|
||||
let release_channel_name = release_channel.dev_name();
|
||||
let main_db_dir = db_dir.join(Path::new(&format!("0-{}", release_channel_name)));
|
||||
|
||||
let connection = async_iife!({
|
||||
let connection = async_maybe!({
|
||||
smol::fs::create_dir_all(&main_db_dir)
|
||||
.await
|
||||
.context("Could not create db directory")
|
||||
|
||||
@@ -20,7 +20,7 @@ use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use util::channel::ReleaseChannel;
|
||||
use util::{async_iife, ResultExt};
|
||||
use util::{async_maybe, ResultExt};
|
||||
|
||||
const CONNECTION_INITIALIZE_QUERY: &'static str = sql!(
|
||||
PRAGMA foreign_keys=TRUE;
|
||||
@@ -57,7 +57,7 @@ pub async fn open_db<M: Migrator + 'static>(
|
||||
let release_channel_name = release_channel.dev_name();
|
||||
let main_db_dir = db_dir.join(Path::new(&format!("0-{}", release_channel_name)));
|
||||
|
||||
let connection = async_iife!({
|
||||
let connection = async_maybe!({
|
||||
smol::fs::create_dir_all(&main_db_dir)
|
||||
.await
|
||||
.context("Could not create db directory")
|
||||
|
||||
@@ -38,6 +38,10 @@ impl DiagnosticIndicator {
|
||||
this.in_progress_checks.remove(language_server_id);
|
||||
cx.notify();
|
||||
}
|
||||
project::Event::DiagnosticsUpdated { .. } => {
|
||||
this.summary = project.read(cx).diagnostic_summary(cx);
|
||||
cx.notify();
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.detach();
|
||||
|
||||
@@ -14,6 +14,7 @@ test-support = [
|
||||
"text/test-support",
|
||||
"language/test-support",
|
||||
"gpui/test-support",
|
||||
"multi_buffer/test-support",
|
||||
"project/test-support",
|
||||
"util/test-support",
|
||||
"workspace/test-support",
|
||||
@@ -34,6 +35,7 @@ git = { path = "../git" }
|
||||
gpui = { path = "../gpui" }
|
||||
language = { path = "../language" }
|
||||
lsp = { path = "../lsp" }
|
||||
multi_buffer = { path = "../multi_buffer" }
|
||||
project = { path = "../project" }
|
||||
rpc = { path = "../rpc" }
|
||||
rich_text = { path = "../rich_text" }
|
||||
@@ -57,7 +59,6 @@ log.workspace = true
|
||||
ordered-float.workspace = true
|
||||
parking_lot.workspace = true
|
||||
postage.workspace = true
|
||||
pulldown-cmark = { version = "0.9.2", default-features = false }
|
||||
rand.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -5,22 +5,24 @@ mod tab_map;
|
||||
mod wrap_map;
|
||||
|
||||
use crate::{
|
||||
link_go_to_definition::InlayHighlight, Anchor, AnchorRangeExt, InlayId, MultiBuffer,
|
||||
MultiBufferSnapshot, ToOffset, ToPoint,
|
||||
link_go_to_definition::InlayHighlight, movement::TextLayoutDetails, Anchor, AnchorRangeExt,
|
||||
EditorStyle, InlayId, MultiBuffer, MultiBufferSnapshot, ToOffset, ToPoint,
|
||||
};
|
||||
pub use block_map::{BlockMap, BlockPoint};
|
||||
use collections::{BTreeMap, HashMap, HashSet};
|
||||
use fold_map::FoldMap;
|
||||
use gpui::{
|
||||
color::Color,
|
||||
fonts::{FontId, HighlightStyle},
|
||||
fonts::{FontId, HighlightStyle, Underline},
|
||||
text_layout::{Line, RunStyle},
|
||||
Entity, ModelContext, ModelHandle,
|
||||
};
|
||||
use inlay_map::InlayMap;
|
||||
use language::{
|
||||
language_settings::language_settings, OffsetUtf16, Point, Subscription as BufferSubscription,
|
||||
};
|
||||
use std::{any::TypeId, fmt::Debug, num::NonZeroU32, ops::Range, sync::Arc};
|
||||
use lsp::DiagnosticSeverity;
|
||||
use std::{any::TypeId, borrow::Cow, fmt::Debug, num::NonZeroU32, ops::Range, sync::Arc};
|
||||
use sum_tree::{Bias, TreeMap};
|
||||
use tab_map::TabMap;
|
||||
use wrap_map::WrapMap;
|
||||
@@ -316,6 +318,12 @@ pub struct Highlights<'a> {
|
||||
pub suggestion_highlight_style: Option<HighlightStyle>,
|
||||
}
|
||||
|
||||
pub struct HighlightedChunk<'a> {
|
||||
pub chunk: &'a str,
|
||||
pub style: Option<HighlightStyle>,
|
||||
pub is_tab: bool,
|
||||
}
|
||||
|
||||
pub struct DisplaySnapshot {
|
||||
pub buffer_snapshot: MultiBufferSnapshot,
|
||||
pub fold_snapshot: fold_map::FoldSnapshot,
|
||||
@@ -485,7 +493,7 @@ impl DisplaySnapshot {
|
||||
language_aware: bool,
|
||||
inlay_highlight_style: Option<HighlightStyle>,
|
||||
suggestion_highlight_style: Option<HighlightStyle>,
|
||||
) -> DisplayChunks<'_> {
|
||||
) -> DisplayChunks<'a> {
|
||||
self.block_snapshot.chunks(
|
||||
display_rows,
|
||||
language_aware,
|
||||
@@ -498,6 +506,140 @@ impl DisplaySnapshot {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn highlighted_chunks<'a>(
|
||||
&'a self,
|
||||
display_rows: Range<u32>,
|
||||
language_aware: bool,
|
||||
style: &'a EditorStyle,
|
||||
) -> impl Iterator<Item = HighlightedChunk<'a>> {
|
||||
self.chunks(
|
||||
display_rows,
|
||||
language_aware,
|
||||
Some(style.theme.hint),
|
||||
Some(style.theme.suggestion),
|
||||
)
|
||||
.map(|chunk| {
|
||||
let mut highlight_style = chunk
|
||||
.syntax_highlight_id
|
||||
.and_then(|id| id.style(&style.syntax));
|
||||
|
||||
if let Some(chunk_highlight) = chunk.highlight_style {
|
||||
if let Some(highlight_style) = highlight_style.as_mut() {
|
||||
highlight_style.highlight(chunk_highlight);
|
||||
} else {
|
||||
highlight_style = Some(chunk_highlight);
|
||||
}
|
||||
}
|
||||
|
||||
let mut diagnostic_highlight = HighlightStyle::default();
|
||||
|
||||
if chunk.is_unnecessary {
|
||||
diagnostic_highlight.fade_out = Some(style.unnecessary_code_fade);
|
||||
}
|
||||
|
||||
if let Some(severity) = chunk.diagnostic_severity {
|
||||
// Omit underlines for HINT/INFO diagnostics on 'unnecessary' code.
|
||||
if severity <= DiagnosticSeverity::WARNING || !chunk.is_unnecessary {
|
||||
let diagnostic_style = super::diagnostic_style(severity, true, style);
|
||||
diagnostic_highlight.underline = Some(Underline {
|
||||
color: Some(diagnostic_style.message.text.color),
|
||||
thickness: 1.0.into(),
|
||||
squiggly: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(highlight_style) = highlight_style.as_mut() {
|
||||
highlight_style.highlight(diagnostic_highlight);
|
||||
} else {
|
||||
highlight_style = Some(diagnostic_highlight);
|
||||
}
|
||||
|
||||
HighlightedChunk {
|
||||
chunk: chunk.text,
|
||||
style: highlight_style,
|
||||
is_tab: chunk.is_tab,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn lay_out_line_for_row(
|
||||
&self,
|
||||
display_row: u32,
|
||||
TextLayoutDetails {
|
||||
font_cache,
|
||||
text_layout_cache,
|
||||
editor_style,
|
||||
}: &TextLayoutDetails,
|
||||
) -> Line {
|
||||
let mut styles = Vec::new();
|
||||
let mut line = String::new();
|
||||
let mut ended_in_newline = false;
|
||||
|
||||
let range = display_row..display_row + 1;
|
||||
for chunk in self.highlighted_chunks(range, false, editor_style) {
|
||||
line.push_str(chunk.chunk);
|
||||
|
||||
let text_style = if let Some(style) = chunk.style {
|
||||
editor_style
|
||||
.text
|
||||
.clone()
|
||||
.highlight(style, font_cache)
|
||||
.map(Cow::Owned)
|
||||
.unwrap_or_else(|_| Cow::Borrowed(&editor_style.text))
|
||||
} else {
|
||||
Cow::Borrowed(&editor_style.text)
|
||||
};
|
||||
ended_in_newline = chunk.chunk.ends_with("\n");
|
||||
|
||||
styles.push((
|
||||
chunk.chunk.len(),
|
||||
RunStyle {
|
||||
font_id: text_style.font_id,
|
||||
color: text_style.color,
|
||||
underline: text_style.underline,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
// our pixel positioning logic assumes each line ends in \n,
|
||||
// this is almost always true except for the last line which
|
||||
// may have no trailing newline.
|
||||
if !ended_in_newline && display_row == self.max_point().row() {
|
||||
line.push_str("\n");
|
||||
|
||||
styles.push((
|
||||
"\n".len(),
|
||||
RunStyle {
|
||||
font_id: editor_style.text.font_id,
|
||||
color: editor_style.text_color,
|
||||
underline: editor_style.text.underline,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
text_layout_cache.layout_str(&line, editor_style.text.font_size, &styles)
|
||||
}
|
||||
|
||||
pub fn x_for_point(
|
||||
&self,
|
||||
display_point: DisplayPoint,
|
||||
text_layout_details: &TextLayoutDetails,
|
||||
) -> f32 {
|
||||
let layout_line = self.lay_out_line_for_row(display_point.row(), text_layout_details);
|
||||
layout_line.x_for_index(display_point.column() as usize)
|
||||
}
|
||||
|
||||
pub fn column_for_x(
|
||||
&self,
|
||||
display_row: u32,
|
||||
x_coordinate: f32,
|
||||
text_layout_details: &TextLayoutDetails,
|
||||
) -> u32 {
|
||||
let layout_line = self.lay_out_line_for_row(display_row, text_layout_details);
|
||||
layout_line.closest_index_for_x(x_coordinate) as u32
|
||||
}
|
||||
|
||||
pub fn chars_at(
|
||||
&self,
|
||||
mut point: DisplayPoint,
|
||||
@@ -869,12 +1011,16 @@ pub fn next_rows(display_row: u32, display_map: &DisplaySnapshot) -> impl Iterat
|
||||
#[cfg(test)]
|
||||
pub mod tests {
|
||||
use super::*;
|
||||
use crate::{movement, test::marked_display_snapshot};
|
||||
use crate::{
|
||||
movement,
|
||||
test::{editor_test_context::EditorTestContext, marked_display_snapshot},
|
||||
};
|
||||
use gpui::{color::Color, elements::*, test::observe, AppContext};
|
||||
use language::{
|
||||
language_settings::{AllLanguageSettings, AllLanguageSettingsContent},
|
||||
Buffer, Language, LanguageConfig, SelectionGoal,
|
||||
};
|
||||
use project::Project;
|
||||
use rand::{prelude::*, Rng};
|
||||
use settings::SettingsStore;
|
||||
use smol::stream::StreamExt;
|
||||
@@ -1148,95 +1294,120 @@ pub mod tests {
|
||||
}
|
||||
|
||||
#[gpui::test(retries = 5)]
|
||||
fn test_soft_wraps(cx: &mut AppContext) {
|
||||
async fn test_soft_wraps(cx: &mut gpui::TestAppContext) {
|
||||
cx.foreground().set_block_on_ticks(usize::MAX..=usize::MAX);
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let font_cache = cx.font_cache();
|
||||
|
||||
let family_id = font_cache
|
||||
.load_family(&["Helvetica"], &Default::default())
|
||||
.unwrap();
|
||||
let font_id = font_cache
|
||||
.select_font(family_id, &Default::default())
|
||||
.unwrap();
|
||||
let font_size = 12.0;
|
||||
let wrap_width = Some(64.);
|
||||
|
||||
let text = "one two three four five\nsix seven eight";
|
||||
let buffer = MultiBuffer::build_simple(text, cx);
|
||||
let map = cx.add_model(|cx| {
|
||||
DisplayMap::new(buffer.clone(), font_id, font_size, wrap_width, 1, 1, cx)
|
||||
cx.update(|cx| {
|
||||
init_test(cx, |_| {});
|
||||
});
|
||||
|
||||
let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
|
||||
assert_eq!(
|
||||
snapshot.text_chunks(0).collect::<String>(),
|
||||
"one two \nthree four \nfive\nsix seven \neight"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.clip_point(DisplayPoint::new(0, 8), Bias::Left),
|
||||
DisplayPoint::new(0, 7)
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.clip_point(DisplayPoint::new(0, 8), Bias::Right),
|
||||
DisplayPoint::new(1, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
movement::right(&snapshot, DisplayPoint::new(0, 7)),
|
||||
DisplayPoint::new(1, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
movement::left(&snapshot, DisplayPoint::new(1, 0)),
|
||||
DisplayPoint::new(0, 7)
|
||||
);
|
||||
assert_eq!(
|
||||
movement::up(
|
||||
&snapshot,
|
||||
DisplayPoint::new(1, 10),
|
||||
SelectionGoal::None,
|
||||
false
|
||||
),
|
||||
(DisplayPoint::new(0, 7), SelectionGoal::Column(10))
|
||||
);
|
||||
assert_eq!(
|
||||
movement::down(
|
||||
&snapshot,
|
||||
DisplayPoint::new(0, 7),
|
||||
SelectionGoal::Column(10),
|
||||
false
|
||||
),
|
||||
(DisplayPoint::new(1, 10), SelectionGoal::Column(10))
|
||||
);
|
||||
assert_eq!(
|
||||
movement::down(
|
||||
&snapshot,
|
||||
DisplayPoint::new(1, 10),
|
||||
SelectionGoal::Column(10),
|
||||
false
|
||||
),
|
||||
(DisplayPoint::new(2, 4), SelectionGoal::Column(10))
|
||||
);
|
||||
let mut cx = EditorTestContext::new(cx).await;
|
||||
let editor = cx.editor.clone();
|
||||
let window = cx.window.clone();
|
||||
|
||||
let ix = snapshot.buffer_snapshot.text().find("seven").unwrap();
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
buffer.edit([(ix..ix, "and ")], None, cx);
|
||||
cx.update_window(window, |cx| {
|
||||
let text_layout_details =
|
||||
editor.read_with(cx, |editor, cx| editor.text_layout_details(cx));
|
||||
|
||||
let font_cache = cx.font_cache().clone();
|
||||
|
||||
let family_id = font_cache
|
||||
.load_family(&["Helvetica"], &Default::default())
|
||||
.unwrap();
|
||||
let font_id = font_cache
|
||||
.select_font(family_id, &Default::default())
|
||||
.unwrap();
|
||||
let font_size = 12.0;
|
||||
let wrap_width = Some(64.);
|
||||
|
||||
let text = "one two three four five\nsix seven eight";
|
||||
let buffer = MultiBuffer::build_simple(text, cx);
|
||||
let map = cx.add_model(|cx| {
|
||||
DisplayMap::new(buffer.clone(), font_id, font_size, wrap_width, 1, 1, cx)
|
||||
});
|
||||
|
||||
let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
|
||||
assert_eq!(
|
||||
snapshot.text_chunks(0).collect::<String>(),
|
||||
"one two \nthree four \nfive\nsix seven \neight"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.clip_point(DisplayPoint::new(0, 8), Bias::Left),
|
||||
DisplayPoint::new(0, 7)
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.clip_point(DisplayPoint::new(0, 8), Bias::Right),
|
||||
DisplayPoint::new(1, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
movement::right(&snapshot, DisplayPoint::new(0, 7)),
|
||||
DisplayPoint::new(1, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
movement::left(&snapshot, DisplayPoint::new(1, 0)),
|
||||
DisplayPoint::new(0, 7)
|
||||
);
|
||||
|
||||
let x = snapshot.x_for_point(DisplayPoint::new(1, 10), &text_layout_details);
|
||||
assert_eq!(
|
||||
movement::up(
|
||||
&snapshot,
|
||||
DisplayPoint::new(1, 10),
|
||||
SelectionGoal::None,
|
||||
false,
|
||||
&text_layout_details,
|
||||
),
|
||||
(
|
||||
DisplayPoint::new(0, 7),
|
||||
SelectionGoal::HorizontalPosition(x)
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
movement::down(
|
||||
&snapshot,
|
||||
DisplayPoint::new(0, 7),
|
||||
SelectionGoal::HorizontalPosition(x),
|
||||
false,
|
||||
&text_layout_details
|
||||
),
|
||||
(
|
||||
DisplayPoint::new(1, 10),
|
||||
SelectionGoal::HorizontalPosition(x)
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
movement::down(
|
||||
&snapshot,
|
||||
DisplayPoint::new(1, 10),
|
||||
SelectionGoal::HorizontalPosition(x),
|
||||
false,
|
||||
&text_layout_details
|
||||
),
|
||||
(
|
||||
DisplayPoint::new(2, 4),
|
||||
SelectionGoal::HorizontalPosition(x)
|
||||
)
|
||||
);
|
||||
|
||||
let ix = snapshot.buffer_snapshot.text().find("seven").unwrap();
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
buffer.edit([(ix..ix, "and ")], None, cx);
|
||||
});
|
||||
|
||||
let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
|
||||
assert_eq!(
|
||||
snapshot.text_chunks(1).collect::<String>(),
|
||||
"three four \nfive\nsix and \nseven eight"
|
||||
);
|
||||
|
||||
// Re-wrap on font size changes
|
||||
map.update(cx, |map, cx| map.set_font(font_id, font_size + 3., cx));
|
||||
|
||||
let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
|
||||
assert_eq!(
|
||||
snapshot.text_chunks(1).collect::<String>(),
|
||||
"three \nfour five\nsix and \nseven \neight"
|
||||
)
|
||||
});
|
||||
|
||||
let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
|
||||
assert_eq!(
|
||||
snapshot.text_chunks(1).collect::<String>(),
|
||||
"three four \nfive\nsix and \nseven eight"
|
||||
);
|
||||
|
||||
// Re-wrap on font size changes
|
||||
map.update(cx, |map, cx| map.set_font(font_id, font_size + 3., cx));
|
||||
|
||||
let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
|
||||
assert_eq!(
|
||||
snapshot.text_chunks(1).collect::<String>(),
|
||||
"three \nfour five\nsix and \nseven \neight"
|
||||
)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
@@ -1731,6 +1902,9 @@ pub mod tests {
|
||||
cx.foreground().forbid_parking();
|
||||
cx.set_global(SettingsStore::test(cx));
|
||||
language::init(cx);
|
||||
crate::init(cx);
|
||||
Project::init_settings(cx);
|
||||
theme::init((), cx);
|
||||
cx.update_global::<SettingsStore, _, _>(|store, cx| {
|
||||
store.update_user_settings::<AllLanguageSettings>(cx, f);
|
||||
});
|
||||
|
||||
@@ -993,8 +993,8 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::display_map::inlay_map::InlayMap;
|
||||
use crate::display_map::{fold_map::FoldMap, tab_map::TabMap, wrap_map::WrapMap};
|
||||
use crate::multi_buffer::MultiBuffer;
|
||||
use gpui::{elements::Empty, Element};
|
||||
use multi_buffer::MultiBuffer;
|
||||
use rand::prelude::*;
|
||||
use settings::SettingsStore;
|
||||
use std::env;
|
||||
|
||||
@@ -91,7 +91,7 @@ impl<'a> FoldMapWriter<'a> {
|
||||
|
||||
// For now, ignore any ranges that span an excerpt boundary.
|
||||
let fold = Fold(buffer.anchor_after(range.start)..buffer.anchor_before(range.end));
|
||||
if fold.0.start.excerpt_id() != fold.0.end.excerpt_id() {
|
||||
if fold.0.start.excerpt_id != fold.0.end.excerpt_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
use crate::{
|
||||
multi_buffer::{MultiBufferChunks, MultiBufferRows},
|
||||
Anchor, InlayId, MultiBufferSnapshot, ToOffset,
|
||||
};
|
||||
use crate::{Anchor, InlayId, MultiBufferSnapshot, ToOffset};
|
||||
use collections::{BTreeMap, BTreeSet};
|
||||
use gpui::fonts::HighlightStyle;
|
||||
use language::{Chunk, Edit, Point, TextSummary};
|
||||
use multi_buffer::{MultiBufferChunks, MultiBufferRows};
|
||||
use std::{
|
||||
any::TypeId,
|
||||
cmp,
|
||||
|
||||
+664
-158
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ pub struct EditorSettings {
|
||||
pub cursor_blink: bool,
|
||||
pub hover_popover_enabled: bool,
|
||||
pub show_completions_on_input: bool,
|
||||
pub show_completion_documentation: bool,
|
||||
pub use_on_type_format: bool,
|
||||
pub scrollbar: Scrollbar,
|
||||
pub relative_line_numbers: bool,
|
||||
@@ -33,6 +34,7 @@ pub struct EditorSettingsContent {
|
||||
pub cursor_blink: Option<bool>,
|
||||
pub hover_popover_enabled: Option<bool>,
|
||||
pub show_completions_on_input: Option<bool>,
|
||||
pub show_completion_documentation: Option<bool>,
|
||||
pub use_on_type_format: Option<bool>,
|
||||
pub scrollbar: Option<ScrollbarContent>,
|
||||
pub relative_line_numbers: Option<bool>,
|
||||
|
||||
@@ -19,8 +19,8 @@ use gpui::{
|
||||
use indoc::indoc;
|
||||
use language::{
|
||||
language_settings::{AllLanguageSettings, AllLanguageSettingsContent, LanguageSettingsContent},
|
||||
BracketPairConfig, BundledFormatter, FakeLspAdapter, LanguageConfig, LanguageConfigOverride,
|
||||
LanguageRegistry, Override, Point,
|
||||
BracketPairConfig, FakeLspAdapter, LanguageConfig, LanguageConfigOverride, LanguageRegistry,
|
||||
Override, Point,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use project::project_settings::{LspSettings, ProjectSettings};
|
||||
@@ -851,7 +851,7 @@ fn test_move_cursor_multibyte(cx: &mut TestAppContext) {
|
||||
|
||||
let view = cx
|
||||
.add_window(|cx| {
|
||||
let buffer = MultiBuffer::build_simple("ⓐⓑⓒⓓⓔ\nabcde\nαβγδε\n", cx);
|
||||
let buffer = MultiBuffer::build_simple("ⓐⓑⓒⓓⓔ\nabcde\nαβγδε", cx);
|
||||
build_editor(buffer.clone(), cx)
|
||||
})
|
||||
.root(cx);
|
||||
@@ -869,7 +869,7 @@ fn test_move_cursor_multibyte(cx: &mut TestAppContext) {
|
||||
true,
|
||||
cx,
|
||||
);
|
||||
assert_eq!(view.display_text(cx), "ⓐⓑ⋯ⓔ\nab⋯e\nαβ⋯ε\n");
|
||||
assert_eq!(view.display_text(cx), "ⓐⓑ⋯ⓔ\nab⋯e\nαβ⋯ε");
|
||||
|
||||
view.move_right(&MoveRight, cx);
|
||||
assert_eq!(
|
||||
@@ -888,6 +888,11 @@ fn test_move_cursor_multibyte(cx: &mut TestAppContext) {
|
||||
);
|
||||
|
||||
view.move_down(&MoveDown, cx);
|
||||
assert_eq!(
|
||||
view.selections.display_ranges(cx),
|
||||
&[empty_range(1, "ab⋯e".len())]
|
||||
);
|
||||
view.move_left(&MoveLeft, cx);
|
||||
assert_eq!(
|
||||
view.selections.display_ranges(cx),
|
||||
&[empty_range(1, "ab⋯".len())]
|
||||
@@ -929,17 +934,18 @@ fn test_move_cursor_multibyte(cx: &mut TestAppContext) {
|
||||
view.selections.display_ranges(cx),
|
||||
&[empty_range(1, "ab⋯e".len())]
|
||||
);
|
||||
view.move_down(&MoveDown, cx);
|
||||
assert_eq!(
|
||||
view.selections.display_ranges(cx),
|
||||
&[empty_range(2, "αβ⋯ε".len())]
|
||||
);
|
||||
view.move_up(&MoveUp, cx);
|
||||
assert_eq!(
|
||||
view.selections.display_ranges(cx),
|
||||
&[empty_range(0, "ⓐⓑ⋯ⓔ".len())]
|
||||
&[empty_range(1, "ab⋯e".len())]
|
||||
);
|
||||
view.move_left(&MoveLeft, cx);
|
||||
assert_eq!(
|
||||
view.selections.display_ranges(cx),
|
||||
&[empty_range(0, "ⓐⓑ⋯".len())]
|
||||
);
|
||||
view.move_left(&MoveLeft, cx);
|
||||
|
||||
view.move_up(&MoveUp, cx);
|
||||
assert_eq!(
|
||||
view.selections.display_ranges(cx),
|
||||
&[empty_range(0, "ⓐⓑ".len())]
|
||||
@@ -949,6 +955,11 @@ fn test_move_cursor_multibyte(cx: &mut TestAppContext) {
|
||||
view.selections.display_ranges(cx),
|
||||
&[empty_range(0, "ⓐ".len())]
|
||||
);
|
||||
view.move_left(&MoveLeft, cx);
|
||||
assert_eq!(
|
||||
view.selections.display_ranges(cx),
|
||||
&[empty_range(0, "".len())]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5084,6 +5095,9 @@ async fn test_document_format_manual_trigger(cx: &mut gpui::TestAppContext) {
|
||||
LanguageConfig {
|
||||
name: "Rust".into(),
|
||||
path_suffixes: vec!["rs".to_string()],
|
||||
// Enable Prettier formatting for the same buffer, and ensure
|
||||
// LSP is called instead of Prettier.
|
||||
prettier_parser_name: Some("test_parser".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
Some(tree_sitter_rust::language()),
|
||||
@@ -5094,12 +5108,6 @@ async fn test_document_format_manual_trigger(cx: &mut gpui::TestAppContext) {
|
||||
document_formatting_provider: Some(lsp::OneOf::Left(true)),
|
||||
..Default::default()
|
||||
},
|
||||
// Enable Prettier formatting for the same buffer, and ensure
|
||||
// LSP is called instead of Prettier.
|
||||
enabled_formatters: vec![BundledFormatter::Prettier {
|
||||
parser_name: Some("test_parser"),
|
||||
plugin_names: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
}))
|
||||
.await;
|
||||
@@ -5109,7 +5117,6 @@ async fn test_document_format_manual_trigger(cx: &mut gpui::TestAppContext) {
|
||||
|
||||
let project = Project::test(fs, ["/file.rs".as_ref()], cx).await;
|
||||
project.update(cx, |project, _| {
|
||||
project.enable_test_prettier(&[]);
|
||||
project.languages().add(Arc::new(language));
|
||||
});
|
||||
let buffer = project
|
||||
@@ -5430,9 +5437,9 @@ async fn test_completion(cx: &mut gpui::TestAppContext) {
|
||||
additional edit
|
||||
"});
|
||||
cx.simulate_keystroke(" ");
|
||||
assert!(cx.editor(|e, _| e.context_menu.is_none()));
|
||||
assert!(cx.editor(|e, _| e.context_menu.read().is_none()));
|
||||
cx.simulate_keystroke("s");
|
||||
assert!(cx.editor(|e, _| e.context_menu.is_none()));
|
||||
assert!(cx.editor(|e, _| e.context_menu.read().is_none()));
|
||||
|
||||
cx.assert_editor_state(indoc! {"
|
||||
one.second_completion
|
||||
@@ -5494,12 +5501,12 @@ async fn test_completion(cx: &mut gpui::TestAppContext) {
|
||||
});
|
||||
cx.set_state("editorˇ");
|
||||
cx.simulate_keystroke(".");
|
||||
assert!(cx.editor(|e, _| e.context_menu.is_none()));
|
||||
assert!(cx.editor(|e, _| e.context_menu.read().is_none()));
|
||||
cx.simulate_keystroke("c");
|
||||
cx.simulate_keystroke("l");
|
||||
cx.simulate_keystroke("o");
|
||||
cx.assert_editor_state("editor.cloˇ");
|
||||
assert!(cx.editor(|e, _| e.context_menu.is_none()));
|
||||
assert!(cx.editor(|e, _| e.context_menu.read().is_none()));
|
||||
cx.update_editor(|editor, cx| {
|
||||
editor.show_completions(&ShowCompletions, cx);
|
||||
});
|
||||
@@ -6710,6 +6717,102 @@ fn test_combine_syntax_and_fuzzy_match_highlights() {
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn go_to_prev_overlapping_diagnostic(
|
||||
deterministic: Arc<Deterministic>,
|
||||
cx: &mut gpui::TestAppContext,
|
||||
) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let mut cx = EditorTestContext::new(cx).await;
|
||||
let project = cx.update_editor(|editor, _| editor.project.clone().unwrap());
|
||||
|
||||
cx.set_state(indoc! {"
|
||||
ˇfn func(abc def: i32) -> u32 {
|
||||
}
|
||||
"});
|
||||
|
||||
cx.update(|cx| {
|
||||
project.update(cx, |project, cx| {
|
||||
project
|
||||
.update_diagnostics(
|
||||
LanguageServerId(0),
|
||||
lsp::PublishDiagnosticsParams {
|
||||
uri: lsp::Url::from_file_path("/root/file").unwrap(),
|
||||
version: None,
|
||||
diagnostics: vec![
|
||||
lsp::Diagnostic {
|
||||
range: lsp::Range::new(
|
||||
lsp::Position::new(0, 11),
|
||||
lsp::Position::new(0, 12),
|
||||
),
|
||||
severity: Some(lsp::DiagnosticSeverity::ERROR),
|
||||
..Default::default()
|
||||
},
|
||||
lsp::Diagnostic {
|
||||
range: lsp::Range::new(
|
||||
lsp::Position::new(0, 12),
|
||||
lsp::Position::new(0, 15),
|
||||
),
|
||||
severity: Some(lsp::DiagnosticSeverity::ERROR),
|
||||
..Default::default()
|
||||
},
|
||||
lsp::Diagnostic {
|
||||
range: lsp::Range::new(
|
||||
lsp::Position::new(0, 25),
|
||||
lsp::Position::new(0, 28),
|
||||
),
|
||||
severity: Some(lsp::DiagnosticSeverity::ERROR),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
},
|
||||
&[],
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
});
|
||||
|
||||
deterministic.run_until_parked();
|
||||
|
||||
cx.update_editor(|editor, cx| {
|
||||
editor.go_to_prev_diagnostic(&GoToPrevDiagnostic, cx);
|
||||
});
|
||||
|
||||
cx.assert_editor_state(indoc! {"
|
||||
fn func(abc def: i32) -> ˇu32 {
|
||||
}
|
||||
"});
|
||||
|
||||
cx.update_editor(|editor, cx| {
|
||||
editor.go_to_prev_diagnostic(&GoToPrevDiagnostic, cx);
|
||||
});
|
||||
|
||||
cx.assert_editor_state(indoc! {"
|
||||
fn func(abc ˇdef: i32) -> u32 {
|
||||
}
|
||||
"});
|
||||
|
||||
cx.update_editor(|editor, cx| {
|
||||
editor.go_to_prev_diagnostic(&GoToPrevDiagnostic, cx);
|
||||
});
|
||||
|
||||
cx.assert_editor_state(indoc! {"
|
||||
fn func(abcˇ def: i32) -> u32 {
|
||||
}
|
||||
"});
|
||||
|
||||
cx.update_editor(|editor, cx| {
|
||||
editor.go_to_prev_diagnostic(&GoToPrevDiagnostic, cx);
|
||||
});
|
||||
|
||||
cx.assert_editor_state(indoc! {"
|
||||
fn func(abc def: i32) -> ˇu32 {
|
||||
}
|
||||
"});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn go_to_hunk(deterministic: Arc<Deterministic>, cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
@@ -6792,6 +6895,46 @@ async fn go_to_hunk(deterministic: Arc<Deterministic>, cx: &mut gpui::TestAppCon
|
||||
.unindent(),
|
||||
);
|
||||
|
||||
cx.update_editor(|editor, cx| {
|
||||
editor.go_to_prev_hunk(&GoToPrevHunk, cx);
|
||||
});
|
||||
|
||||
cx.assert_editor_state(
|
||||
&r#"
|
||||
use some::modified;
|
||||
|
||||
ˇ
|
||||
fn main() {
|
||||
println!("hello there");
|
||||
|
||||
println!("around the");
|
||||
println!("world");
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
);
|
||||
|
||||
cx.update_editor(|editor, cx| {
|
||||
for _ in 0..3 {
|
||||
editor.go_to_prev_hunk(&GoToPrevHunk, cx);
|
||||
}
|
||||
});
|
||||
|
||||
cx.assert_editor_state(
|
||||
&r#"
|
||||
use some::modified;
|
||||
|
||||
|
||||
fn main() {
|
||||
ˇ println!("hello there");
|
||||
|
||||
println!("around the");
|
||||
println!("world");
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
);
|
||||
|
||||
cx.update_editor(|editor, cx| {
|
||||
editor.fold(&Fold, cx);
|
||||
|
||||
@@ -7788,7 +7931,7 @@ async fn test_completions_in_languages_with_extra_word_characters(cx: &mut gpui:
|
||||
cx.simulate_keystroke("-");
|
||||
cx.foreground().run_until_parked();
|
||||
cx.update_editor(|editor, _| {
|
||||
if let Some(ContextMenu::Completions(menu)) = &editor.context_menu {
|
||||
if let Some(ContextMenu::Completions(menu)) = editor.context_menu.read().as_ref() {
|
||||
assert_eq!(
|
||||
menu.matches.iter().map(|m| &m.string).collect::<Vec<_>>(),
|
||||
&["bg-red", "bg-blue", "bg-yellow"]
|
||||
@@ -7801,7 +7944,7 @@ async fn test_completions_in_languages_with_extra_word_characters(cx: &mut gpui:
|
||||
cx.simulate_keystroke("l");
|
||||
cx.foreground().run_until_parked();
|
||||
cx.update_editor(|editor, _| {
|
||||
if let Some(ContextMenu::Completions(menu)) = &editor.context_menu {
|
||||
if let Some(ContextMenu::Completions(menu)) = editor.context_menu.read().as_ref() {
|
||||
assert_eq!(
|
||||
menu.matches.iter().map(|m| &m.string).collect::<Vec<_>>(),
|
||||
&["bg-blue", "bg-yellow"]
|
||||
@@ -7817,7 +7960,7 @@ async fn test_completions_in_languages_with_extra_word_characters(cx: &mut gpui:
|
||||
cx.simulate_keystroke("l");
|
||||
cx.foreground().run_until_parked();
|
||||
cx.update_editor(|editor, _| {
|
||||
if let Some(ContextMenu::Completions(menu)) = &editor.context_menu {
|
||||
if let Some(ContextMenu::Completions(menu)) = editor.context_menu.read().as_ref() {
|
||||
assert_eq!(
|
||||
menu.matches.iter().map(|m| &m.string).collect::<Vec<_>>(),
|
||||
&["bg-yellow"]
|
||||
@@ -7838,6 +7981,7 @@ async fn test_document_format_with_prettier(cx: &mut gpui::TestAppContext) {
|
||||
LanguageConfig {
|
||||
name: "Rust".into(),
|
||||
path_suffixes: vec!["rs".to_string()],
|
||||
prettier_parser_name: Some("test_parser".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
Some(tree_sitter_rust::language()),
|
||||
@@ -7846,10 +7990,7 @@ async fn test_document_format_with_prettier(cx: &mut gpui::TestAppContext) {
|
||||
let test_plugin = "test_plugin";
|
||||
let _ = language
|
||||
.set_fake_lsp_adapter(Arc::new(FakeLspAdapter {
|
||||
enabled_formatters: vec![BundledFormatter::Prettier {
|
||||
parser_name: Some("test_parser"),
|
||||
plugin_names: vec![test_plugin],
|
||||
}],
|
||||
prettier_plugins: vec![test_plugin],
|
||||
..Default::default()
|
||||
}))
|
||||
.await;
|
||||
@@ -7858,10 +7999,9 @@ async fn test_document_format_with_prettier(cx: &mut gpui::TestAppContext) {
|
||||
fs.insert_file("/file.rs", Default::default()).await;
|
||||
|
||||
let project = Project::test(fs, ["/file.rs".as_ref()], cx).await;
|
||||
let prettier_format_suffix = project.update(cx, |project, _| {
|
||||
let suffix = project.enable_test_prettier(&[test_plugin]);
|
||||
let prettier_format_suffix = project::TEST_PRETTIER_FORMAT_SUFFIX;
|
||||
project.update(cx, |project, _| {
|
||||
project.languages().add(Arc::new(language));
|
||||
suffix
|
||||
});
|
||||
let buffer = project
|
||||
.update(cx, |project, cx| project.open_local_buffer("/file.rs", cx))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user