From a801a4aeef93e24d235da51a9a1fdbe9f5d1d6d1 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Wed, 27 Sep 2023 13:16:32 -0600 Subject: [PATCH 01/90] Remove some unnecessary Eqs --- .cargo/config.toml | 2 +- crates/language/src/buffer.rs | 4 ++-- crates/text/src/selection.rs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 9da6b3be08..e22bdb0f2c 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -3,4 +3,4 @@ xtask = "run --package xtask --" [build] # v0 mangling scheme provides more detailed backtraces around closures -rustflags = ["-C", "symbol-mangling-version=v0"] +rustflags = ["-C", "symbol-mangling-version=v0", "-C", "link-arg=-fuse-ld=/opt/homebrew/Cellar/llvm/16.0.6/bin/ld64.lld"] diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 207c41e7cd..19e5e290b9 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -159,7 +159,7 @@ pub struct CodeAction { pub lsp_action: lsp::CodeAction, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] pub enum Operation { Buffer(text::Operation), @@ -182,7 +182,7 @@ pub enum Operation { }, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] pub enum Event { Operation(Operation), Edited, diff --git a/crates/text/src/selection.rs b/crates/text/src/selection.rs index 205c27239d..60d5e2f1c4 100644 --- a/crates/text/src/selection.rs +++ b/crates/text/src/selection.rs @@ -2,14 +2,14 @@ use crate::{Anchor, BufferSnapshot, TextDimension}; use std::cmp::Ordering; use std::ops::Range; -#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[derive(Copy, Clone, Debug, PartialEq)] pub enum SelectionGoal { None, Column(u32), ColumnRange { start: u32, end: u32 }, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct Selection { pub id: usize, pub start: T, From dacc8cb5f47ae8272afaf560979c7eb6d67e3354 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Wed, 27 Sep 2023 15:10:50 -0600 Subject: [PATCH 02/90] Begin to use pixels for column selection For zed-industries/community#759 For zed-industries/community#1966 Co-Authored-By: Julia --- crates/editor/src/display_map.rs | 391 +++++++++++++++++++++++-------- crates/editor/src/editor.rs | 51 +++- crates/editor/src/element.rs | 59 +---- crates/editor/src/movement.rs | 50 +++- crates/text/src/selection.rs | 2 + 5 files changed, 387 insertions(+), 166 deletions(-) diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index d97db9695a..3d13447fc2 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -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, 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}, - Entity, ModelContext, ModelHandle, + fonts::{FontId, HighlightStyle, Underline}, + text_layout::{Line, RunStyle}, + AppContext, Entity, FontCache, ModelContext, ModelHandle, TextLayoutCache, }; 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, } +pub struct HighlightedChunk<'a> { + pub chunk: &'a str, + pub style: Option, + 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, suggestion_highlight_style: Option, - ) -> DisplayChunks<'_> { + ) -> DisplayChunks<'a> { self.block_snapshot.chunks( display_rows, language_aware, @@ -498,6 +506,174 @@ impl DisplaySnapshot { ) } + pub fn highlighted_chunks<'a>( + &'a self, + display_rows: Range, + style: &'a EditorStyle, + ) -> impl Iterator> { + self.chunks( + display_rows, + true, + 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, + } + }) + } + + fn layout_line_for_row( + &self, + display_row: u32, + font_cache: &FontCache, + text_layout_cache: &TextLayoutCache, + editor_style: &EditorStyle, + ) -> Line { + let mut styles = Vec::new(); + let mut line = String::new(); + + let range = display_row..display_row + 1; + for chunk in self.highlighted_chunks(range, editor_style) { + dbg!(chunk.chunk); + 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) + }; + + styles.push(( + chunk.chunk.len(), + RunStyle { + font_id: text_style.font_id, + color: text_style.color, + underline: text_style.underline, + }, + )); + } + + dbg!(&line, &editor_style.text.font_size, &styles); + text_layout_cache.layout_str(&line, editor_style.text.font_size, &styles) + } + + pub fn x_for_point( + &self, + display_point: DisplayPoint, + font_cache: &FontCache, + text_layout_cache: &TextLayoutCache, + editor_style: &EditorStyle, + ) -> f32 { + let layout_line = self.layout_line_for_row( + display_point.row(), + font_cache, + text_layout_cache, + editor_style, + ); + layout_line.x_for_index(display_point.column() as usize) + } + + pub fn column_for_x( + &self, + display_row: u32, + x_coordinate: f32, + font_cache: &FontCache, + text_layout_cache: &TextLayoutCache, + editor_style: &EditorStyle, + ) -> Option { + let layout_line = + self.layout_line_for_row(display_row, font_cache, text_layout_cache, editor_style); + layout_line.index_for_x(x_coordinate).map(|c| c as u32) + } + + // column_for_x(row, x) + + fn point( + &self, + display_point: DisplayPoint, + text_layout_cache: &TextLayoutCache, + editor_style: &EditorStyle, + cx: &AppContext, + ) -> f32 { + let mut styles = Vec::new(); + let mut line = String::new(); + + let range = display_point.row()..display_point.row() + 1; + for chunk in self.highlighted_chunks(range, editor_style) { + dbg!(chunk.chunk); + line.push_str(chunk.chunk); + + let text_style = if let Some(style) = chunk.style { + editor_style + .text + .clone() + .highlight(style, cx.font_cache()) + .map(Cow::Owned) + .unwrap_or_else(|_| Cow::Borrowed(&editor_style.text)) + } else { + Cow::Borrowed(&editor_style.text) + }; + + styles.push(( + chunk.chunk.len(), + RunStyle { + font_id: text_style.font_id, + color: text_style.color, + underline: text_style.underline, + }, + )); + } + + dbg!(&line, &editor_style.text.font_size, &styles); + let layout_line = text_layout_cache.layout_str(&line, editor_style.text.font_size, &styles); + layout_line.x_for_index(display_point.column() as usize) + } + pub fn chars_at( &self, mut point: DisplayPoint, @@ -869,17 +1045,21 @@ 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; use std::{env, sync::Arc}; - use theme::SyntaxTheme; + use theme::{SyntaxTheme, Theme}; use util::test::{marked_text_ranges, sample_text}; use Bias::*; @@ -1148,95 +1328,119 @@ 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::(), - "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 editor_style = editor.read(&cx).style(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::(), + "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), + cx.font_cache(), + cx.text_layout_cache(), + &editor_style, + ); + dbg!(x); + assert_eq!( + movement::up( + &snapshot, + DisplayPoint::new(1, 10), + SelectionGoal::None, + false, + cx.font_cache(), + cx.text_layout_cache(), + &editor_style, + ), + ( + DisplayPoint::new(0, 7), + SelectionGoal::HorizontalPosition(x) + ) + ); + 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 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::(), + "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::(), + "three \nfour five\nsix and \nseven \neight" + ) }); - - let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); - assert_eq!( - snapshot.text_chunks(1).collect::(), - "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::(), - "three \nfour five\nsix and \nseven \neight" - ) } #[gpui::test] @@ -1731,6 +1935,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::(|store, cx| { store.update_user_settings::(cx, f); }); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 24ffa64a6a..bf1aa2e6b5 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -48,9 +48,9 @@ use gpui::{ impl_actions, keymap_matcher::KeymapContext, platform::{CursorStyle, MouseButton}, - serde_json, AnyElement, AnyViewHandle, AppContext, AsyncAppContext, ClipboardItem, Element, - Entity, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, - WindowContext, + serde_json, text_layout, AnyElement, AnyViewHandle, AppContext, AsyncAppContext, ClipboardItem, + Element, Entity, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, + WeakViewHandle, WindowContext, }; use highlight_matching_bracket::refresh_matching_bracket_highlights; use hover_popover::{hide_hover, HoverState}; @@ -5274,13 +5274,25 @@ impl Editor { return; } + let font_cache = cx.font_cache().clone(); + let text_layout_cache = cx.text_layout_cache().clone(); + let editor_style = self.style(cx); + self.change_selections(Some(Autoscroll::fit()), cx, |s| { let line_mode = s.line_mode; s.move_with(|map, selection| { if !selection.is_empty() && !line_mode { selection.goal = SelectionGoal::None; } - let (cursor, goal) = movement::up(map, selection.start, selection.goal, false); + let (cursor, goal) = movement::up( + map, + selection.start, + selection.goal, + false, + &font_cache, + &text_layout_cache, + &editor_style, + ); selection.collapse_to(cursor, goal); }); }) @@ -5308,22 +5320,47 @@ impl Editor { Autoscroll::fit() }; + let font_cache = cx.font_cache().clone(); + let text_layout = cx.text_layout_cache().clone(); + let editor_style = self.style(cx); + self.change_selections(Some(autoscroll), cx, |s| { let line_mode = s.line_mode; s.move_with(|map, selection| { if !selection.is_empty() && !line_mode { selection.goal = SelectionGoal::None; } - let (cursor, goal) = - movement::up_by_rows(map, selection.end, row_count, selection.goal, false); + let (cursor, goal) = movement::up_by_rows( + map, + selection.end, + row_count, + selection.goal, + false, + &font_cache, + &text_layout, + &editor_style, + ); selection.collapse_to(cursor, goal); }); }); } pub fn select_up(&mut self, _: &SelectUp, cx: &mut ViewContext) { + let font_cache = cx.font_cache().clone(); + let text_layout = cx.text_layout_cache().clone(); + let editor_style = self.style(cx); self.change_selections(Some(Autoscroll::fit()), cx, |s| { - s.move_heads_with(|map, head, goal| movement::up(map, head, goal, false)) + s.move_heads_with(|map, head, goal| { + movement::up( + map, + head, + goal, + false, + &font_cache, + &text_layout, + &editor_style, + ) + }) }) } diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 924d66c21c..24cbadfd37 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -4,7 +4,7 @@ use super::{ MAX_LINE_LEN, }; use crate::{ - display_map::{BlockStyle, DisplaySnapshot, FoldStatus, TransformBlock}, + display_map::{BlockStyle, DisplaySnapshot, FoldStatus, HighlightedChunk, TransformBlock}, editor_settings::ShowScrollbar, git::{diff_hunk_to_display, DisplayDiffHunk}, hover_popover::{ @@ -1584,56 +1584,7 @@ impl EditorElement { .collect() } else { let style = &self.style; - let chunks = snapshot - .chunks( - rows.clone(), - true, - 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, - } - }); + let chunks = snapshot.highlighted_chunks(rows.clone(), style); LineWithInvisibles::from_chunks( chunks, @@ -1870,12 +1821,6 @@ impl EditorElement { } } -struct HighlightedChunk<'a> { - chunk: &'a str, - style: Option, - is_tab: bool, -} - #[derive(Debug)] pub struct LineWithInvisibles { pub line: Line, diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index 974af4bc24..3403790681 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -1,5 +1,6 @@ use super::{Bias, DisplayPoint, DisplaySnapshot, SelectionGoal, ToDisplayPoint}; -use crate::{char_kind, CharKind, ToOffset, ToPoint}; +use crate::{char_kind, CharKind, EditorStyle, ToOffset, ToPoint}; +use gpui::{FontCache, TextLayoutCache, WindowContext}; use language::Point; use std::ops::Range; @@ -47,8 +48,20 @@ pub fn up( start: DisplayPoint, goal: SelectionGoal, preserve_column_at_start: bool, + font_cache: &FontCache, + text_layout_cache: &TextLayoutCache, + editor_style: &EditorStyle, ) -> (DisplayPoint, SelectionGoal) { - up_by_rows(map, start, 1, goal, preserve_column_at_start) + up_by_rows( + map, + start, + 1, + goal, + preserve_column_at_start, + font_cache, + text_layout_cache, + editor_style, + ) } pub fn down( @@ -66,11 +79,14 @@ pub fn up_by_rows( row_count: u32, goal: SelectionGoal, preserve_column_at_start: bool, + font_cache: &FontCache, + text_layout_cache: &TextLayoutCache, + editor_style: &EditorStyle, ) -> (DisplayPoint, SelectionGoal) { - let mut goal_column = match goal { - SelectionGoal::Column(column) => column, - SelectionGoal::ColumnRange { end, .. } => end, - _ => map.column_to_chars(start.row(), start.column()), + let mut goal_x = match goal { + SelectionGoal::HorizontalPosition(x) => x, + SelectionGoal::HorizontalRange { end, .. } => end, + _ => map.x_for_point(start, font_cache, text_layout_cache, editor_style), }; let prev_row = start.row().saturating_sub(row_count); @@ -79,19 +95,27 @@ pub fn up_by_rows( Bias::Left, ); if point.row() < start.row() { - *point.column_mut() = map.column_from_chars(point.row(), goal_column); + *point.column_mut() = map + .column_for_x( + point.row(), + goal_x, + font_cache, + text_layout_cache, + editor_style, + ) + .unwrap_or(point.column()); } else if preserve_column_at_start { return (start, goal); } else { point = DisplayPoint::new(0, 0); - goal_column = 0; + goal_x = 0.0; } let mut clipped_point = map.clip_point(point, Bias::Left); if clipped_point.row() < point.row() { clipped_point = map.clip_point(point, Bias::Right); } - (clipped_point, SelectionGoal::Column(goal_column)) + (clipped_point, SelectionGoal::HorizontalPosition(goal_x)) } pub fn down_by_rows( @@ -692,6 +716,7 @@ mod tests { #[gpui::test] fn test_move_up_and_down_with_excerpts(cx: &mut gpui::AppContext) { + /* init_test(cx); let family_id = cx @@ -727,6 +752,7 @@ mod tests { cx.add_model(|cx| DisplayMap::new(multibuffer, font_id, 14.0, None, 2, 2, cx)); let snapshot = display_map.update(cx, |map, cx| map.snapshot(cx)); + assert_eq!(snapshot.text(), "\n\nabc\ndefg\n\n\nhijkl\nmn"); // Can't move up into the first excerpt's header @@ -737,7 +763,10 @@ mod tests { SelectionGoal::Column(2), false ), - (DisplayPoint::new(2, 0), SelectionGoal::Column(0)), + ( + DisplayPoint::new(2, 0), + SelectionGoal::HorizontalPosition(0.0) + ), ); assert_eq!( up( @@ -808,6 +837,7 @@ mod tests { ), (DisplayPoint::new(7, 2), SelectionGoal::Column(2)), ); + */ } fn init_test(cx: &mut gpui::AppContext) { diff --git a/crates/text/src/selection.rs b/crates/text/src/selection.rs index 60d5e2f1c4..38831f92c2 100644 --- a/crates/text/src/selection.rs +++ b/crates/text/src/selection.rs @@ -5,6 +5,8 @@ use std::ops::Range; #[derive(Copy, Clone, Debug, PartialEq)] pub enum SelectionGoal { None, + HorizontalPosition(f32), + HorizontalRange { start: f32, end: f32 }, Column(u32), ColumnRange { start: u32, end: u32 }, } From e7badb38e96ffedbf9c24780d671b2d6c8cc7cdd Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 28 Sep 2023 20:35:06 -0600 Subject: [PATCH 03/90] Refactor to pass a TextLayoutDetails around --- crates/editor/src/display_map.rs | 44 +++++++++++------------------- crates/editor/src/editor.rs | 31 +++++---------------- crates/editor/src/movement.rs | 46 ++++++++++++++++++-------------- 3 files changed, 48 insertions(+), 73 deletions(-) diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 3d13447fc2..45b4c0abed 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -5,8 +5,8 @@ mod tab_map; mod wrap_map; use crate::{ - link_go_to_definition::InlayHighlight, Anchor, AnchorRangeExt, EditorStyle, 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}; @@ -565,9 +565,11 @@ impl DisplaySnapshot { fn layout_line_for_row( &self, display_row: u32, - font_cache: &FontCache, - text_layout_cache: &TextLayoutCache, - editor_style: &EditorStyle, + TextLayoutDetails { + font_cache, + text_layout_cache, + editor_style, + }: &TextLayoutDetails, ) -> Line { let mut styles = Vec::new(); let mut line = String::new(); @@ -605,16 +607,9 @@ impl DisplaySnapshot { pub fn x_for_point( &self, display_point: DisplayPoint, - font_cache: &FontCache, - text_layout_cache: &TextLayoutCache, - editor_style: &EditorStyle, + text_layout_details: &TextLayoutDetails, ) -> f32 { - let layout_line = self.layout_line_for_row( - display_point.row(), - font_cache, - text_layout_cache, - editor_style, - ); + let layout_line = self.layout_line_for_row(display_point.row(), text_layout_details); layout_line.x_for_index(display_point.column() as usize) } @@ -622,12 +617,9 @@ impl DisplaySnapshot { &self, display_row: u32, x_coordinate: f32, - font_cache: &FontCache, - text_layout_cache: &TextLayoutCache, - editor_style: &EditorStyle, + text_layout_details: &TextLayoutDetails, ) -> Option { - let layout_line = - self.layout_line_for_row(display_row, font_cache, text_layout_cache, editor_style); + let layout_line = self.layout_line_for_row(display_row, text_layout_details); layout_line.index_for_x(x_coordinate).map(|c| c as u32) } @@ -1339,7 +1331,8 @@ pub mod tests { let window = cx.window.clone(); cx.update_window(window, |cx| { - let editor_style = editor.read(&cx).style(cx); + let text_layout_details = + editor.read_with(cx, |editor, cx| TextLayoutDetails::new(editor, cx)); let font_cache = cx.font_cache().clone(); @@ -1380,12 +1373,7 @@ pub mod tests { DisplayPoint::new(0, 7) ); - let x = snapshot.x_for_point( - DisplayPoint::new(1, 10), - cx.font_cache(), - cx.text_layout_cache(), - &editor_style, - ); + let x = snapshot.x_for_point(DisplayPoint::new(1, 10), &text_layout_details); dbg!(x); assert_eq!( movement::up( @@ -1393,9 +1381,7 @@ pub mod tests { DisplayPoint::new(1, 10), SelectionGoal::None, false, - cx.font_cache(), - cx.text_layout_cache(), - &editor_style, + &text_layout_details, ), ( DisplayPoint::new(0, 7), diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index bf1aa2e6b5..081d33c8a0 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -71,6 +71,7 @@ use link_go_to_definition::{ }; use log::error; use lsp::LanguageServerId; +use movement::TextLayoutDetails; use multi_buffer::ToOffsetUtf16; pub use multi_buffer::{ Anchor, AnchorRangeExt, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, ToOffset, @@ -5274,9 +5275,7 @@ impl Editor { return; } - let font_cache = cx.font_cache().clone(); - let text_layout_cache = cx.text_layout_cache().clone(); - let editor_style = self.style(cx); + let text_layout_details = TextLayoutDetails::new(&self, cx); self.change_selections(Some(Autoscroll::fit()), cx, |s| { let line_mode = s.line_mode; @@ -5289,9 +5288,7 @@ impl Editor { selection.start, selection.goal, false, - &font_cache, - &text_layout_cache, - &editor_style, + &text_layout_details, ); selection.collapse_to(cursor, goal); }); @@ -5320,9 +5317,7 @@ impl Editor { Autoscroll::fit() }; - let font_cache = cx.font_cache().clone(); - let text_layout = cx.text_layout_cache().clone(); - let editor_style = self.style(cx); + let text_layout_details = TextLayoutDetails::new(&self, cx); self.change_selections(Some(autoscroll), cx, |s| { let line_mode = s.line_mode; @@ -5336,9 +5331,7 @@ impl Editor { row_count, selection.goal, false, - &font_cache, - &text_layout, - &editor_style, + &text_layout_details, ); selection.collapse_to(cursor, goal); }); @@ -5346,20 +5339,10 @@ impl Editor { } pub fn select_up(&mut self, _: &SelectUp, cx: &mut ViewContext) { - let font_cache = cx.font_cache().clone(); - let text_layout = cx.text_layout_cache().clone(); - let editor_style = self.style(cx); + let text_layout_details = TextLayoutDetails::new(&self, cx); self.change_selections(Some(Autoscroll::fit()), cx, |s| { s.move_heads_with(|map, head, goal| { - movement::up( - map, - head, - goal, - false, - &font_cache, - &text_layout, - &editor_style, - ) + movement::up(map, head, goal, false, &text_layout_details) }) }) } diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index 3403790681..836d5dda2f 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -1,8 +1,8 @@ use super::{Bias, DisplayPoint, DisplaySnapshot, SelectionGoal, ToDisplayPoint}; -use crate::{char_kind, CharKind, EditorStyle, ToOffset, ToPoint}; -use gpui::{FontCache, TextLayoutCache, WindowContext}; +use crate::{char_kind, CharKind, Editor, EditorStyle, ToOffset, ToPoint}; +use gpui::{text_layout, FontCache, TextLayoutCache, WindowContext}; use language::Point; -use std::ops::Range; +use std::{ops::Range, sync::Arc}; #[derive(Debug, PartialEq)] pub enum FindRange { @@ -10,6 +10,24 @@ pub enum FindRange { MultiLine, } +/// TextLayoutDetails encompasses everything we need to move vertically +/// taking into account variable width characters. +pub struct TextLayoutDetails { + pub font_cache: Arc, + pub text_layout_cache: Arc, + pub editor_style: EditorStyle, +} + +impl TextLayoutDetails { + pub fn new(editor: &Editor, cx: &WindowContext) -> TextLayoutDetails { + TextLayoutDetails { + font_cache: cx.font_cache().clone(), + text_layout_cache: cx.text_layout_cache().clone(), + editor_style: editor.style(cx), + } + } +} + pub fn left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint { if point.column() > 0 { *point.column_mut() -= 1; @@ -48,9 +66,7 @@ pub fn up( start: DisplayPoint, goal: SelectionGoal, preserve_column_at_start: bool, - font_cache: &FontCache, - text_layout_cache: &TextLayoutCache, - editor_style: &EditorStyle, + text_layout_details: &TextLayoutDetails, ) -> (DisplayPoint, SelectionGoal) { up_by_rows( map, @@ -58,9 +74,7 @@ pub fn up( 1, goal, preserve_column_at_start, - font_cache, - text_layout_cache, - editor_style, + text_layout_details, ) } @@ -79,14 +93,12 @@ pub fn up_by_rows( row_count: u32, goal: SelectionGoal, preserve_column_at_start: bool, - font_cache: &FontCache, - text_layout_cache: &TextLayoutCache, - editor_style: &EditorStyle, + text_layout_details: &TextLayoutDetails, ) -> (DisplayPoint, SelectionGoal) { let mut goal_x = match goal { SelectionGoal::HorizontalPosition(x) => x, SelectionGoal::HorizontalRange { end, .. } => end, - _ => map.x_for_point(start, font_cache, text_layout_cache, editor_style), + _ => map.x_for_point(start, text_layout_details), }; let prev_row = start.row().saturating_sub(row_count); @@ -96,13 +108,7 @@ pub fn up_by_rows( ); if point.row() < start.row() { *point.column_mut() = map - .column_for_x( - point.row(), - goal_x, - font_cache, - text_layout_cache, - editor_style, - ) + .column_for_x(point.row(), goal_x, text_layout_details) .unwrap_or(point.column()); } else if preserve_column_at_start { return (start, goal); From ef7e2c5d86208a8ecf738ae16c355188fa5d357a Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 28 Sep 2023 21:39:24 -0600 Subject: [PATCH 04/90] Get the project running! --- crates/editor/src/display_map.rs | 21 +- crates/editor/src/editor.rs | 37 +++- crates/editor/src/editor_tests.rs | 1 + crates/editor/src/movement.rs | 311 +++++++++++++++++----------- crates/vim/src/motion.rs | 32 ++- crates/vim/src/normal.rs | 34 +-- crates/vim/src/normal/change.rs | 31 ++- crates/vim/src/normal/delete.rs | 7 +- crates/vim/src/normal/paste.rs | 17 +- crates/vim/src/normal/substitute.rs | 26 ++- crates/vim/src/normal/yank.rs | 4 +- crates/vim/src/visual.rs | 15 +- 12 files changed, 353 insertions(+), 183 deletions(-) diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 45b4c0abed..cebafbd651 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -1392,19 +1392,28 @@ pub mod tests { movement::down( &snapshot, DisplayPoint::new(0, 7), - SelectionGoal::Column(10), - false + SelectionGoal::HorizontalPosition(x), + false, + &text_layout_details ), - (DisplayPoint::new(1, 10), SelectionGoal::Column(10)) + ( + DisplayPoint::new(1, 10), + SelectionGoal::HorizontalPosition(x) + ) ); + dbg!("starting down..."); assert_eq!( movement::down( &snapshot, DisplayPoint::new(1, 10), - SelectionGoal::Column(10), - false + SelectionGoal::HorizontalPosition(x), + false, + &text_layout_details ), - (DisplayPoint::new(2, 4), SelectionGoal::Column(10)) + ( + DisplayPoint::new(2, 4), + SelectionGoal::HorizontalPosition(x) + ) ); let ix = snapshot.buffer_snapshot.text().find("seven").unwrap(); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 081d33c8a0..e68b1f008f 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -4988,6 +4988,7 @@ impl Editor { } pub fn transpose(&mut self, _: &Transpose, cx: &mut ViewContext) { + let text_layout_details = TextLayoutDetails::new(&self, cx); self.transact(cx, |this, cx| { let edits = this.change_selections(Some(Autoscroll::fit()), cx, |s| { let mut edits: Vec<(Range, String)> = Default::default(); @@ -5011,7 +5012,10 @@ impl Editor { *head.column_mut() += 1; head = display_map.clip_point(head, Bias::Right); - selection.collapse_to(head, SelectionGoal::Column(head.column())); + let goal = SelectionGoal::HorizontalPosition( + display_map.x_for_point(head, &text_layout_details), + ); + selection.collapse_to(head, goal); let transpose_start = display_map .buffer_snapshot @@ -5355,13 +5359,20 @@ impl Editor { return; } + let text_layout_details = TextLayoutDetails::new(&self, cx); self.change_selections(Some(Autoscroll::fit()), cx, |s| { let line_mode = s.line_mode; s.move_with(|map, selection| { if !selection.is_empty() && !line_mode { selection.goal = SelectionGoal::None; } - let (cursor, goal) = movement::down(map, selection.end, selection.goal, false); + let (cursor, goal) = movement::down( + map, + selection.end, + selection.goal, + false, + &text_layout_details, + ); selection.collapse_to(cursor, goal); }); }); @@ -5398,22 +5409,32 @@ impl Editor { Autoscroll::fit() }; + let text_layout_details = TextLayoutDetails::new(&self, cx); self.change_selections(Some(autoscroll), cx, |s| { let line_mode = s.line_mode; s.move_with(|map, selection| { if !selection.is_empty() && !line_mode { selection.goal = SelectionGoal::None; } - let (cursor, goal) = - movement::down_by_rows(map, selection.end, row_count, selection.goal, false); + let (cursor, goal) = movement::down_by_rows( + map, + selection.end, + row_count, + selection.goal, + false, + &text_layout_details, + ); selection.collapse_to(cursor, goal); }); }); } pub fn select_down(&mut self, _: &SelectDown, cx: &mut ViewContext) { + let text_layout_details = TextLayoutDetails::new(&self, cx); self.change_selections(Some(Autoscroll::fit()), cx, |s| { - s.move_heads_with(|map, head, goal| movement::down(map, head, goal, false)) + s.move_heads_with(|map, head, goal| { + movement::down(map, head, goal, false, &text_layout_details) + }) }); } @@ -6286,6 +6307,7 @@ impl Editor { } pub fn toggle_comments(&mut self, action: &ToggleComments, cx: &mut ViewContext) { + let text_layout_details = TextLayoutDetails::new(&self, cx); self.transact(cx, |this, cx| { let mut selections = this.selections.all::(cx); let mut edits = Vec::new(); @@ -6528,7 +6550,10 @@ impl Editor { point.row += 1; point = snapshot.clip_point(point, Bias::Left); let display_point = point.to_display_point(display_snapshot); - (display_point, SelectionGoal::Column(display_point.column())) + let goal = SelectionGoal::HorizontalPosition( + display_snapshot.x_for_point(display_point, &text_layout_details), + ); + (display_point, goal) }) }); } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index dee27e0121..affe9f60a2 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -847,6 +847,7 @@ fn test_move_cursor(cx: &mut TestAppContext) { #[gpui::test] fn test_move_cursor_multibyte(cx: &mut TestAppContext) { + todo!(); init_test(cx, |_| {}); let view = cx diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index 836d5dda2f..e2306a1b2d 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -83,8 +83,16 @@ pub fn down( start: DisplayPoint, goal: SelectionGoal, preserve_column_at_end: bool, + text_layout_details: &TextLayoutDetails, ) -> (DisplayPoint, SelectionGoal) { - down_by_rows(map, start, 1, goal, preserve_column_at_end) + down_by_rows( + map, + start, + 1, + goal, + preserve_column_at_end, + text_layout_details, + ) } pub fn up_by_rows( @@ -130,29 +138,32 @@ pub fn down_by_rows( row_count: u32, goal: SelectionGoal, preserve_column_at_end: bool, + text_layout_details: &TextLayoutDetails, ) -> (DisplayPoint, SelectionGoal) { - let mut goal_column = match goal { - SelectionGoal::Column(column) => column, - SelectionGoal::ColumnRange { end, .. } => end, - _ => map.column_to_chars(start.row(), start.column()), + let mut goal_x = match goal { + SelectionGoal::HorizontalPosition(x) => x, + SelectionGoal::HorizontalRange { end, .. } => end, + _ => map.x_for_point(start, text_layout_details), }; let new_row = start.row() + row_count; let mut point = map.clip_point(DisplayPoint::new(new_row, 0), Bias::Right); if point.row() > start.row() { - *point.column_mut() = map.column_from_chars(point.row(), goal_column); + *point.column_mut() = map + .column_for_x(point.row(), goal_x, text_layout_details) + .unwrap_or(map.line_len(point.row())); } else if preserve_column_at_end { return (start, goal); } else { point = map.max_point(); - goal_column = map.column_to_chars(point.row(), point.column()) + goal_x = map.x_for_point(point, text_layout_details) } let mut clipped_point = map.clip_point(point, Bias::Right); if clipped_point.row() > point.row() { clipped_point = map.clip_point(point, Bias::Left); } - (clipped_point, SelectionGoal::Column(goal_column)) + (clipped_point, SelectionGoal::HorizontalPosition(goal_x)) } pub fn line_beginning( @@ -426,9 +437,12 @@ pub fn split_display_range_by_lines( mod tests { use super::*; use crate::{ - display_map::Inlay, test::marked_display_snapshot, Buffer, DisplayMap, ExcerptRange, - InlayId, MultiBuffer, + display_map::Inlay, + test::{editor_test_context::EditorTestContext, marked_display_snapshot}, + Buffer, DisplayMap, ExcerptRange, InlayId, MultiBuffer, }; + use language::language_settings::AllLanguageSettings; + use project::Project; use settings::SettingsStore; use util::post_inc; @@ -721,129 +735,173 @@ mod tests { } #[gpui::test] - fn test_move_up_and_down_with_excerpts(cx: &mut gpui::AppContext) { - /* - init_test(cx); - - let family_id = cx - .font_cache() - .load_family(&["Helvetica"], &Default::default()) - .unwrap(); - let font_id = cx - .font_cache() - .select_font(family_id, &Default::default()) - .unwrap(); - - let buffer = - cx.add_model(|cx| Buffer::new(0, cx.model_id() as u64, "abc\ndefg\nhijkl\nmn")); - let multibuffer = cx.add_model(|cx| { - let mut multibuffer = MultiBuffer::new(0); - multibuffer.push_excerpts( - buffer.clone(), - [ - ExcerptRange { - context: Point::new(0, 0)..Point::new(1, 4), - primary: None, - }, - ExcerptRange { - context: Point::new(2, 0)..Point::new(3, 2), - primary: None, - }, - ], - cx, - ); - multibuffer + async fn test_move_up_and_down_with_excerpts(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + init_test(cx); }); - let display_map = - cx.add_model(|cx| DisplayMap::new(multibuffer, font_id, 14.0, None, 2, 2, cx)); - let snapshot = display_map.update(cx, |map, cx| map.snapshot(cx)); + let mut cx = EditorTestContext::new(cx).await; + let editor = cx.editor.clone(); + let window = cx.window.clone(); + cx.update_window(window, |cx| { + let text_layout_details = + editor.read_with(cx, |editor, cx| TextLayoutDetails::new(editor, cx)); - assert_eq!(snapshot.text(), "\n\nabc\ndefg\n\n\nhijkl\nmn"); + let family_id = cx + .font_cache() + .load_family(&["Helvetica"], &Default::default()) + .unwrap(); + let font_id = cx + .font_cache() + .select_font(family_id, &Default::default()) + .unwrap(); - // Can't move up into the first excerpt's header - assert_eq!( - up( - &snapshot, - DisplayPoint::new(2, 2), - SelectionGoal::Column(2), - false - ), - ( - DisplayPoint::new(2, 0), - SelectionGoal::HorizontalPosition(0.0) - ), - ); - assert_eq!( - up( - &snapshot, - DisplayPoint::new(2, 0), - SelectionGoal::None, - false - ), - (DisplayPoint::new(2, 0), SelectionGoal::Column(0)), - ); + let buffer = + cx.add_model(|cx| Buffer::new(0, cx.model_id() as u64, "abc\ndefg\nhijkl\nmn")); + let multibuffer = cx.add_model(|cx| { + let mut multibuffer = MultiBuffer::new(0); + multibuffer.push_excerpts( + buffer.clone(), + [ + ExcerptRange { + context: Point::new(0, 0)..Point::new(1, 4), + primary: None, + }, + ExcerptRange { + context: Point::new(2, 0)..Point::new(3, 2), + primary: None, + }, + ], + cx, + ); + multibuffer + }); + let display_map = + cx.add_model(|cx| DisplayMap::new(multibuffer, font_id, 14.0, None, 2, 2, cx)); + let snapshot = display_map.update(cx, |map, cx| map.snapshot(cx)); - // Move up and down within first excerpt - assert_eq!( - up( - &snapshot, - DisplayPoint::new(3, 4), - SelectionGoal::Column(4), - false - ), - (DisplayPoint::new(2, 3), SelectionGoal::Column(4)), - ); - assert_eq!( - down( - &snapshot, - DisplayPoint::new(2, 3), - SelectionGoal::Column(4), - false - ), - (DisplayPoint::new(3, 4), SelectionGoal::Column(4)), - ); + assert_eq!(snapshot.text(), "\n\nabc\ndefg\n\n\nhijkl\nmn"); - // Move up and down across second excerpt's header - assert_eq!( - up( - &snapshot, - DisplayPoint::new(6, 5), - SelectionGoal::Column(5), - false - ), - (DisplayPoint::new(3, 4), SelectionGoal::Column(5)), - ); - assert_eq!( - down( - &snapshot, - DisplayPoint::new(3, 4), - SelectionGoal::Column(5), - false - ), - (DisplayPoint::new(6, 5), SelectionGoal::Column(5)), - ); + let col_2_x = snapshot.x_for_point(DisplayPoint::new(2, 2), &text_layout_details); - // Can't move down off the end - assert_eq!( - down( - &snapshot, - DisplayPoint::new(7, 0), - SelectionGoal::Column(0), - false - ), - (DisplayPoint::new(7, 2), SelectionGoal::Column(2)), - ); - assert_eq!( - down( - &snapshot, - DisplayPoint::new(7, 2), - SelectionGoal::Column(2), - false - ), - (DisplayPoint::new(7, 2), SelectionGoal::Column(2)), - ); - */ + // Can't move up into the first excerpt's header + assert_eq!( + up( + &snapshot, + DisplayPoint::new(2, 2), + SelectionGoal::HorizontalPosition(col_2_x), + false, + &text_layout_details + ), + ( + DisplayPoint::new(2, 0), + SelectionGoal::HorizontalPosition(0.0) + ), + ); + assert_eq!( + up( + &snapshot, + DisplayPoint::new(2, 0), + SelectionGoal::None, + false, + &text_layout_details + ), + ( + DisplayPoint::new(2, 0), + SelectionGoal::HorizontalPosition(0.0) + ), + ); + + let col_4_x = snapshot.x_for_point(DisplayPoint::new(3, 4), &text_layout_details); + + // Move up and down within first excerpt + assert_eq!( + up( + &snapshot, + DisplayPoint::new(3, 4), + SelectionGoal::HorizontalPosition(col_4_x), + false, + &text_layout_details + ), + ( + DisplayPoint::new(2, 3), + SelectionGoal::HorizontalPosition(col_4_x) + ), + ); + assert_eq!( + down( + &snapshot, + DisplayPoint::new(2, 3), + SelectionGoal::HorizontalPosition(col_4_x), + false, + &text_layout_details + ), + ( + DisplayPoint::new(3, 4), + SelectionGoal::HorizontalPosition(col_4_x) + ), + ); + + let col_5_x = snapshot.x_for_point(DisplayPoint::new(6, 5), &text_layout_details); + + // Move up and down across second excerpt's header + assert_eq!( + up( + &snapshot, + DisplayPoint::new(6, 5), + SelectionGoal::HorizontalPosition(col_5_x), + false, + &text_layout_details + ), + ( + DisplayPoint::new(3, 4), + SelectionGoal::HorizontalPosition(col_5_x) + ), + ); + assert_eq!( + down( + &snapshot, + DisplayPoint::new(3, 4), + SelectionGoal::HorizontalPosition(col_5_x), + false, + &text_layout_details + ), + ( + DisplayPoint::new(6, 5), + SelectionGoal::HorizontalPosition(col_5_x) + ), + ); + + let max_point_x = snapshot.x_for_point(DisplayPoint::new(7, 2), &text_layout_details); + + // Can't move down off the end + assert_eq!( + down( + &snapshot, + DisplayPoint::new(7, 0), + SelectionGoal::HorizontalPosition(0.0), + false, + &text_layout_details + ), + ( + DisplayPoint::new(7, 2), + SelectionGoal::HorizontalPosition(max_point_x) + ), + ); + assert_eq!( + down( + &snapshot, + DisplayPoint::new(7, 2), + SelectionGoal::HorizontalPosition(max_point_x), + false, + &text_layout_details + ), + ( + DisplayPoint::new(7, 2), + SelectionGoal::HorizontalPosition(max_point_x) + ), + ); + }); } fn init_test(cx: &mut gpui::AppContext) { @@ -851,5 +909,6 @@ mod tests { theme::init((), cx); language::init(cx); crate::init(cx); + Project::init_settings(cx); } } diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index a197121626..2f1e376c3e 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -3,7 +3,7 @@ use std::cmp; use editor::{ char_kind, display_map::{DisplaySnapshot, FoldPoint, ToDisplayPoint}, - movement::{self, find_boundary, find_preceding_boundary, FindRange}, + movement::{self, find_boundary, find_preceding_boundary, FindRange, TextLayoutDetails}, Bias, CharKind, DisplayPoint, ToOffset, }; use gpui::{actions, impl_actions, AppContext, WindowContext}; @@ -361,6 +361,7 @@ impl Motion { point: DisplayPoint, goal: SelectionGoal, maybe_times: Option, + text_layout_details: &TextLayoutDetails, ) -> Option<(DisplayPoint, SelectionGoal)> { let times = maybe_times.unwrap_or(1); use Motion::*; @@ -373,13 +374,13 @@ impl Motion { } => down(map, point, goal, times), Down { display_lines: true, - } => down_display(map, point, goal, times), + } => down_display(map, point, goal, times, &text_layout_details), Up { display_lines: false, } => up(map, point, goal, times), Up { display_lines: true, - } => up_display(map, point, goal, times), + } => up_display(map, point, goal, times, &text_layout_details), Right => (right(map, point, times), SelectionGoal::None), NextWordStart { ignore_punctuation } => ( next_word_start(map, point, *ignore_punctuation, times), @@ -442,10 +443,15 @@ impl Motion { selection: &mut Selection, times: Option, expand_to_surrounding_newline: bool, + text_layout_details: &TextLayoutDetails, ) -> bool { - if let Some((new_head, goal)) = - self.move_point(map, selection.head(), selection.goal, times) - { + if let Some((new_head, goal)) = self.move_point( + map, + selection.head(), + selection.goal, + times, + &text_layout_details, + ) { selection.set_head(new_head, goal); if self.linewise() { @@ -566,9 +572,10 @@ fn down_display( mut point: DisplayPoint, mut goal: SelectionGoal, times: usize, + text_layout_details: &TextLayoutDetails, ) -> (DisplayPoint, SelectionGoal) { for _ in 0..times { - (point, goal) = movement::down(map, point, goal, true); + (point, goal) = movement::down(map, point, goal, true, text_layout_details); } (point, goal) @@ -606,9 +613,10 @@ fn up_display( mut point: DisplayPoint, mut goal: SelectionGoal, times: usize, + text_layout_details: &TextLayoutDetails, ) -> (DisplayPoint, SelectionGoal) { for _ in 0..times { - (point, goal) = movement::up(map, point, goal, true); + (point, goal) = movement::up(map, point, goal, true, &text_layout_details); } (point, goal) @@ -707,7 +715,7 @@ fn previous_word_start( point } -fn first_non_whitespace( +pub(crate) fn first_non_whitespace( map: &DisplaySnapshot, display_lines: bool, from: DisplayPoint, @@ -890,7 +898,11 @@ fn next_line_start(map: &DisplaySnapshot, point: DisplayPoint, times: usize) -> first_non_whitespace(map, false, correct_line) } -fn next_line_end(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint { +pub(crate) fn next_line_end( + map: &DisplaySnapshot, + mut point: DisplayPoint, + times: usize, +) -> DisplayPoint { if times > 1 { point = down(map, point, SelectionGoal::None, times - 1).0; } diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 36eab2c4c0..9c93f19fc7 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -12,13 +12,13 @@ mod yank; use std::sync::Arc; use crate::{ - motion::{self, Motion}, + motion::{self, first_non_whitespace, next_line_end, right, Motion}, object::Object, state::{Mode, Operator}, Vim, }; use collections::HashSet; -use editor::scroll::autoscroll::Autoscroll; +use editor::{movement::TextLayoutDetails, scroll::autoscroll::Autoscroll}; use editor::{Bias, DisplayPoint}; use gpui::{actions, AppContext, ViewContext, WindowContext}; use language::SelectionGoal; @@ -177,10 +177,11 @@ pub(crate) fn move_cursor( cx: &mut WindowContext, ) { vim.update_active_editor(cx, |editor, cx| { + let text_layout_details = TextLayoutDetails::new(editor, cx); editor.change_selections(Some(Autoscroll::fit()), cx, |s| { s.move_cursors_with(|map, cursor, goal| { motion - .move_point(map, cursor, goal, times) + .move_point(map, cursor, goal, times, &text_layout_details) .unwrap_or((cursor, goal)) }) }) @@ -193,8 +194,8 @@ fn insert_after(_: &mut Workspace, _: &InsertAfter, cx: &mut ViewContext, cx: &m | Motion::StartOfLine { .. } ); vim.update_active_editor(cx, |editor, cx| { + let text_layout_details = TextLayoutDetails::new(editor, cx); editor.transact(cx, |editor, cx| { // We are swapping to insert mode anyway. Just set the line end clipping behavior now editor.set_clip_at_line_ends(false, cx); @@ -27,9 +28,15 @@ pub fn change_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &m s.move_with(|map, selection| { motion_succeeded |= if let Motion::NextWordStart { ignore_punctuation } = motion { - expand_changed_word_selection(map, selection, times, ignore_punctuation) + expand_changed_word_selection( + map, + selection, + times, + ignore_punctuation, + &text_layout_details, + ) } else { - motion.expand_selection(map, selection, times, false) + motion.expand_selection(map, selection, times, false, &text_layout_details) }; }); }); @@ -81,6 +88,7 @@ fn expand_changed_word_selection( selection: &mut Selection, times: Option, ignore_punctuation: bool, + text_layout_details: &TextLayoutDetails, ) -> bool { if times.is_none() || times.unwrap() == 1 { let scope = map @@ -103,11 +111,22 @@ fn expand_changed_word_selection( }); true } else { - Motion::NextWordStart { ignore_punctuation } - .expand_selection(map, selection, None, false) + Motion::NextWordStart { ignore_punctuation }.expand_selection( + map, + selection, + None, + false, + &text_layout_details, + ) } } else { - Motion::NextWordStart { ignore_punctuation }.expand_selection(map, selection, times, false) + Motion::NextWordStart { ignore_punctuation }.expand_selection( + map, + selection, + times, + false, + &text_layout_details, + ) } } diff --git a/crates/vim/src/normal/delete.rs b/crates/vim/src/normal/delete.rs index 517ece8033..1ad91ff308 100644 --- a/crates/vim/src/normal/delete.rs +++ b/crates/vim/src/normal/delete.rs @@ -1,12 +1,15 @@ use crate::{motion::Motion, object::Object, utils::copy_selections_content, Vim}; use collections::{HashMap, HashSet}; -use editor::{display_map::ToDisplayPoint, scroll::autoscroll::Autoscroll, Bias}; +use editor::{ + display_map::ToDisplayPoint, movement::TextLayoutDetails, scroll::autoscroll::Autoscroll, Bias, +}; use gpui::WindowContext; use language::Point; pub fn delete_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &mut WindowContext) { vim.stop_recording(); vim.update_active_editor(cx, |editor, cx| { + let text_layout_details = TextLayoutDetails::new(editor, cx); editor.transact(cx, |editor, cx| { editor.set_clip_at_line_ends(false, cx); let mut original_columns: HashMap<_, _> = Default::default(); @@ -14,7 +17,7 @@ pub fn delete_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &m s.move_with(|map, selection| { let original_head = selection.head(); original_columns.insert(selection.id, original_head.column()); - motion.expand_selection(map, selection, times, true); + motion.expand_selection(map, selection, times, true, &text_layout_details); // Motion::NextWordStart on an empty line should delete it. if let Motion::NextWordStart { diff --git a/crates/vim/src/normal/paste.rs b/crates/vim/src/normal/paste.rs index dda8dea1e4..7cb5261c49 100644 --- a/crates/vim/src/normal/paste.rs +++ b/crates/vim/src/normal/paste.rs @@ -1,8 +1,10 @@ use std::{borrow::Cow, cmp}; use editor::{ - display_map::ToDisplayPoint, movement, scroll::autoscroll::Autoscroll, ClipboardSelection, - DisplayPoint, + display_map::ToDisplayPoint, + movement::{self, TextLayoutDetails}, + scroll::autoscroll::Autoscroll, + ClipboardSelection, DisplayPoint, }; use gpui::{impl_actions, AppContext, ViewContext}; use language::{Bias, SelectionGoal}; @@ -30,6 +32,7 @@ fn paste(_: &mut Workspace, action: &Paste, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { vim.record_current_action(cx); vim.update_active_editor(cx, |editor, cx| { + let text_layout_details = TextLayoutDetails::new(editor, cx); editor.transact(cx, |editor, cx| { editor.set_clip_at_line_ends(false, cx); @@ -168,8 +171,14 @@ fn paste(_: &mut Workspace, action: &Paste, cx: &mut ViewContext) { let mut cursor = anchor.to_display_point(map); if *line_mode { if !before { - cursor = - movement::down(map, cursor, SelectionGoal::None, false).0; + cursor = movement::down( + map, + cursor, + SelectionGoal::None, + false, + &text_layout_details, + ) + .0; } cursor = movement::indented_line_beginning(map, cursor, true); } else if !is_multiline { diff --git a/crates/vim/src/normal/substitute.rs b/crates/vim/src/normal/substitute.rs index bb6e1abf92..ddc937d03f 100644 --- a/crates/vim/src/normal/substitute.rs +++ b/crates/vim/src/normal/substitute.rs @@ -1,9 +1,13 @@ -use editor::movement; +use editor::movement::{self, TextLayoutDetails}; use gpui::{actions, AppContext, WindowContext}; use language::Point; use workspace::Workspace; -use crate::{motion::Motion, utils::copy_selections_content, Mode, Vim}; +use crate::{ + motion::{right, Motion}, + utils::copy_selections_content, + Mode, Vim, +}; actions!(vim, [Substitute, SubstituteLine]); @@ -32,10 +36,17 @@ pub fn substitute(vim: &mut Vim, count: Option, line_mode: bool, cx: &mut vim.update_active_editor(cx, |editor, cx| { editor.set_clip_at_line_ends(false, cx); editor.transact(cx, |editor, cx| { + let text_layout_details = TextLayoutDetails::new(editor, cx); editor.change_selections(None, cx, |s| { s.move_with(|map, selection| { if selection.start == selection.end { - Motion::Right.expand_selection(map, selection, count, true); + Motion::Right.expand_selection( + map, + selection, + count, + true, + &text_layout_details, + ); } if line_mode { // in Visual mode when the selection contains the newline at the end @@ -43,7 +54,13 @@ pub fn substitute(vim: &mut Vim, count: Option, line_mode: bool, cx: &mut if !selection.is_empty() && selection.end.column() == 0 { selection.end = movement::left(map, selection.end); } - Motion::CurrentLine.expand_selection(map, selection, None, false); + Motion::CurrentLine.expand_selection( + map, + selection, + None, + false, + &text_layout_details, + ); if let Some((point, _)) = (Motion::FirstNonWhitespace { display_lines: false, }) @@ -52,6 +69,7 @@ pub fn substitute(vim: &mut Vim, count: Option, line_mode: bool, cx: &mut selection.start, selection.goal, None, + &text_layout_details, ) { selection.start = point; } diff --git a/crates/vim/src/normal/yank.rs b/crates/vim/src/normal/yank.rs index 7212a865bd..b50fcdf7ec 100644 --- a/crates/vim/src/normal/yank.rs +++ b/crates/vim/src/normal/yank.rs @@ -1,9 +1,11 @@ use crate::{motion::Motion, object::Object, utils::copy_selections_content, Vim}; use collections::HashMap; +use editor::movement::TextLayoutDetails; use gpui::WindowContext; pub fn yank_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &mut WindowContext) { vim.update_active_editor(cx, |editor, cx| { + let text_layout_details = TextLayoutDetails::new(editor, cx); editor.transact(cx, |editor, cx| { editor.set_clip_at_line_ends(false, cx); let mut original_positions: HashMap<_, _> = Default::default(); @@ -11,7 +13,7 @@ pub fn yank_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &mut s.move_with(|map, selection| { let original_position = (selection.head(), selection.goal); original_positions.insert(selection.id, original_position); - motion.expand_selection(map, selection, times, true); + motion.expand_selection(map, selection, times, true, &text_layout_details); }); }); copy_selections_content(editor, motion.linewise(), cx); diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index eac823de61..bec91007e3 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -4,7 +4,7 @@ use std::{cmp, sync::Arc}; use collections::HashMap; use editor::{ display_map::{DisplaySnapshot, ToDisplayPoint}, - movement, + movement::{self, TextLayoutDetails}, scroll::autoscroll::Autoscroll, Bias, DisplayPoint, Editor, }; @@ -57,6 +57,7 @@ pub fn init(cx: &mut AppContext) { pub fn visual_motion(motion: Motion, times: Option, cx: &mut WindowContext) { Vim::update(cx, |vim, cx| { vim.update_active_editor(cx, |editor, cx| { + let text_layout_details = TextLayoutDetails::new(editor, cx); if vim.state().mode == Mode::VisualBlock && !matches!( motion, @@ -67,7 +68,7 @@ pub fn visual_motion(motion: Motion, times: Option, cx: &mut WindowContex { let is_up_or_down = matches!(motion, Motion::Up { .. } | Motion::Down { .. }); visual_block_motion(is_up_or_down, editor, cx, |map, point, goal| { - motion.move_point(map, point, goal, times) + motion.move_point(map, point, goal, times, &text_layout_details) }) } else { editor.change_selections(Some(Autoscroll::fit()), cx, |s| { @@ -89,9 +90,13 @@ pub fn visual_motion(motion: Motion, times: Option, cx: &mut WindowContex current_head = movement::left(map, selection.end) } - let Some((new_head, goal)) = - motion.move_point(map, current_head, selection.goal, times) - else { + let Some((new_head, goal)) = motion.move_point( + map, + current_head, + selection.goal, + times, + &text_layout_details, + ) else { return; }; From 002e2cc42c41f4fcb847061ce0b25721fa1895d5 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 28 Sep 2023 22:40:27 -0600 Subject: [PATCH 05/90] Round better for up/down --- crates/editor/src/display_map.rs | 4 ++-- crates/editor/src/movement.rs | 8 ++------ crates/gpui/src/text_layout.rs | 24 ++++++++++++++++++++++++ 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index cebafbd651..424ff1518a 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -618,9 +618,9 @@ impl DisplaySnapshot { display_row: u32, x_coordinate: f32, text_layout_details: &TextLayoutDetails, - ) -> Option { + ) -> u32 { let layout_line = self.layout_line_for_row(display_row, text_layout_details); - layout_line.index_for_x(x_coordinate).map(|c| c as u32) + layout_line.closest_index_for_x(x_coordinate) as u32 } // column_for_x(row, x) diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index e2306a1b2d..38cf5cd6c1 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -115,9 +115,7 @@ pub fn up_by_rows( Bias::Left, ); if point.row() < start.row() { - *point.column_mut() = map - .column_for_x(point.row(), goal_x, text_layout_details) - .unwrap_or(point.column()); + *point.column_mut() = map.column_for_x(point.row(), goal_x, text_layout_details) } else if preserve_column_at_start { return (start, goal); } else { @@ -149,9 +147,7 @@ pub fn down_by_rows( let new_row = start.row() + row_count; let mut point = map.clip_point(DisplayPoint::new(new_row, 0), Bias::Right); if point.row() > start.row() { - *point.column_mut() = map - .column_for_x(point.row(), goal_x, text_layout_details) - .unwrap_or(map.line_len(point.row())); + *point.column_mut() = map.column_for_x(point.row(), goal_x, text_layout_details) } else if preserve_column_at_end { return (start, goal); } else { diff --git a/crates/gpui/src/text_layout.rs b/crates/gpui/src/text_layout.rs index 97f4b7a12d..7fb87b10df 100644 --- a/crates/gpui/src/text_layout.rs +++ b/crates/gpui/src/text_layout.rs @@ -266,6 +266,8 @@ impl Line { self.layout.len == 0 } + /// index_for_x returns the character containing the given x coordinate. + /// (e.g. to handle a mouse-click) pub fn index_for_x(&self, x: f32) -> Option { if x >= self.layout.width { None @@ -281,6 +283,28 @@ impl Line { } } + /// closest_index_for_x returns the character boundary closest to the given x coordinate + /// (e.g. to handle aligning up/down arrow keys) + pub fn closest_index_for_x(&self, x: f32) -> usize { + let mut prev_index = 0; + let mut prev_x = 0.0; + + for run in self.layout.runs.iter() { + for glyph in run.glyphs.iter() { + if glyph.position.x() >= x { + if glyph.position.x() - x < x - prev_x { + return glyph.index; + } else { + return prev_index; + } + } + prev_index = glyph.index; + prev_x = glyph.position.x(); + } + } + prev_index + } + pub fn paint( &self, origin: Vector2F, From ab050d18901e47d41fdcd11da3e950688ae2e665 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 3 Oct 2023 19:10:01 -0600 Subject: [PATCH 06/90] Use Horizontal ranges everywhere --- crates/editor/src/display_map.rs | 49 +-------- crates/editor/src/editor.rs | 28 +++-- crates/editor/src/element.rs | 5 +- crates/editor/src/movement.rs | 6 +- crates/editor/src/selections_collection.rs | 23 ++-- crates/text/src/selection.rs | 4 +- crates/vim/src/motion.rs | 120 ++++++++++++--------- crates/vim/src/normal.rs | 32 ++++-- crates/vim/src/normal/substitute.rs | 6 +- crates/vim/src/test.rs | 56 ++++++++++ crates/vim/src/vim.rs | 2 +- crates/vim/src/visual.rs | 44 ++++++-- crates/vim/test_data/test_j.json | 3 + 13 files changed, 229 insertions(+), 149 deletions(-) diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 424ff1518a..0f2b5665c6 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -15,7 +15,7 @@ use gpui::{ color::Color, fonts::{FontId, HighlightStyle, Underline}, text_layout::{Line, RunStyle}, - AppContext, Entity, FontCache, ModelContext, ModelHandle, TextLayoutCache, + Entity, ModelContext, ModelHandle, }; use inlay_map::InlayMap; use language::{ @@ -576,7 +576,6 @@ impl DisplaySnapshot { let range = display_row..display_row + 1; for chunk in self.highlighted_chunks(range, editor_style) { - dbg!(chunk.chunk); line.push_str(chunk.chunk); let text_style = if let Some(style) = chunk.style { @@ -600,7 +599,6 @@ impl DisplaySnapshot { )); } - dbg!(&line, &editor_style.text.font_size, &styles); text_layout_cache.layout_str(&line, editor_style.text.font_size, &styles) } @@ -623,49 +621,6 @@ impl DisplaySnapshot { layout_line.closest_index_for_x(x_coordinate) as u32 } - // column_for_x(row, x) - - fn point( - &self, - display_point: DisplayPoint, - text_layout_cache: &TextLayoutCache, - editor_style: &EditorStyle, - cx: &AppContext, - ) -> f32 { - let mut styles = Vec::new(); - let mut line = String::new(); - - let range = display_point.row()..display_point.row() + 1; - for chunk in self.highlighted_chunks(range, editor_style) { - dbg!(chunk.chunk); - line.push_str(chunk.chunk); - - let text_style = if let Some(style) = chunk.style { - editor_style - .text - .clone() - .highlight(style, cx.font_cache()) - .map(Cow::Owned) - .unwrap_or_else(|_| Cow::Borrowed(&editor_style.text)) - } else { - Cow::Borrowed(&editor_style.text) - }; - - styles.push(( - chunk.chunk.len(), - RunStyle { - font_id: text_style.font_id, - color: text_style.color, - underline: text_style.underline, - }, - )); - } - - dbg!(&line, &editor_style.text.font_size, &styles); - let layout_line = text_layout_cache.layout_str(&line, editor_style.text.font_size, &styles); - layout_line.x_for_index(display_point.column() as usize) - } - pub fn chars_at( &self, mut point: DisplayPoint, @@ -1374,7 +1329,6 @@ pub mod tests { ); let x = snapshot.x_for_point(DisplayPoint::new(1, 10), &text_layout_details); - dbg!(x); assert_eq!( movement::up( &snapshot, @@ -1401,7 +1355,6 @@ pub mod tests { SelectionGoal::HorizontalPosition(x) ) ); - dbg!("starting down..."); assert_eq!( movement::down( &snapshot, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index e68b1f008f..88db2f1dfe 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -48,9 +48,9 @@ use gpui::{ impl_actions, keymap_matcher::KeymapContext, platform::{CursorStyle, MouseButton}, - serde_json, text_layout, AnyElement, AnyViewHandle, AppContext, AsyncAppContext, ClipboardItem, - Element, Entity, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, - WeakViewHandle, WindowContext, + serde_json, AnyElement, AnyViewHandle, AppContext, AsyncAppContext, ClipboardItem, Element, + Entity, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, + WindowContext, }; use highlight_matching_bracket::refresh_matching_bracket_highlights; use hover_popover::{hide_hover, HoverState}; @@ -5953,11 +5953,14 @@ impl Editor { fn add_selection(&mut self, above: bool, cx: &mut ViewContext) { let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let mut selections = self.selections.all::(cx); + let text_layout_details = TextLayoutDetails::new(self, cx); let mut state = self.add_selections_state.take().unwrap_or_else(|| { let oldest_selection = selections.iter().min_by_key(|s| s.id).unwrap().clone(); let range = oldest_selection.display_range(&display_map).sorted(); - let columns = cmp::min(range.start.column(), range.end.column()) - ..cmp::max(range.start.column(), range.end.column()); + + let start_x = display_map.x_for_point(range.start, &text_layout_details); + let end_x = display_map.x_for_point(range.end, &text_layout_details); + let positions = start_x.min(end_x)..start_x.max(end_x); selections.clear(); let mut stack = Vec::new(); @@ -5965,8 +5968,9 @@ impl Editor { if let Some(selection) = self.selections.build_columnar_selection( &display_map, row, - &columns, + &positions, oldest_selection.reversed, + &text_layout_details, ) { stack.push(selection.id); selections.push(selection); @@ -5994,12 +5998,15 @@ impl Editor { let range = selection.display_range(&display_map).sorted(); debug_assert_eq!(range.start.row(), range.end.row()); let mut row = range.start.row(); - let columns = if let SelectionGoal::ColumnRange { start, end } = selection.goal + let positions = if let SelectionGoal::HorizontalRange { start, end } = + selection.goal { start..end } else { - cmp::min(range.start.column(), range.end.column()) - ..cmp::max(range.start.column(), range.end.column()) + let start_x = display_map.x_for_point(range.start, &text_layout_details); + let end_x = display_map.x_for_point(range.end, &text_layout_details); + + start_x.min(end_x)..start_x.max(end_x) }; while row != end_row { @@ -6012,8 +6019,9 @@ impl Editor { if let Some(new_selection) = self.selections.build_columnar_selection( &display_map, row, - &columns, + &positions, selection.reversed, + &text_layout_details, ) { state.stack.push(new_selection.id); if above { diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 24cbadfd37..30015eb760 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -22,7 +22,7 @@ use git::diff::DiffHunkStatus; use gpui::{ color::Color, elements::*, - fonts::{HighlightStyle, TextStyle, Underline}, + fonts::TextStyle, geometry::{ rect::RectF, vector::{vec2f, Vector2F}, @@ -37,8 +37,7 @@ use gpui::{ use itertools::Itertools; use json::json; use language::{ - language_settings::ShowWhitespaceSetting, Bias, CursorShape, DiagnosticSeverity, OffsetUtf16, - Selection, + language_settings::ShowWhitespaceSetting, Bias, CursorShape, OffsetUtf16, Selection, }; use project::{ project_settings::{GitGutterSetting, ProjectSettings}, diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index 38cf5cd6c1..7e75ae5e5d 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -1,6 +1,6 @@ use super::{Bias, DisplayPoint, DisplaySnapshot, SelectionGoal, ToDisplayPoint}; use crate::{char_kind, CharKind, Editor, EditorStyle, ToOffset, ToPoint}; -use gpui::{text_layout, FontCache, TextLayoutCache, WindowContext}; +use gpui::{FontCache, TextLayoutCache, WindowContext}; use language::Point; use std::{ops::Range, sync::Arc}; @@ -105,7 +105,9 @@ pub fn up_by_rows( ) -> (DisplayPoint, SelectionGoal) { let mut goal_x = match goal { SelectionGoal::HorizontalPosition(x) => x, + SelectionGoal::WrappedHorizontalPosition((_, x)) => x, SelectionGoal::HorizontalRange { end, .. } => end, + SelectionGoal::WrappedHorizontalRange { end: (_, end), .. } => end, _ => map.x_for_point(start, text_layout_details), }; @@ -140,7 +142,9 @@ pub fn down_by_rows( ) -> (DisplayPoint, SelectionGoal) { let mut goal_x = match goal { SelectionGoal::HorizontalPosition(x) => x, + SelectionGoal::WrappedHorizontalPosition((_, x)) => x, SelectionGoal::HorizontalRange { end, .. } => end, + SelectionGoal::WrappedHorizontalRange { end: (_, end), .. } => end, _ => map.x_for_point(start, text_layout_details), }; diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs index 6a21c898ef..2fa8ffe408 100644 --- a/crates/editor/src/selections_collection.rs +++ b/crates/editor/src/selections_collection.rs @@ -1,6 +1,6 @@ use std::{ cell::Ref, - cmp, iter, mem, + iter, mem, ops::{Deref, DerefMut, Range, Sub}, sync::Arc, }; @@ -13,6 +13,7 @@ use util::post_inc; use crate::{ display_map::{DisplayMap, DisplaySnapshot, ToDisplayPoint}, + movement::TextLayoutDetails, Anchor, DisplayPoint, ExcerptId, MultiBuffer, MultiBufferSnapshot, SelectMode, ToOffset, }; @@ -305,23 +306,27 @@ impl SelectionsCollection { &mut self, display_map: &DisplaySnapshot, row: u32, - columns: &Range, + positions: &Range, reversed: bool, + text_layout_details: &TextLayoutDetails, ) -> Option> { - let is_empty = columns.start == columns.end; + let is_empty = positions.start == positions.end; let line_len = display_map.line_len(row); - if columns.start < line_len || (is_empty && columns.start == line_len) { - let start = DisplayPoint::new(row, columns.start); - let end = DisplayPoint::new(row, cmp::min(columns.end, line_len)); + + let start_col = display_map.column_for_x(row, positions.start, text_layout_details); + if start_col < line_len || (is_empty && start_col == line_len) { + let start = DisplayPoint::new(row, start_col); + let end_col = display_map.column_for_x(row, positions.end, text_layout_details); + let end = DisplayPoint::new(row, end_col); Some(Selection { id: post_inc(&mut self.next_selection_id), start: start.to_point(display_map), end: end.to_point(display_map), reversed, - goal: SelectionGoal::ColumnRange { - start: columns.start, - end: columns.end, + goal: SelectionGoal::HorizontalRange { + start: positions.start, + end: positions.end, }, }) } else { diff --git a/crates/text/src/selection.rs b/crates/text/src/selection.rs index 38831f92c2..e127083112 100644 --- a/crates/text/src/selection.rs +++ b/crates/text/src/selection.rs @@ -7,8 +7,8 @@ pub enum SelectionGoal { None, HorizontalPosition(f32), HorizontalRange { start: f32, end: f32 }, - Column(u32), - ColumnRange { start: u32, end: u32 }, + WrappedHorizontalPosition((u32, f32)), + WrappedHorizontalRange { start: (u32, f32), end: (u32, f32) }, } #[derive(Clone, Debug, PartialEq)] diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 2f1e376c3e..36514f8cc4 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -1,5 +1,3 @@ -use std::cmp; - use editor::{ char_kind, display_map::{DisplaySnapshot, FoldPoint, ToDisplayPoint}, @@ -371,13 +369,13 @@ impl Motion { Backspace => (backspace(map, point, times), SelectionGoal::None), Down { display_lines: false, - } => down(map, point, goal, times), + } => up_down_buffer_rows(map, point, goal, times as isize, &text_layout_details), Down { display_lines: true, } => down_display(map, point, goal, times, &text_layout_details), Up { display_lines: false, - } => up(map, point, goal, times), + } => up_down_buffer_rows(map, point, goal, 0 - times as isize, &text_layout_details), Up { display_lines: true, } => up_display(map, point, goal, times, &text_layout_details), @@ -536,35 +534,86 @@ fn backspace(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> Di point } -fn down( +pub(crate) fn start_of_relative_buffer_row( + map: &DisplaySnapshot, + point: DisplayPoint, + times: isize, +) -> DisplayPoint { + let start = map.display_point_to_fold_point(point, Bias::Left); + let target = start.row() as isize + times; + let new_row = (target.max(0) as u32).min(map.fold_snapshot.max_point().row()); + + map.clip_point( + map.fold_point_to_display_point( + map.fold_snapshot + .clip_point(FoldPoint::new(new_row, 0), Bias::Right), + ), + Bias::Right, + ) +} + +fn up_down_buffer_rows( map: &DisplaySnapshot, point: DisplayPoint, mut goal: SelectionGoal, - times: usize, + times: isize, + text_layout_details: &TextLayoutDetails, ) -> (DisplayPoint, SelectionGoal) { let start = map.display_point_to_fold_point(point, Bias::Left); + let begin_folded_line = map.fold_point_to_display_point( + map.fold_snapshot + .clip_point(FoldPoint::new(start.row(), 0), Bias::Left), + ); + let select_nth_wrapped_row = point.row() - begin_folded_line.row(); - let goal_column = match goal { - SelectionGoal::Column(column) => column, - SelectionGoal::ColumnRange { end, .. } => end, + let (goal_wrap, goal_x) = match goal { + SelectionGoal::WrappedHorizontalPosition((row, x)) => (row, x), + SelectionGoal::WrappedHorizontalRange { end: (row, x), .. } => (row, x), + SelectionGoal::HorizontalRange { end, .. } => (select_nth_wrapped_row, end), + SelectionGoal::HorizontalPosition(x) => (select_nth_wrapped_row, x), _ => { - goal = SelectionGoal::Column(start.column()); - start.column() + let x = map.x_for_point(point, text_layout_details); + goal = SelectionGoal::WrappedHorizontalPosition((select_nth_wrapped_row, x)); + (select_nth_wrapped_row, x) } }; - let new_row = cmp::min( - start.row() + times as u32, - map.fold_snapshot.max_point().row(), - ); - let new_col = cmp::min(goal_column, map.fold_snapshot.line_len(new_row)); - let point = map.fold_point_to_display_point( + let target = start.row() as isize + times; + let new_row = (target.max(0) as u32).min(map.fold_snapshot.max_point().row()); + + let mut begin_folded_line = map.fold_point_to_display_point( map.fold_snapshot - .clip_point(FoldPoint::new(new_row, new_col), Bias::Left), + .clip_point(FoldPoint::new(new_row, 0), Bias::Left), ); - // clip twice to "clip at end of line" - (map.clip_point(point, Bias::Left), goal) + let mut i = 0; + while i < goal_wrap && begin_folded_line.row() < map.max_point().row() { + let next_folded_line = DisplayPoint::new(begin_folded_line.row() + 1, 0); + if map + .display_point_to_fold_point(next_folded_line, Bias::Right) + .row() + == new_row + { + i += 1; + begin_folded_line = next_folded_line; + } else { + break; + } + } + + let new_col = if i == goal_wrap { + map.column_for_x(begin_folded_line.row(), goal_x, text_layout_details) + } else { + map.line_len(begin_folded_line.row()) + }; + + ( + map.clip_point( + DisplayPoint::new(begin_folded_line.row(), new_col), + Bias::Left, + ), + goal, + ) } fn down_display( @@ -581,33 +630,6 @@ fn down_display( (point, goal) } -pub(crate) fn up( - map: &DisplaySnapshot, - point: DisplayPoint, - mut goal: SelectionGoal, - times: usize, -) -> (DisplayPoint, SelectionGoal) { - let start = map.display_point_to_fold_point(point, Bias::Left); - - let goal_column = match goal { - SelectionGoal::Column(column) => column, - SelectionGoal::ColumnRange { end, .. } => end, - _ => { - goal = SelectionGoal::Column(start.column()); - start.column() - } - }; - - let new_row = start.row().saturating_sub(times as u32); - let new_col = cmp::min(goal_column, map.fold_snapshot.line_len(new_row)); - let point = map.fold_point_to_display_point( - map.fold_snapshot - .clip_point(FoldPoint::new(new_row, new_col), Bias::Left), - ); - - (map.clip_point(point, Bias::Left), goal) -} - fn up_display( map: &DisplaySnapshot, mut point: DisplayPoint, @@ -894,7 +916,7 @@ fn find_backward( } fn next_line_start(map: &DisplaySnapshot, point: DisplayPoint, times: usize) -> DisplayPoint { - let correct_line = down(map, point, SelectionGoal::None, times).0; + let correct_line = start_of_relative_buffer_row(map, point, times as isize); first_non_whitespace(map, false, correct_line) } @@ -904,7 +926,7 @@ pub(crate) fn next_line_end( times: usize, ) -> DisplayPoint { if times > 1 { - point = down(map, point, SelectionGoal::None, times - 1).0; + point = start_of_relative_buffer_row(map, point, times as isize - 1); } end_of_line(map, false, point) } diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 9c93f19fc7..0e883cd758 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -194,9 +194,7 @@ fn insert_after(_: &mut Workspace, _: &InsertAfter, cx: &mut ViewContext, cx: &mut WindowContext) { Vim::update(cx, |vim, cx| { if vim.enabled && vim.state().mode == Mode::Normal && !newest.is_empty() { - if matches!(newest.goal, SelectionGoal::ColumnRange { .. }) { + if matches!(newest.goal, SelectionGoal::HorizontalRange { .. }) { vim.switch_mode(Mode::VisualBlock, false, cx); } else { vim.switch_mode(Mode::Visual, false, cx) diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index bec91007e3..ac4c5478a1 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -140,17 +140,21 @@ pub fn visual_block_motion( SelectionGoal, ) -> Option<(DisplayPoint, SelectionGoal)>, ) { + let text_layout_details = TextLayoutDetails::new(editor, cx); editor.change_selections(Some(Autoscroll::fit()), cx, |s| { let map = &s.display_map(); let mut head = s.newest_anchor().head().to_display_point(map); let mut tail = s.oldest_anchor().tail().to_display_point(map); let (start, end) = match s.newest_anchor().goal { - SelectionGoal::ColumnRange { start, end } if preserve_goal => (start, end), - SelectionGoal::Column(start) if preserve_goal => (start, start + 1), - _ => (tail.column(), head.column()), + SelectionGoal::HorizontalRange { start, end } if preserve_goal => (start, end), + SelectionGoal::HorizontalPosition(start) if preserve_goal => (start, start + 10.0), + _ => ( + map.x_for_point(tail, &text_layout_details), + map.x_for_point(head, &text_layout_details), + ), }; - let goal = SelectionGoal::ColumnRange { start, end }; + let goal = SelectionGoal::HorizontalRange { start, end }; let was_reversed = tail.column() > head.column(); if !was_reversed && !preserve_goal { @@ -172,21 +176,39 @@ pub fn visual_block_motion( head = movement::saturating_right(map, head) } - let columns = if is_reversed { - head.column()..tail.column() + let positions = if is_reversed { + map.x_for_point(head, &text_layout_details)..map.x_for_point(tail, &text_layout_details) } else if head.column() == tail.column() { - head.column()..(head.column() + 1) + map.x_for_point(head, &text_layout_details) + ..map.x_for_point(head, &text_layout_details) + 10.0 } else { - tail.column()..head.column() + map.x_for_point(tail, &text_layout_details)..map.x_for_point(head, &text_layout_details) }; let mut selections = Vec::new(); let mut row = tail.row(); loop { - let start = map.clip_point(DisplayPoint::new(row, columns.start), Bias::Left); - let end = map.clip_point(DisplayPoint::new(row, columns.end), Bias::Left); - if columns.start <= map.line_len(row) { + let start = map.clip_point( + DisplayPoint::new( + row, + map.column_for_x(row, positions.start, &text_layout_details), + ), + Bias::Left, + ); + let end = map.clip_point( + DisplayPoint::new( + row, + map.column_for_x(row, positions.end, &text_layout_details), + ), + Bias::Left, + ); + if positions.start + <= map.x_for_point( + DisplayPoint::new(row, map.line_len(row)), + &text_layout_details, + ) + { let selection = Selection { id: s.new_selection_id(), start: start.to_point(map), diff --git a/crates/vim/test_data/test_j.json b/crates/vim/test_data/test_j.json index 64aaf65ef8..703f69d22c 100644 --- a/crates/vim/test_data/test_j.json +++ b/crates/vim/test_data/test_j.json @@ -1,3 +1,6 @@ +{"Put":{"state":"aaˇaa\n😃😃"}} +{"Key":"j"} +{"Get":{"state":"aaaa\n😃ˇ😃","mode":"Normal"}} {"Put":{"state":"ˇThe quick brown\nfox jumps"}} {"Key":"j"} {"Get":{"state":"The quick brown\nˇfox jumps","mode":"Normal"}} From cf6ce0dbadf971d9366e418415992907e4b871e5 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 4 Oct 2023 14:16:32 -0700 Subject: [PATCH 07/90] Start work on storing notifications in the database --- Cargo.lock | 23 +++ Cargo.toml | 1 + .../20221109000000_test_schema.sql | 19 +++ .../20231004130100_create_notifications.sql | 18 +++ crates/collab/src/db.rs | 2 +- crates/collab/src/db/ids.rs | 1 + crates/collab/src/db/queries.rs | 1 + crates/collab/src/db/queries/access_tokens.rs | 1 + crates/collab/src/db/queries/notifications.rs | 140 ++++++++++++++++++ crates/collab/src/db/tables.rs | 2 + crates/collab/src/db/tables/notification.rs | 29 ++++ .../collab/src/db/tables/notification_kind.rs | 14 ++ crates/rpc/Cargo.toml | 1 + crates/rpc/proto/zed.proto | 41 ++++- crates/rpc/src/notification.rs | 105 +++++++++++++ crates/rpc/src/rpc.rs | 3 + 16 files changed, 399 insertions(+), 2 deletions(-) create mode 100644 crates/collab/migrations/20231004130100_create_notifications.sql create mode 100644 crates/collab/src/db/queries/notifications.rs create mode 100644 crates/collab/src/db/tables/notification.rs create mode 100644 crates/collab/src/db/tables/notification_kind.rs create mode 100644 crates/rpc/src/notification.rs diff --git a/Cargo.lock b/Cargo.lock index 01153ca0f8..a426a6a1ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6403,6 +6403,7 @@ dependencies = [ "serde_derive", "smol", "smol-timeout", + "strum", "tempdir", "tracing", "util", @@ -6623,6 +6624,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" + [[package]] name = "rustybuzz" version = "0.3.0" @@ -7698,6 +7705,22 @@ name = "strum" version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8d03b598d3d0fff69bf533ee3ef19b8eeb342729596df84bcc7e1f96ec4059" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.37", +] [[package]] name = "subtle" diff --git a/Cargo.toml b/Cargo.toml index 532610efd6..adb7fedb26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -112,6 +112,7 @@ serde_derive = { version = "1.0", features = ["deserialize_in_place"] } serde_json = { version = "1.0", features = ["preserve_order", "raw_value"] } smallvec = { version = "1.6", features = ["union"] } smol = { version = "1.2" } +strum = { version = "0.25.0", features = ["derive"] } sysinfo = "0.29.10" tempdir = { version = "0.3.7" } thiserror = { version = "1.0.29" } diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 5a84bfd796..0e811d8455 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -312,3 +312,22 @@ 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 NOT NULL, + "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 now, + "recipent_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + "kind" INTEGER NOT NULL REFERENCES notification_kinds (id), + "is_read" BOOLEAN NOT NULL DEFAULT FALSE, + "entity_id_1" INTEGER, + "entity_id_2" INTEGER +); + +CREATE INDEX "index_notifications_on_recipient_id" ON "notifications" ("recipient_id"); diff --git a/crates/collab/migrations/20231004130100_create_notifications.sql b/crates/collab/migrations/20231004130100_create_notifications.sql new file mode 100644 index 0000000000..e0c7b290b4 --- /dev/null +++ b/crates/collab/migrations/20231004130100_create_notifications.sql @@ -0,0 +1,18 @@ +CREATE TABLE "notification_kinds" ( + "id" INTEGER PRIMARY KEY NOT NULL, + "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(), + "recipent_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + "kind" INTEGER NOT NULL REFERENCES notification_kinds (id), + "is_read" BOOLEAN NOT NULL DEFAULT FALSE + "entity_id_1" INTEGER, + "entity_id_2" INTEGER +); + +CREATE INDEX "index_notifications_on_recipient_id" ON "notifications" ("recipient_id"); diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index e60b7cc33d..56e7c0d942 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -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, diff --git a/crates/collab/src/db/ids.rs b/crates/collab/src/db/ids.rs index 23bb9e53bf..b5873a152f 100644 --- a/crates/collab/src/db/ids.rs +++ b/crates/collab/src/db/ids.rs @@ -80,3 +80,4 @@ id_type!(SignupId); id_type!(UserId); id_type!(ChannelBufferCollaboratorId); id_type!(FlagId); +id_type!(NotificationId); diff --git a/crates/collab/src/db/queries.rs b/crates/collab/src/db/queries.rs index 80bd8704b2..629e26f1a9 100644 --- a/crates/collab/src/db/queries.rs +++ b/crates/collab/src/db/queries.rs @@ -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; diff --git a/crates/collab/src/db/queries/access_tokens.rs b/crates/collab/src/db/queries/access_tokens.rs index def9428a2b..589b6483df 100644 --- a/crates/collab/src/db/queries/access_tokens.rs +++ b/crates/collab/src/db/queries/access_tokens.rs @@ -1,4 +1,5 @@ use super::*; +use sea_orm::sea_query::Query; impl Database { pub async fn create_access_token( diff --git a/crates/collab/src/db/queries/notifications.rs b/crates/collab/src/db/queries/notifications.rs new file mode 100644 index 0000000000..2907ad85b7 --- /dev/null +++ b/crates/collab/src/db/queries/notifications.rs @@ -0,0 +1,140 @@ +use super::*; +use rpc::{Notification, NotificationEntityKind, NotificationKind}; + +impl Database { + pub async fn ensure_notification_kinds(&self) -> Result<()> { + self.transaction(|tx| async move { + notification_kind::Entity::insert_many(NotificationKind::all().map(|kind| { + notification_kind::ActiveModel { + id: ActiveValue::Set(kind as i32), + name: ActiveValue::Set(kind.to_string()), + } + })) + .on_conflict(OnConflict::new().do_nothing().to_owned()) + .exec(&*tx) + .await?; + Ok(()) + }) + .await + } + + pub async fn get_notifications( + &self, + recipient_id: UserId, + limit: usize, + ) -> Result { + self.transaction(|tx| async move { + let mut result = proto::AddNotifications::default(); + + let mut rows = notification::Entity::find() + .filter(notification::Column::RecipientId.eq(recipient_id)) + .order_by_desc(notification::Column::Id) + .limit(limit as u64) + .stream(&*tx) + .await?; + + let mut user_ids = Vec::new(); + let mut channel_ids = Vec::new(); + let mut message_ids = Vec::new(); + while let Some(row) = rows.next().await { + let row = row?; + + let Some(kind) = NotificationKind::from_i32(row.kind) else { + continue; + }; + let Some(notification) = Notification::from_fields( + kind, + [ + row.entity_id_1.map(|id| id as u64), + row.entity_id_2.map(|id| id as u64), + row.entity_id_3.map(|id| id as u64), + ], + ) else { + continue; + }; + + // Gather the ids of all associated entities. + let (_, associated_entities) = notification.to_fields(); + for entity in associated_entities { + let Some((id, kind)) = entity else { + break; + }; + match kind { + NotificationEntityKind::User => &mut user_ids, + NotificationEntityKind::Channel => &mut channel_ids, + NotificationEntityKind::ChannelMessage => &mut message_ids, + } + .push(id); + } + + result.notifications.push(proto::Notification { + kind: row.kind as u32, + timestamp: row.created_at.assume_utc().unix_timestamp() as u64, + is_read: row.is_read, + entity_id_1: row.entity_id_1.map(|id| id as u64), + entity_id_2: row.entity_id_2.map(|id| id as u64), + entity_id_3: row.entity_id_3.map(|id| id as u64), + }); + } + + let users = user::Entity::find() + .filter(user::Column::Id.is_in(user_ids)) + .all(&*tx) + .await?; + let channels = channel::Entity::find() + .filter(user::Column::Id.is_in(channel_ids)) + .all(&*tx) + .await?; + let messages = channel_message::Entity::find() + .filter(user::Column::Id.is_in(message_ids)) + .all(&*tx) + .await?; + + for user in users { + result.users.push(proto::User { + id: user.id.to_proto(), + github_login: user.github_login, + avatar_url: String::new(), + }); + } + for channel in channels { + result.channels.push(proto::Channel { + id: channel.id.to_proto(), + name: channel.name, + }); + } + for message in messages { + result.messages.push(proto::ChannelMessage { + id: message.id.to_proto(), + body: message.body, + timestamp: message.sent_at.assume_utc().unix_timestamp() as u64, + sender_id: message.sender_id.to_proto(), + nonce: None, + }); + } + + Ok(result) + }) + .await + } + + pub async fn create_notification( + &self, + recipient_id: UserId, + notification: Notification, + tx: &DatabaseTransaction, + ) -> Result<()> { + let (kind, associated_entities) = notification.to_fields(); + notification::ActiveModel { + recipient_id: ActiveValue::Set(recipient_id), + kind: ActiveValue::Set(kind as i32), + entity_id_1: ActiveValue::Set(associated_entities[0].map(|(id, _)| id as i32)), + entity_id_2: ActiveValue::Set(associated_entities[1].map(|(id, _)| id as i32)), + entity_id_3: ActiveValue::Set(associated_entities[2].map(|(id, _)| id as i32)), + ..Default::default() + } + .save(&*tx) + .await?; + Ok(()) + } +} diff --git a/crates/collab/src/db/tables.rs b/crates/collab/src/db/tables.rs index e19391da7d..4336217b23 100644 --- a/crates/collab/src/db/tables.rs +++ b/crates/collab/src/db/tables.rs @@ -12,6 +12,8 @@ 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; diff --git a/crates/collab/src/db/tables/notification.rs b/crates/collab/src/db/tables/notification.rs new file mode 100644 index 0000000000..6a0abe9dc6 --- /dev/null +++ b/crates/collab/src/db/tables/notification.rs @@ -0,0 +1,29 @@ +use crate::db::{NotificationId, 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 recipient_id: UserId, + pub kind: i32, + pub is_read: bool, + pub created_at: PrimitiveDateTime, + pub entity_id_1: Option, + pub entity_id_2: Option, + pub entity_id_3: Option, +} + +#[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 {} diff --git a/crates/collab/src/db/tables/notification_kind.rs b/crates/collab/src/db/tables/notification_kind.rs new file mode 100644 index 0000000000..32dfb2065a --- /dev/null +++ b/crates/collab/src/db/tables/notification_kind.rs @@ -0,0 +1,14 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "notification_kinds")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/rpc/Cargo.toml b/crates/rpc/Cargo.toml index 3c307be4fb..bc750374dd 100644 --- a/crates/rpc/Cargo.toml +++ b/crates/rpc/Cargo.toml @@ -29,6 +29,7 @@ rsa = "0.4" serde.workspace = true serde_derive.workspace = true smol-timeout = "0.6" +strum.workspace = true tracing = { version = "0.1.34", features = ["log"] } zstd = "0.11" diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 3501e70e6a..f51d11d3db 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -170,7 +170,9 @@ message Envelope { LinkChannel link_channel = 140; UnlinkChannel unlink_channel = 141; - MoveChannel move_channel = 142; // current max: 144 + MoveChannel move_channel = 142; + + AddNotifications add_notification = 145; // Current max } } @@ -1557,3 +1559,40 @@ message UpdateDiffBase { uint64 buffer_id = 2; optional string diff_base = 3; } + +message AddNotifications { + repeated Notification notifications = 1; + repeated User users = 2; + repeated Channel channels = 3; + repeated ChannelMessage messages = 4; +} + +message Notification { + uint32 kind = 1; + uint64 timestamp = 2; + bool is_read = 3; + optional uint64 entity_id_1 = 4; + optional uint64 entity_id_2 = 5; + optional uint64 entity_id_3 = 6; + + // oneof variant { + // ContactRequest contact_request = 3; + // ChannelInvitation channel_invitation = 4; + // ChatMessageMention chat_message_mention = 5; + // }; + + // message ContactRequest { + // uint64 requester_id = 1; + // } + + // message ChannelInvitation { + // uint64 inviter_id = 1; + // uint64 channel_id = 2; + // } + + // message ChatMessageMention { + // uint64 sender_id = 1; + // uint64 channel_id = 2; + // uint64 message_id = 3; + // } +} diff --git a/crates/rpc/src/notification.rs b/crates/rpc/src/notification.rs new file mode 100644 index 0000000000..40794a11c3 --- /dev/null +++ b/crates/rpc/src/notification.rs @@ -0,0 +1,105 @@ +use strum::{Display, EnumIter, EnumString, IntoEnumIterator}; + +// An integer indicating a type of notification. The variants' numerical +// values are stored in the database, so they should never be removed +// or changed. +#[repr(i32)] +#[derive(Copy, Clone, Debug, EnumIter, EnumString, Display)] +pub enum NotificationKind { + ContactRequest = 0, + ChannelInvitation = 1, + ChannelMessageMention = 2, +} + +pub enum Notification { + ContactRequest { + requester_id: u64, + }, + ChannelInvitation { + inviter_id: u64, + channel_id: u64, + }, + ChannelMessageMention { + sender_id: u64, + channel_id: u64, + message_id: u64, + }, +} + +#[derive(Copy, Clone)] +pub enum NotificationEntityKind { + User, + Channel, + ChannelMessage, +} + +impl Notification { + pub fn from_fields(kind: NotificationKind, entity_ids: [Option; 3]) -> Option { + use NotificationKind::*; + + Some(match kind { + ContactRequest => Self::ContactRequest { + requester_id: entity_ids[0]?, + }, + ChannelInvitation => Self::ChannelInvitation { + inviter_id: entity_ids[0]?, + channel_id: entity_ids[1]?, + }, + ChannelMessageMention => Self::ChannelMessageMention { + sender_id: entity_ids[0]?, + channel_id: entity_ids[1]?, + message_id: entity_ids[2]?, + }, + }) + } + + pub fn to_fields(&self) -> (NotificationKind, [Option<(u64, NotificationEntityKind)>; 3]) { + use NotificationKind::*; + + match self { + Self::ContactRequest { requester_id } => ( + ContactRequest, + [ + Some((*requester_id, NotificationEntityKind::User)), + None, + None, + ], + ), + + Self::ChannelInvitation { + inviter_id, + channel_id, + } => ( + ChannelInvitation, + [ + Some((*inviter_id, NotificationEntityKind::User)), + Some((*channel_id, NotificationEntityKind::User)), + None, + ], + ), + + Self::ChannelMessageMention { + sender_id, + channel_id, + message_id, + } => ( + ChannelMessageMention, + [ + Some((*sender_id, NotificationEntityKind::User)), + Some((*channel_id, NotificationEntityKind::ChannelMessage)), + Some((*message_id, NotificationEntityKind::Channel)), + ], + ), + } + } +} + +impl NotificationKind { + pub fn all() -> impl Iterator { + Self::iter() + } + + pub fn from_i32(i: i32) -> Option { + Self::iter().find(|kind| *kind as i32 == i) + } +} diff --git a/crates/rpc/src/rpc.rs b/crates/rpc/src/rpc.rs index 942672b94b..539ef014bb 100644 --- a/crates/rpc/src/rpc.rs +++ b/crates/rpc/src/rpc.rs @@ -1,9 +1,12 @@ pub mod auth; mod conn; +mod notification; mod peer; pub mod proto; + pub use conn::Connection; pub use peer::*; +pub use notification::*; mod macros; pub const PROTOCOL_VERSION: u32 = 64; From 50cf25ae970decfd11b24d4bd0bba579de097708 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 5 Oct 2023 16:43:18 -0700 Subject: [PATCH 08/90] Add notification doc comments --- crates/collab/src/db/queries/notifications.rs | 6 +++--- crates/rpc/src/notification.rs | 20 +++++++++++++++++-- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/collab/src/db/queries/notifications.rs b/crates/collab/src/db/queries/notifications.rs index 2907ad85b7..67fd00e3ec 100644 --- a/crates/collab/src/db/queries/notifications.rs +++ b/crates/collab/src/db/queries/notifications.rs @@ -42,7 +42,7 @@ impl Database { let Some(kind) = NotificationKind::from_i32(row.kind) else { continue; }; - let Some(notification) = Notification::from_fields( + let Some(notification) = Notification::from_parts( kind, [ row.entity_id_1.map(|id| id as u64), @@ -54,7 +54,7 @@ impl Database { }; // Gather the ids of all associated entities. - let (_, associated_entities) = notification.to_fields(); + let (_, associated_entities) = notification.to_parts(); for entity in associated_entities { let Some((id, kind)) = entity else { break; @@ -124,7 +124,7 @@ impl Database { notification: Notification, tx: &DatabaseTransaction, ) -> Result<()> { - let (kind, associated_entities) = notification.to_fields(); + let (kind, associated_entities) = notification.to_parts(); notification::ActiveModel { recipient_id: ActiveValue::Set(recipient_id), kind: ActiveValue::Set(kind as i32), diff --git a/crates/rpc/src/notification.rs b/crates/rpc/src/notification.rs index 40794a11c3..512a4731b4 100644 --- a/crates/rpc/src/notification.rs +++ b/crates/rpc/src/notification.rs @@ -34,7 +34,13 @@ pub enum NotificationEntityKind { } impl Notification { - pub fn from_fields(kind: NotificationKind, entity_ids: [Option; 3]) -> Option { + /// Load this notification from its generic representation, which is + /// used to represent it in the database, and in the wire protocol. + /// + /// The order in which a given notification type's fields are listed must + /// match the order they're listed in the `to_parts` method, and it must + /// not change, because they're stored in that order in the database. + pub fn from_parts(kind: NotificationKind, entity_ids: [Option; 3]) -> Option { use NotificationKind::*; Some(match kind { @@ -53,7 +59,17 @@ impl Notification { }) } - pub fn to_fields(&self) -> (NotificationKind, [Option<(u64, NotificationEntityKind)>; 3]) { + /// Convert this notification into its generic representation, which is + /// used to represent it in the database, and in the wire protocol. + /// + /// The order in which a given notification type's fields are listed must + /// match the order they're listed in the `from_parts` method, and it must + /// not change, because they're stored in that order in the database. + /// + /// Along with each field, provide the kind of entity that the field refers + /// to. This is used to load the associated entities for a batch of + /// notifications from the database. + pub fn to_parts(&self) -> (NotificationKind, [Option<(u64, NotificationEntityKind)>; 3]) { use NotificationKind::*; match self { From d1756b621f62c7541cffc86f632fb305e2ab2228 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 6 Oct 2023 12:56:18 -0700 Subject: [PATCH 09/90] Start work on notification panel --- Cargo.lock | 22 + Cargo.toml | 1 + assets/icons/bell.svg | 3 + assets/settings/default.json | 8 + crates/channel/src/channel_chat.rs | 41 +- crates/channel/src/channel_store.rs | 29 +- .../20221109000000_test_schema.sql | 3 +- .../20231004130100_create_notifications.sql | 9 +- crates/collab/src/db/queries/contacts.rs | 15 +- crates/collab/src/db/queries/notifications.rs | 96 +--- crates/collab/src/rpc.rs | 17 +- crates/collab_ui/Cargo.toml | 2 + crates/collab_ui/src/chat_panel.rs | 69 +-- crates/collab_ui/src/collab_ui.rs | 66 ++- crates/collab_ui/src/notification_panel.rs | 427 ++++++++++++++++++ crates/collab_ui/src/panel_settings.rs | 23 +- crates/notifications/Cargo.toml | 42 ++ .../notifications/src/notification_store.rs | 256 +++++++++++ crates/rpc/proto/zed.proto | 44 +- crates/rpc/src/notification.rs | 57 +-- crates/rpc/src/proto.rs | 3 + crates/zed/Cargo.toml | 1 + crates/zed/src/main.rs | 1 + crates/zed/src/zed.rs | 27 +- 24 files changed, 1021 insertions(+), 241 deletions(-) create mode 100644 assets/icons/bell.svg create mode 100644 crates/collab_ui/src/notification_panel.rs create mode 100644 crates/notifications/Cargo.toml create mode 100644 crates/notifications/src/notification_store.rs diff --git a/Cargo.lock b/Cargo.lock index a426a6a1ca..e43cc8b5eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1559,6 +1559,7 @@ dependencies = [ "language", "log", "menu", + "notifications", "picker", "postage", "project", @@ -4727,6 +4728,26 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notifications" +version = "0.1.0" +dependencies = [ + "anyhow", + "channel", + "client", + "clock", + "collections", + "db", + "feature_flags", + "gpui", + "rpc", + "settings", + "sum_tree", + "text", + "time", + "util", +] + [[package]] name = "ntapi" version = "0.3.7" @@ -10123,6 +10144,7 @@ dependencies = [ "log", "lsp", "node_runtime", + "notifications", "num_cpus", "outline", "parking_lot 0.11.2", diff --git a/Cargo.toml b/Cargo.toml index adb7fedb26..ca4a308bae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ members = [ "crates/media", "crates/menu", "crates/node_runtime", + "crates/notifications", "crates/outline", "crates/picker", "crates/plugin", diff --git a/assets/icons/bell.svg b/assets/icons/bell.svg new file mode 100644 index 0000000000..46b01b6b38 --- /dev/null +++ b/assets/icons/bell.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/settings/default.json b/assets/settings/default.json index 1611d80e2f..bab114b2f0 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -139,6 +139,14 @@ // Default width of the channels panel. "default_width": 240 }, + "notification_panel": { + // Whether to show the collaboration panel button in the status bar. + "button": true, + // Where to dock channels panel. Can be 'left' or 'right'. + "dock": "right", + // Default width of the channels panel. + "default_width": 240 + }, "assistant": { // Whether to show the assistant panel button in the status bar. "button": true, diff --git a/crates/channel/src/channel_chat.rs b/crates/channel/src/channel_chat.rs index 734182886b..5c4e0f88f6 100644 --- a/crates/channel/src/channel_chat.rs +++ b/crates/channel/src/channel_chat.rs @@ -451,22 +451,7 @@ async fn messages_from_proto( user_store: &ModelHandle, cx: &mut AsyncAppContext, ) -> Result> { - let unique_user_ids = proto_messages - .iter() - .map(|m| m.sender_id) - .collect::>() - .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) @@ -498,6 +483,30 @@ impl ChannelMessage { pub fn is_pending(&self) -> bool { matches!(self.id, ChannelMessageId::Pending(_)) } + + pub async fn from_proto_vec( + proto_messages: Vec, + user_store: &ModelHandle, + cx: &mut AsyncAppContext, + ) -> Result> { + let unique_user_ids = proto_messages + .iter() + .map(|m| m.sender_id) + .collect::>() + .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) + } } impl sum_tree::Item for ChannelMessage { diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index bceb2c094d..4a1292cdb2 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -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}; @@ -248,6 +248,33 @@ impl ChannelStore { ) } + pub fn fetch_channel_messages( + &self, + message_ids: Vec, + cx: &mut ModelContext, + ) -> Task>> { + 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 { self.channel_index .by_id() diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 0e811d8455..70c913dc95 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -327,7 +327,8 @@ CREATE TABLE "notifications" ( "kind" INTEGER NOT NULL REFERENCES notification_kinds (id), "is_read" BOOLEAN NOT NULL DEFAULT FALSE, "entity_id_1" INTEGER, - "entity_id_2" INTEGER + "entity_id_2" INTEGER, + "entity_id_3" INTEGER ); CREATE INDEX "index_notifications_on_recipient_id" ON "notifications" ("recipient_id"); diff --git a/crates/collab/migrations/20231004130100_create_notifications.sql b/crates/collab/migrations/20231004130100_create_notifications.sql index e0c7b290b4..cac3f2d8df 100644 --- a/crates/collab/migrations/20231004130100_create_notifications.sql +++ b/crates/collab/migrations/20231004130100_create_notifications.sql @@ -1,6 +1,6 @@ CREATE TABLE "notification_kinds" ( "id" INTEGER PRIMARY KEY NOT NULL, - "name" VARCHAR NOT NULL, + "name" VARCHAR NOT NULL ); CREATE UNIQUE INDEX "index_notification_kinds_on_name" ON "notification_kinds" ("name"); @@ -8,11 +8,12 @@ CREATE UNIQUE INDEX "index_notification_kinds_on_name" ON "notification_kinds" ( CREATE TABLE notifications ( "id" SERIAL PRIMARY KEY, "created_at" TIMESTAMP NOT NULL DEFAULT now(), - "recipent_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + "recipient_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, "kind" INTEGER NOT NULL REFERENCES notification_kinds (id), - "is_read" BOOLEAN NOT NULL DEFAULT FALSE + "is_read" BOOLEAN NOT NULL DEFAULT FALSE, "entity_id_1" INTEGER, - "entity_id_2" INTEGER + "entity_id_2" INTEGER, + "entity_id_3" INTEGER ); CREATE INDEX "index_notifications_on_recipient_id" ON "notifications" ("recipient_id"); diff --git a/crates/collab/src/db/queries/contacts.rs b/crates/collab/src/db/queries/contacts.rs index 2171f1a6bf..d922bc5ca2 100644 --- a/crates/collab/src/db/queries/contacts.rs +++ b/crates/collab/src/db/queries/contacts.rs @@ -124,7 +124,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 { self.transaction(|tx| async move { let (id_a, id_b, a_to_b) = if sender_id < receiver_id { (sender_id, receiver_id, true) @@ -162,7 +166,14 @@ impl Database { .await?; if rows_affected == 1 { - Ok(()) + self.create_notification( + receiver_id, + rpc::Notification::ContactRequest { + requester_id: sender_id.to_proto(), + }, + &*tx, + ) + .await } else { Err(anyhow!("contact already requested"))? } diff --git a/crates/collab/src/db/queries/notifications.rs b/crates/collab/src/db/queries/notifications.rs index 67fd00e3ec..293b896a50 100644 --- a/crates/collab/src/db/queries/notifications.rs +++ b/crates/collab/src/db/queries/notifications.rs @@ -1,5 +1,5 @@ use super::*; -use rpc::{Notification, NotificationEntityKind, NotificationKind}; +use rpc::{Notification, NotificationKind}; impl Database { pub async fn ensure_notification_kinds(&self) -> Result<()> { @@ -25,49 +25,16 @@ impl Database { ) -> Result { self.transaction(|tx| async move { let mut result = proto::AddNotifications::default(); - let mut rows = notification::Entity::find() .filter(notification::Column::RecipientId.eq(recipient_id)) .order_by_desc(notification::Column::Id) .limit(limit as u64) .stream(&*tx) .await?; - - let mut user_ids = Vec::new(); - let mut channel_ids = Vec::new(); - let mut message_ids = Vec::new(); while let Some(row) = rows.next().await { let row = row?; - - let Some(kind) = NotificationKind::from_i32(row.kind) else { - continue; - }; - let Some(notification) = Notification::from_parts( - kind, - [ - row.entity_id_1.map(|id| id as u64), - row.entity_id_2.map(|id| id as u64), - row.entity_id_3.map(|id| id as u64), - ], - ) else { - continue; - }; - - // Gather the ids of all associated entities. - let (_, associated_entities) = notification.to_parts(); - for entity in associated_entities { - let Some((id, kind)) = entity else { - break; - }; - match kind { - NotificationEntityKind::User => &mut user_ids, - NotificationEntityKind::Channel => &mut channel_ids, - NotificationEntityKind::ChannelMessage => &mut message_ids, - } - .push(id); - } - result.notifications.push(proto::Notification { + id: row.id.to_proto(), kind: row.kind as u32, timestamp: row.created_at.assume_utc().unix_timestamp() as u64, is_read: row.is_read, @@ -76,43 +43,7 @@ impl Database { entity_id_3: row.entity_id_3.map(|id| id as u64), }); } - - let users = user::Entity::find() - .filter(user::Column::Id.is_in(user_ids)) - .all(&*tx) - .await?; - let channels = channel::Entity::find() - .filter(user::Column::Id.is_in(channel_ids)) - .all(&*tx) - .await?; - let messages = channel_message::Entity::find() - .filter(user::Column::Id.is_in(message_ids)) - .all(&*tx) - .await?; - - for user in users { - result.users.push(proto::User { - id: user.id.to_proto(), - github_login: user.github_login, - avatar_url: String::new(), - }); - } - for channel in channels { - result.channels.push(proto::Channel { - id: channel.id.to_proto(), - name: channel.name, - }); - } - for message in messages { - result.messages.push(proto::ChannelMessage { - id: message.id.to_proto(), - body: message.body, - timestamp: message.sent_at.assume_utc().unix_timestamp() as u64, - sender_id: message.sender_id.to_proto(), - nonce: None, - }); - } - + result.notifications.reverse(); Ok(result) }) .await @@ -123,18 +54,27 @@ impl Database { recipient_id: UserId, notification: Notification, tx: &DatabaseTransaction, - ) -> Result<()> { + ) -> Result { let (kind, associated_entities) = notification.to_parts(); - notification::ActiveModel { + let model = notification::ActiveModel { recipient_id: ActiveValue::Set(recipient_id), kind: ActiveValue::Set(kind as i32), - entity_id_1: ActiveValue::Set(associated_entities[0].map(|(id, _)| id as i32)), - entity_id_2: ActiveValue::Set(associated_entities[1].map(|(id, _)| id as i32)), - entity_id_3: ActiveValue::Set(associated_entities[2].map(|(id, _)| id as i32)), + entity_id_1: ActiveValue::Set(associated_entities[0].map(|id| id as i32)), + entity_id_2: ActiveValue::Set(associated_entities[1].map(|id| id as i32)), + entity_id_3: ActiveValue::Set(associated_entities[2].map(|id| id as i32)), ..Default::default() } .save(&*tx) .await?; - Ok(()) + + Ok(proto::Notification { + id: model.id.as_ref().to_proto(), + kind: *model.kind.as_ref() as u32, + timestamp: model.created_at.as_ref().assume_utc().unix_timestamp() as u64, + is_read: false, + entity_id_1: model.entity_id_1.as_ref().map(|id| id as u64), + entity_id_2: model.entity_id_2.as_ref().map(|id| id as u64), + entity_id_3: model.entity_id_3.as_ref().map(|id| id as u64), + }) } } diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index e5c6d94ce0..eb123cf960 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -70,6 +70,7 @@ pub const CLEANUP_TIMEOUT: Duration = Duration::from_secs(10); const MESSAGE_COUNT_PER_PAGE: usize = 100; const MAX_MESSAGE_LEN: usize = 1024; +const INITIAL_NOTIFICATION_COUNT: usize = 30; lazy_static! { static ref METRIC_CONNECTIONS: IntGauge = @@ -290,6 +291,8 @@ impl Server { let pool = self.connection_pool.clone(); let live_kit_client = self.app_state.live_kit_client.clone(); + self.app_state.db.ensure_notification_kinds().await?; + let span = info_span!("start server"); self.executor.spawn_detached( async move { @@ -578,15 +581,17 @@ impl Server { this.app_state.db.set_user_connected_once(user_id, true).await?; } - let (contacts, channels_for_user, channel_invites) = future::try_join3( + let (contacts, channels_for_user, channel_invites, notifications) = future::try_join4( this.app_state.db.get_contacts(user_id), this.app_state.db.get_channels_for_user(user_id), - this.app_state.db.get_channel_invites_for_user(user_id) + this.app_state.db.get_channel_invites_for_user(user_id), + this.app_state.db.get_notifications(user_id, INITIAL_NOTIFICATION_COUNT) ).await?; { let mut pool = this.connection_pool.lock(); pool.add_connection(connection_id, user_id, user.admin); + this.peer.send(connection_id, notifications)?; this.peer.send(connection_id, build_initial_contacts_update(contacts, &pool))?; this.peer.send(connection_id, build_initial_channels_update( channels_for_user, @@ -2064,7 +2069,7 @@ async fn request_contact( return Err(anyhow!("cannot add yourself as a contact"))?; } - session + let notification = session .db() .await .send_contact_request(requester_id, responder_id) @@ -2095,6 +2100,12 @@ async fn request_contact( .user_connection_ids(responder_id) { session.peer.send(connection_id, update.clone())?; + session.peer.send( + connection_id, + proto::AddNotifications { + notifications: vec![notification.clone()], + }, + )?; } response.send(proto::Ack {})?; diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index 98790778c9..25f2d9f91a 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -37,6 +37,7 @@ 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" } @@ -65,6 +66,7 @@ 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"] } settings = { path = "../settings", features = ["test-support"] } util = { path = "../util", features = ["test-support"] } diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index 1a17b48f19..d58a406d78 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -1,4 +1,7 @@ -use crate::{channel_view::ChannelView, ChatPanelSettings}; +use crate::{ + channel_view::ChannelView, format_timestamp, is_channels_feature_enabled, render_avatar, + ChatPanelSettings, +}; use anyhow::Result; use call::ActiveCall; use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore}; @@ -6,15 +9,14 @@ 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 menu::Confirm; @@ -675,32 +677,6 @@ impl ChatPanel { } } -fn render_avatar(avatar: Option>, theme: &Arc) -> AnyElement { - 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, cx: &mut ViewContext<'_, '_, ChatPanel>, @@ -810,14 +786,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::(cx).button && is_chat_feature_enabled(cx)) + (settings::get::(cx).button && is_channels_feature_enabled(cx)) .then(|| "icons/conversations.svg") } @@ -842,35 +818,6 @@ impl Panel for ChatPanel { } } -fn is_chat_feature_enabled(cx: &gpui::WindowContext<'_>) -> bool { - cx.is_staff() || cx.has_flag::() -} - -fn format_timestamp( - mut timestamp: OffsetDateTime, - mut now: OffsetDateTime, - local_timezone: UtcOffset, -) -> String { - timestamp = timestamp.to_offset(local_timezone); - now = now.to_offset(local_timezone); - - let today = now.date(); - let date = timestamp.date(); - let mut hour = timestamp.hour(); - let mut part = "am"; - if hour > 12 { - hour -= 12; - part = "pm"; - } - if date == today { - format!("{:02}:{:02}{}", hour, timestamp.minute(), part) - } else if date.next_day() == Some(today) { - format!("yesterday at {:02}:{:02}{}", hour, timestamp.minute(), part) - } else { - format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year()) - } -} - fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element { Svg::new(svg_path) .with_color(style.color) diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index 57d6f7b4f6..0a22c063be 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -5,27 +5,34 @@ mod collab_titlebar_item; mod contact_notification; mod face_pile; mod incoming_call_notification; +pub mod notification_panel; 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::{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::Theme; +use time::{OffsetDateTime, UtcOffset}; 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,6 +42,7 @@ actions!( pub fn init(app_state: &Arc, cx: &mut AppContext) { settings::register::(cx); settings::register::(cx); + settings::register::(cx); vcs_menu::init(cx); collab_titlebar_item::init(cx); @@ -130,3 +138,57 @@ fn notification_window_options( screen: Some(screen), } } + +fn render_avatar(avatar: Option>, theme: &Arc) -> AnyElement { + 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 format_timestamp( + mut timestamp: OffsetDateTime, + mut now: OffsetDateTime, + local_timezone: UtcOffset, +) -> String { + timestamp = timestamp.to_offset(local_timezone); + now = now.to_offset(local_timezone); + + let today = now.date(); + let date = timestamp.date(); + let mut hour = timestamp.hour(); + let mut part = "am"; + if hour > 12 { + hour -= 12; + part = "pm"; + } + if date == today { + format!("{:02}:{:02}{}", hour, timestamp.minute(), part) + } else if date.next_day() == Some(today) { + format!("yesterday at {:02}:{:02}{}", hour, timestamp.minute(), part) + } else { + format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year()) + } +} + +fn is_channels_feature_enabled(cx: &gpui::WindowContext<'_>) -> bool { + cx.is_staff() || cx.has_flag::() +} diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs new file mode 100644 index 0000000000..a78caf5ff6 --- /dev/null +++ b/crates/collab_ui/src/notification_panel.rs @@ -0,0 +1,427 @@ +use crate::{ + format_timestamp, is_channels_feature_enabled, render_avatar, NotificationPanelSettings, +}; +use anyhow::Result; +use channel::ChannelStore; +use client::{Client, Notification, UserStore}; +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 serde::{Deserialize, Serialize}; +use settings::SettingsStore; +use std::sync::Arc; +use theme::{IconButton, Theme}; +use time::{OffsetDateTime, UtcOffset}; +use util::{ResultExt, TryFutureExt}; +use workspace::{ + dock::{DockPosition, Panel}, + Workspace, +}; + +const NOTIFICATION_PANEL_KEY: &'static str = "NotificationPanel"; + +pub struct NotificationPanel { + client: Arc, + user_store: ModelHandle, + channel_store: ModelHandle, + notification_store: ModelHandle, + fs: Arc, + width: Option, + active: bool, + notification_list: ListState, + pending_serialization: Task>, + subscriptions: Vec, + local_timezone: UtcOffset, + has_focus: bool, +} + +#[derive(Serialize, Deserialize)] +struct SerializedNotificationPanel { + width: Option, +} + +#[derive(Debug)] +pub enum Event { + DockPositionChanged, + Focus, + Dismissed, +} + +actions!(chat_panel, [ToggleFocus]); + +pub fn init(_cx: &mut AppContext) {} + +impl NotificationPanel { + pub fn new(workspace: &mut Workspace, cx: &mut ViewContext) -> ViewHandle { + let fs = workspace.app_state().fs.clone(); + let client = workspace.app_state().client.clone(); + let user_store = workspace.app_state().user_store.clone(); + + let notification_list = + ListState::::new(0, Orientation::Top, 1000., move |this, ix, cx| { + this.render_notification(ix, cx) + }); + + 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 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), + has_focus: false, + subscriptions: Vec::new(), + active: false, + width: None, + }; + + let mut old_dock_position = this.position(cx); + this.subscriptions.extend([ + cx.subscribe(&this.notification_store, Self::on_notification_event), + cx.observe_global::(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, + cx: AsyncAppContext, + ) -> Task>> { + 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::(&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) { + 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) -> AnyElement { + self.try_render_notification(ix, cx) + .unwrap_or_else(|| Empty::new().into_any()) + } + + fn try_render_notification( + &mut self, + ix: usize, + cx: &mut ViewContext, + ) -> Option> { + let notification_store = self.notification_store.read(cx); + let user_store = self.user_store.read(cx); + let channel_store = self.channel_store.read(cx); + let entry = notification_store.notification_at(ix).unwrap(); + let now = OffsetDateTime::now_utc(); + let timestamp = entry.timestamp; + + let icon; + let text; + let actor; + match entry.notification { + Notification::ContactRequest { requester_id } => { + actor = user_store.get_cached_user(requester_id)?; + icon = "icons/plus.svg"; + text = format!("{} wants to add you as a contact", actor.github_login); + } + Notification::ContactRequestAccepted { contact_id } => { + actor = user_store.get_cached_user(contact_id)?; + icon = "icons/plus.svg"; + text = format!("{} accepted your contact invite", actor.github_login); + } + Notification::ChannelInvitation { + inviter_id, + channel_id, + } => { + actor = user_store.get_cached_user(inviter_id)?; + let channel = channel_store.channel_for_id(channel_id)?; + + icon = "icons/hash.svg"; + text = format!( + "{} invited you to join the #{} channel", + actor.github_login, channel.name + ); + } + Notification::ChannelMessageMention { + sender_id, + channel_id, + message_id, + } => { + actor = user_store.get_cached_user(sender_id)?; + let channel = channel_store.channel_for_id(channel_id)?; + let message = notification_store.channel_message_for_id(message_id)?; + + icon = "icons/conversations.svg"; + text = format!( + "{} mentioned you in the #{} channel:\n{}", + actor.github_login, channel.name, message.body, + ); + } + } + + let theme = theme::current(cx); + let style = &theme.chat_panel.message; + + Some( + MouseEventHandler::new::(ix, cx, |state, _| { + let container = style.container.style_for(state); + + Flex::column() + .with_child( + Flex::row() + .with_child(render_avatar(actor.avatar.clone(), &theme)) + .with_child(render_icon_button(&theme.chat_panel.icon_button, icon)) + .with_child( + Label::new( + format_timestamp(timestamp, now, self.local_timezone), + style.timestamp.text.clone(), + ) + .contained() + .with_style(style.timestamp.container), + ) + .align_children_center(), + ) + .with_child(Text::new(text, style.body.clone())) + .contained() + .with_style(*container) + .into_any() + }) + .into_any(), + ) + } + + fn render_sign_in_prompt( + &self, + theme: &Arc, + cx: &mut ViewContext, + ) -> AnyElement { + enum SignInPromptLabel {} + + MouseEventHandler::new::(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 on_notification_event( + &mut self, + _: ModelHandle, + event: &NotificationEvent, + _: &mut ViewContext, + ) { + match event { + NotificationEvent::NotificationsUpdated { + old_range, + new_count, + } => { + self.notification_list.splice(old_range.clone(), *new_count); + } + } + } +} + +impl Entity for NotificationPanel { + type Event = Event; +} + +impl View for NotificationPanel { + fn ui_name() -> &'static str { + "NotificationPanel" + } + + fn render(&mut self, cx: &mut ViewContext) -> AnyElement { + let theme = theme::current(cx); + let element = if self.client.user_id().is_some() { + List::new(self.notification_list.clone()) + .contained() + .with_style(theme.chat_panel.list) + .into_any() + } else { + self.render_sign_in_prompt(&theme, cx) + }; + element + .contained() + .with_style(theme.chat_panel.container) + .constrained() + .with_min_width(150.) + .into_any() + } + + fn focus_in(&mut self, _: AnyViewHandle, _: &mut ViewContext) { + self.has_focus = true; + } + + fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext) { + self.has_focus = false; + } +} + +impl Panel for NotificationPanel { + fn position(&self, cx: &gpui::WindowContext) -> DockPosition { + settings::get::(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) { + settings::update_settings_file::( + self.fs.clone(), + cx, + move |settings| settings.dock = Some(position), + ); + } + + fn size(&self, cx: &gpui::WindowContext) -> f32 { + self.width + .unwrap_or_else(|| settings::get::(cx).default_width) + } + + fn set_size(&mut self, size: Option, cx: &mut ViewContext) { + self.width = size; + self.serialize(cx); + cx.notify(); + } + + fn set_active(&mut self, active: bool, cx: &mut ViewContext) { + self.active = active; + if active { + if !is_channels_feature_enabled(cx) { + cx.emit(Event::Dismissed); + } + } + } + + fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> { + (settings::get::(cx).button && is_channels_feature_enabled(cx)) + .then(|| "icons/bell.svg") + } + + fn icon_tooltip(&self) -> (String, Option>) { + ( + "Notification Panel".to_string(), + Some(Box::new(ToggleFocus)), + ) + } + + fn icon_label(&self, cx: &WindowContext) -> Option { + 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) + } +} + +fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element { + Svg::new(svg_path) + .with_color(style.color) + .constrained() + .with_width(style.icon_width) + .aligned() + .constrained() + .with_width(style.button_width) + .with_height(style.button_width) + .contained() + .with_style(style.container) +} diff --git a/crates/collab_ui/src/panel_settings.rs b/crates/collab_ui/src/panel_settings.rs index c1aa6e5e01..f8678d774e 100644 --- a/crates/collab_ui/src/panel_settings.rs +++ b/crates/collab_ui/src/panel_settings.rs @@ -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, @@ -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::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], diff --git a/crates/notifications/Cargo.toml b/crates/notifications/Cargo.toml new file mode 100644 index 0000000000..1425e079d6 --- /dev/null +++ b/crates/notifications/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "notifications" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/notification_store.rs" +doctest = false + +[features] +test-support = [ + "channel/test-support", + "collections/test-support", + "gpui/test-support", + "rpc/test-support", +] + +[dependencies] +channel = { path = "../channel" } +client = { path = "../client" } +clock = { path = "../clock" } +collections = { path = "../collections" } +db = { path = "../db" } +feature_flags = { path = "../feature_flags" } +gpui = { path = "../gpui" } +rpc = { path = "../rpc" } +settings = { path = "../settings" } +sum_tree = { path = "../sum_tree" } +text = { path = "../text" } +util = { path = "../util" } + +anyhow.workspace = true +time.workspace = true + +[dev-dependencies] +client = { path = "../client", features = ["test-support"] } +collections = { path = "../collections", features = ["test-support"] } +gpui = { path = "../gpui", features = ["test-support"] } +rpc = { path = "../rpc", features = ["test-support"] } +settings = { path = "../settings", features = ["test-support"] } +util = { path = "../util", features = ["test-support"] } diff --git a/crates/notifications/src/notification_store.rs b/crates/notifications/src/notification_store.rs new file mode 100644 index 0000000000..9bfa67c76e --- /dev/null +++ b/crates/notifications/src/notification_store.rs @@ -0,0 +1,256 @@ +use anyhow::Result; +use channel::{ChannelMessage, ChannelMessageId, ChannelStore}; +use client::{Client, UserStore}; +use collections::HashMap; +use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle}; +use rpc::{proto, Notification, NotificationKind, TypedEnvelope}; +use std::{ops::Range, sync::Arc}; +use sum_tree::{Bias, SumTree}; +use time::OffsetDateTime; + +pub fn init(client: Arc, user_store: ModelHandle, cx: &mut AppContext) { + let notification_store = cx.add_model(|cx| NotificationStore::new(client, user_store, cx)); + cx.set_global(notification_store); +} + +pub struct NotificationStore { + _client: Arc, + user_store: ModelHandle, + channel_messages: HashMap, + channel_store: ModelHandle, + notifications: SumTree, + _subscriptions: Vec, +} + +pub enum NotificationEvent { + NotificationsUpdated { + old_range: Range, + new_count: usize, + }, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct NotificationEntry { + pub id: u64, + pub notification: Notification, + pub timestamp: OffsetDateTime, + pub is_read: bool, +} + +#[derive(Clone, Debug, Default)] +pub struct NotificationSummary { + max_id: u64, + count: usize, + unread_count: usize, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +struct Count(usize); + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +struct UnreadCount(usize); + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +struct NotificationId(u64); + +impl NotificationStore { + pub fn global(cx: &AppContext) -> ModelHandle { + cx.global::>().clone() + } + + pub fn new( + client: Arc, + user_store: ModelHandle, + cx: &mut ModelContext, + ) -> Self { + Self { + channel_store: ChannelStore::global(cx), + notifications: Default::default(), + channel_messages: Default::default(), + _subscriptions: vec![ + client.add_message_handler(cx.handle(), Self::handle_add_notifications) + ], + user_store, + _client: client, + } + } + + pub fn notification_count(&self) -> usize { + self.notifications.summary().count + } + + pub fn unread_notification_count(&self) -> usize { + self.notifications.summary().unread_count + } + + pub fn channel_message_for_id(&self, id: u64) -> Option<&ChannelMessage> { + self.channel_messages.get(&id) + } + + pub fn notification_at(&self, ix: usize) -> Option<&NotificationEntry> { + let mut cursor = self.notifications.cursor::(); + cursor.seek(&Count(ix), Bias::Right, &()); + cursor.item() + } + + async fn handle_add_notifications( + this: ModelHandle, + envelope: TypedEnvelope, + _: Arc, + mut cx: AsyncAppContext, + ) -> Result<()> { + let mut user_ids = Vec::new(); + let mut message_ids = Vec::new(); + + let notifications = envelope + .payload + .notifications + .into_iter() + .filter_map(|message| { + Some(NotificationEntry { + id: message.id, + is_read: message.is_read, + timestamp: OffsetDateTime::from_unix_timestamp(message.timestamp as i64) + .ok()?, + notification: Notification::from_parts( + NotificationKind::from_i32(message.kind as i32)?, + [ + message.entity_id_1, + message.entity_id_2, + message.entity_id_3, + ], + )?, + }) + }) + .collect::>(); + if notifications.is_empty() { + return Ok(()); + } + + for entry in ¬ifications { + match entry.notification { + Notification::ChannelInvitation { inviter_id, .. } => { + user_ids.push(inviter_id); + } + Notification::ContactRequest { requester_id } => { + user_ids.push(requester_id); + } + Notification::ContactRequestAccepted { contact_id } => { + user_ids.push(contact_id); + } + Notification::ChannelMessageMention { + sender_id, + message_id, + .. + } => { + user_ids.push(sender_id); + message_ids.push(message_id); + } + } + } + + let (user_store, channel_store) = this.read_with(&cx, |this, _| { + (this.user_store.clone(), this.channel_store.clone()) + }); + + user_store + .update(&mut cx, |store, cx| store.get_users(user_ids, cx)) + .await?; + let messages = channel_store + .update(&mut cx, |store, cx| { + store.fetch_channel_messages(message_ids, cx) + }) + .await?; + this.update(&mut cx, |this, cx| { + this.channel_messages + .extend(messages.into_iter().filter_map(|message| { + if let ChannelMessageId::Saved(id) = message.id { + Some((id, message)) + } else { + None + } + })); + + let mut cursor = this.notifications.cursor::<(NotificationId, Count)>(); + let mut new_notifications = SumTree::new(); + let mut old_range = 0..0; + for (i, notification) in notifications.into_iter().enumerate() { + new_notifications.append( + cursor.slice(&NotificationId(notification.id), Bias::Left, &()), + &(), + ); + + if i == 0 { + old_range.start = cursor.start().1 .0; + } + + if cursor + .item() + .map_or(true, |existing| existing.id != notification.id) + { + cursor.next(&()); + } + + new_notifications.push(notification, &()); + } + + old_range.end = cursor.start().1 .0; + let new_count = new_notifications.summary().count; + new_notifications.append(cursor.suffix(&()), &()); + drop(cursor); + + this.notifications = new_notifications; + cx.emit(NotificationEvent::NotificationsUpdated { + old_range, + new_count, + }); + }); + + Ok(()) + } +} + +impl Entity for NotificationStore { + type Event = NotificationEvent; +} + +impl sum_tree::Item for NotificationEntry { + type Summary = NotificationSummary; + + fn summary(&self) -> Self::Summary { + NotificationSummary { + max_id: self.id, + count: 1, + unread_count: if self.is_read { 0 } else { 1 }, + } + } +} + +impl sum_tree::Summary for NotificationSummary { + type Context = (); + + fn add_summary(&mut self, summary: &Self, _: &()) { + self.max_id = self.max_id.max(summary.max_id); + self.count += summary.count; + self.unread_count += summary.unread_count; + } +} + +impl<'a> sum_tree::Dimension<'a, NotificationSummary> for NotificationId { + fn add_summary(&mut self, summary: &NotificationSummary, _: &()) { + debug_assert!(summary.max_id > self.0); + self.0 = summary.max_id; + } +} + +impl<'a> sum_tree::Dimension<'a, NotificationSummary> for Count { + fn add_summary(&mut self, summary: &NotificationSummary, _: &()) { + self.0 += summary.count; + } +} + +impl<'a> sum_tree::Dimension<'a, NotificationSummary> for UnreadCount { + fn add_summary(&mut self, summary: &NotificationSummary, _: &()) { + self.0 += summary.unread_count; + } +} diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index f51d11d3db..4b5c17ae8b 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -172,7 +172,8 @@ message Envelope { UnlinkChannel unlink_channel = 141; MoveChannel move_channel = 142; - AddNotifications add_notification = 145; // Current max + AddNotifications add_notifications = 145; + GetChannelMessagesById get_channel_messages_by_id = 146; // Current max } } @@ -1101,6 +1102,10 @@ message GetChannelMessagesResponse { bool done = 2; } +message GetChannelMessagesById { + repeated uint64 message_ids = 1; +} + message LinkChannel { uint64 channel_id = 1; uint64 to = 2; @@ -1562,37 +1567,14 @@ message UpdateDiffBase { message AddNotifications { repeated Notification notifications = 1; - repeated User users = 2; - repeated Channel channels = 3; - repeated ChannelMessage messages = 4; } message Notification { - uint32 kind = 1; - uint64 timestamp = 2; - bool is_read = 3; - optional uint64 entity_id_1 = 4; - optional uint64 entity_id_2 = 5; - optional uint64 entity_id_3 = 6; - - // oneof variant { - // ContactRequest contact_request = 3; - // ChannelInvitation channel_invitation = 4; - // ChatMessageMention chat_message_mention = 5; - // }; - - // message ContactRequest { - // uint64 requester_id = 1; - // } - - // message ChannelInvitation { - // uint64 inviter_id = 1; - // uint64 channel_id = 2; - // } - - // message ChatMessageMention { - // uint64 sender_id = 1; - // uint64 channel_id = 2; - // uint64 message_id = 3; - // } + uint64 id = 1; + uint32 kind = 2; + uint64 timestamp = 3; + bool is_read = 4; + optional uint64 entity_id_1 = 5; + optional uint64 entity_id_2 = 6; + optional uint64 entity_id_3 = 7; } diff --git a/crates/rpc/src/notification.rs b/crates/rpc/src/notification.rs index 512a4731b4..fc6dc54d15 100644 --- a/crates/rpc/src/notification.rs +++ b/crates/rpc/src/notification.rs @@ -7,14 +7,19 @@ use strum::{Display, EnumIter, EnumString, IntoEnumIterator}; #[derive(Copy, Clone, Debug, EnumIter, EnumString, Display)] pub enum NotificationKind { ContactRequest = 0, - ChannelInvitation = 1, - ChannelMessageMention = 2, + ContactRequestAccepted = 1, + ChannelInvitation = 2, + ChannelMessageMention = 3, } +#[derive(Debug, Clone, PartialEq, Eq)] pub enum Notification { ContactRequest { requester_id: u64, }, + ContactRequestAccepted { + contact_id: u64, + }, ChannelInvitation { inviter_id: u64, channel_id: u64, @@ -26,13 +31,6 @@ pub enum Notification { }, } -#[derive(Copy, Clone)] -pub enum NotificationEntityKind { - User, - Channel, - ChannelMessage, -} - impl Notification { /// Load this notification from its generic representation, which is /// used to represent it in the database, and in the wire protocol. @@ -42,15 +40,20 @@ impl Notification { /// not change, because they're stored in that order in the database. pub fn from_parts(kind: NotificationKind, entity_ids: [Option; 3]) -> Option { use NotificationKind::*; - Some(match kind { ContactRequest => Self::ContactRequest { requester_id: entity_ids[0]?, }, + + ContactRequestAccepted => Self::ContactRequest { + requester_id: entity_ids[0]?, + }, + ChannelInvitation => Self::ChannelInvitation { inviter_id: entity_ids[0]?, channel_id: entity_ids[1]?, }, + ChannelMessageMention => Self::ChannelMessageMention { sender_id: entity_ids[0]?, channel_id: entity_ids[1]?, @@ -65,33 +68,23 @@ impl Notification { /// The order in which a given notification type's fields are listed must /// match the order they're listed in the `from_parts` method, and it must /// not change, because they're stored in that order in the database. - /// - /// Along with each field, provide the kind of entity that the field refers - /// to. This is used to load the associated entities for a batch of - /// notifications from the database. - pub fn to_parts(&self) -> (NotificationKind, [Option<(u64, NotificationEntityKind)>; 3]) { + pub fn to_parts(&self) -> (NotificationKind, [Option; 3]) { use NotificationKind::*; - match self { - Self::ContactRequest { requester_id } => ( - ContactRequest, - [ - Some((*requester_id, NotificationEntityKind::User)), - None, - None, - ], - ), + Self::ContactRequest { requester_id } => { + (ContactRequest, [Some(*requester_id), None, None]) + } + + Self::ContactRequestAccepted { contact_id } => { + (ContactRequest, [Some(*contact_id), None, None]) + } Self::ChannelInvitation { inviter_id, channel_id, } => ( ChannelInvitation, - [ - Some((*inviter_id, NotificationEntityKind::User)), - Some((*channel_id, NotificationEntityKind::User)), - None, - ], + [Some(*inviter_id), Some(*channel_id), None], ), Self::ChannelMessageMention { @@ -100,11 +93,7 @@ impl Notification { message_id, } => ( ChannelMessageMention, - [ - Some((*sender_id, NotificationEntityKind::User)), - Some((*channel_id, NotificationEntityKind::ChannelMessage)), - Some((*message_id, NotificationEntityKind::Channel)), - ], + [Some(*sender_id), Some(*channel_id), Some(*message_id)], ), } } diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index f0d7937f6f..4d8f60c896 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -133,6 +133,7 @@ impl fmt::Display for PeerId { messages!( (Ack, Foreground), + (AddNotifications, Foreground), (AddProjectCollaborator, Foreground), (ApplyCodeAction, Background), (ApplyCodeActionResponse, Background), @@ -166,6 +167,7 @@ messages!( (GetHoverResponse, Background), (GetChannelMessages, Background), (GetChannelMessagesResponse, Background), + (GetChannelMessagesById, Background), (SendChannelMessage, Background), (SendChannelMessageResponse, Background), (GetCompletions, Background), @@ -329,6 +331,7 @@ request_messages!( (SetChannelMemberAdmin, Ack), (SendChannelMessage, SendChannelMessageResponse), (GetChannelMessages, GetChannelMessagesResponse), + (GetChannelMessagesById, GetChannelMessagesResponse), (GetChannelMembers, GetChannelMembersResponse), (JoinChannel, JoinRoomResponse), (RemoveChannelMessage, Ack), diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 4174f7d6d5..c9dab0d223 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -50,6 +50,7 @@ language_selector = { path = "../language_selector" } lsp = { path = "../lsp" } language_tools = { path = "../language_tools" } node_runtime = { path = "../node_runtime" } +notifications = { path = "../notifications" } assistant = { path = "../assistant" } outline = { path = "../outline" } plugin_runtime = { path = "../plugin_runtime",optional = true } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 16189f6c4e..52ba8247b7 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -202,6 +202,7 @@ fn main() { activity_indicator::init(cx); language_tools::init(cx); call::init(app_state.client.clone(), app_state.user_store.clone(), cx); + notifications::init(app_state.client.clone(), app_state.user_store.clone(), cx); collab_ui::init(&app_state, cx); feedback::init(cx); welcome::init(cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 4e9a34c269..8caff21c5f 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -221,6 +221,13 @@ pub fn init(app_state: &Arc, cx: &mut gpui::AppContext) { workspace.toggle_panel_focus::(cx); }, ); + cx.add_action( + |workspace: &mut Workspace, + _: &collab_ui::notification_panel::ToggleFocus, + cx: &mut ViewContext| { + workspace.toggle_panel_focus::(cx); + }, + ); cx.add_action( |workspace: &mut Workspace, _: &terminal_panel::ToggleFocus, @@ -275,9 +282,8 @@ pub fn initialize_workspace( QuickActionBar::new(buffer_search_bar, workspace) }); toolbar.add_item(quick_action_bar, cx); - let diagnostic_editor_controls = cx.add_view(|_| { - diagnostics::ToolbarControls::new() - }); + let diagnostic_editor_controls = + cx.add_view(|_| diagnostics::ToolbarControls::new()); toolbar.add_item(diagnostic_editor_controls, cx); let project_search_bar = cx.add_view(|_| ProjectSearchBar::new()); toolbar.add_item(project_search_bar, cx); @@ -351,12 +357,24 @@ pub fn initialize_workspace( collab_ui::collab_panel::CollabPanel::load(workspace_handle.clone(), cx.clone()); let chat_panel = collab_ui::chat_panel::ChatPanel::load(workspace_handle.clone(), cx.clone()); - let (project_panel, terminal_panel, assistant_panel, channels_panel, chat_panel) = futures::try_join!( + let notification_panel = collab_ui::notification_panel::NotificationPanel::load( + workspace_handle.clone(), + cx.clone(), + ); + let ( project_panel, terminal_panel, assistant_panel, channels_panel, chat_panel, + notification_panel, + ) = futures::try_join!( + project_panel, + terminal_panel, + assistant_panel, + channels_panel, + chat_panel, + notification_panel, )?; workspace_handle.update(&mut cx, |workspace, cx| { let project_panel_position = project_panel.position(cx); @@ -377,6 +395,7 @@ pub fn initialize_workspace( workspace.add_panel(assistant_panel, cx); workspace.add_panel(channels_panel, cx); workspace.add_panel(chat_panel, cx); + workspace.add_panel(notification_panel, cx); if !was_deserialized && workspace From 69c65597d96925cdcf011a75bb97eb0c005e9efc Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 11 Oct 2023 17:39:15 -0700 Subject: [PATCH 10/90] Fix use statement order --- crates/rpc/src/rpc.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rpc/src/rpc.rs b/crates/rpc/src/rpc.rs index 539ef014bb..4bf90669b2 100644 --- a/crates/rpc/src/rpc.rs +++ b/crates/rpc/src/rpc.rs @@ -5,8 +5,8 @@ mod peer; pub mod proto; pub use conn::Connection; -pub use peer::*; pub use notification::*; +pub use peer::*; mod macros; pub const PROTOCOL_VERSION: u32 = 64; From 1e1256dbdd82dd59458f78d53dc7593a3b9760b7 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 11 Oct 2023 17:39:41 -0700 Subject: [PATCH 11/90] Set RUST_LOG to info by default in zed-local script --- script/zed-local | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/script/zed-local b/script/zed-local index 683e31ef14..b7574a903b 100755 --- a/script/zed-local +++ b/script/zed-local @@ -55,6 +55,8 @@ let users = [ 'iamnbutler' ] +const RUST_LOG = process.env.RUST_LOG || 'info' + // If a user is specified, make sure it's first in the list const user = process.env.ZED_IMPERSONATE if (user) { @@ -81,7 +83,8 @@ setTimeout(() => { ZED_ALWAYS_ACTIVE: '1', ZED_SERVER_URL: 'http://localhost:8080', ZED_ADMIN_API_TOKEN: 'secret', - ZED_WINDOW_SIZE: `${instanceWidth},${instanceHeight}` + ZED_WINDOW_SIZE: `${instanceWidth},${instanceHeight}`, + RUST_LOG, } }) } From fed3ffb681645b32ad8718aa721858740519ca7f Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 12 Oct 2023 14:43:36 -0700 Subject: [PATCH 12/90] Set up notification store for integration tests --- Cargo.lock | 1 + crates/collab/Cargo.toml | 1 + .../migrations.sqlite/20221109000000_test_schema.sql | 8 ++++---- crates/collab/src/tests/test_server.rs | 3 ++- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e43cc8b5eb..02deccb39a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1502,6 +1502,7 @@ dependencies = [ "lsp", "nanoid", "node_runtime", + "notifications", "parking_lot 0.11.2", "pretty_assertions", "project", diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index b91f0e1a5f..c139da831e 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -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"] } diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 70c913dc95..c5c556500f 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -192,7 +192,7 @@ 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 ); CREATE TABLE IF NOT EXISTS "channel_chat_participants" ( @@ -315,15 +315,15 @@ CREATE UNIQUE INDEX "index_observed_channel_messages_user_and_channel_id" ON "ob CREATE TABLE "notification_kinds" ( "id" INTEGER PRIMARY KEY NOT NULL, - "name" VARCHAR NOT NULL, + "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 now, - "recipent_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + "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), "is_read" BOOLEAN NOT NULL DEFAULT FALSE, "entity_id_1" INTEGER, diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index 7397489b34..9d03d1e17e 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -231,7 +231,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 From 324112884073afd168227a5cd1a3df3388127ac1 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 12 Oct 2023 17:17:45 -0700 Subject: [PATCH 13/90] Make notification db representation more flexible --- Cargo.lock | 1 + .../20221109000000_test_schema.sql | 9 +- .../20231004130100_create_notifications.sql | 9 +- crates/collab/src/db.rs | 9 + crates/collab/src/db/ids.rs | 1 + crates/collab/src/db/queries/contacts.rs | 37 +++-- crates/collab/src/db/queries/notifications.rs | 69 ++++---- crates/collab/src/db/tables/notification.rs | 11 +- .../collab/src/db/tables/notification_kind.rs | 3 +- crates/collab/src/db/tests.rs | 6 +- crates/collab/src/lib.rs | 4 +- crates/collab/src/rpc.rs | 2 - crates/collab_ui/src/notification_panel.rs | 12 +- .../notifications/src/notification_store.rs | 30 ++-- crates/rpc/Cargo.toml | 2 + crates/rpc/proto/zed.proto | 11 +- crates/rpc/src/notification.rs | 156 ++++++++---------- 17 files changed, 197 insertions(+), 175 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 02deccb39a..c6d7a5ef85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6423,6 +6423,7 @@ dependencies = [ "rsa 0.4.0", "serde", "serde_derive", + "serde_json", "smol", "smol-timeout", "strum", diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index c5c556500f..a10155fd1d 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -314,7 +314,7 @@ 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 NOT NULL, + "id" INTEGER PRIMARY KEY AUTOINCREMENT, "name" VARCHAR NOT NULL ); @@ -322,13 +322,12 @@ CREATE UNIQUE INDEX "index_notification_kinds_on_name" ON "notification_kinds" ( CREATE TABLE "notifications" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "is_read" BOOLEAN NOT NULL DEFAULT FALSE, "created_at" TIMESTAMP NOT NULL default CURRENT_TIMESTAMP, "recipient_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + "actor_id" INTEGER REFERENCES users (id) ON DELETE CASCADE, "kind" INTEGER NOT NULL REFERENCES notification_kinds (id), - "is_read" BOOLEAN NOT NULL DEFAULT FALSE, - "entity_id_1" INTEGER, - "entity_id_2" INTEGER, - "entity_id_3" INTEGER + "content" TEXT ); CREATE INDEX "index_notifications_on_recipient_id" ON "notifications" ("recipient_id"); diff --git a/crates/collab/migrations/20231004130100_create_notifications.sql b/crates/collab/migrations/20231004130100_create_notifications.sql index cac3f2d8df..83cfd43978 100644 --- a/crates/collab/migrations/20231004130100_create_notifications.sql +++ b/crates/collab/migrations/20231004130100_create_notifications.sql @@ -1,5 +1,5 @@ CREATE TABLE "notification_kinds" ( - "id" INTEGER PRIMARY KEY NOT NULL, + "id" SERIAL PRIMARY KEY, "name" VARCHAR NOT NULL ); @@ -7,13 +7,12 @@ CREATE UNIQUE INDEX "index_notification_kinds_on_name" ON "notification_kinds" ( CREATE TABLE notifications ( "id" SERIAL PRIMARY KEY, + "is_read" BOOLEAN NOT NULL DEFAULT FALSE, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "recipient_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + "actor_id" INTEGER REFERENCES users (id) ON DELETE CASCADE, "kind" INTEGER NOT NULL REFERENCES notification_kinds (id), - "is_read" BOOLEAN NOT NULL DEFAULT FALSE, - "entity_id_1" INTEGER, - "entity_id_2" INTEGER, - "entity_id_3" INTEGER + "content" TEXT ); CREATE INDEX "index_notifications_on_recipient_id" ON "notifications" ("recipient_id"); diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 56e7c0d942..9aea23ca84 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -55,6 +55,8 @@ pub struct Database { rooms: DashMap>>, rng: Mutex, executor: Executor, + notification_kinds_by_id: HashMap, + notification_kinds_by_name: HashMap, #[cfg(test)] runtime: Option, } @@ -69,6 +71,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 +125,11 @@ impl Database { Ok(new_migrations) } + pub async fn initialize_static_data(&mut self) -> Result<()> { + self.initialize_notification_enum().await?; + Ok(()) + } + pub async fn transaction(&self, f: F) -> Result where F: Send + Fn(TransactionHandle) -> Fut, diff --git a/crates/collab/src/db/ids.rs b/crates/collab/src/db/ids.rs index b5873a152f..bd07af8a35 100644 --- a/crates/collab/src/db/ids.rs +++ b/crates/collab/src/db/ids.rs @@ -81,3 +81,4 @@ id_type!(UserId); id_type!(ChannelBufferCollaboratorId); id_type!(FlagId); id_type!(NotificationId); +id_type!(NotificationKindId); diff --git a/crates/collab/src/db/queries/contacts.rs b/crates/collab/src/db/queries/contacts.rs index d922bc5ca2..083315e290 100644 --- a/crates/collab/src/db/queries/contacts.rs +++ b/crates/collab/src/db/queries/contacts.rs @@ -165,18 +165,18 @@ impl Database { .exec_without_returning(&*tx) .await?; - if rows_affected == 1 { - self.create_notification( - receiver_id, - rpc::Notification::ContactRequest { - requester_id: sender_id.to_proto(), - }, - &*tx, - ) - .await - } else { - Err(anyhow!("contact already requested"))? + if rows_affected == 0 { + Err(anyhow!("contact already requested"))?; } + + self.create_notification( + receiver_id, + rpc::Notification::ContactRequest { + actor_id: sender_id.to_proto(), + }, + &*tx, + ) + .await }) .await } @@ -260,7 +260,7 @@ impl Database { responder_id: UserId, requester_id: UserId, accept: bool, - ) -> Result<()> { + ) -> Result { self.transaction(|tx| async move { let (id_a, id_b, a_to_b) = if responder_id < requester_id { (responder_id, requester_id, false) @@ -298,11 +298,18 @@ impl Database { result.rows_affected }; - if rows_affected == 1 { - Ok(()) - } else { + if rows_affected == 0 { Err(anyhow!("no such contact request"))? } + + self.create_notification( + requester_id, + rpc::Notification::ContactRequestAccepted { + actor_id: responder_id.to_proto(), + }, + &*tx, + ) + .await }) .await } diff --git a/crates/collab/src/db/queries/notifications.rs b/crates/collab/src/db/queries/notifications.rs index 293b896a50..8c4c511299 100644 --- a/crates/collab/src/db/queries/notifications.rs +++ b/crates/collab/src/db/queries/notifications.rs @@ -1,21 +1,25 @@ use super::*; -use rpc::{Notification, NotificationKind}; +use rpc::Notification; impl Database { - pub async fn ensure_notification_kinds(&self) -> Result<()> { - self.transaction(|tx| async move { - notification_kind::Entity::insert_many(NotificationKind::all().map(|kind| { - notification_kind::ActiveModel { - id: ActiveValue::Set(kind as i32), - name: ActiveValue::Set(kind.to_string()), - } - })) - .on_conflict(OnConflict::new().do_nothing().to_owned()) - .exec(&*tx) - .await?; - Ok(()) - }) - .await + pub async fn initialize_notification_enum(&mut self) -> Result<()> { + notification_kind::Entity::insert_many(Notification::all_kinds().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); + } + + Ok(()) } pub async fn get_notifications( @@ -33,14 +37,16 @@ impl Database { .await?; while let Some(row) = rows.next().await { let row = row?; + let Some(kind) = self.notification_kinds_by_id.get(&row.kind) else { + continue; + }; result.notifications.push(proto::Notification { id: row.id.to_proto(), - kind: row.kind as u32, + kind: kind.to_string(), timestamp: row.created_at.assume_utc().unix_timestamp() as u64, is_read: row.is_read, - entity_id_1: row.entity_id_1.map(|id| id as u64), - entity_id_2: row.entity_id_2.map(|id| id as u64), - entity_id_3: row.entity_id_3.map(|id| id as u64), + content: row.content, + actor_id: row.actor_id.map(|id| id.to_proto()), }); } result.notifications.reverse(); @@ -55,26 +61,31 @@ impl Database { notification: Notification, tx: &DatabaseTransaction, ) -> Result { - let (kind, associated_entities) = notification.to_parts(); + let notification = notification.to_any(); + let kind = *self + .notification_kinds_by_name + .get(notification.kind.as_ref()) + .ok_or_else(|| anyhow!("invalid notification kind {:?}", notification.kind))?; + let model = notification::ActiveModel { recipient_id: ActiveValue::Set(recipient_id), - kind: ActiveValue::Set(kind as i32), - entity_id_1: ActiveValue::Set(associated_entities[0].map(|id| id as i32)), - entity_id_2: ActiveValue::Set(associated_entities[1].map(|id| id as i32)), - entity_id_3: ActiveValue::Set(associated_entities[2].map(|id| id as i32)), - ..Default::default() + kind: ActiveValue::Set(kind), + content: ActiveValue::Set(notification.content.clone()), + actor_id: ActiveValue::Set(notification.actor_id.map(|id| UserId::from_proto(id))), + is_read: ActiveValue::NotSet, + created_at: ActiveValue::NotSet, + id: ActiveValue::NotSet, } .save(&*tx) .await?; Ok(proto::Notification { id: model.id.as_ref().to_proto(), - kind: *model.kind.as_ref() as u32, + kind: notification.kind.to_string(), timestamp: model.created_at.as_ref().assume_utc().unix_timestamp() as u64, is_read: false, - entity_id_1: model.entity_id_1.as_ref().map(|id| id as u64), - entity_id_2: model.entity_id_2.as_ref().map(|id| id as u64), - entity_id_3: model.entity_id_3.as_ref().map(|id| id as u64), + content: notification.content, + actor_id: notification.actor_id, }) } } diff --git a/crates/collab/src/db/tables/notification.rs b/crates/collab/src/db/tables/notification.rs index 6a0abe9dc6..a35e00fb5b 100644 --- a/crates/collab/src/db/tables/notification.rs +++ b/crates/collab/src/db/tables/notification.rs @@ -1,4 +1,4 @@ -use crate::db::{NotificationId, UserId}; +use crate::db::{NotificationId, NotificationKindId, UserId}; use sea_orm::entity::prelude::*; use time::PrimitiveDateTime; @@ -7,13 +7,12 @@ use time::PrimitiveDateTime; pub struct Model { #[sea_orm(primary_key)] pub id: NotificationId, - pub recipient_id: UserId, - pub kind: i32, pub is_read: bool, pub created_at: PrimitiveDateTime, - pub entity_id_1: Option, - pub entity_id_2: Option, - pub entity_id_3: Option, + pub recipient_id: UserId, + pub actor_id: Option, + pub kind: NotificationKindId, + pub content: String, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/collab/src/db/tables/notification_kind.rs b/crates/collab/src/db/tables/notification_kind.rs index 32dfb2065a..865b5da04b 100644 --- a/crates/collab/src/db/tables/notification_kind.rs +++ b/crates/collab/src/db/tables/notification_kind.rs @@ -1,10 +1,11 @@ +use crate::db::NotificationKindId; use sea_orm::entity::prelude::*; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "notification_kinds")] pub struct Model { #[sea_orm(primary_key)] - pub id: i32, + pub id: NotificationKindId, pub name: String, } diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index 6a91fd6ffe..465ff56444 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -31,7 +31,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 +45,7 @@ impl TestDb { )) .await .unwrap(); + db.initialize_notification_enum().await.unwrap(); db }); @@ -79,11 +80,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_enum().await.unwrap(); db }); diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index 13fb8ed0eb..1722424217 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -119,7 +119,9 @@ impl AppState { pub async fn new(config: Config) -> Result> { 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_enum().await?; + let live_kit_client = if let Some(((server, key), secret)) = config .live_kit_server .as_ref() diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index eb123cf960..01da0dc88a 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -291,8 +291,6 @@ impl Server { let pool = self.connection_pool.clone(); let live_kit_client = self.app_state.live_kit_client.clone(); - self.app_state.db.ensure_notification_kinds().await?; - let span = info_span!("start server"); self.executor.spawn_detached( async move { diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index a78caf5ff6..334d844cf5 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -185,18 +185,22 @@ impl NotificationPanel { let text; let actor; match entry.notification { - Notification::ContactRequest { requester_id } => { + Notification::ContactRequest { + actor_id: requester_id, + } => { actor = user_store.get_cached_user(requester_id)?; icon = "icons/plus.svg"; text = format!("{} wants to add you as a contact", actor.github_login); } - Notification::ContactRequestAccepted { contact_id } => { + Notification::ContactRequestAccepted { + actor_id: contact_id, + } => { actor = user_store.get_cached_user(contact_id)?; icon = "icons/plus.svg"; text = format!("{} accepted your contact invite", actor.github_login); } Notification::ChannelInvitation { - inviter_id, + actor_id: inviter_id, channel_id, } => { actor = user_store.get_cached_user(inviter_id)?; @@ -209,7 +213,7 @@ impl NotificationPanel { ); } Notification::ChannelMessageMention { - sender_id, + actor_id: sender_id, channel_id, message_id, } => { diff --git a/crates/notifications/src/notification_store.rs b/crates/notifications/src/notification_store.rs index 9bfa67c76e..4ebbf46093 100644 --- a/crates/notifications/src/notification_store.rs +++ b/crates/notifications/src/notification_store.rs @@ -3,7 +3,7 @@ use channel::{ChannelMessage, ChannelMessageId, ChannelStore}; use client::{Client, UserStore}; use collections::HashMap; use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle}; -use rpc::{proto, Notification, NotificationKind, TypedEnvelope}; +use rpc::{proto, AnyNotification, Notification, TypedEnvelope}; use std::{ops::Range, sync::Arc}; use sum_tree::{Bias, SumTree}; use time::OffsetDateTime; @@ -112,14 +112,11 @@ impl NotificationStore { is_read: message.is_read, timestamp: OffsetDateTime::from_unix_timestamp(message.timestamp as i64) .ok()?, - notification: Notification::from_parts( - NotificationKind::from_i32(message.kind as i32)?, - [ - message.entity_id_1, - message.entity_id_2, - message.entity_id_3, - ], - )?, + notification: Notification::from_any(&AnyNotification { + actor_id: message.actor_id, + kind: message.kind.into(), + content: message.content, + })?, }) }) .collect::>(); @@ -129,17 +126,24 @@ impl NotificationStore { for entry in ¬ifications { match entry.notification { - Notification::ChannelInvitation { inviter_id, .. } => { + Notification::ChannelInvitation { + actor_id: inviter_id, + .. + } => { user_ids.push(inviter_id); } - Notification::ContactRequest { requester_id } => { + Notification::ContactRequest { + actor_id: requester_id, + } => { user_ids.push(requester_id); } - Notification::ContactRequestAccepted { contact_id } => { + Notification::ContactRequestAccepted { + actor_id: contact_id, + } => { user_ids.push(contact_id); } Notification::ChannelMessageMention { - sender_id, + actor_id: sender_id, message_id, .. } => { diff --git a/crates/rpc/Cargo.toml b/crates/rpc/Cargo.toml index bc750374dd..a2895e5f1b 100644 --- a/crates/rpc/Cargo.toml +++ b/crates/rpc/Cargo.toml @@ -17,6 +17,7 @@ clock = { path = "../clock" } collections = { path = "../collections" } gpui = { path = "../gpui", optional = true } util = { path = "../util" } + anyhow.workspace = true async-lock = "2.4" async-tungstenite = "0.16" @@ -27,6 +28,7 @@ prost.workspace = true rand.workspace = true rsa = "0.4" serde.workspace = true +serde_json.workspace = true serde_derive.workspace = true smol-timeout = "0.6" strum.workspace = true diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 4b5c17ae8b..f767189024 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -1571,10 +1571,9 @@ message AddNotifications { message Notification { uint64 id = 1; - uint32 kind = 2; - uint64 timestamp = 3; - bool is_read = 4; - optional uint64 entity_id_1 = 5; - optional uint64 entity_id_2 = 6; - optional uint64 entity_id_3 = 7; + uint64 timestamp = 2; + bool is_read = 3; + string kind = 4; + string content = 5; + optional uint64 actor_id = 6; } diff --git a/crates/rpc/src/notification.rs b/crates/rpc/src/notification.rs index fc6dc54d15..839966aea6 100644 --- a/crates/rpc/src/notification.rs +++ b/crates/rpc/src/notification.rs @@ -1,110 +1,94 @@ -use strum::{Display, EnumIter, EnumString, IntoEnumIterator}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::borrow::Cow; +use strum::{EnumVariantNames, IntoStaticStr, VariantNames as _}; -// An integer indicating a type of notification. The variants' numerical -// values are stored in the database, so they should never be removed -// or changed. -#[repr(i32)] -#[derive(Copy, Clone, Debug, EnumIter, EnumString, Display)] -pub enum NotificationKind { - ContactRequest = 0, - ContactRequestAccepted = 1, - ChannelInvitation = 2, - ChannelMessageMention = 3, -} +const KIND: &'static str = "kind"; +const ACTOR_ID: &'static str = "actor_id"; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, EnumVariantNames, IntoStaticStr, Serialize, Deserialize)] +#[serde(tag = "kind")] pub enum Notification { ContactRequest { - requester_id: u64, + actor_id: u64, }, ContactRequestAccepted { - contact_id: u64, + actor_id: u64, }, ChannelInvitation { - inviter_id: u64, + actor_id: u64, channel_id: u64, }, ChannelMessageMention { - sender_id: u64, + actor_id: u64, channel_id: u64, message_id: u64, }, } +#[derive(Debug)] +pub struct AnyNotification { + pub kind: Cow<'static, str>, + pub actor_id: Option, + pub content: String, +} + impl Notification { - /// Load this notification from its generic representation, which is - /// used to represent it in the database, and in the wire protocol. - /// - /// The order in which a given notification type's fields are listed must - /// match the order they're listed in the `to_parts` method, and it must - /// not change, because they're stored in that order in the database. - pub fn from_parts(kind: NotificationKind, entity_ids: [Option; 3]) -> Option { - use NotificationKind::*; - Some(match kind { - ContactRequest => Self::ContactRequest { - requester_id: entity_ids[0]?, - }, - - ContactRequestAccepted => Self::ContactRequest { - requester_id: entity_ids[0]?, - }, - - ChannelInvitation => Self::ChannelInvitation { - inviter_id: entity_ids[0]?, - channel_id: entity_ids[1]?, - }, - - ChannelMessageMention => Self::ChannelMessageMention { - sender_id: entity_ids[0]?, - channel_id: entity_ids[1]?, - message_id: entity_ids[2]?, - }, - }) - } - - /// Convert this notification into its generic representation, which is - /// used to represent it in the database, and in the wire protocol. - /// - /// The order in which a given notification type's fields are listed must - /// match the order they're listed in the `from_parts` method, and it must - /// not change, because they're stored in that order in the database. - pub fn to_parts(&self) -> (NotificationKind, [Option; 3]) { - use NotificationKind::*; - match self { - Self::ContactRequest { requester_id } => { - (ContactRequest, [Some(*requester_id), None, None]) - } - - Self::ContactRequestAccepted { contact_id } => { - (ContactRequest, [Some(*contact_id), None, None]) - } - - Self::ChannelInvitation { - inviter_id, - channel_id, - } => ( - ChannelInvitation, - [Some(*inviter_id), Some(*channel_id), None], - ), - - Self::ChannelMessageMention { - sender_id, - channel_id, - message_id, - } => ( - ChannelMessageMention, - [Some(*sender_id), Some(*channel_id), Some(*message_id)], - ), + pub fn to_any(&self) -> AnyNotification { + let kind: &'static str = self.into(); + let mut value = serde_json::to_value(self).unwrap(); + let mut actor_id = None; + if let Some(value) = value.as_object_mut() { + value.remove("kind"); + actor_id = value + .remove("actor_id") + .and_then(|value| Some(value.as_i64()? as u64)); + } + AnyNotification { + kind: Cow::Borrowed(kind), + actor_id, + content: serde_json::to_string(&value).unwrap(), } } -} -impl NotificationKind { - pub fn all() -> impl Iterator { - Self::iter() + pub fn from_any(notification: &AnyNotification) -> Option { + let mut value = serde_json::from_str::(¬ification.content).ok()?; + let object = value.as_object_mut()?; + object.insert(KIND.into(), notification.kind.to_string().into()); + if let Some(actor_id) = notification.actor_id { + object.insert(ACTOR_ID.into(), actor_id.into()); + } + serde_json::from_value(value).ok() } - pub fn from_i32(i: i32) -> Option { - Self::iter().find(|kind| *kind as i32 == i) + pub fn all_kinds() -> &'static [&'static str] { + Self::VARIANTS } } + +#[test] +fn test_notification() { + // Notifications can be serialized and deserialized. + for notification in [ + Notification::ContactRequest { actor_id: 1 }, + Notification::ContactRequestAccepted { actor_id: 2 }, + Notification::ChannelInvitation { + actor_id: 0, + channel_id: 100, + }, + Notification::ChannelMessageMention { + actor_id: 200, + channel_id: 30, + message_id: 1, + }, + ] { + let serialized = notification.to_any(); + let deserialized = Notification::from_any(&serialized).unwrap(); + assert_eq!(deserialized, notification); + } + + // When notifications are serialized, redundant data is not stored + // in the JSON. + let notification = Notification::ContactRequest { actor_id: 1 }; + assert_eq!(notification.to_any().content, "{}"); +} From 034e9935d4b3792a6b8e1e0f439379caae2325eb Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 12 Oct 2023 17:39:04 -0700 Subject: [PATCH 14/90] Remove old contact request notification mechanism, use notification instead --- crates/client/src/user.rs | 35 +++++---------- crates/collab/src/db.rs | 15 ++----- crates/collab/src/db/queries/contacts.rs | 5 --- crates/collab/src/db/tests/db_tests.rs | 19 +-------- crates/collab/src/rpc.rs | 54 ++++++++++-------------- crates/rpc/proto/zed.proto | 2 - crates/rpc/src/notification.rs | 12 +++++- crates/zed/src/zed.rs | 1 + 8 files changed, 49 insertions(+), 94 deletions(-) diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index 6aa41708e3..d02c22d797 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -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| { diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 9aea23ca84..67055d27ee 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -370,18 +370,9 @@ impl RoomGuard { #[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 { diff --git a/crates/collab/src/db/queries/contacts.rs b/crates/collab/src/db/queries/contacts.rs index 083315e290..f02bae667a 100644 --- a/crates/collab/src/db/queries/contacts.rs +++ b/crates/collab/src/db/queries/contacts.rs @@ -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 { diff --git a/crates/collab/src/db/tests/db_tests.rs b/crates/collab/src/db/tests/db_tests.rs index 1520e081c0..d175bd743d 100644 --- a/crates/collab/src/db/tests/db_tests.rs +++ b/crates/collab/src/db/tests/db_tests.rs @@ -264,10 +264,7 @@ async fn test_add_contacts(db: &Arc) { ); 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 +277,7 @@ async fn test_add_contacts(db: &Arc) { .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 +293,6 @@ async fn test_add_contacts(db: &Arc) { db.get_contacts(user_1).await.unwrap(), &[Contact::Accepted { user_id: user_2, - should_notify: true, busy: false, }], ); @@ -309,7 +302,6 @@ async fn test_add_contacts(db: &Arc) { db.get_contacts(user_2).await.unwrap(), &[Contact::Accepted { user_id: user_1, - should_notify: false, busy: false, }] ); @@ -326,7 +318,6 @@ async fn test_add_contacts(db: &Arc) { db.get_contacts(user_1).await.unwrap(), &[Contact::Accepted { user_id: user_2, - should_notify: true, busy: false, }] ); @@ -339,7 +330,6 @@ async fn test_add_contacts(db: &Arc) { db.get_contacts(user_1).await.unwrap(), &[Contact::Accepted { user_id: user_2, - should_notify: false, busy: false, }] ); @@ -353,12 +343,10 @@ async fn test_add_contacts(db: &Arc) { &[ Contact::Accepted { user_id: user_2, - should_notify: false, busy: false, }, Contact::Accepted { user_id: user_3, - should_notify: false, busy: false, } ] @@ -367,7 +355,6 @@ async fn test_add_contacts(db: &Arc) { db.get_contacts(user_3).await.unwrap(), &[Contact::Accepted { user_id: user_1, - should_notify: false, busy: false, }], ); @@ -383,7 +370,6 @@ async fn test_add_contacts(db: &Arc) { db.get_contacts(user_2).await.unwrap(), &[Contact::Accepted { user_id: user_1, - should_notify: false, busy: false, }] ); @@ -391,7 +377,6 @@ async fn test_add_contacts(db: &Arc) { db.get_contacts(user_3).await.unwrap(), &[Contact::Accepted { user_id: user_1, - should_notify: false, busy: false, }], ); diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 01da0dc88a..60cdaeec70 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -388,7 +388,7 @@ impl Server { let contacts = app_state.db.get_contacts(user_id).await.trace_err(); if let Some((busy, contacts)) = busy.zip(contacts) { let pool = pool.lock(); - let updated_contact = contact_for_user(user_id, false, busy, &pool); + let updated_contact = contact_for_user(user_id, busy, &pool); for contact in contacts { if let db::Contact::Accepted { user_id: contact_user_id, @@ -690,7 +690,7 @@ impl Server { if let Some(user) = self.app_state.db.get_user_by_id(inviter_id).await? { if let Some(code) = &user.invite_code { let pool = self.connection_pool.lock(); - let invitee_contact = contact_for_user(invitee_id, true, false, &pool); + let invitee_contact = contact_for_user(invitee_id, false, &pool); for connection_id in pool.user_connection_ids(inviter_id) { self.peer.send( connection_id, @@ -2090,7 +2090,6 @@ async fn request_contact( .incoming_requests .push(proto::IncomingContactRequest { requester_id: requester_id.to_proto(), - should_notify: true, }); for connection_id in session .connection_pool() @@ -2124,7 +2123,8 @@ async fn respond_to_contact_request( } else { let accept = request.response == proto::ContactRequestResponse::Accept as i32; - db.respond_to_contact_request(responder_id, requester_id, accept) + let notification = db + .respond_to_contact_request(responder_id, requester_id, accept) .await?; let requester_busy = db.is_user_busy(requester_id).await?; let responder_busy = db.is_user_busy(responder_id).await?; @@ -2135,7 +2135,7 @@ async fn respond_to_contact_request( if accept { update .contacts - .push(contact_for_user(requester_id, false, requester_busy, &pool)); + .push(contact_for_user(requester_id, requester_busy, &pool)); } update .remove_incoming_requests @@ -2149,13 +2149,19 @@ async fn respond_to_contact_request( if accept { update .contacts - .push(contact_for_user(responder_id, true, responder_busy, &pool)); + .push(contact_for_user(responder_id, responder_busy, &pool)); } update .remove_outgoing_requests .push(responder_id.to_proto()); for connection_id in pool.user_connection_ids(requester_id) { session.peer.send(connection_id, update.clone())?; + session.peer.send( + connection_id, + proto::AddNotifications { + notifications: vec![notification.clone()], + }, + )?; } } @@ -3127,42 +3133,28 @@ fn build_initial_contacts_update( for contact in contacts { match contact { - db::Contact::Accepted { - user_id, - should_notify, - busy, - } => { - update - .contacts - .push(contact_for_user(user_id, should_notify, busy, &pool)); + db::Contact::Accepted { user_id, busy } => { + update.contacts.push(contact_for_user(user_id, busy, &pool)); } db::Contact::Outgoing { user_id } => update.outgoing_requests.push(user_id.to_proto()), - db::Contact::Incoming { - user_id, - should_notify, - } => update - .incoming_requests - .push(proto::IncomingContactRequest { - requester_id: user_id.to_proto(), - should_notify, - }), + db::Contact::Incoming { user_id } => { + update + .incoming_requests + .push(proto::IncomingContactRequest { + requester_id: user_id.to_proto(), + }) + } } } update } -fn contact_for_user( - user_id: UserId, - should_notify: bool, - busy: bool, - pool: &ConnectionPool, -) -> proto::Contact { +fn contact_for_user(user_id: UserId, busy: bool, pool: &ConnectionPool) -> proto::Contact { proto::Contact { user_id: user_id.to_proto(), online: pool.is_user_online(user_id), busy, - should_notify, } } @@ -3223,7 +3215,7 @@ async fn update_user_contacts(user_id: UserId, session: &Session) -> Result<()> let busy = db.is_user_busy(user_id).await?; let pool = session.connection_pool().await; - let updated_contact = contact_for_user(user_id, false, busy, &pool); + let updated_contact = contact_for_user(user_id, busy, &pool); for contact in contacts { if let db::Contact::Accepted { user_id: contact_user_id, diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index f767189024..8dca38bdfd 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -1223,7 +1223,6 @@ message ShowContacts {} message IncomingContactRequest { uint64 requester_id = 1; - bool should_notify = 2; } message UpdateDiagnostics { @@ -1549,7 +1548,6 @@ message Contact { uint64 user_id = 1; bool online = 2; bool busy = 3; - bool should_notify = 4; } message WorktreeMetadata { diff --git a/crates/rpc/src/notification.rs b/crates/rpc/src/notification.rs index 839966aea6..8aabb9b9df 100644 --- a/crates/rpc/src/notification.rs +++ b/crates/rpc/src/notification.rs @@ -6,6 +6,12 @@ use strum::{EnumVariantNames, IntoStaticStr, VariantNames as _}; const KIND: &'static str = "kind"; const ACTOR_ID: &'static str = "actor_id"; +/// A notification that can be stored, associated with a given user. +/// +/// This struct is stored in the collab database as JSON, so it shouldn't be +/// changed in a backward-incompatible way. +/// +/// For example, when renaming a variant, add a serde alias for the old name. #[derive(Debug, Clone, PartialEq, Eq, EnumVariantNames, IntoStaticStr, Serialize, Deserialize)] #[serde(tag = "kind")] pub enum Notification { @@ -26,6 +32,8 @@ pub enum Notification { }, } +/// The representation of a notification that is stored in the database and +/// sent over the wire. #[derive(Debug)] pub struct AnyNotification { pub kind: Cow<'static, str>, @@ -87,8 +95,8 @@ fn test_notification() { assert_eq!(deserialized, notification); } - // When notifications are serialized, redundant data is not stored - // in the JSON. + // When notifications are serialized, the `kind` and `actor_id` fields are + // stored separately, and do not appear redundantly in the JSON. let notification = Notification::ContactRequest { actor_id: 1 }; assert_eq!(notification.to_any().content, "{}"); } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 8caff21c5f..5226557235 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -2445,6 +2445,7 @@ mod tests { audio::init((), cx); channel::init(&app_state.client, app_state.user_store.clone(), cx); call::init(app_state.client.clone(), app_state.user_store.clone(), cx); + notifications::init(app_state.client.clone(), app_state.user_store.clone(), cx); workspace::init(app_state.clone(), cx); Project::init_settings(cx); language::init(cx); From 8db86dcebfe4a2520d737e3c8f0889a3c0152343 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 13 Oct 2023 11:21:45 -0700 Subject: [PATCH 15/90] Connect notification panel to notification toasts --- Cargo.lock | 1 + crates/collab/src/db/queries/notifications.rs | 18 +- crates/collab/src/rpc.rs | 35 +++- crates/collab/src/tests/following_tests.rs | 2 +- crates/collab_ui/Cargo.toml | 4 +- crates/collab_ui/src/collab_titlebar_item.rs | 28 +-- crates/collab_ui/src/collab_ui.rs | 8 +- crates/collab_ui/src/notification_panel.rs | 37 +++- crates/collab_ui/src/notifications.rs | 12 +- .../contact_notification.rs | 15 +- .../incoming_call_notification.rs | 0 .../project_shared_notification.rs | 0 .../notifications/src/notification_store.rs | 58 ++++-- crates/rpc/proto/zed.proto | 45 +++-- crates/rpc/src/proto.rs | 187 +++++++++--------- 15 files changed, 272 insertions(+), 178 deletions(-) rename crates/collab_ui/src/{ => notifications}/contact_notification.rs (91%) rename crates/collab_ui/src/{ => notifications}/incoming_call_notification.rs (100%) rename crates/collab_ui/src/{ => notifications}/project_shared_notification.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index c6d7a5ef85..8ee5449f9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1566,6 +1566,7 @@ dependencies = [ "project", "recent_projects", "rich_text", + "rpc", "schemars", "serde", "serde_derive", diff --git a/crates/collab/src/db/queries/notifications.rs b/crates/collab/src/db/queries/notifications.rs index 8c4c511299..7c48ad42cb 100644 --- a/crates/collab/src/db/queries/notifications.rs +++ b/crates/collab/src/db/queries/notifications.rs @@ -26,11 +26,19 @@ impl Database { &self, recipient_id: UserId, limit: usize, - ) -> Result { + before_id: Option, + ) -> Result> { self.transaction(|tx| async move { - let mut result = proto::AddNotifications::default(); + 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(notification::Column::RecipientId.eq(recipient_id)) + .filter(condition) .order_by_desc(notification::Column::Id) .limit(limit as u64) .stream(&*tx) @@ -40,7 +48,7 @@ impl Database { let Some(kind) = self.notification_kinds_by_id.get(&row.kind) else { continue; }; - result.notifications.push(proto::Notification { + result.push(proto::Notification { id: row.id.to_proto(), kind: kind.to_string(), timestamp: row.created_at.assume_utc().unix_timestamp() as u64, @@ -49,7 +57,7 @@ impl Database { actor_id: row.actor_id.map(|id| id.to_proto()), }); } - result.notifications.reverse(); + result.reverse(); Ok(result) }) .await diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 60cdaeec70..abf7ac5857 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -70,7 +70,7 @@ pub const CLEANUP_TIMEOUT: Duration = Duration::from_secs(10); const MESSAGE_COUNT_PER_PAGE: usize = 100; const MAX_MESSAGE_LEN: usize = 1024; -const INITIAL_NOTIFICATION_COUNT: usize = 30; +const NOTIFICATION_COUNT_PER_PAGE: usize = 50; lazy_static! { static ref METRIC_CONNECTIONS: IntGauge = @@ -269,6 +269,7 @@ impl Server { .add_request_handler(send_channel_message) .add_request_handler(remove_channel_message) .add_request_handler(get_channel_messages) + .add_request_handler(get_notifications) .add_request_handler(link_channel) .add_request_handler(unlink_channel) .add_request_handler(move_channel) @@ -579,17 +580,15 @@ impl Server { this.app_state.db.set_user_connected_once(user_id, true).await?; } - let (contacts, channels_for_user, channel_invites, notifications) = future::try_join4( + let (contacts, channels_for_user, channel_invites) = future::try_join3( this.app_state.db.get_contacts(user_id), this.app_state.db.get_channels_for_user(user_id), this.app_state.db.get_channel_invites_for_user(user_id), - this.app_state.db.get_notifications(user_id, INITIAL_NOTIFICATION_COUNT) ).await?; { let mut pool = this.connection_pool.lock(); pool.add_connection(connection_id, user_id, user.admin); - this.peer.send(connection_id, notifications)?; this.peer.send(connection_id, build_initial_contacts_update(contacts, &pool))?; this.peer.send(connection_id, build_initial_channels_update( channels_for_user, @@ -2099,8 +2098,8 @@ async fn request_contact( session.peer.send(connection_id, update.clone())?; session.peer.send( connection_id, - proto::AddNotifications { - notifications: vec![notification.clone()], + proto::NewNotification { + notification: Some(notification.clone()), }, )?; } @@ -2158,8 +2157,8 @@ async fn respond_to_contact_request( session.peer.send(connection_id, update.clone())?; session.peer.send( connection_id, - proto::AddNotifications { - notifications: vec![notification.clone()], + proto::NewNotification { + notification: Some(notification.clone()), }, )?; } @@ -3008,6 +3007,26 @@ async fn get_channel_messages( Ok(()) } +async fn get_notifications( + request: proto::GetNotifications, + response: Response, + session: Session, +) -> Result<()> { + let notifications = session + .db() + .await + .get_notifications( + session.user_id, + NOTIFICATION_COUNT_PER_PAGE, + request + .before_id + .map(|id| db::NotificationId::from_proto(id)), + ) + .await?; + response.send(proto::GetNotificationsResponse { notifications })?; + Ok(()) +} + async fn update_diff_base(request: proto::UpdateDiffBase, session: Session) -> Result<()> { let project_id = ProjectId::from_proto(request.project_id); let project_connection_ids = session diff --git a/crates/collab/src/tests/following_tests.rs b/crates/collab/src/tests/following_tests.rs index f3857e3db3..a28f2ae87f 100644 --- a/crates/collab/src/tests/following_tests.rs +++ b/crates/collab/src/tests/following_tests.rs @@ -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; diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index 25f2d9f91a..4a0f8c5e8b 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -41,7 +41,8 @@ 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" } @@ -68,6 +69,7 @@ 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"] } diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index 211ee863e8..dca8f892e4 100644 --- a/crates/collab_ui/src/collab_titlebar_item.rs +++ b/crates/collab_ui/src/collab_titlebar_item.rs @@ -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::{ @@ -151,28 +151,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(), diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index 0a22c063be..c9a758e0ad 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -2,13 +2,10 @@ 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; pub mod notification_panel; -mod notifications; +pub mod notifications; mod panel_settings; -pub mod project_shared_notification; mod sharing_status_indicator; use call::{report_call_event_for_room, ActiveCall, Room}; @@ -48,8 +45,7 @@ pub fn init(app_state: &Arc, cx: &mut AppContext) { 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); + notifications::init(&app_state, cx); sharing_status_indicator::init(cx); cx.add_global_action(toggle_screen_sharing); diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index 334d844cf5..bae2f88bc6 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -1,5 +1,7 @@ use crate::{ - format_timestamp, is_channels_feature_enabled, render_avatar, NotificationPanelSettings, + format_timestamp, is_channels_feature_enabled, + notifications::contact_notification::ContactNotification, render_avatar, + NotificationPanelSettings, }; use anyhow::Result; use channel::ChannelStore; @@ -39,6 +41,7 @@ pub struct NotificationPanel { notification_list: ListState, pending_serialization: Task>, subscriptions: Vec, + workspace: WeakViewHandle, local_timezone: UtcOffset, has_focus: bool, } @@ -64,6 +67,7 @@ impl NotificationPanel { 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(); let notification_list = ListState::::new(0, Orientation::Top, 1000., move |this, ix, cx| { @@ -96,6 +100,7 @@ impl NotificationPanel { notification_store: NotificationStore::global(cx), notification_list, pending_serialization: Task::ready(None), + workspace: workspace_handle, has_focus: false, subscriptions: Vec::new(), active: false, @@ -177,7 +182,7 @@ impl NotificationPanel { let notification_store = self.notification_store.read(cx); let user_store = self.user_store.read(cx); let channel_store = self.channel_store.read(cx); - let entry = notification_store.notification_at(ix).unwrap(); + let entry = notification_store.notification_at(ix)?; let now = OffsetDateTime::now_utc(); let timestamp = entry.timestamp; @@ -293,7 +298,7 @@ impl NotificationPanel { &mut self, _: ModelHandle, event: &NotificationEvent, - _: &mut ViewContext, + cx: &mut ViewContext, ) { match event { NotificationEvent::NotificationsUpdated { @@ -301,7 +306,33 @@ impl NotificationPanel { new_count, } => { self.notification_list.splice(old_range.clone(), *new_count); + cx.notify(); } + NotificationEvent::NewNotification { entry } => match entry.notification { + Notification::ContactRequest { actor_id } + | Notification::ContactRequestAccepted { actor_id } => { + let user_store = self.user_store.clone(); + let Some(user) = user_store.read(cx).get_cached_user(actor_id) else { + return; + }; + self.workspace + .update(cx, |workspace, cx| { + workspace.show_notification(actor_id as usize, cx, |cx| { + cx.add_view(|cx| { + ContactNotification::new( + user.clone(), + entry.notification.clone(), + user_store, + cx, + ) + }) + }) + }) + .ok(); + } + Notification::ChannelInvitation { .. } => {} + Notification::ChannelMessageMention { .. } => {} + }, } } } diff --git a/crates/collab_ui/src/notifications.rs b/crates/collab_ui/src/notifications.rs index 5943e016cb..e4456163c6 100644 --- a/crates/collab_ui/src/notifications.rs +++ b/crates/collab_ui/src/notifications.rs @@ -2,13 +2,23 @@ use client::User; use gpui::{ elements::*, platform::{CursorStyle, MouseButton}, - AnyElement, Element, ViewContext, + AnyElement, AppContext, Element, ViewContext, }; use std::sync::Arc; +use workspace::AppState; + +pub mod contact_notification; +pub mod incoming_call_notification; +pub mod project_shared_notification; enum Dismiss {} enum Button {} +pub fn init(app_state: &Arc, cx: &mut AppContext) { + incoming_call_notification::init(app_state, cx); + project_shared_notification::init(app_state, cx); +} + pub fn render_user_notification( user: Arc, title: &'static str, diff --git a/crates/collab_ui/src/contact_notification.rs b/crates/collab_ui/src/notifications/contact_notification.rs similarity index 91% rename from crates/collab_ui/src/contact_notification.rs rename to crates/collab_ui/src/notifications/contact_notification.rs index a998be8efd..cbd5f237f8 100644 --- a/crates/collab_ui/src/contact_notification.rs +++ b/crates/collab_ui/src/notifications/contact_notification.rs @@ -1,14 +1,13 @@ -use std::sync::Arc; - use crate::notifications::render_user_notification; use client::{ContactEventKind, User, UserStore}; use gpui::{elements::*, Entity, ModelHandle, View, ViewContext}; +use std::sync::Arc; use workspace::notifications::Notification; pub struct ContactNotification { user_store: ModelHandle, user: Arc, - kind: client::ContactEventKind, + notification: rpc::Notification, } #[derive(Clone, PartialEq)] @@ -34,8 +33,8 @@ impl View for ContactNotification { } fn render(&mut self, cx: &mut ViewContext) -> AnyElement { - match self.kind { - ContactEventKind::Requested => render_user_notification( + match self.notification { + rpc::Notification::ContactRequest { .. } => render_user_notification( self.user.clone(), "wants to add you as a contact", Some("They won't be alerted if you decline."), @@ -56,7 +55,7 @@ impl View for ContactNotification { ], cx, ), - ContactEventKind::Accepted => render_user_notification( + rpc::Notification::ContactRequestAccepted { .. } => render_user_notification( self.user.clone(), "accepted your contact request", None, @@ -78,7 +77,7 @@ impl Notification for ContactNotification { impl ContactNotification { pub fn new( user: Arc, - kind: client::ContactEventKind, + notification: rpc::Notification, user_store: ModelHandle, cx: &mut ViewContext, ) -> Self { @@ -97,7 +96,7 @@ impl ContactNotification { Self { user, - kind, + notification, user_store, } } diff --git a/crates/collab_ui/src/incoming_call_notification.rs b/crates/collab_ui/src/notifications/incoming_call_notification.rs similarity index 100% rename from crates/collab_ui/src/incoming_call_notification.rs rename to crates/collab_ui/src/notifications/incoming_call_notification.rs diff --git a/crates/collab_ui/src/project_shared_notification.rs b/crates/collab_ui/src/notifications/project_shared_notification.rs similarity index 100% rename from crates/collab_ui/src/project_shared_notification.rs rename to crates/collab_ui/src/notifications/project_shared_notification.rs diff --git a/crates/notifications/src/notification_store.rs b/crates/notifications/src/notification_store.rs index 4ebbf46093..6583b4a4c6 100644 --- a/crates/notifications/src/notification_store.rs +++ b/crates/notifications/src/notification_store.rs @@ -2,7 +2,7 @@ use anyhow::Result; use channel::{ChannelMessage, ChannelMessageId, ChannelStore}; use client::{Client, UserStore}; use collections::HashMap; -use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle}; +use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Task}; use rpc::{proto, AnyNotification, Notification, TypedEnvelope}; use std::{ops::Range, sync::Arc}; use sum_tree::{Bias, SumTree}; @@ -14,7 +14,7 @@ pub fn init(client: Arc, user_store: ModelHandle, cx: &mut Ap } pub struct NotificationStore { - _client: Arc, + client: Arc, user_store: ModelHandle, channel_messages: HashMap, channel_store: ModelHandle, @@ -27,6 +27,9 @@ pub enum NotificationEvent { old_range: Range, new_count: usize, }, + NewNotification { + entry: NotificationEntry, + }, } #[derive(Debug, PartialEq, Eq, Clone)] @@ -63,16 +66,19 @@ impl NotificationStore { user_store: ModelHandle, cx: &mut ModelContext, ) -> Self { - Self { + let this = Self { channel_store: ChannelStore::global(cx), notifications: Default::default(), channel_messages: Default::default(), _subscriptions: vec![ - client.add_message_handler(cx.handle(), Self::handle_add_notifications) + client.add_message_handler(cx.handle(), Self::handle_new_notification) ], user_store, - _client: client, - } + client, + }; + + this.load_more_notifications(cx).detach(); + this } pub fn notification_count(&self) -> usize { @@ -93,18 +99,42 @@ impl NotificationStore { cursor.item() } - async fn handle_add_notifications( + pub fn load_more_notifications(&self, cx: &mut ModelContext) -> Task> { + let request = self + .client + .request(proto::GetNotifications { before_id: None }); + cx.spawn(|this, cx| async move { + let response = request.await?; + Self::add_notifications(this, false, response.notifications, cx).await?; + Ok(()) + }) + } + + async fn handle_new_notification( this: ModelHandle, - envelope: TypedEnvelope, + envelope: TypedEnvelope, _: Arc, + cx: AsyncAppContext, + ) -> Result<()> { + Self::add_notifications( + this, + true, + envelope.payload.notification.into_iter().collect(), + cx, + ) + .await + } + + async fn add_notifications( + this: ModelHandle, + is_new: bool, + notifications: Vec, mut cx: AsyncAppContext, ) -> Result<()> { let mut user_ids = Vec::new(); let mut message_ids = Vec::new(); - let notifications = envelope - .payload - .notifications + let notifications = notifications .into_iter() .filter_map(|message| { Some(NotificationEntry { @@ -195,6 +225,12 @@ impl NotificationStore { cursor.next(&()); } + if is_new { + cx.emit(NotificationEvent::NewNotification { + entry: notification.clone(), + }); + } + new_notifications.push(notification, &()); } diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 8dca38bdfd..3f47dfaab5 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -155,25 +155,28 @@ message Envelope { UpdateChannelBufferCollaborators update_channel_buffer_collaborators = 128; RejoinChannelBuffers rejoin_channel_buffers = 129; RejoinChannelBuffersResponse rejoin_channel_buffers_response = 130; - AckBufferOperation ack_buffer_operation = 143; + AckBufferOperation ack_buffer_operation = 131; - JoinChannelChat join_channel_chat = 131; - JoinChannelChatResponse join_channel_chat_response = 132; - LeaveChannelChat leave_channel_chat = 133; - SendChannelMessage send_channel_message = 134; - SendChannelMessageResponse send_channel_message_response = 135; - ChannelMessageSent channel_message_sent = 136; - GetChannelMessages get_channel_messages = 137; - GetChannelMessagesResponse get_channel_messages_response = 138; - RemoveChannelMessage remove_channel_message = 139; - AckChannelMessage ack_channel_message = 144; + JoinChannelChat join_channel_chat = 132; + JoinChannelChatResponse join_channel_chat_response = 133; + LeaveChannelChat leave_channel_chat = 134; + SendChannelMessage send_channel_message = 135; + SendChannelMessageResponse send_channel_message_response = 136; + ChannelMessageSent channel_message_sent = 137; + GetChannelMessages get_channel_messages = 138; + GetChannelMessagesResponse get_channel_messages_response = 139; + RemoveChannelMessage remove_channel_message = 140; + AckChannelMessage ack_channel_message = 141; + GetChannelMessagesById get_channel_messages_by_id = 142; - LinkChannel link_channel = 140; - UnlinkChannel unlink_channel = 141; - MoveChannel move_channel = 142; + LinkChannel link_channel = 143; + UnlinkChannel unlink_channel = 144; + MoveChannel move_channel = 145; + + NewNotification new_notification = 146; + GetNotifications get_notifications = 147; + GetNotificationsResponse get_notifications_response = 148; // Current max - AddNotifications add_notifications = 145; - GetChannelMessagesById get_channel_messages_by_id = 146; // Current max } } @@ -1563,7 +1566,15 @@ message UpdateDiffBase { optional string diff_base = 3; } -message AddNotifications { +message GetNotifications { + optional uint64 before_id = 1; +} + +message NewNotification { + Notification notification = 1; +} + +message GetNotificationsResponse { repeated Notification notifications = 1; } diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index 4d8f60c896..eb548efd39 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -133,7 +133,8 @@ impl fmt::Display for PeerId { messages!( (Ack, Foreground), - (AddNotifications, Foreground), + (AckBufferOperation, Background), + (AckChannelMessage, Background), (AddProjectCollaborator, Foreground), (ApplyCodeAction, Background), (ApplyCodeActionResponse, Background), @@ -144,58 +145,74 @@ messages!( (Call, Foreground), (CallCanceled, Foreground), (CancelCall, Foreground), + (ChannelMessageSent, Foreground), (CopyProjectEntry, Foreground), (CreateBufferForPeer, Foreground), (CreateChannel, Foreground), (CreateChannelResponse, Foreground), - (ChannelMessageSent, Foreground), (CreateProjectEntry, Foreground), (CreateRoom, Foreground), (CreateRoomResponse, Foreground), (DeclineCall, Foreground), + (DeleteChannel, Foreground), (DeleteProjectEntry, Foreground), (Error, Foreground), (ExpandProjectEntry, Foreground), + (ExpandProjectEntryResponse, Foreground), (Follow, Foreground), (FollowResponse, Foreground), (FormatBuffers, Foreground), (FormatBuffersResponse, Foreground), (FuzzySearchUsers, Foreground), + (GetChannelMembers, Foreground), + (GetChannelMembersResponse, Foreground), + (GetChannelMessages, Background), + (GetChannelMessagesById, Background), + (GetChannelMessagesResponse, Background), (GetCodeActions, Background), (GetCodeActionsResponse, Background), - (GetHover, Background), - (GetHoverResponse, Background), - (GetChannelMessages, Background), - (GetChannelMessagesResponse, Background), - (GetChannelMessagesById, Background), - (SendChannelMessage, Background), - (SendChannelMessageResponse, Background), (GetCompletions, Background), (GetCompletionsResponse, Background), (GetDefinition, Background), (GetDefinitionResponse, Background), - (GetTypeDefinition, Background), - (GetTypeDefinitionResponse, Background), (GetDocumentHighlights, Background), (GetDocumentHighlightsResponse, Background), - (GetReferences, Background), - (GetReferencesResponse, Background), + (GetHover, Background), + (GetHoverResponse, Background), + (GetNotifications, Foreground), + (GetNotificationsResponse, Foreground), + (GetPrivateUserInfo, Foreground), + (GetPrivateUserInfoResponse, Foreground), (GetProjectSymbols, Background), (GetProjectSymbolsResponse, Background), + (GetReferences, Background), + (GetReferencesResponse, Background), + (GetTypeDefinition, Background), + (GetTypeDefinitionResponse, Background), (GetUsers, Foreground), (Hello, Foreground), (IncomingCall, Foreground), + (InlayHints, Background), + (InlayHintsResponse, Background), (InviteChannelMember, Foreground), - (UsersResponse, Foreground), + (JoinChannel, Foreground), + (JoinChannelBuffer, Foreground), + (JoinChannelBufferResponse, Foreground), + (JoinChannelChat, Foreground), + (JoinChannelChatResponse, Foreground), (JoinProject, Foreground), (JoinProjectResponse, Foreground), (JoinRoom, Foreground), (JoinRoomResponse, Foreground), - (JoinChannelChat, Foreground), - (JoinChannelChatResponse, Foreground), + (LeaveChannelBuffer, Background), (LeaveChannelChat, Foreground), (LeaveProject, Foreground), (LeaveRoom, Foreground), + (LinkChannel, Foreground), + (MoveChannel, Foreground), + (NewNotification, Foreground), + (OnTypeFormatting, Background), + (OnTypeFormattingResponse, Background), (OpenBufferById, Background), (OpenBufferByPath, Background), (OpenBufferForSymbol, Background), @@ -203,58 +220,54 @@ messages!( (OpenBufferResponse, Background), (PerformRename, Background), (PerformRenameResponse, Background), - (OnTypeFormatting, Background), - (OnTypeFormattingResponse, Background), - (InlayHints, Background), - (InlayHintsResponse, Background), - (ResolveInlayHint, Background), - (ResolveInlayHintResponse, Background), - (RefreshInlayHints, Foreground), (Ping, Foreground), (PrepareRename, Background), (PrepareRenameResponse, Background), - (ExpandProjectEntryResponse, Foreground), (ProjectEntryResponse, Foreground), + (RefreshInlayHints, Foreground), + (RejoinChannelBuffers, Foreground), + (RejoinChannelBuffersResponse, Foreground), (RejoinRoom, Foreground), (RejoinRoomResponse, Foreground), - (RemoveContact, Foreground), - (RemoveChannelMember, Foreground), - (RemoveChannelMessage, Foreground), (ReloadBuffers, Foreground), (ReloadBuffersResponse, Foreground), + (RemoveChannelMember, Foreground), + (RemoveChannelMessage, Foreground), + (RemoveContact, Foreground), (RemoveProjectCollaborator, Foreground), - (RenameProjectEntry, Foreground), - (RequestContact, Foreground), - (RespondToContactRequest, Foreground), - (RespondToChannelInvite, Foreground), - (JoinChannel, Foreground), - (RoomUpdated, Foreground), - (SaveBuffer, Foreground), (RenameChannel, Foreground), (RenameChannelResponse, Foreground), - (SetChannelMemberAdmin, Foreground), + (RenameProjectEntry, Foreground), + (RequestContact, Foreground), + (ResolveInlayHint, Background), + (ResolveInlayHintResponse, Background), + (RespondToChannelInvite, Foreground), + (RespondToContactRequest, Foreground), + (RoomUpdated, Foreground), + (SaveBuffer, Foreground), (SearchProject, Background), (SearchProjectResponse, Background), + (SendChannelMessage, Background), + (SendChannelMessageResponse, Background), + (SetChannelMemberAdmin, Foreground), (ShareProject, Foreground), (ShareProjectResponse, Foreground), (ShowContacts, Foreground), (StartLanguageServer, Foreground), (SynchronizeBuffers, Foreground), (SynchronizeBuffersResponse, Foreground), - (RejoinChannelBuffers, Foreground), - (RejoinChannelBuffersResponse, Foreground), (Test, Foreground), (Unfollow, Foreground), + (UnlinkChannel, Foreground), (UnshareProject, Foreground), (UpdateBuffer, Foreground), (UpdateBufferFile, Foreground), - (UpdateContacts, Foreground), - (DeleteChannel, Foreground), - (MoveChannel, Foreground), - (LinkChannel, Foreground), - (UnlinkChannel, Foreground), + (UpdateChannelBuffer, Foreground), + (UpdateChannelBufferCollaborators, Foreground), (UpdateChannels, Foreground), + (UpdateContacts, Foreground), (UpdateDiagnosticSummary, Foreground), + (UpdateDiffBase, Foreground), (UpdateFollowers, Foreground), (UpdateInviteInfo, Foreground), (UpdateLanguageServer, Foreground), @@ -263,18 +276,7 @@ messages!( (UpdateProjectCollaborator, Foreground), (UpdateWorktree, Foreground), (UpdateWorktreeSettings, Foreground), - (UpdateDiffBase, Foreground), - (GetPrivateUserInfo, Foreground), - (GetPrivateUserInfoResponse, Foreground), - (GetChannelMembers, Foreground), - (GetChannelMembersResponse, Foreground), - (JoinChannelBuffer, Foreground), - (JoinChannelBufferResponse, Foreground), - (LeaveChannelBuffer, Background), - (UpdateChannelBuffer, Foreground), - (UpdateChannelBufferCollaborators, Foreground), - (AckBufferOperation, Background), - (AckChannelMessage, Background), + (UsersResponse, Foreground), ); request_messages!( @@ -286,73 +288,74 @@ request_messages!( (Call, Ack), (CancelCall, Ack), (CopyProjectEntry, ProjectEntryResponse), + (CreateChannel, CreateChannelResponse), (CreateProjectEntry, ProjectEntryResponse), (CreateRoom, CreateRoomResponse), - (CreateChannel, CreateChannelResponse), (DeclineCall, Ack), + (DeleteChannel, Ack), (DeleteProjectEntry, ProjectEntryResponse), (ExpandProjectEntry, ExpandProjectEntryResponse), (Follow, FollowResponse), (FormatBuffers, FormatBuffersResponse), + (FuzzySearchUsers, UsersResponse), + (GetChannelMembers, GetChannelMembersResponse), + (GetChannelMessages, GetChannelMessagesResponse), + (GetChannelMessagesById, GetChannelMessagesResponse), (GetCodeActions, GetCodeActionsResponse), - (GetHover, GetHoverResponse), (GetCompletions, GetCompletionsResponse), (GetDefinition, GetDefinitionResponse), - (GetTypeDefinition, GetTypeDefinitionResponse), (GetDocumentHighlights, GetDocumentHighlightsResponse), - (GetReferences, GetReferencesResponse), + (GetHover, GetHoverResponse), + (GetNotifications, GetNotificationsResponse), (GetPrivateUserInfo, GetPrivateUserInfoResponse), (GetProjectSymbols, GetProjectSymbolsResponse), - (FuzzySearchUsers, UsersResponse), + (GetReferences, GetReferencesResponse), + (GetTypeDefinition, GetTypeDefinitionResponse), (GetUsers, UsersResponse), + (IncomingCall, Ack), + (InlayHints, InlayHintsResponse), (InviteChannelMember, Ack), + (JoinChannel, JoinRoomResponse), + (JoinChannelBuffer, JoinChannelBufferResponse), + (JoinChannelChat, JoinChannelChatResponse), (JoinProject, JoinProjectResponse), (JoinRoom, JoinRoomResponse), - (JoinChannelChat, JoinChannelChatResponse), + (LeaveChannelBuffer, Ack), (LeaveRoom, Ack), - (RejoinRoom, RejoinRoomResponse), - (IncomingCall, Ack), + (LinkChannel, Ack), + (MoveChannel, Ack), + (OnTypeFormatting, OnTypeFormattingResponse), (OpenBufferById, OpenBufferResponse), (OpenBufferByPath, OpenBufferResponse), (OpenBufferForSymbol, OpenBufferForSymbolResponse), - (Ping, Ack), (PerformRename, PerformRenameResponse), + (Ping, Ack), (PrepareRename, PrepareRenameResponse), - (OnTypeFormatting, OnTypeFormattingResponse), - (InlayHints, InlayHintsResponse), - (ResolveInlayHint, ResolveInlayHintResponse), (RefreshInlayHints, Ack), + (RejoinChannelBuffers, RejoinChannelBuffersResponse), + (RejoinRoom, RejoinRoomResponse), (ReloadBuffers, ReloadBuffersResponse), - (RequestContact, Ack), (RemoveChannelMember, Ack), - (RemoveContact, Ack), - (RespondToContactRequest, Ack), - (RespondToChannelInvite, Ack), - (SetChannelMemberAdmin, Ack), - (SendChannelMessage, SendChannelMessageResponse), - (GetChannelMessages, GetChannelMessagesResponse), - (GetChannelMessagesById, GetChannelMessagesResponse), - (GetChannelMembers, GetChannelMembersResponse), - (JoinChannel, JoinRoomResponse), (RemoveChannelMessage, Ack), - (DeleteChannel, Ack), - (RenameProjectEntry, ProjectEntryResponse), + (RemoveContact, Ack), (RenameChannel, RenameChannelResponse), - (LinkChannel, Ack), - (UnlinkChannel, Ack), - (MoveChannel, Ack), + (RenameProjectEntry, ProjectEntryResponse), + (RequestContact, Ack), + (ResolveInlayHint, ResolveInlayHintResponse), + (RespondToChannelInvite, Ack), + (RespondToContactRequest, Ack), (SaveBuffer, BufferSaved), (SearchProject, SearchProjectResponse), + (SendChannelMessage, SendChannelMessageResponse), + (SetChannelMemberAdmin, Ack), (ShareProject, ShareProjectResponse), (SynchronizeBuffers, SynchronizeBuffersResponse), - (RejoinChannelBuffers, RejoinChannelBuffersResponse), (Test, Test), + (UnlinkChannel, Ack), (UpdateBuffer, Ack), (UpdateParticipantLocation, Ack), (UpdateProject, Ack), (UpdateWorktree, Ack), - (JoinChannelBuffer, JoinChannelBufferResponse), - (LeaveChannelBuffer, Ack) ); entity_messages!( @@ -371,25 +374,25 @@ entity_messages!( GetCodeActions, GetCompletions, GetDefinition, - GetTypeDefinition, GetDocumentHighlights, GetHover, - GetReferences, GetProjectSymbols, + GetReferences, + GetTypeDefinition, + InlayHints, JoinProject, LeaveProject, + OnTypeFormatting, OpenBufferById, OpenBufferByPath, OpenBufferForSymbol, PerformRename, - OnTypeFormatting, - InlayHints, - ResolveInlayHint, - RefreshInlayHints, PrepareRename, + RefreshInlayHints, ReloadBuffers, RemoveProjectCollaborator, RenameProjectEntry, + ResolveInlayHint, SaveBuffer, SearchProject, StartLanguageServer, @@ -398,19 +401,19 @@ entity_messages!( UpdateBuffer, UpdateBufferFile, UpdateDiagnosticSummary, + UpdateDiffBase, UpdateLanguageServer, UpdateProject, UpdateProjectCollaborator, UpdateWorktree, UpdateWorktreeSettings, - UpdateDiffBase ); entity_messages!( channel_id, ChannelMessageSent, - UpdateChannelBuffer, RemoveChannelMessage, + UpdateChannelBuffer, UpdateChannelBufferCollaborators, ); From bc6ba5f547496853ed37a3da709b830f83b22d0b Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 13 Oct 2023 11:23:39 -0700 Subject: [PATCH 16/90] Bump protocol version --- crates/rpc/src/rpc.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rpc/src/rpc.rs b/crates/rpc/src/rpc.rs index 4bf90669b2..682ba6ac73 100644 --- a/crates/rpc/src/rpc.rs +++ b/crates/rpc/src/rpc.rs @@ -9,4 +9,4 @@ pub use notification::*; pub use peer::*; mod macros; -pub const PROTOCOL_VERSION: u32 = 64; +pub const PROTOCOL_VERSION: u32 = 65; From 39e3ddb0803f77c2bd4e1600fd3b363ad6c38353 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Fri, 13 Oct 2023 15:00:32 -0400 Subject: [PATCH 17/90] Update bell.svg --- assets/icons/bell.svg | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/assets/icons/bell.svg b/assets/icons/bell.svg index 46b01b6b38..ea1c6dd42e 100644 --- a/assets/icons/bell.svg +++ b/assets/icons/bell.svg @@ -1,3 +1,8 @@ - - + + From 83fb8d20b7b49e51209ae9582d0980e75577a4a8 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 13 Oct 2023 15:37:08 -0700 Subject: [PATCH 18/90] Remove contact notifications when cancelling a contact request --- crates/channel/src/channel_store.rs | 3 - crates/collab/src/db/queries/contacts.rs | 22 ++- crates/collab/src/db/queries/notifications.rs | 45 ++++++- crates/collab/src/rpc.rs | 11 +- crates/collab_ui/src/notification_panel.rs | 66 +++++---- .../src/notifications/contact_notification.rs | 16 +-- .../notifications/src/notification_store.rs | 126 +++++++++++++----- crates/rpc/proto/zed.proto | 7 +- crates/rpc/src/notification.rs | 14 +- crates/rpc/src/proto.rs | 1 + 10 files changed, 224 insertions(+), 87 deletions(-) diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index 4a1292cdb2..918a1e1dc1 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -127,9 +127,6 @@ impl ChannelStore { this.update(&mut cx, |this, cx| this.handle_disconnect(true, cx)); } } - if status.is_connected() { - } else { - } } Some(()) }); diff --git a/crates/collab/src/db/queries/contacts.rs b/crates/collab/src/db/queries/contacts.rs index f02bae667a..ddb7959ef2 100644 --- a/crates/collab/src/db/queries/contacts.rs +++ b/crates/collab/src/db/queries/contacts.rs @@ -185,7 +185,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 { + pub async fn remove_contact( + &self, + requester_id: UserId, + responder_id: UserId, + ) -> Result<(bool, Option)> { self.transaction(|tx| async move { let (id_a, id_b) = if responder_id < requester_id { (responder_id, requester_id) @@ -204,7 +208,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 + .delete_notification( + responder_id, + rpc::Notification::ContactRequest { + actor_id: requester_id.to_proto(), + }, + &*tx, + ) + .await?; + } + + Ok((contact.accepted, deleted_notification_id)) }) .await } diff --git a/crates/collab/src/db/queries/notifications.rs b/crates/collab/src/db/queries/notifications.rs index 7c48ad42cb..2ea5fd149f 100644 --- a/crates/collab/src/db/queries/notifications.rs +++ b/crates/collab/src/db/queries/notifications.rs @@ -3,12 +3,12 @@ use rpc::Notification; impl Database { pub async fn initialize_notification_enum(&mut self) -> Result<()> { - notification_kind::Entity::insert_many(Notification::all_kinds().iter().map(|kind| { - notification_kind::ActiveModel { + 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?; @@ -19,6 +19,12 @@ impl Database { 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(()) } @@ -46,6 +52,7 @@ impl Database { while let Some(row) = rows.next().await { let row = row?; let Some(kind) = self.notification_kinds_by_id.get(&row.kind) else { + log::warn!("unknown notification kind {:?}", row.kind); continue; }; result.push(proto::Notification { @@ -96,4 +103,34 @@ impl Database { actor_id: notification.actor_id, }) } + + pub async fn delete_notification( + &self, + recipient_id: UserId, + notification: Notification, + tx: &DatabaseTransaction, + ) -> Result> { + let notification = notification.to_any(); + let kind = *self + .notification_kinds_by_name + .get(notification.kind.as_ref()) + .ok_or_else(|| anyhow!("invalid notification kind {:?}", notification.kind))?; + let actor_id = notification.actor_id.map(|id| UserId::from_proto(id)); + let notification = notification::Entity::find() + .filter( + Condition::all() + .add(notification::Column::RecipientId.eq(recipient_id)) + .add(notification::Column::Kind.eq(kind)) + .add(notification::Column::ActorId.eq(actor_id)) + .add(notification::Column::Content.eq(notification.content)), + ) + .one(tx) + .await?; + if let Some(notification) = ¬ification { + notification::Entity::delete_by_id(notification.id) + .exec(tx) + .await?; + } + Ok(notification.map(|notification| notification.id)) + } } diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 921ebccfb1..7a3cdb13ab 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2177,7 +2177,8 @@ async fn remove_contact( let requester_id = session.user_id; let responder_id = UserId::from_proto(request.user_id); let db = session.db().await; - let contact_accepted = db.remove_contact(requester_id, responder_id).await?; + let (contact_accepted, deleted_notification_id) = + db.remove_contact(requester_id, responder_id).await?; let pool = session.connection_pool().await; // Update outgoing contact requests of requester @@ -2204,6 +2205,14 @@ async fn remove_contact( } for connection_id in pool.user_connection_ids(responder_id) { session.peer.send(connection_id, update.clone())?; + if let Some(notification_id) = deleted_notification_id { + session.peer.send( + connection_id, + proto::DeleteNotification { + notification_id: notification_id.to_proto(), + }, + )?; + } } response.send(proto::Ack {})?; diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index bae2f88bc6..978255a081 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -301,6 +301,8 @@ impl NotificationPanel { cx: &mut ViewContext, ) { match event { + NotificationEvent::NewNotification { entry } => self.add_toast(entry, cx), + NotificationEvent::NotificationRemoved { entry } => self.remove_toast(entry, cx), NotificationEvent::NotificationsUpdated { old_range, new_count, @@ -308,31 +310,49 @@ impl NotificationPanel { self.notification_list.splice(old_range.clone(), *new_count); cx.notify(); } - NotificationEvent::NewNotification { entry } => match entry.notification { - Notification::ContactRequest { actor_id } - | Notification::ContactRequestAccepted { actor_id } => { - let user_store = self.user_store.clone(); - let Some(user) = user_store.read(cx).get_cached_user(actor_id) else { - return; - }; - self.workspace - .update(cx, |workspace, cx| { - workspace.show_notification(actor_id as usize, cx, |cx| { - cx.add_view(|cx| { - ContactNotification::new( - user.clone(), - entry.notification.clone(), - user_store, - cx, - ) - }) + } + } + + fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext) { + let id = entry.id as usize; + match entry.notification { + Notification::ContactRequest { actor_id } + | Notification::ContactRequestAccepted { actor_id } => { + let user_store = self.user_store.clone(); + let Some(user) = user_store.read(cx).get_cached_user(actor_id) else { + return; + }; + self.workspace + .update(cx, |workspace, cx| { + workspace.show_notification(id, cx, |cx| { + cx.add_view(|_| { + ContactNotification::new( + user, + entry.notification.clone(), + user_store, + ) }) }) - .ok(); - } - Notification::ChannelInvitation { .. } => {} - Notification::ChannelMessageMention { .. } => {} - }, + }) + .ok(); + } + Notification::ChannelInvitation { .. } => {} + Notification::ChannelMessageMention { .. } => {} + } + } + + fn remove_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext) { + let id = entry.id as usize; + match entry.notification { + Notification::ContactRequest { .. } | Notification::ContactRequestAccepted { .. } => { + self.workspace + .update(cx, |workspace, cx| { + workspace.dismiss_notification::(id, cx) + }) + .ok(); + } + Notification::ChannelInvitation { .. } => {} + Notification::ChannelMessageMention { .. } => {} } } } diff --git a/crates/collab_ui/src/notifications/contact_notification.rs b/crates/collab_ui/src/notifications/contact_notification.rs index cbd5f237f8..2e3c3ca58a 100644 --- a/crates/collab_ui/src/notifications/contact_notification.rs +++ b/crates/collab_ui/src/notifications/contact_notification.rs @@ -1,5 +1,5 @@ use crate::notifications::render_user_notification; -use client::{ContactEventKind, User, UserStore}; +use client::{User, UserStore}; use gpui::{elements::*, Entity, ModelHandle, View, ViewContext}; use std::sync::Arc; use workspace::notifications::Notification; @@ -79,21 +79,7 @@ impl ContactNotification { user: Arc, notification: rpc::Notification, user_store: ModelHandle, - cx: &mut ViewContext, ) -> 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, notification, diff --git a/crates/notifications/src/notification_store.rs b/crates/notifications/src/notification_store.rs index 6583b4a4c6..087637a100 100644 --- a/crates/notifications/src/notification_store.rs +++ b/crates/notifications/src/notification_store.rs @@ -2,11 +2,13 @@ use anyhow::Result; use channel::{ChannelMessage, ChannelMessageId, ChannelStore}; use client::{Client, UserStore}; use collections::HashMap; +use db::smol::stream::StreamExt; use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Task}; use rpc::{proto, AnyNotification, Notification, TypedEnvelope}; use std::{ops::Range, sync::Arc}; use sum_tree::{Bias, SumTree}; use time::OffsetDateTime; +use util::ResultExt; pub fn init(client: Arc, user_store: ModelHandle, cx: &mut AppContext) { let notification_store = cx.add_model(|cx| NotificationStore::new(client, user_store, cx)); @@ -19,6 +21,7 @@ pub struct NotificationStore { channel_messages: HashMap, channel_store: ModelHandle, notifications: SumTree, + _watch_connection_status: Task>, _subscriptions: Vec, } @@ -30,6 +33,9 @@ pub enum NotificationEvent { NewNotification { entry: NotificationEntry, }, + NotificationRemoved { + entry: NotificationEntry, + }, } #[derive(Debug, PartialEq, Eq, Clone)] @@ -66,19 +72,34 @@ impl NotificationStore { user_store: ModelHandle, cx: &mut ModelContext, ) -> Self { - let this = Self { + let mut connection_status = client.status(); + let watch_connection_status = cx.spawn_weak(|this, mut cx| async move { + while let Some(status) = connection_status.next().await { + let this = this.upgrade(&cx)?; + match status { + client::Status::Connected { .. } => { + this.update(&mut cx, |this, cx| this.handle_connect(cx)) + .await + .log_err()?; + } + _ => this.update(&mut cx, |this, cx| this.handle_disconnect(cx)), + } + } + Some(()) + }); + + Self { channel_store: ChannelStore::global(cx), notifications: Default::default(), channel_messages: Default::default(), + _watch_connection_status: watch_connection_status, _subscriptions: vec![ - client.add_message_handler(cx.handle(), Self::handle_new_notification) + client.add_message_handler(cx.handle(), Self::handle_new_notification), + client.add_message_handler(cx.handle(), Self::handle_delete_notification), ], user_store, client, - }; - - this.load_more_notifications(cx).detach(); - this + } } pub fn notification_count(&self) -> usize { @@ -110,6 +131,16 @@ impl NotificationStore { }) } + fn handle_connect(&mut self, cx: &mut ModelContext) -> Task> { + self.notifications = Default::default(); + self.channel_messages = Default::default(); + self.load_more_notifications(cx) + } + + fn handle_disconnect(&mut self, cx: &mut ModelContext) { + cx.notify() + } + async fn handle_new_notification( this: ModelHandle, envelope: TypedEnvelope, @@ -125,6 +156,18 @@ impl NotificationStore { .await } + async fn handle_delete_notification( + this: ModelHandle, + envelope: TypedEnvelope, + _: Arc, + mut cx: AsyncAppContext, + ) -> Result<()> { + this.update(&mut cx, |this, cx| { + this.splice_notifications([(envelope.payload.notification_id, None)], false, cx); + Ok(()) + }) + } + async fn add_notifications( this: ModelHandle, is_new: bool, @@ -205,26 +248,47 @@ impl NotificationStore { } })); - let mut cursor = this.notifications.cursor::<(NotificationId, Count)>(); - let mut new_notifications = SumTree::new(); - let mut old_range = 0..0; - for (i, notification) in notifications.into_iter().enumerate() { - new_notifications.append( - cursor.slice(&NotificationId(notification.id), Bias::Left, &()), - &(), - ); + this.splice_notifications( + notifications + .into_iter() + .map(|notification| (notification.id, Some(notification))), + is_new, + cx, + ); + }); - if i == 0 { - old_range.start = cursor.start().1 .0; - } + Ok(()) + } - if cursor - .item() - .map_or(true, |existing| existing.id != notification.id) - { + fn splice_notifications( + &mut self, + notifications: impl IntoIterator)>, + is_new: bool, + cx: &mut ModelContext<'_, NotificationStore>, + ) { + let mut cursor = self.notifications.cursor::<(NotificationId, Count)>(); + let mut new_notifications = SumTree::new(); + let mut old_range = 0..0; + + for (i, (id, new_notification)) in notifications.into_iter().enumerate() { + new_notifications.append(cursor.slice(&NotificationId(id), Bias::Left, &()), &()); + + if i == 0 { + old_range.start = cursor.start().1 .0; + } + + if let Some(existing_notification) = cursor.item() { + if existing_notification.id == id { + if new_notification.is_none() { + cx.emit(NotificationEvent::NotificationRemoved { + entry: existing_notification.clone(), + }); + } cursor.next(&()); } + } + if let Some(notification) = new_notification { if is_new { cx.emit(NotificationEvent::NewNotification { entry: notification.clone(), @@ -233,20 +297,18 @@ impl NotificationStore { new_notifications.push(notification, &()); } + } - old_range.end = cursor.start().1 .0; - let new_count = new_notifications.summary().count; - new_notifications.append(cursor.suffix(&()), &()); - drop(cursor); + old_range.end = cursor.start().1 .0; + let new_count = new_notifications.summary().count - old_range.start; + new_notifications.append(cursor.suffix(&()), &()); + drop(cursor); - this.notifications = new_notifications; - cx.emit(NotificationEvent::NotificationsUpdated { - old_range, - new_count, - }); + self.notifications = new_notifications; + cx.emit(NotificationEvent::NotificationsUpdated { + old_range, + new_count, }); - - Ok(()) } } diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 30e43dc43b..d27bbade6f 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -177,7 +177,8 @@ message Envelope { NewNotification new_notification = 148; GetNotifications get_notifications = 149; - GetNotificationsResponse get_notifications_response = 150; // Current max + GetNotificationsResponse get_notifications_response = 150; + DeleteNotification delete_notification = 151; // Current max } } @@ -1590,6 +1591,10 @@ message GetNotificationsResponse { repeated Notification notifications = 1; } +message DeleteNotification { + uint64 notification_id = 1; +} + message Notification { uint64 id = 1; uint64 timestamp = 2; diff --git a/crates/rpc/src/notification.rs b/crates/rpc/src/notification.rs index 8aabb9b9df..8224c2696c 100644 --- a/crates/rpc/src/notification.rs +++ b/crates/rpc/src/notification.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{map, Value}; use std::borrow::Cow; use strum::{EnumVariantNames, IntoStaticStr, VariantNames as _}; @@ -47,10 +47,12 @@ impl Notification { let mut value = serde_json::to_value(self).unwrap(); let mut actor_id = None; if let Some(value) = value.as_object_mut() { - value.remove("kind"); - actor_id = value - .remove("actor_id") - .and_then(|value| Some(value.as_i64()? as u64)); + value.remove(KIND); + if let map::Entry::Occupied(e) = value.entry(ACTOR_ID) { + if e.get().is_u64() { + actor_id = e.remove().as_u64(); + } + } } AnyNotification { kind: Cow::Borrowed(kind), @@ -69,7 +71,7 @@ impl Notification { serde_json::from_value(value).ok() } - pub fn all_kinds() -> &'static [&'static str] { + pub fn all_variant_names() -> &'static [&'static str] { Self::VARIANTS } } diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index bca56e9c77..b2a72c4ce1 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -155,6 +155,7 @@ messages!( (CreateRoomResponse, Foreground), (DeclineCall, Foreground), (DeleteChannel, Foreground), + (DeleteNotification, Foreground), (DeleteProjectEntry, Foreground), (Error, Foreground), (ExpandProjectEntry, Foreground), From 5a0afcc83541a725b3dc140b1982b159f671abfd Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 13 Oct 2023 15:49:31 -0700 Subject: [PATCH 19/90] Simplify notification serialization --- crates/collab/src/db.rs | 3 +- crates/collab/src/db/queries/notifications.rs | 8 +-- .../notifications/src/notification_store.rs | 8 +-- crates/rpc/src/notification.rs | 50 ++++++++----------- 4 files changed, 29 insertions(+), 40 deletions(-) diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 67055d27ee..1bf5c95f6b 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -13,6 +13,7 @@ use anyhow::anyhow; use collections::{BTreeMap, HashMap, HashSet}; use dashmap::DashMap; use futures::StreamExt; +use queries::channels::ChannelGraph; use rand::{prelude::StdRng, Rng, SeedableRng}; use rpc::{ proto::{self}, @@ -47,8 +48,6 @@ 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, diff --git a/crates/collab/src/db/queries/notifications.rs b/crates/collab/src/db/queries/notifications.rs index 2ea5fd149f..bf9c9d74ef 100644 --- a/crates/collab/src/db/queries/notifications.rs +++ b/crates/collab/src/db/queries/notifications.rs @@ -76,10 +76,10 @@ impl Database { notification: Notification, tx: &DatabaseTransaction, ) -> Result { - let notification = notification.to_any(); + let notification = notification.to_proto(); let kind = *self .notification_kinds_by_name - .get(notification.kind.as_ref()) + .get(¬ification.kind) .ok_or_else(|| anyhow!("invalid notification kind {:?}", notification.kind))?; let model = notification::ActiveModel { @@ -110,10 +110,10 @@ impl Database { notification: Notification, tx: &DatabaseTransaction, ) -> Result> { - let notification = notification.to_any(); + let notification = notification.to_proto(); let kind = *self .notification_kinds_by_name - .get(notification.kind.as_ref()) + .get(¬ification.kind) .ok_or_else(|| anyhow!("invalid notification kind {:?}", notification.kind))?; let actor_id = notification.actor_id.map(|id| UserId::from_proto(id)); let notification = notification::Entity::find() diff --git a/crates/notifications/src/notification_store.rs b/crates/notifications/src/notification_store.rs index 087637a100..af39941d2f 100644 --- a/crates/notifications/src/notification_store.rs +++ b/crates/notifications/src/notification_store.rs @@ -4,7 +4,7 @@ use client::{Client, UserStore}; use collections::HashMap; use db::smol::stream::StreamExt; use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Task}; -use rpc::{proto, AnyNotification, Notification, TypedEnvelope}; +use rpc::{proto, Notification, TypedEnvelope}; use std::{ops::Range, sync::Arc}; use sum_tree::{Bias, SumTree}; use time::OffsetDateTime; @@ -185,11 +185,7 @@ impl NotificationStore { is_read: message.is_read, timestamp: OffsetDateTime::from_unix_timestamp(message.timestamp as i64) .ok()?, - notification: Notification::from_any(&AnyNotification { - actor_id: message.actor_id, - kind: message.kind.into(), - content: message.content, - })?, + notification: Notification::from_proto(&message)?, }) }) .collect::>(); diff --git a/crates/rpc/src/notification.rs b/crates/rpc/src/notification.rs index 8224c2696c..6ff9660159 100644 --- a/crates/rpc/src/notification.rs +++ b/crates/rpc/src/notification.rs @@ -1,7 +1,7 @@ +use crate::proto; use serde::{Deserialize, Serialize}; use serde_json::{map, Value}; -use std::borrow::Cow; -use strum::{EnumVariantNames, IntoStaticStr, VariantNames as _}; +use strum::{EnumVariantNames, VariantNames as _}; const KIND: &'static str = "kind"; const ACTOR_ID: &'static str = "actor_id"; @@ -9,10 +9,12 @@ const ACTOR_ID: &'static str = "actor_id"; /// A notification that can be stored, associated with a given user. /// /// This struct is stored in the collab database as JSON, so it shouldn't be -/// changed in a backward-incompatible way. +/// changed in a backward-incompatible way. For example, when renaming a +/// variant, add a serde alias for the old name. /// -/// For example, when renaming a variant, add a serde alias for the old name. -#[derive(Debug, Clone, PartialEq, Eq, EnumVariantNames, IntoStaticStr, Serialize, Deserialize)] +/// When a notification is initiated by a user, use the `actor_id` field +/// to store the user's id. +#[derive(Debug, Clone, PartialEq, Eq, EnumVariantNames, Serialize, Deserialize)] #[serde(tag = "kind")] pub enum Notification { ContactRequest { @@ -32,36 +34,28 @@ pub enum Notification { }, } -/// The representation of a notification that is stored in the database and -/// sent over the wire. -#[derive(Debug)] -pub struct AnyNotification { - pub kind: Cow<'static, str>, - pub actor_id: Option, - pub content: String, -} - impl Notification { - pub fn to_any(&self) -> AnyNotification { - let kind: &'static str = self.into(); + pub fn to_proto(&self) -> proto::Notification { let mut value = serde_json::to_value(self).unwrap(); let mut actor_id = None; - if let Some(value) = value.as_object_mut() { - value.remove(KIND); - if let map::Entry::Occupied(e) = value.entry(ACTOR_ID) { - if e.get().is_u64() { - actor_id = e.remove().as_u64(); - } + let value = value.as_object_mut().unwrap(); + let Some(Value::String(kind)) = value.remove(KIND) else { + unreachable!() + }; + if let map::Entry::Occupied(e) = value.entry(ACTOR_ID) { + if e.get().is_u64() { + actor_id = e.remove().as_u64(); } } - AnyNotification { - kind: Cow::Borrowed(kind), + proto::Notification { + kind, actor_id, content: serde_json::to_string(&value).unwrap(), + ..Default::default() } } - pub fn from_any(notification: &AnyNotification) -> Option { + pub fn from_proto(notification: &proto::Notification) -> Option { let mut value = serde_json::from_str::(¬ification.content).ok()?; let object = value.as_object_mut()?; object.insert(KIND.into(), notification.kind.to_string().into()); @@ -92,13 +86,13 @@ fn test_notification() { message_id: 1, }, ] { - let serialized = notification.to_any(); - let deserialized = Notification::from_any(&serialized).unwrap(); + let message = notification.to_proto(); + let deserialized = Notification::from_proto(&message).unwrap(); assert_eq!(deserialized, notification); } // When notifications are serialized, the `kind` and `actor_id` fields are // stored separately, and do not appear redundantly in the JSON. let notification = Notification::ContactRequest { actor_id: 1 }; - assert_eq!(notification.to_any().content, "{}"); + assert_eq!(notification.to_proto().content, "{}"); } From cb7b011d6be5ba45022c62446448a2a46afe6341 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 13 Oct 2023 16:57:28 -0700 Subject: [PATCH 20/90] Avoid creating duplicate invite notifications --- .../20221109000000_test_schema.sql | 2 +- crates/collab/src/db/queries/channels.rs | 13 ++- crates/collab/src/db/queries/contacts.rs | 8 +- crates/collab/src/db/queries/notifications.rs | 83 +++++++++++++------ crates/collab/src/rpc.rs | 40 ++++++--- crates/collab_ui/src/notification_panel.rs | 7 +- 6 files changed, 109 insertions(+), 44 deletions(-) diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index a10155fd1d..4372d7dc8a 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -330,4 +330,4 @@ CREATE TABLE "notifications" ( "content" TEXT ); -CREATE INDEX "index_notifications_on_recipient_id" ON "notifications" ("recipient_id"); +CREATE INDEX "index_notifications_on_recipient_id_is_read" ON "notifications" ("recipient_id", "is_read"); diff --git a/crates/collab/src/db/queries/channels.rs b/crates/collab/src/db/queries/channels.rs index c576d2406b..d64b8028e3 100644 --- a/crates/collab/src/db/queries/channels.rs +++ b/crates/collab/src/db/queries/channels.rs @@ -161,7 +161,7 @@ impl Database { invitee_id: UserId, inviter_id: UserId, is_admin: bool, - ) -> Result<()> { + ) -> Result> { self.transaction(move |tx| async move { self.check_user_is_channel_admin(channel_id, inviter_id, &*tx) .await?; @@ -176,7 +176,16 @@ impl Database { .insert(&*tx) .await?; - Ok(()) + self.create_notification( + invitee_id, + rpc::Notification::ChannelInvitation { + actor_id: inviter_id.to_proto(), + channel_id: channel_id.to_proto(), + }, + true, + &*tx, + ) + .await }) .await } diff --git a/crates/collab/src/db/queries/contacts.rs b/crates/collab/src/db/queries/contacts.rs index ddb7959ef2..709ed941f7 100644 --- a/crates/collab/src/db/queries/contacts.rs +++ b/crates/collab/src/db/queries/contacts.rs @@ -123,7 +123,7 @@ impl Database { &self, sender_id: UserId, receiver_id: UserId, - ) -> Result { + ) -> Result> { self.transaction(|tx| async move { let (id_a, id_b, a_to_b) = if sender_id < receiver_id { (sender_id, receiver_id, true) @@ -169,6 +169,7 @@ impl Database { rpc::Notification::ContactRequest { actor_id: sender_id.to_proto(), }, + true, &*tx, ) .await @@ -212,7 +213,7 @@ impl Database { let mut deleted_notification_id = None; if !contact.accepted { deleted_notification_id = self - .delete_notification( + .remove_notification( responder_id, rpc::Notification::ContactRequest { actor_id: requester_id.to_proto(), @@ -273,7 +274,7 @@ impl Database { responder_id: UserId, requester_id: UserId, accept: bool, - ) -> Result { + ) -> Result> { self.transaction(|tx| async move { let (id_a, id_b, a_to_b) = if responder_id < requester_id { (responder_id, requester_id, false) @@ -320,6 +321,7 @@ impl Database { rpc::Notification::ContactRequestAccepted { actor_id: responder_id.to_proto(), }, + true, &*tx, ) .await diff --git a/crates/collab/src/db/queries/notifications.rs b/crates/collab/src/db/queries/notifications.rs index bf9c9d74ef..b8b2a15421 100644 --- a/crates/collab/src/db/queries/notifications.rs +++ b/crates/collab/src/db/queries/notifications.rs @@ -51,18 +51,12 @@ impl Database { .await?; while let Some(row) = rows.next().await { let row = row?; - let Some(kind) = self.notification_kinds_by_id.get(&row.kind) else { - log::warn!("unknown notification kind {:?}", row.kind); - continue; - }; - result.push(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, - content: row.content, - actor_id: row.actor_id.map(|id| id.to_proto()), - }); + let kind = row.kind; + if let Some(proto) = self.model_to_proto(row) { + result.push(proto); + } else { + log::warn!("unknown notification kind {:?}", kind); + } } result.reverse(); Ok(result) @@ -74,19 +68,48 @@ impl Database { &self, recipient_id: UserId, notification: Notification, + avoid_duplicates: bool, tx: &DatabaseTransaction, - ) -> Result { - let notification = notification.to_proto(); + ) -> Result> { + let notification_proto = notification.to_proto(); let kind = *self .notification_kinds_by_name - .get(¬ification.kind) - .ok_or_else(|| anyhow!("invalid notification kind {:?}", notification.kind))?; + .get(¬ification_proto.kind) + .ok_or_else(|| anyhow!("invalid notification kind {:?}", notification_proto.kind))?; + let actor_id = notification_proto.actor_id.map(|id| UserId::from_proto(id)); + + if avoid_duplicates { + let mut existing_notifications = notification::Entity::find() + .filter( + Condition::all() + .add(notification::Column::RecipientId.eq(recipient_id)) + .add(notification::Column::IsRead.eq(false)) + .add(notification::Column::Kind.eq(kind)) + .add(notification::Column::ActorId.eq(actor_id)), + ) + .stream(&*tx) + .await?; + + // Check if this notification already exists. Don't rely on the + // JSON serialization being identical, in case the notification enum + // is changed in backward-compatible ways over time. + while let Some(row) = existing_notifications.next().await { + let row = row?; + if let Some(proto) = self.model_to_proto(row) { + if let Some(existing) = Notification::from_proto(&proto) { + if existing == notification { + return Ok(None); + } + } + } + } + } let model = notification::ActiveModel { recipient_id: ActiveValue::Set(recipient_id), kind: ActiveValue::Set(kind), - content: ActiveValue::Set(notification.content.clone()), - actor_id: ActiveValue::Set(notification.actor_id.map(|id| UserId::from_proto(id))), + content: ActiveValue::Set(notification_proto.content.clone()), + actor_id: ActiveValue::Set(actor_id), is_read: ActiveValue::NotSet, created_at: ActiveValue::NotSet, id: ActiveValue::NotSet, @@ -94,17 +117,17 @@ impl Database { .save(&*tx) .await?; - Ok(proto::Notification { + Ok(Some(proto::Notification { id: model.id.as_ref().to_proto(), - kind: notification.kind.to_string(), + kind: notification_proto.kind.to_string(), timestamp: model.created_at.as_ref().assume_utc().unix_timestamp() as u64, is_read: false, - content: notification.content, - actor_id: notification.actor_id, - }) + content: notification_proto.content, + actor_id: notification_proto.actor_id, + })) } - pub async fn delete_notification( + pub async fn remove_notification( &self, recipient_id: UserId, notification: Notification, @@ -133,4 +156,16 @@ impl Database { } Ok(notification.map(|notification| notification.id)) } + + fn model_to_proto(&self, row: notification::Model) -> Option { + let kind = self.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, + content: row.content, + actor_id: row.actor_id.map(|id| id.to_proto()), + }) + } } diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 7a3cdb13ab..cd82490649 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2097,12 +2097,14 @@ async fn request_contact( .user_connection_ids(responder_id) { session.peer.send(connection_id, update.clone())?; - session.peer.send( - connection_id, - proto::NewNotification { - notification: Some(notification.clone()), - }, - )?; + if let Some(notification) = ¬ification { + session.peer.send( + connection_id, + proto::NewNotification { + notification: Some(notification.clone()), + }, + )?; + } } response.send(proto::Ack {})?; @@ -2156,12 +2158,14 @@ async fn respond_to_contact_request( .push(responder_id.to_proto()); for connection_id in pool.user_connection_ids(requester_id) { session.peer.send(connection_id, update.clone())?; - session.peer.send( - connection_id, - proto::NewNotification { - notification: Some(notification.clone()), - }, - )?; + if let Some(notification) = ¬ification { + session.peer.send( + connection_id, + proto::NewNotification { + notification: Some(notification.clone()), + }, + )?; + } } } @@ -2306,7 +2310,8 @@ async fn invite_channel_member( let db = session.db().await; let channel_id = ChannelId::from_proto(request.channel_id); let invitee_id = UserId::from_proto(request.user_id); - db.invite_channel_member(channel_id, invitee_id, session.user_id, request.admin) + let notification = db + .invite_channel_member(channel_id, invitee_id, session.user_id, request.admin) .await?; let (channel, _) = db @@ -2319,12 +2324,21 @@ async fn invite_channel_member( id: channel.id.to_proto(), name: channel.name, }); + for connection_id in session .connection_pool() .await .user_connection_ids(invitee_id) { session.peer.send(connection_id, update.clone())?; + if let Some(notification) = ¬ification { + session.peer.send( + connection_id, + proto::NewNotification { + notification: Some(notification.clone()), + }, + )?; + } } response.send(proto::Ack {})?; diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index 978255a081..9f69b7144c 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -209,7 +209,12 @@ impl NotificationPanel { channel_id, } => { actor = user_store.get_cached_user(inviter_id)?; - let channel = channel_store.channel_for_id(channel_id)?; + let channel = channel_store.channel_for_id(channel_id).or_else(|| { + channel_store + .channel_invitations() + .iter() + .find(|c| c.id == channel_id) + })?; icon = "icons/hash.svg"; text = format!( From ff245c61d2eb9bf2da51c8f9feb2cd091b697554 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 13 Oct 2023 17:10:46 -0700 Subject: [PATCH 21/90] Reduce duplication in notification queries --- crates/collab/src/db/queries/notifications.rs | 89 ++++++++++--------- 1 file changed, 48 insertions(+), 41 deletions(-) diff --git a/crates/collab/src/db/queries/notifications.rs b/crates/collab/src/db/queries/notifications.rs index b8b2a15421..50e961957c 100644 --- a/crates/collab/src/db/queries/notifications.rs +++ b/crates/collab/src/db/queries/notifications.rs @@ -71,6 +71,16 @@ impl Database { avoid_duplicates: bool, tx: &DatabaseTransaction, ) -> Result> { + if avoid_duplicates { + if self + .find_notification(recipient_id, ¬ification, tx) + .await? + .is_some() + { + return Ok(None); + } + } + let notification_proto = notification.to_proto(); let kind = *self .notification_kinds_by_name @@ -78,33 +88,6 @@ impl Database { .ok_or_else(|| anyhow!("invalid notification kind {:?}", notification_proto.kind))?; let actor_id = notification_proto.actor_id.map(|id| UserId::from_proto(id)); - if avoid_duplicates { - let mut existing_notifications = notification::Entity::find() - .filter( - Condition::all() - .add(notification::Column::RecipientId.eq(recipient_id)) - .add(notification::Column::IsRead.eq(false)) - .add(notification::Column::Kind.eq(kind)) - .add(notification::Column::ActorId.eq(actor_id)), - ) - .stream(&*tx) - .await?; - - // Check if this notification already exists. Don't rely on the - // JSON serialization being identical, in case the notification enum - // is changed in backward-compatible ways over time. - while let Some(row) = existing_notifications.next().await { - let row = row?; - if let Some(proto) = self.model_to_proto(row) { - if let Some(existing) = Notification::from_proto(&proto) { - if existing == notification { - return Ok(None); - } - } - } - } - } - let model = notification::ActiveModel { recipient_id: ActiveValue::Set(recipient_id), kind: ActiveValue::Set(kind), @@ -119,7 +102,7 @@ impl Database { Ok(Some(proto::Notification { id: model.id.as_ref().to_proto(), - kind: notification_proto.kind.to_string(), + kind: notification_proto.kind, timestamp: model.created_at.as_ref().assume_utc().unix_timestamp() as u64, is_read: false, content: notification_proto.content, @@ -133,28 +116,52 @@ impl Database { notification: Notification, tx: &DatabaseTransaction, ) -> Result> { - let notification = notification.to_proto(); + 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) + } + + pub async fn find_notification( + &self, + recipient_id: UserId, + notification: &Notification, + tx: &DatabaseTransaction, + ) -> Result> { + let proto = notification.to_proto(); let kind = *self .notification_kinds_by_name - .get(¬ification.kind) - .ok_or_else(|| anyhow!("invalid notification kind {:?}", notification.kind))?; - let actor_id = notification.actor_id.map(|id| UserId::from_proto(id)); - let notification = notification::Entity::find() + .get(&proto.kind) + .ok_or_else(|| anyhow!("invalid notification kind {:?}", proto.kind))?; + let mut rows = notification::Entity::find() .filter( Condition::all() .add(notification::Column::RecipientId.eq(recipient_id)) + .add(notification::Column::IsRead.eq(false)) .add(notification::Column::Kind.eq(kind)) - .add(notification::Column::ActorId.eq(actor_id)) - .add(notification::Column::Content.eq(notification.content)), + .add(notification::Column::ActorId.eq(proto.actor_id)), ) - .one(tx) + .stream(&*tx) .await?; - if let Some(notification) = ¬ification { - notification::Entity::delete_by_id(notification.id) - .exec(tx) - .await?; + + // Don't rely on the JSON serialization being identical, in case the + // notification type is changed in backward-compatible ways. + while let Some(row) = rows.next().await { + let row = row?; + let id = row.id; + if let Some(proto) = self.model_to_proto(row) { + if let Some(existing) = Notification::from_proto(&proto) { + if existing == *notification { + return Ok(Some(id)); + } + } + } } - Ok(notification.map(|notification| notification.id)) + + Ok(None) } fn model_to_proto(&self, row: notification::Model) -> Option { From 40755961ea0d0f3e252e2248b027fdbf21a2f659 Mon Sep 17 00:00:00 2001 From: KCaverly Date: Mon, 16 Oct 2023 11:54:32 -0400 Subject: [PATCH 22/90] added initial template outline --- crates/ai/src/ai.rs | 1 + crates/ai/src/templates.rs | 76 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 crates/ai/src/templates.rs diff --git a/crates/ai/src/ai.rs b/crates/ai/src/ai.rs index 5256a6a643..04e9e14536 100644 --- a/crates/ai/src/ai.rs +++ b/crates/ai/src/ai.rs @@ -1,2 +1,3 @@ pub mod completion; pub mod embedding; +pub mod templates; diff --git a/crates/ai/src/templates.rs b/crates/ai/src/templates.rs new file mode 100644 index 0000000000..d9771ce569 --- /dev/null +++ b/crates/ai/src/templates.rs @@ -0,0 +1,76 @@ +use std::fmt::Write; + +pub struct PromptCodeSnippet { + path: Option, + language_name: Option, + content: String, +} + +enum PromptFileType { + Text, + Code, +} + +#[derive(Default)] +struct PromptArguments { + pub language_name: Option, + pub project_name: Option, + pub snippets: Vec, +} + +impl PromptArguments { + pub 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 + } + } +} + +trait PromptTemplate { + fn generate(args: PromptArguments) -> String; +} + +struct EngineerPreamble {} + +impl PromptTemplate for EngineerPreamble { + fn generate(args: PromptArguments) -> String { + let mut prompt = String::new(); + + match args.get_file_type() { + PromptFileType::Code => { + writeln!( + prompt, + "You are an expert {} engineer.", + args.language_name.unwrap_or("".to_string()) + ) + .unwrap(); + } + PromptFileType::Text => { + writeln!(prompt, "You are an expert engineer.").unwrap(); + } + } + + if let Some(project_name) = args.project_name { + writeln!( + prompt, + "You are currently working inside the '{project_name}' in Zed the code editor." + ) + .unwrap(); + } + + prompt + } +} + +struct RepositorySnippets {} + +impl PromptTemplate for RepositorySnippets { + fn generate(args: PromptArguments) -> String {} +} From c66385f0f9099b89f07f2a6997da182218b4f69e Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 16 Oct 2023 12:54:44 -0700 Subject: [PATCH 23/90] Add an empty state to the notification panel --- crates/collab_ui/src/notification_panel.rs | 21 ++++++++++++++++++--- crates/gpui/src/elements/list.rs | 4 ++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index 9f69b7144c..7bf5000ec8 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -299,6 +299,19 @@ impl NotificationPanel { .into_any() } + fn render_empty_state( + &self, + theme: &Arc, + _cx: &mut ViewContext, + ) -> AnyElement { + 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, @@ -373,13 +386,15 @@ impl View for NotificationPanel { fn render(&mut self, cx: &mut ViewContext) -> AnyElement { let theme = theme::current(cx); - let element = if self.client.user_id().is_some() { + 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 { List::new(self.notification_list.clone()) .contained() .with_style(theme.chat_panel.list) .into_any() - } else { - self.render_sign_in_prompt(&theme, cx) }; element .contained() diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index a23b6fc5e3..eaa09a0392 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -378,6 +378,10 @@ impl ListState { .extend((0..element_count).map(|_| ListItem::Unrendered), &()); } + pub fn item_count(&self) -> usize { + self.0.borrow().items.summary().count + } + pub fn splice(&self, old_range: Range, count: usize) { let state = &mut *self.0.borrow_mut(); From 500af6d7754adf1a60f245200271e4dd40d7fb8f Mon Sep 17 00:00:00 2001 From: KCaverly Date: Mon, 16 Oct 2023 18:47:10 -0400 Subject: [PATCH 24/90] progress on prompt chains --- Cargo.lock | 1 + crates/ai/Cargo.toml | 1 + crates/ai/src/prompts.rs | 149 ++++++++++++++++++ crates/ai/src/templates.rs | 76 --------- crates/ai/src/templates/base.rs | 112 +++++++++++++ crates/ai/src/templates/mod.rs | 3 + crates/ai/src/templates/preamble.rs | 34 ++++ crates/ai/src/templates/repository_context.rs | 49 ++++++ 8 files changed, 349 insertions(+), 76 deletions(-) create mode 100644 crates/ai/src/prompts.rs delete mode 100644 crates/ai/src/templates.rs create mode 100644 crates/ai/src/templates/base.rs create mode 100644 crates/ai/src/templates/mod.rs create mode 100644 crates/ai/src/templates/preamble.rs create mode 100644 crates/ai/src/templates/repository_context.rs diff --git a/Cargo.lock b/Cargo.lock index cd9dee0bda..9938c5d2fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,6 +91,7 @@ dependencies = [ "futures 0.3.28", "gpui", "isahc", + "language", "lazy_static", "log", "matrixmultiply", diff --git a/crates/ai/Cargo.toml b/crates/ai/Cargo.toml index 542d7f422f..b24c4e5ece 100644 --- a/crates/ai/Cargo.toml +++ b/crates/ai/Cargo.toml @@ -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 diff --git a/crates/ai/src/prompts.rs b/crates/ai/src/prompts.rs new file mode 100644 index 0000000000..6d2c0629fa --- /dev/null +++ b/crates/ai/src/prompts.rs @@ -0,0 +1,149 @@ +use gpui::{AsyncAppContext, ModelHandle}; +use language::{Anchor, Buffer}; +use std::{fmt::Write, ops::Range, path::PathBuf}; + +pub struct PromptCodeSnippet { + path: Option, + language_name: Option, + content: String, +} + +impl PromptCodeSnippet { + pub fn new(buffer: ModelHandle, range: Range, 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::(); + + let language_name = buffer + .language() + .and_then(|language| Some(language.name().to_string())); + + let file_path = buffer + .file() + .and_then(|file| Some(file.path().to_path_buf())); + + (content, language_name, file_path) + }); + + PromptCodeSnippet { + path: file_path, + language_name, + content, + } + } +} + +impl ToString for PromptCodeSnippet { + fn to_string(&self) -> String { + let path = self + .path + .as_ref() + .and_then(|path| Some(path.to_string_lossy().to_string())) + .unwrap_or("".to_string()); + let language_name = self.language_name.clone().unwrap_or("".to_string()); + let content = self.content.clone(); + + format!("The below code snippet may be relevant from file: {path}\n```{language_name}\n{content}\n```") + } +} + +enum PromptFileType { + Text, + Code, +} + +#[derive(Default)] +struct PromptArguments { + pub language_name: Option, + pub project_name: Option, + pub snippets: Vec, + pub model_name: String, +} + +impl PromptArguments { + pub 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 + } + } +} + +trait PromptTemplate { + fn generate(args: PromptArguments, max_token_length: Option) -> String; +} + +struct EngineerPreamble {} + +impl PromptTemplate for EngineerPreamble { + fn generate(args: PromptArguments, max_token_length: Option) -> String { + let mut prompt = String::new(); + + match args.get_file_type() { + PromptFileType::Code => { + writeln!( + prompt, + "You are an expert {} engineer.", + args.language_name.unwrap_or("".to_string()) + ) + .unwrap(); + } + PromptFileType::Text => { + writeln!(prompt, "You are an expert engineer.").unwrap(); + } + } + + if let Some(project_name) = args.project_name { + writeln!( + prompt, + "You are currently working inside the '{project_name}' in Zed the code editor." + ) + .unwrap(); + } + + prompt + } +} + +struct RepositorySnippets {} + +impl PromptTemplate for RepositorySnippets { + fn generate(args: PromptArguments, max_token_length: Option) -> String { + const MAXIMUM_SNIPPET_TOKEN_COUNT: usize = 500; + let mut template = "You are working inside a large repository, here are a few code snippets that may be useful"; + let mut prompt = String::new(); + + if let Ok(encoding) = tiktoken_rs::get_bpe_from_model(args.model_name.as_str()) { + let default_token_count = + tiktoken_rs::model::get_context_size(args.model_name.as_str()); + let mut remaining_token_count = max_token_length.unwrap_or(default_token_count); + + 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 = encoding + .encode_with_special_tokens(snippet_prompt.as_str()) + .len(); + if token_count <= remaining_token_count { + if token_count < MAXIMUM_SNIPPET_TOKEN_COUNT { + writeln!(prompt, "{snippet_prompt}").unwrap(); + remaining_token_count -= token_count; + template = ""; + } + } else { + break; + } + } + } + + prompt + } +} diff --git a/crates/ai/src/templates.rs b/crates/ai/src/templates.rs deleted file mode 100644 index d9771ce569..0000000000 --- a/crates/ai/src/templates.rs +++ /dev/null @@ -1,76 +0,0 @@ -use std::fmt::Write; - -pub struct PromptCodeSnippet { - path: Option, - language_name: Option, - content: String, -} - -enum PromptFileType { - Text, - Code, -} - -#[derive(Default)] -struct PromptArguments { - pub language_name: Option, - pub project_name: Option, - pub snippets: Vec, -} - -impl PromptArguments { - pub 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 - } - } -} - -trait PromptTemplate { - fn generate(args: PromptArguments) -> String; -} - -struct EngineerPreamble {} - -impl PromptTemplate for EngineerPreamble { - fn generate(args: PromptArguments) -> String { - let mut prompt = String::new(); - - match args.get_file_type() { - PromptFileType::Code => { - writeln!( - prompt, - "You are an expert {} engineer.", - args.language_name.unwrap_or("".to_string()) - ) - .unwrap(); - } - PromptFileType::Text => { - writeln!(prompt, "You are an expert engineer.").unwrap(); - } - } - - if let Some(project_name) = args.project_name { - writeln!( - prompt, - "You are currently working inside the '{project_name}' in Zed the code editor." - ) - .unwrap(); - } - - prompt - } -} - -struct RepositorySnippets {} - -impl PromptTemplate for RepositorySnippets { - fn generate(args: PromptArguments) -> String {} -} diff --git a/crates/ai/src/templates/base.rs b/crates/ai/src/templates/base.rs new file mode 100644 index 0000000000..3d8479e512 --- /dev/null +++ b/crates/ai/src/templates/base.rs @@ -0,0 +1,112 @@ +use std::cmp::Reverse; + +use crate::templates::repository_context::PromptCodeSnippet; + +pub(crate) enum PromptFileType { + Text, + Code, +} + +#[derive(Default)] +pub struct PromptArguments { + pub model_name: String, + pub language_name: Option, + pub project_name: Option, + pub snippets: Vec, + pub reserved_tokens: 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) -> String; +} + +#[repr(i8)] +#[derive(PartialEq, Eq, PartialOrd, Ord)] +pub enum PromptPriority { + Low, + Medium, + High, +} + +pub struct PromptChain { + args: PromptArguments, + templates: Vec<(PromptPriority, Box)>, +} + +impl PromptChain { + pub fn new( + args: PromptArguments, + templates: Vec<(PromptPriority, Box)>, + ) -> Self { + // templates.sort_by(|a, b| a.0.cmp(&b.0)); + + PromptChain { args, templates } + } + + pub fn generate(&self, truncate: bool) -> anyhow::Result { + // Argsort based on Prompt Priority + let mut sorted_indices = (0..self.templates.len()).collect::>(); + sorted_indices.sort_by_key(|&i| Reverse(&self.templates[i].0)); + + println!("{:?}", sorted_indices); + + let mut prompts = Vec::new(); + for (_, template) in &self.templates { + prompts.push(template.generate(&self.args, None)); + } + + anyhow::Ok(prompts.join("\n")) + } +} + +#[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) -> String { + "This is a test prompt template".to_string() + } + } + + struct TestLowPriorityTemplate {} + impl PromptTemplate for TestLowPriorityTemplate { + fn generate(&self, args: &PromptArguments, max_token_length: Option) -> String { + "This is a low priority test prompt template".to_string() + } + } + + let args = PromptArguments { + model_name: "gpt-4".to_string(), + ..Default::default() + }; + + let templates: Vec<(PromptPriority, Box)> = vec![ + (PromptPriority::High, Box::new(TestPromptTemplate {})), + (PromptPriority::Medium, Box::new(TestLowPriorityTemplate {})), + ]; + let chain = PromptChain::new(args, templates); + + let prompt = chain.generate(false); + println!("{:?}", prompt); + panic!(); + } +} diff --git a/crates/ai/src/templates/mod.rs b/crates/ai/src/templates/mod.rs new file mode 100644 index 0000000000..62cb600eca --- /dev/null +++ b/crates/ai/src/templates/mod.rs @@ -0,0 +1,3 @@ +pub mod base; +pub mod preamble; +pub mod repository_context; diff --git a/crates/ai/src/templates/preamble.rs b/crates/ai/src/templates/preamble.rs new file mode 100644 index 0000000000..b1d33f885e --- /dev/null +++ b/crates/ai/src/templates/preamble.rs @@ -0,0 +1,34 @@ +use crate::templates::base::{PromptArguments, PromptFileType, PromptTemplate}; +use std::fmt::Write; + +struct EngineerPreamble {} + +impl PromptTemplate for EngineerPreamble { + fn generate(&self, args: &PromptArguments, max_token_length: Option) -> String { + let mut prompt = String::new(); + + match args.get_file_type() { + PromptFileType::Code => { + writeln!( + prompt, + "You are an expert {} engineer.", + args.language_name.clone().unwrap_or("".to_string()) + ) + .unwrap(); + } + PromptFileType::Text => { + writeln!(prompt, "You are an expert engineer.").unwrap(); + } + } + + if let Some(project_name) = args.project_name.clone() { + writeln!( + prompt, + "You are currently working inside the '{project_name}' in Zed the code editor." + ) + .unwrap(); + } + + prompt + } +} diff --git a/crates/ai/src/templates/repository_context.rs b/crates/ai/src/templates/repository_context.rs new file mode 100644 index 0000000000..f9c2253c65 --- /dev/null +++ b/crates/ai/src/templates/repository_context.rs @@ -0,0 +1,49 @@ +use std::{ops::Range, path::PathBuf}; + +use gpui::{AsyncAppContext, ModelHandle}; +use language::{Anchor, Buffer}; + +pub struct PromptCodeSnippet { + path: Option, + language_name: Option, + content: String, +} + +impl PromptCodeSnippet { + pub fn new(buffer: ModelHandle, range: Range, 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::(); + + let language_name = buffer + .language() + .and_then(|language| Some(language.name().to_string())); + + let file_path = buffer + .file() + .and_then(|file| Some(file.path().to_path_buf())); + + (content, language_name, file_path) + }); + + PromptCodeSnippet { + path: file_path, + language_name, + content, + } + } +} + +impl ToString for PromptCodeSnippet { + fn to_string(&self) -> String { + let path = self + .path + .as_ref() + .and_then(|path| Some(path.to_string_lossy().to_string())) + .unwrap_or("".to_string()); + let language_name = self.language_name.clone().unwrap_or("".to_string()); + let content = self.content.clone(); + + format!("The below code snippet may be relevant from file: {path}\n```{language_name}\n{content}\n```") + } +} From ad92fe49c7deeb098dcd442bc996602630f4f056 Mon Sep 17 00:00:00 2001 From: KCaverly Date: Tue, 17 Oct 2023 11:58:45 -0400 Subject: [PATCH 25/90] implement initial concept of prompt chain --- crates/ai/src/templates/base.rs | 229 +++++++++++++++++++++++++++++--- 1 file changed, 208 insertions(+), 21 deletions(-) diff --git a/crates/ai/src/templates/base.rs b/crates/ai/src/templates/base.rs index 3d8479e512..74a4c424ae 100644 --- a/crates/ai/src/templates/base.rs +++ b/crates/ai/src/templates/base.rs @@ -1,15 +1,25 @@ -use std::cmp::Reverse; +use std::fmt::Write; +use std::{cmp::Reverse, sync::Arc}; + +use util::ResultExt; use crate::templates::repository_context::PromptCodeSnippet; +pub trait LanguageModel { + fn name(&self) -> String; + fn count_tokens(&self, content: &str) -> usize; + fn truncate(&self, content: &str, length: usize) -> String; + fn capacity(&self) -> usize; +} + pub(crate) enum PromptFileType { Text, Code, } -#[derive(Default)] +// TODO: Set this up to manage for defaults well pub struct PromptArguments { - pub model_name: String, + pub model: Arc, pub language_name: Option, pub project_name: Option, pub snippets: Vec, @@ -32,7 +42,11 @@ impl PromptArguments { } pub trait PromptTemplate { - fn generate(&self, args: &PromptArguments, max_token_length: Option) -> String; + fn generate( + &self, + args: &PromptArguments, + max_token_length: Option, + ) -> anyhow::Result<(String, usize)>; } #[repr(i8)] @@ -53,24 +67,52 @@ impl PromptChain { args: PromptArguments, templates: Vec<(PromptPriority, Box)>, ) -> Self { - // templates.sort_by(|a, b| a.0.cmp(&b.0)); - PromptChain { args, templates } } - pub fn generate(&self, truncate: bool) -> anyhow::Result { + 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::>(); sorted_indices.sort_by_key(|&i| Reverse(&self.templates[i].0)); - println!("{:?}", sorted_indices); - let mut prompts = Vec::new(); - for (_, template) in &self.templates { - prompts.push(template.generate(&self.args, None)); + + // If Truncate + let mut tokens_outstanding = if truncate { + Some(self.args.model.capacity() - self.args.reserved_tokens) + } else { + None + }; + + 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() + { + println!( + "GENERATED PROMPT ({:?}): {:?}", + &prompt_token_count, &template_prompt + ); + if template_prompt != "" { + prompts.push(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) + }; + } + } + } } - anyhow::Ok(prompts.join("\n")) + 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)) } } @@ -82,21 +124,81 @@ pub(crate) mod tests { pub fn test_prompt_chain() { struct TestPromptTemplate {} impl PromptTemplate for TestPromptTemplate { - fn generate(&self, args: &PromptArguments, max_token_length: Option) -> String { - "This is a test prompt template".to_string() + fn generate( + &self, + args: &PromptArguments, + max_token_length: Option, + ) -> 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) -> String { - "This is a low priority test prompt template".to_string() + fn generate( + &self, + args: &PromptArguments, + max_token_length: Option, + ) -> 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 DummyLanguageModel { + fn set_capacity(&mut self, capacity: usize) { + self.capacity = capacity + } + } + + impl LanguageModel for DummyLanguageModel { + fn name(&self) -> String { + "dummy".to_string() + } + fn count_tokens(&self, content: &str) -> usize { + content.chars().collect::>().len() + } + fn truncate(&self, content: &str, length: usize) -> String { + content.chars().collect::>()[..length] + .into_iter() + .collect::() + } + fn capacity(&self) -> usize { + self.capacity + } + } + + let model: Arc = Arc::new(DummyLanguageModel { capacity: 100 }); let args = PromptArguments { - model_name: "gpt-4".to_string(), - ..Default::default() + model: model.clone(), + language_name: None, + project_name: None, + snippets: Vec::new(), + reserved_tokens: 0, }; let templates: Vec<(PromptPriority, Box)> = vec![ @@ -105,8 +207,93 @@ pub(crate) mod tests { ]; let chain = PromptChain::new(args, templates); - let prompt = chain.generate(false); - println!("{:?}", prompt); - panic!(); + 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), token_count); + + // Testing with Truncation Off + // Should ignore capacity and return all prompts + let model: Arc = Arc::new(DummyLanguageModel { capacity: 20 }); + let args = PromptArguments { + model: model.clone(), + language_name: None, + project_name: None, + snippets: Vec::new(), + reserved_tokens: 0, + }; + + let templates: Vec<(PromptPriority, Box)> = vec![ + (PromptPriority::High, Box::new(TestPromptTemplate {})), + (PromptPriority::Medium, 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), token_count); + + // Testing with Truncation Off + // Should ignore capacity and return all prompts + let capacity = 20; + let model: Arc = Arc::new(DummyLanguageModel { capacity }); + let args = PromptArguments { + model: model.clone(), + language_name: None, + project_name: None, + snippets: Vec::new(), + reserved_tokens: 0, + }; + + let templates: Vec<(PromptPriority, Box)> = vec![ + (PromptPriority::High, Box::new(TestPromptTemplate {})), + (PromptPriority::Medium, Box::new(TestLowPriorityTemplate {})), + (PromptPriority::Low, 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 = Arc::new(DummyLanguageModel { capacity }); + let args = PromptArguments { + model: model.clone(), + language_name: None, + project_name: None, + snippets: Vec::new(), + reserved_tokens, + }; + let templates: Vec<(PromptPriority, Box)> = vec![ + (PromptPriority::Medium, Box::new(TestPromptTemplate {})), + (PromptPriority::High, Box::new(TestLowPriorityTemplate {})), + (PromptPriority::Low, Box::new(TestLowPriorityTemplate {})), + ]; + let chain = PromptChain::new(args, templates); + + let (prompt, token_count) = chain.generate(true).unwrap(); + println!("TOKEN COUNT: {:?}", token_count); + + 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); } } From f225039d360e21a84eda2d6c157103d4169af83e Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 17 Oct 2023 09:12:55 -0700 Subject: [PATCH 26/90] Display invite response buttons inline in notification panel --- crates/channel/src/channel_store.rs | 7 +- .../20221109000000_test_schema.sql | 5 +- .../20231004130100_create_notifications.sql | 5 +- crates/collab/src/db.rs | 2 + crates/collab/src/db/queries/channels.rs | 57 ++++--- crates/collab/src/db/queries/contacts.rs | 62 ++++--- crates/collab/src/db/queries/notifications.rs | 84 +++++++--- crates/collab/src/db/tables/notification.rs | 3 +- crates/collab/src/rpc.rs | 82 +++++----- crates/collab/src/tests/channel_tests.rs | 8 +- crates/collab/src/tests/test_server.rs | 8 +- crates/collab_ui/src/collab_panel.rs | 9 +- crates/collab_ui/src/notification_panel.rs | 154 ++++++++++++++---- .../notifications/src/notification_store.rs | 9 +- crates/rpc/proto/zed.proto | 9 +- crates/rpc/src/notification.rs | 11 +- crates/theme/src/theme.rs | 16 ++ styles/src/style_tree/app.ts | 2 + styles/src/style_tree/notification_panel.ts | 57 +++++++ 19 files changed, 421 insertions(+), 169 deletions(-) create mode 100644 styles/src/style_tree/notification_panel.ts diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index 918a1e1dc1..d8dc7896ea 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -673,14 +673,15 @@ impl ChannelStore { &mut self, channel_id: ChannelId, accept: bool, - ) -> impl Future> { + cx: &mut ModelContext, + ) -> Task> { 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( diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 4372d7dc8a..8e714f1444 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -322,12 +322,13 @@ CREATE UNIQUE INDEX "index_notification_kinds_on_name" ON "notification_kinds" ( CREATE TABLE "notifications" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, - "is_read" BOOLEAN NOT NULL DEFAULT FALSE, "created_at" TIMESTAMP NOT NULL default CURRENT_TIMESTAMP, "recipient_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, "actor_id" INTEGER REFERENCES users (id) ON DELETE CASCADE, "kind" INTEGER NOT NULL REFERENCES notification_kinds (id), - "content" TEXT + "content" TEXT, + "is_read" BOOLEAN NOT NULL DEFAULT FALSE, + "response" BOOLEAN ); CREATE INDEX "index_notifications_on_recipient_id_is_read" ON "notifications" ("recipient_id", "is_read"); diff --git a/crates/collab/migrations/20231004130100_create_notifications.sql b/crates/collab/migrations/20231004130100_create_notifications.sql index 83cfd43978..277f16f4e3 100644 --- a/crates/collab/migrations/20231004130100_create_notifications.sql +++ b/crates/collab/migrations/20231004130100_create_notifications.sql @@ -7,12 +7,13 @@ CREATE UNIQUE INDEX "index_notification_kinds_on_name" ON "notification_kinds" ( CREATE TABLE notifications ( "id" SERIAL PRIMARY KEY, - "is_read" BOOLEAN NOT NULL DEFAULT FALSE, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "recipient_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, "actor_id" INTEGER REFERENCES users (id) ON DELETE CASCADE, "kind" INTEGER NOT NULL REFERENCES notification_kinds (id), - "content" TEXT + "content" TEXT, + "is_read" BOOLEAN NOT NULL DEFAULT FALSE, + "response" BOOLEAN ); CREATE INDEX "index_notifications_on_recipient_id" ON "notifications" ("recipient_id"); diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 1bf5c95f6b..852d3645dd 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -384,6 +384,8 @@ impl Contact { } } +pub type NotificationBatch = Vec<(UserId, proto::Notification)>; + #[derive(Clone, Debug, PartialEq, Eq, FromQueryResult, Serialize, Deserialize)] pub struct Invite { pub email_address: String, diff --git a/crates/collab/src/db/queries/channels.rs b/crates/collab/src/db/queries/channels.rs index d64b8028e3..9754c2ac83 100644 --- a/crates/collab/src/db/queries/channels.rs +++ b/crates/collab/src/db/queries/channels.rs @@ -161,7 +161,7 @@ impl Database { invitee_id: UserId, inviter_id: UserId, is_admin: bool, - ) -> Result> { + ) -> Result { self.transaction(move |tx| async move { self.check_user_is_channel_admin(channel_id, inviter_id, &*tx) .await?; @@ -176,16 +176,18 @@ impl Database { .insert(&*tx) .await?; - self.create_notification( - invitee_id, - rpc::Notification::ChannelInvitation { - actor_id: inviter_id.to_proto(), - channel_id: channel_id.to_proto(), - }, - true, - &*tx, - ) - .await + Ok(self + .create_notification( + invitee_id, + rpc::Notification::ChannelInvitation { + channel_id: channel_id.to_proto(), + }, + true, + &*tx, + ) + .await? + .into_iter() + .collect()) }) .await } @@ -228,7 +230,7 @@ impl Database { channel_id: ChannelId, user_id: UserId, accept: bool, - ) -> Result<()> { + ) -> Result { self.transaction(move |tx| async move { let rows_affected = if accept { channel_member::Entity::update_many() @@ -246,21 +248,34 @@ impl Database { .await? .rows_affected } else { - channel_member::ActiveModel { - channel_id: ActiveValue::Unchanged(channel_id), - user_id: ActiveValue::Unchanged(user_id), - ..Default::default() - } - .delete(&*tx) - .await? - .rows_affected + channel_member::Entity::delete_many() + .filter( + channel_member::Column::ChannelId + .eq(channel_id) + .and(channel_member::Column::UserId.eq(user_id)) + .and(channel_member::Column::Accepted.eq(false)), + ) + .exec(&*tx) + .await? + .rows_affected }; if rows_affected == 0 { Err(anyhow!("no such invitation"))?; } - Ok(()) + Ok(self + .respond_to_notification( + user_id, + &rpc::Notification::ChannelInvitation { + channel_id: channel_id.to_proto(), + }, + accept, + &*tx, + ) + .await? + .into_iter() + .collect()) }) .await } diff --git a/crates/collab/src/db/queries/contacts.rs b/crates/collab/src/db/queries/contacts.rs index 709ed941f7..4509bb8495 100644 --- a/crates/collab/src/db/queries/contacts.rs +++ b/crates/collab/src/db/queries/contacts.rs @@ -123,7 +123,7 @@ impl Database { &self, sender_id: UserId, receiver_id: UserId, - ) -> Result> { + ) -> Result { self.transaction(|tx| async move { let (id_a, id_b, a_to_b) = if sender_id < receiver_id { (sender_id, receiver_id, true) @@ -164,15 +164,18 @@ impl Database { Err(anyhow!("contact already requested"))?; } - self.create_notification( - receiver_id, - rpc::Notification::ContactRequest { - actor_id: sender_id.to_proto(), - }, - true, - &*tx, - ) - .await + Ok(self + .create_notification( + receiver_id, + rpc::Notification::ContactRequest { + actor_id: sender_id.to_proto(), + }, + true, + &*tx, + ) + .await? + .into_iter() + .collect()) }) .await } @@ -274,7 +277,7 @@ impl Database { responder_id: UserId, requester_id: UserId, accept: bool, - ) -> Result> { + ) -> Result { self.transaction(|tx| async move { let (id_a, id_b, a_to_b) = if responder_id < requester_id { (responder_id, requester_id, false) @@ -316,15 +319,34 @@ impl Database { Err(anyhow!("no such contact request"))? } - self.create_notification( - requester_id, - rpc::Notification::ContactRequestAccepted { - actor_id: responder_id.to_proto(), - }, - true, - &*tx, - ) - .await + let mut notifications = Vec::new(); + notifications.extend( + self.respond_to_notification( + responder_id, + &rpc::Notification::ContactRequest { + actor_id: requester_id.to_proto(), + }, + accept, + &*tx, + ) + .await?, + ); + + if accept { + notifications.extend( + self.create_notification( + requester_id, + rpc::Notification::ContactRequestAccepted { + actor_id: responder_id.to_proto(), + }, + true, + &*tx, + ) + .await?, + ); + } + + Ok(notifications) }) .await } diff --git a/crates/collab/src/db/queries/notifications.rs b/crates/collab/src/db/queries/notifications.rs index 50e961957c..d4024232b0 100644 --- a/crates/collab/src/db/queries/notifications.rs +++ b/crates/collab/src/db/queries/notifications.rs @@ -52,7 +52,7 @@ impl Database { while let Some(row) = rows.next().await { let row = row?; let kind = row.kind; - if let Some(proto) = self.model_to_proto(row) { + if let Some(proto) = model_to_proto(self, row) { result.push(proto); } else { log::warn!("unknown notification kind {:?}", kind); @@ -70,7 +70,7 @@ impl Database { notification: Notification, avoid_duplicates: bool, tx: &DatabaseTransaction, - ) -> Result> { + ) -> Result> { if avoid_duplicates { if self .find_notification(recipient_id, ¬ification, tx) @@ -94,20 +94,25 @@ impl Database { content: ActiveValue::Set(notification_proto.content.clone()), actor_id: ActiveValue::Set(actor_id), is_read: ActiveValue::NotSet, + response: ActiveValue::NotSet, created_at: ActiveValue::NotSet, id: ActiveValue::NotSet, } .save(&*tx) .await?; - Ok(Some(proto::Notification { - id: model.id.as_ref().to_proto(), - kind: notification_proto.kind, - timestamp: model.created_at.as_ref().assume_utc().unix_timestamp() as u64, - is_read: false, - content: notification_proto.content, - actor_id: notification_proto.actor_id, - })) + Ok(Some(( + recipient_id, + proto::Notification { + id: model.id.as_ref().to_proto(), + kind: notification_proto.kind, + timestamp: model.created_at.as_ref().assume_utc().unix_timestamp() as u64, + is_read: false, + response: None, + content: notification_proto.content, + actor_id: notification_proto.actor_id, + }, + ))) } pub async fn remove_notification( @@ -125,6 +130,32 @@ impl Database { Ok(id) } + pub async fn respond_to_notification( + &self, + recipient_id: UserId, + notification: &Notification, + response: bool, + tx: &DatabaseTransaction, + ) -> Result> { + 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), + response: ActiveValue::Set(Some(response)), + is_read: ActiveValue::Set(true), + ..Default::default() + }) + .exec(tx) + .await?; + Ok(model_to_proto(self, row).map(|notification| (recipient_id, notification))) + } else { + Ok(None) + } + } + pub async fn find_notification( &self, recipient_id: UserId, @@ -142,7 +173,11 @@ impl Database { .add(notification::Column::RecipientId.eq(recipient_id)) .add(notification::Column::IsRead.eq(false)) .add(notification::Column::Kind.eq(kind)) - .add(notification::Column::ActorId.eq(proto.actor_id)), + .add(if proto.actor_id.is_some() { + notification::Column::ActorId.eq(proto.actor_id) + } else { + notification::Column::ActorId.is_null() + }), ) .stream(&*tx) .await?; @@ -152,7 +187,7 @@ impl Database { while let Some(row) = rows.next().await { let row = row?; let id = row.id; - if let Some(proto) = self.model_to_proto(row) { + if let Some(proto) = model_to_proto(self, row) { if let Some(existing) = Notification::from_proto(&proto) { if existing == *notification { return Ok(Some(id)); @@ -163,16 +198,17 @@ impl Database { Ok(None) } - - fn model_to_proto(&self, row: notification::Model) -> Option { - let kind = self.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, - content: row.content, - actor_id: row.actor_id.map(|id| id.to_proto()), - }) - } +} + +fn model_to_proto(this: &Database, row: notification::Model) -> Option { + 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, + actor_id: row.actor_id.map(|id| id.to_proto()), + }) } diff --git a/crates/collab/src/db/tables/notification.rs b/crates/collab/src/db/tables/notification.rs index a35e00fb5b..12517c04f6 100644 --- a/crates/collab/src/db/tables/notification.rs +++ b/crates/collab/src/db/tables/notification.rs @@ -7,12 +7,13 @@ use time::PrimitiveDateTime; pub struct Model { #[sea_orm(primary_key)] pub id: NotificationId, - pub is_read: bool, pub created_at: PrimitiveDateTime, pub recipient_id: UserId, pub actor_id: Option, pub kind: NotificationKindId, pub content: String, + pub is_read: bool, + pub response: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index cd82490649..9f3c22ce97 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2067,7 +2067,7 @@ async fn request_contact( return Err(anyhow!("cannot add yourself as a contact"))?; } - let notification = session + let notifications = session .db() .await .send_contact_request(requester_id, responder_id) @@ -2091,22 +2091,13 @@ async fn request_contact( .push(proto::IncomingContactRequest { requester_id: requester_id.to_proto(), }); - for connection_id in session - .connection_pool() - .await - .user_connection_ids(responder_id) - { + let connection_pool = session.connection_pool().await; + for connection_id in connection_pool.user_connection_ids(responder_id) { session.peer.send(connection_id, update.clone())?; - if let Some(notification) = ¬ification { - session.peer.send( - connection_id, - proto::NewNotification { - notification: Some(notification.clone()), - }, - )?; - } } + send_notifications(&*connection_pool, &session.peer, notifications); + response.send(proto::Ack {})?; Ok(()) } @@ -2125,7 +2116,7 @@ async fn respond_to_contact_request( } else { let accept = request.response == proto::ContactRequestResponse::Accept as i32; - let notification = db + let notifications = db .respond_to_contact_request(responder_id, requester_id, accept) .await?; let requester_busy = db.is_user_busy(requester_id).await?; @@ -2156,17 +2147,12 @@ async fn respond_to_contact_request( update .remove_outgoing_requests .push(responder_id.to_proto()); + for connection_id in pool.user_connection_ids(requester_id) { session.peer.send(connection_id, update.clone())?; - if let Some(notification) = ¬ification { - session.peer.send( - connection_id, - proto::NewNotification { - notification: Some(notification.clone()), - }, - )?; - } } + + send_notifications(&*pool, &session.peer, notifications); } response.send(proto::Ack {})?; @@ -2310,7 +2296,7 @@ async fn invite_channel_member( let db = session.db().await; let channel_id = ChannelId::from_proto(request.channel_id); let invitee_id = UserId::from_proto(request.user_id); - let notification = db + let notifications = db .invite_channel_member(channel_id, invitee_id, session.user_id, request.admin) .await?; @@ -2325,22 +2311,13 @@ async fn invite_channel_member( name: channel.name, }); - for connection_id in session - .connection_pool() - .await - .user_connection_ids(invitee_id) - { + let pool = session.connection_pool().await; + for connection_id in pool.user_connection_ids(invitee_id) { session.peer.send(connection_id, update.clone())?; - if let Some(notification) = ¬ification { - session.peer.send( - connection_id, - proto::NewNotification { - notification: Some(notification.clone()), - }, - )?; - } } + send_notifications(&*pool, &session.peer, notifications); + response.send(proto::Ack {})?; Ok(()) } @@ -2588,7 +2565,8 @@ async fn respond_to_channel_invite( ) -> Result<()> { let db = session.db().await; let channel_id = ChannelId::from_proto(request.channel_id); - db.respond_to_channel_invite(channel_id, session.user_id, request.accept) + let notifications = db + .respond_to_channel_invite(channel_id, session.user_id, request.accept) .await?; let mut update = proto::UpdateChannels::default(); @@ -2636,6 +2614,11 @@ async fn respond_to_channel_invite( ); } session.peer.send(session.connection_id, update)?; + send_notifications( + &*session.connection_pool().await, + &session.peer, + notifications, + ); response.send(proto::Ack {})?; Ok(()) @@ -2853,6 +2836,29 @@ fn channel_buffer_updated( }); } +fn send_notifications( + connection_pool: &ConnectionPool, + peer: &Peer, + notifications: db::NotificationBatch, +) { + for (user_id, notification) in notifications { + for connection_id in connection_pool.user_connection_ids(user_id) { + if let Err(error) = peer.send( + connection_id, + proto::NewNotification { + notification: Some(notification.clone()), + }, + ) { + tracing::error!( + "failed to send notification to {:?} {}", + connection_id, + error + ); + } + } + } +} + async fn send_channel_message( request: proto::SendChannelMessage, response: Response, diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index 7cfcce832b..fa82f55b39 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -117,8 +117,8 @@ async fn test_core_channels( // Client B accepts the invitation. client_b .channel_store() - .update(cx_b, |channels, _| { - channels.respond_to_channel_invite(channel_a_id, true) + .update(cx_b, |channels, cx| { + channels.respond_to_channel_invite(channel_a_id, true, cx) }) .await .unwrap(); @@ -856,8 +856,8 @@ async fn test_lost_channel_creation( // Client B accepts the invite client_b .channel_store() - .update(cx_b, |channel_store, _| { - channel_store.respond_to_channel_invite(channel_id, true) + .update(cx_b, |channel_store, cx| { + channel_store.respond_to_channel_invite(channel_id, true, cx) }) .await .unwrap(); diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index 9d03d1e17e..2dddd5961b 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -339,8 +339,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(); @@ -626,8 +626,8 @@ impl TestClient { other_cx .read(ChannelStore::global) - .update(other_cx, |channel_store, _| { - channel_store.respond_to_channel_invite(channel, true) + .update(other_cx, |channel_store, cx| { + channel_store.respond_to_channel_invite(channel, true, cx) }) .await .unwrap(); diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 30505b0876..911b94ae93 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -3181,10 +3181,11 @@ impl CollabPanel { accept: bool, cx: &mut ViewContext, ) { - let respond = self.channel_store.update(cx, |store, _| { - store.respond_to_channel_invite(channel_id, accept) - }); - cx.foreground().spawn(respond).detach(); + self.channel_store + .update(cx, |store, cx| { + store.respond_to_channel_invite(channel_id, accept, cx) + }) + .detach(); } fn call( diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index 7bf5000ec8..73c07949d0 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -183,32 +183,31 @@ impl NotificationPanel { let user_store = self.user_store.read(cx); let channel_store = self.channel_store.read(cx); let entry = notification_store.notification_at(ix)?; + let notification = entry.notification.clone(); let now = OffsetDateTime::now_utc(); let timestamp = entry.timestamp; let icon; let text; let actor; - match entry.notification { - Notification::ContactRequest { - actor_id: requester_id, - } => { - actor = user_store.get_cached_user(requester_id)?; + let needs_acceptance; + match notification { + Notification::ContactRequest { actor_id } => { + let requester = user_store.get_cached_user(actor_id)?; icon = "icons/plus.svg"; - text = format!("{} wants to add you as a contact", actor.github_login); + text = format!("{} wants to add you as a contact", requester.github_login); + needs_acceptance = true; + actor = Some(requester); } - Notification::ContactRequestAccepted { - actor_id: contact_id, - } => { - actor = user_store.get_cached_user(contact_id)?; + Notification::ContactRequestAccepted { actor_id } => { + let responder = user_store.get_cached_user(actor_id)?; icon = "icons/plus.svg"; - text = format!("{} accepted your contact invite", actor.github_login); + text = format!("{} accepted your contact invite", responder.github_login); + needs_acceptance = false; + actor = Some(responder); } - Notification::ChannelInvitation { - actor_id: inviter_id, - channel_id, - } => { - actor = user_store.get_cached_user(inviter_id)?; + Notification::ChannelInvitation { channel_id } => { + actor = None; let channel = channel_store.channel_for_id(channel_id).or_else(|| { channel_store .channel_invitations() @@ -217,39 +216,51 @@ impl NotificationPanel { })?; icon = "icons/hash.svg"; - text = format!( - "{} invited you to join the #{} channel", - actor.github_login, channel.name - ); + text = format!("you were invited to join the #{} channel", channel.name); + needs_acceptance = true; } Notification::ChannelMessageMention { - actor_id: sender_id, + actor_id, channel_id, message_id, } => { - actor = user_store.get_cached_user(sender_id)?; + let sender = user_store.get_cached_user(actor_id)?; let channel = channel_store.channel_for_id(channel_id)?; let message = notification_store.channel_message_for_id(message_id)?; icon = "icons/conversations.svg"; text = format!( "{} mentioned you in the #{} channel:\n{}", - actor.github_login, channel.name, message.body, + sender.github_login, channel.name, message.body, ); + needs_acceptance = false; + actor = Some(sender); } } let theme = theme::current(cx); - let style = &theme.chat_panel.message; + let style = &theme.notification_panel; + let response = entry.response; + + let message_style = if entry.is_read { + style.read_text.clone() + } else { + style.unread_text.clone() + }; + + enum Decline {} + enum Accept {} Some( - MouseEventHandler::new::(ix, cx, |state, _| { - let container = style.container.style_for(state); + MouseEventHandler::new::(ix, cx, |_, cx| { + let container = message_style.container; Flex::column() .with_child( Flex::row() - .with_child(render_avatar(actor.avatar.clone(), &theme)) + .with_children( + actor.map(|actor| render_avatar(actor.avatar.clone(), &theme)), + ) .with_child(render_icon_button(&theme.chat_panel.icon_button, icon)) .with_child( Label::new( @@ -261,9 +272,69 @@ impl NotificationPanel { ) .align_children_center(), ) - .with_child(Text::new(text, style.body.clone())) + .with_child(Text::new(text, message_style.text.clone())) + .with_children(if let Some(is_accepted) = response { + Some( + Label::new( + if is_accepted { "Accepted" } else { "Declined" }, + style.button.text.clone(), + ) + .into_any(), + ) + } else if needs_acceptance { + Some( + Flex::row() + .with_children([ + MouseEventHandler::new::(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::(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, + ); + } + }, + ), + ]) + .aligned() + .right() + .into_any(), + ) + } else { + None + }) .contained() - .with_style(*container) + .with_style(container) .into_any() }) .into_any(), @@ -373,6 +444,31 @@ impl NotificationPanel { Notification::ChannelMessageMention { .. } => {} } } + + fn respond_to_notification( + &mut self, + notification: Notification, + response: bool, + cx: &mut ViewContext, + ) { + match notification { + Notification::ContactRequest { actor_id } => { + self.user_store + .update(cx, |store, cx| { + store.respond_to_contact_request(actor_id, response, cx) + }) + .detach(); + } + Notification::ChannelInvitation { channel_id, .. } => { + self.channel_store + .update(cx, |store, cx| { + store.respond_to_channel_invite(channel_id, response, cx) + }) + .detach(); + } + _ => {} + } + } } impl Entity for NotificationPanel { diff --git a/crates/notifications/src/notification_store.rs b/crates/notifications/src/notification_store.rs index af39941d2f..d0691db106 100644 --- a/crates/notifications/src/notification_store.rs +++ b/crates/notifications/src/notification_store.rs @@ -44,6 +44,7 @@ pub struct NotificationEntry { pub notification: Notification, pub timestamp: OffsetDateTime, pub is_read: bool, + pub response: Option, } #[derive(Clone, Debug, Default)] @@ -186,6 +187,7 @@ impl NotificationStore { timestamp: OffsetDateTime::from_unix_timestamp(message.timestamp as i64) .ok()?, notification: Notification::from_proto(&message)?, + response: message.response, }) }) .collect::>(); @@ -195,12 +197,7 @@ impl NotificationStore { for entry in ¬ifications { match entry.notification { - Notification::ChannelInvitation { - actor_id: inviter_id, - .. - } => { - user_ids.push(inviter_id); - } + Notification::ChannelInvitation { .. } => {} Notification::ContactRequest { actor_id: requester_id, } => { diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index d27bbade6f..46db82047e 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -1598,8 +1598,9 @@ message DeleteNotification { message Notification { uint64 id = 1; uint64 timestamp = 2; - bool is_read = 3; - string kind = 4; - string content = 5; - optional uint64 actor_id = 6; + string kind = 3; + string content = 4; + optional uint64 actor_id = 5; + bool is_read = 6; + optional bool response = 7; } diff --git a/crates/rpc/src/notification.rs b/crates/rpc/src/notification.rs index 6ff9660159..b03e928197 100644 --- a/crates/rpc/src/notification.rs +++ b/crates/rpc/src/notification.rs @@ -13,7 +13,8 @@ const ACTOR_ID: &'static str = "actor_id"; /// variant, add a serde alias for the old name. /// /// When a notification is initiated by a user, use the `actor_id` field -/// to store the user's id. +/// to store the user's id. This is value is stored in a dedicated column +/// in the database, so it can be queried more efficiently. #[derive(Debug, Clone, PartialEq, Eq, EnumVariantNames, Serialize, Deserialize)] #[serde(tag = "kind")] pub enum Notification { @@ -24,7 +25,6 @@ pub enum Notification { actor_id: u64, }, ChannelInvitation { - actor_id: u64, channel_id: u64, }, ChannelMessageMention { @@ -40,7 +40,7 @@ impl Notification { let mut actor_id = None; let value = value.as_object_mut().unwrap(); let Some(Value::String(kind)) = value.remove(KIND) else { - unreachable!() + unreachable!("kind is the enum tag") }; if let map::Entry::Occupied(e) = value.entry(ACTOR_ID) { if e.get().is_u64() { @@ -76,10 +76,7 @@ fn test_notification() { for notification in [ Notification::ContactRequest { actor_id: 1 }, Notification::ContactRequestAccepted { actor_id: 2 }, - Notification::ChannelInvitation { - actor_id: 0, - channel_id: 100, - }, + Notification::ChannelInvitation { channel_id: 100 }, Notification::ChannelMessageMention { actor_id: 200, channel_id: 30, diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index f335444b58..389d15ef05 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -53,6 +53,7 @@ pub struct Theme { pub collab_panel: CollabPanel, pub project_panel: ProjectPanel, pub chat_panel: ChatPanel, + pub notification_panel: NotificationPanel, pub command_palette: CommandPalette, pub picker: Picker, pub editor: Editor, @@ -644,6 +645,21 @@ pub struct ChatPanel { pub icon_button: Interactive, } +#[derive(Deserialize, Default, JsonSchema)] +pub struct NotificationPanel { + #[serde(flatten)] + pub container: ContainerStyle, + pub list: ContainerStyle, + pub avatar: AvatarStyle, + pub avatar_container: ContainerStyle, + pub sign_in_prompt: Interactive, + pub icon_button: Interactive, + pub unread_text: ContainedText, + pub read_text: ContainedText, + pub timestamp: ContainedText, + pub button: Interactive, +} + #[derive(Deserialize, Default, JsonSchema)] pub struct ChatMessage { #[serde(flatten)] diff --git a/styles/src/style_tree/app.ts b/styles/src/style_tree/app.ts index 3233909fd0..aff934e9c6 100644 --- a/styles/src/style_tree/app.ts +++ b/styles/src/style_tree/app.ts @@ -13,6 +13,7 @@ import project_shared_notification from "./project_shared_notification" import tooltip from "./tooltip" import terminal from "./terminal" import chat_panel from "./chat_panel" +import notification_panel from "./notification_panel" import collab_panel from "./collab_panel" import toolbar_dropdown_menu from "./toolbar_dropdown_menu" import incoming_call_notification from "./incoming_call_notification" @@ -57,6 +58,7 @@ export default function app(): any { assistant: assistant(), feedback: feedback(), chat_panel: chat_panel(), + notification_panel: notification_panel(), component_test: component_test(), } } diff --git a/styles/src/style_tree/notification_panel.ts b/styles/src/style_tree/notification_panel.ts new file mode 100644 index 0000000000..9afdf1e00a --- /dev/null +++ b/styles/src/style_tree/notification_panel.ts @@ -0,0 +1,57 @@ +import { background, text } from "./components" +import { icon_button } from "../component/icon_button" +import { useTheme } from "../theme" +import { interactive } from "../element" + +export default function chat_panel(): any { + const theme = useTheme() + const layer = theme.middle + + return { + background: background(layer), + avatar: { + icon_width: 24, + icon_height: 24, + corner_radius: 4, + outer_width: 24, + outer_corner_radius: 16, + }, + read_text: text(layer, "sans", "base"), + unread_text: text(layer, "sans", "base"), + button: interactive({ + base: { + ...text(theme.lowest, "sans", "on", { size: "xs" }), + background: background(theme.lowest, "on"), + padding: 4, + corner_radius: 6, + margin: { left: 6 }, + }, + + state: { + hovered: { + background: background(theme.lowest, "on", "hovered"), + }, + }, + }), + timestamp: text(layer, "sans", "base", "disabled"), + avatar_container: { + padding: { + right: 6, + left: 2, + top: 2, + bottom: 2, + } + }, + list: { + + }, + icon_button: icon_button({ + variant: "ghost", + color: "variant", + size: "sm", + }), + sign_in_prompt: { + default: text(layer, "sans", "base"), + } + } +} From f2d36a47ae5df75b4e52f027b3a1835740bce678 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 17 Oct 2023 10:34:50 -0700 Subject: [PATCH 27/90] Generalize notifications' actor id to entity id This way, we can retrieve channel invite notifications when responding to the invites. --- .../20221109000000_test_schema.sql | 2 +- .../20231004130100_create_notifications.sql | 2 +- crates/collab/src/db.rs | 2 +- crates/collab/src/db/queries/channels.rs | 7 ++ crates/collab/src/db/queries/contacts.rs | 8 +- crates/collab/src/db/queries/notifications.rs | 91 ++++++++++--------- crates/collab/src/db/tables/notification.rs | 2 +- crates/collab/src/db/tests.rs | 4 +- crates/collab/src/lib.rs | 2 +- crates/collab_ui/src/notification_panel.rs | 37 ++++---- .../notifications/src/notification_store.rs | 6 +- crates/rpc/proto/zed.proto | 4 +- crates/rpc/src/notification.rs | 46 ++++++---- 13 files changed, 115 insertions(+), 98 deletions(-) diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 8e714f1444..1efd14e6eb 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -324,8 +324,8 @@ 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, - "actor_id" INTEGER 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 diff --git a/crates/collab/migrations/20231004130100_create_notifications.sql b/crates/collab/migrations/20231004130100_create_notifications.sql index 277f16f4e3..cdc6674ff1 100644 --- a/crates/collab/migrations/20231004130100_create_notifications.sql +++ b/crates/collab/migrations/20231004130100_create_notifications.sql @@ -9,8 +9,8 @@ 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, - "actor_id" INTEGER 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 diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 852d3645dd..4c9e47a270 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -125,7 +125,7 @@ impl Database { } pub async fn initialize_static_data(&mut self) -> Result<()> { - self.initialize_notification_enum().await?; + self.initialize_notification_kinds().await?; Ok(()) } diff --git a/crates/collab/src/db/queries/channels.rs b/crates/collab/src/db/queries/channels.rs index 9754c2ac83..745bd6e3ab 100644 --- a/crates/collab/src/db/queries/channels.rs +++ b/crates/collab/src/db/queries/channels.rs @@ -166,6 +166,11 @@ impl Database { self.check_user_is_channel_admin(channel_id, inviter_id, &*tx) .await?; + let channel = channel::Entity::find_by_id(channel_id) + .one(&*tx) + .await? + .ok_or_else(|| anyhow!("no such channel"))?; + channel_member::ActiveModel { channel_id: ActiveValue::Set(channel_id), user_id: ActiveValue::Set(invitee_id), @@ -181,6 +186,7 @@ impl Database { invitee_id, rpc::Notification::ChannelInvitation { channel_id: channel_id.to_proto(), + channel_name: channel.name, }, true, &*tx, @@ -269,6 +275,7 @@ impl Database { user_id, &rpc::Notification::ChannelInvitation { channel_id: channel_id.to_proto(), + channel_name: Default::default(), }, accept, &*tx, diff --git a/crates/collab/src/db/queries/contacts.rs b/crates/collab/src/db/queries/contacts.rs index 4509bb8495..841f9faa20 100644 --- a/crates/collab/src/db/queries/contacts.rs +++ b/crates/collab/src/db/queries/contacts.rs @@ -168,7 +168,7 @@ impl Database { .create_notification( receiver_id, rpc::Notification::ContactRequest { - actor_id: sender_id.to_proto(), + sender_id: sender_id.to_proto(), }, true, &*tx, @@ -219,7 +219,7 @@ impl Database { .remove_notification( responder_id, rpc::Notification::ContactRequest { - actor_id: requester_id.to_proto(), + sender_id: requester_id.to_proto(), }, &*tx, ) @@ -324,7 +324,7 @@ impl Database { self.respond_to_notification( responder_id, &rpc::Notification::ContactRequest { - actor_id: requester_id.to_proto(), + sender_id: requester_id.to_proto(), }, accept, &*tx, @@ -337,7 +337,7 @@ impl Database { self.create_notification( requester_id, rpc::Notification::ContactRequestAccepted { - actor_id: responder_id.to_proto(), + responder_id: responder_id.to_proto(), }, true, &*tx, diff --git a/crates/collab/src/db/queries/notifications.rs b/crates/collab/src/db/queries/notifications.rs index d4024232b0..893bedb72b 100644 --- a/crates/collab/src/db/queries/notifications.rs +++ b/crates/collab/src/db/queries/notifications.rs @@ -2,7 +2,7 @@ use super::*; use rpc::Notification; impl Database { - pub async fn initialize_notification_enum(&mut self) -> Result<()> { + 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()), @@ -64,6 +64,9 @@ impl Database { .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, @@ -81,22 +84,14 @@ impl Database { } } - let notification_proto = notification.to_proto(); - let kind = *self - .notification_kinds_by_name - .get(¬ification_proto.kind) - .ok_or_else(|| anyhow!("invalid notification kind {:?}", notification_proto.kind))?; - let actor_id = notification_proto.actor_id.map(|id| UserId::from_proto(id)); - + 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), - content: ActiveValue::Set(notification_proto.content.clone()), - actor_id: ActiveValue::Set(actor_id), - is_read: ActiveValue::NotSet, - response: ActiveValue::NotSet, - created_at: ActiveValue::NotSet, - id: ActiveValue::NotSet, + entity_id: ActiveValue::Set(proto.entity_id.map(|id| id as i32)), + content: ActiveValue::Set(proto.content.clone()), + ..Default::default() } .save(&*tx) .await?; @@ -105,16 +100,18 @@ impl Database { recipient_id, proto::Notification { id: model.id.as_ref().to_proto(), - kind: notification_proto.kind, + kind: proto.kind, timestamp: model.created_at.as_ref().assume_utc().unix_timestamp() as u64, is_read: false, response: None, - content: notification_proto.content, - actor_id: notification_proto.actor_id, + 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, @@ -130,6 +127,8 @@ impl Database { Ok(id) } + /// Populate the response for the notification with the given kind and + /// entity id. pub async fn respond_to_notification( &self, recipient_id: UserId, @@ -156,47 +155,38 @@ impl Database { } } - pub async fn find_notification( + /// Find an unread notification by its recipient, kind and entity id. + async fn find_notification( &self, recipient_id: UserId, notification: &Notification, tx: &DatabaseTransaction, ) -> Result> { let proto = notification.to_proto(); - let kind = *self - .notification_kinds_by_name - .get(&proto.kind) - .ok_or_else(|| anyhow!("invalid notification kind {:?}", proto.kind))?; - let mut rows = notification::Entity::find() + 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.actor_id.is_some() { - notification::Column::ActorId.eq(proto.actor_id) + .add(if proto.entity_id.is_some() { + notification::Column::EntityId.eq(proto.entity_id) } else { - notification::Column::ActorId.is_null() + notification::Column::EntityId.is_null() }), ) - .stream(&*tx) - .await?; - - // Don't rely on the JSON serialization being identical, in case the - // notification type is changed in backward-compatible ways. - while let Some(row) = rows.next().await { - let row = row?; - let id = row.id; - if let Some(proto) = model_to_proto(self, row) { - if let Some(existing) = Notification::from_proto(&proto) { - if existing == *notification { - return Ok(Some(id)); - } - } - } - } - - Ok(None) + .into_values::<_, QueryIds>() + .one(&*tx) + .await?) } } @@ -209,6 +199,17 @@ fn model_to_proto(this: &Database, row: notification::Model) -> Option Result { + Ok(this + .notification_kinds_by_name + .get(&proto.kind) + .copied() + .ok_or_else(|| anyhow!("invalid notification kind {:?}", proto.kind))?) +} diff --git a/crates/collab/src/db/tables/notification.rs b/crates/collab/src/db/tables/notification.rs index 12517c04f6..3105198fa2 100644 --- a/crates/collab/src/db/tables/notification.rs +++ b/crates/collab/src/db/tables/notification.rs @@ -9,8 +9,8 @@ pub struct Model { pub id: NotificationId, pub created_at: PrimitiveDateTime, pub recipient_id: UserId, - pub actor_id: Option, pub kind: NotificationKindId, + pub entity_id: Option, pub content: String, pub is_read: bool, pub response: Option, diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index 465ff56444..f05a4cbebb 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -45,7 +45,7 @@ impl TestDb { )) .await .unwrap(); - db.initialize_notification_enum().await.unwrap(); + db.initialize_notification_kinds().await.unwrap(); db }); @@ -85,7 +85,7 @@ impl TestDb { .unwrap(); let migrations_path = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations"); db.migrate(Path::new(migrations_path), false).await.unwrap(); - db.initialize_notification_enum().await.unwrap(); + db.initialize_notification_kinds().await.unwrap(); db }); diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index 1722424217..85216525b0 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -120,7 +120,7 @@ impl AppState { let mut db_options = db::ConnectOptions::new(config.database_url.clone()); db_options.max_connections(config.database_max_connections); let mut db = Database::new(db_options, Executor::Production).await?; - db.initialize_notification_enum().await?; + db.initialize_notification_kinds().await?; let live_kit_client = if let Some(((server, key), secret)) = config .live_kit_server diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index 73c07949d0..3f1bafb10e 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -192,39 +192,34 @@ impl NotificationPanel { let actor; let needs_acceptance; match notification { - Notification::ContactRequest { actor_id } => { - let requester = user_store.get_cached_user(actor_id)?; + Notification::ContactRequest { sender_id } => { + let requester = user_store.get_cached_user(sender_id)?; icon = "icons/plus.svg"; text = format!("{} wants to add you as a contact", requester.github_login); needs_acceptance = true; actor = Some(requester); } - Notification::ContactRequestAccepted { actor_id } => { - let responder = user_store.get_cached_user(actor_id)?; + Notification::ContactRequestAccepted { responder_id } => { + let responder = user_store.get_cached_user(responder_id)?; icon = "icons/plus.svg"; text = format!("{} accepted your contact invite", responder.github_login); needs_acceptance = false; actor = Some(responder); } - Notification::ChannelInvitation { channel_id } => { + Notification::ChannelInvitation { + ref channel_name, .. + } => { actor = None; - let channel = channel_store.channel_for_id(channel_id).or_else(|| { - channel_store - .channel_invitations() - .iter() - .find(|c| c.id == channel_id) - })?; - icon = "icons/hash.svg"; - text = format!("you were invited to join the #{} channel", channel.name); + text = format!("you were invited to join the #{channel_name} channel"); needs_acceptance = true; } Notification::ChannelMessageMention { - actor_id, + sender_id, channel_id, message_id, } => { - let sender = user_store.get_cached_user(actor_id)?; + let sender = user_store.get_cached_user(sender_id)?; let channel = channel_store.channel_for_id(channel_id)?; let message = notification_store.channel_message_for_id(message_id)?; @@ -405,8 +400,12 @@ impl NotificationPanel { fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext) { let id = entry.id as usize; match entry.notification { - Notification::ContactRequest { actor_id } - | Notification::ContactRequestAccepted { actor_id } => { + Notification::ContactRequest { + sender_id: actor_id, + } + | Notification::ContactRequestAccepted { + responder_id: actor_id, + } => { let user_store = self.user_store.clone(); let Some(user) = user_store.read(cx).get_cached_user(actor_id) else { return; @@ -452,7 +451,9 @@ impl NotificationPanel { cx: &mut ViewContext, ) { match notification { - Notification::ContactRequest { actor_id } => { + Notification::ContactRequest { + sender_id: actor_id, + } => { self.user_store .update(cx, |store, cx| { store.respond_to_contact_request(actor_id, response, cx) diff --git a/crates/notifications/src/notification_store.rs b/crates/notifications/src/notification_store.rs index d0691db106..43afb8181a 100644 --- a/crates/notifications/src/notification_store.rs +++ b/crates/notifications/src/notification_store.rs @@ -199,17 +199,17 @@ impl NotificationStore { match entry.notification { Notification::ChannelInvitation { .. } => {} Notification::ContactRequest { - actor_id: requester_id, + sender_id: requester_id, } => { user_ids.push(requester_id); } Notification::ContactRequestAccepted { - actor_id: contact_id, + responder_id: contact_id, } => { user_ids.push(contact_id); } Notification::ChannelMessageMention { - actor_id: sender_id, + sender_id, message_id, .. } => { diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 46db82047e..a5ba1c1cf7 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -1599,8 +1599,8 @@ message Notification { uint64 id = 1; uint64 timestamp = 2; string kind = 3; - string content = 4; - optional uint64 actor_id = 5; + optional uint64 entity_id = 4; + string content = 5; bool is_read = 6; optional bool response = 7; } diff --git a/crates/rpc/src/notification.rs b/crates/rpc/src/notification.rs index b03e928197..06dff82b75 100644 --- a/crates/rpc/src/notification.rs +++ b/crates/rpc/src/notification.rs @@ -4,32 +4,37 @@ use serde_json::{map, Value}; use strum::{EnumVariantNames, VariantNames as _}; const KIND: &'static str = "kind"; -const ACTOR_ID: &'static str = "actor_id"; +const ENTITY_ID: &'static str = "entity_id"; -/// A notification that can be stored, associated with a given user. +/// A notification that can be stored, associated with a given recipient. /// /// This struct is stored in the collab database as JSON, so it shouldn't be /// changed in a backward-incompatible way. For example, when renaming a /// variant, add a serde alias for the old name. /// -/// When a notification is initiated by a user, use the `actor_id` field -/// to store the user's id. This is value is stored in a dedicated column -/// in the database, so it can be queried more efficiently. +/// Most notification types have a special field which is aliased to +/// `entity_id`. This field is stored in its own database column, and can +/// be used to query the notification. #[derive(Debug, Clone, PartialEq, Eq, EnumVariantNames, Serialize, Deserialize)] #[serde(tag = "kind")] pub enum Notification { ContactRequest { - actor_id: u64, + #[serde(rename = "entity_id")] + sender_id: u64, }, ContactRequestAccepted { - actor_id: u64, + #[serde(rename = "entity_id")] + responder_id: u64, }, ChannelInvitation { + #[serde(rename = "entity_id")] channel_id: u64, + channel_name: String, }, ChannelMessageMention { - actor_id: u64, + sender_id: u64, channel_id: u64, + #[serde(rename = "entity_id")] message_id: u64, }, } @@ -37,19 +42,19 @@ pub enum Notification { impl Notification { pub fn to_proto(&self) -> proto::Notification { let mut value = serde_json::to_value(self).unwrap(); - let mut actor_id = None; + let mut entity_id = None; let value = value.as_object_mut().unwrap(); let Some(Value::String(kind)) = value.remove(KIND) else { unreachable!("kind is the enum tag") }; - if let map::Entry::Occupied(e) = value.entry(ACTOR_ID) { + if let map::Entry::Occupied(e) = value.entry(ENTITY_ID) { if e.get().is_u64() { - actor_id = e.remove().as_u64(); + entity_id = e.remove().as_u64(); } } proto::Notification { kind, - actor_id, + entity_id, content: serde_json::to_string(&value).unwrap(), ..Default::default() } @@ -59,8 +64,8 @@ impl Notification { let mut value = serde_json::from_str::(¬ification.content).ok()?; let object = value.as_object_mut()?; object.insert(KIND.into(), notification.kind.to_string().into()); - if let Some(actor_id) = notification.actor_id { - object.insert(ACTOR_ID.into(), actor_id.into()); + if let Some(entity_id) = notification.entity_id { + object.insert(ENTITY_ID.into(), entity_id.into()); } serde_json::from_value(value).ok() } @@ -74,11 +79,14 @@ impl Notification { fn test_notification() { // Notifications can be serialized and deserialized. for notification in [ - Notification::ContactRequest { actor_id: 1 }, - Notification::ContactRequestAccepted { actor_id: 2 }, - Notification::ChannelInvitation { channel_id: 100 }, + Notification::ContactRequest { sender_id: 1 }, + Notification::ContactRequestAccepted { responder_id: 2 }, + Notification::ChannelInvitation { + channel_id: 100, + channel_name: "the-channel".into(), + }, Notification::ChannelMessageMention { - actor_id: 200, + sender_id: 200, channel_id: 30, message_id: 1, }, @@ -90,6 +98,6 @@ fn test_notification() { // When notifications are serialized, the `kind` and `actor_id` fields are // stored separately, and do not appear redundantly in the JSON. - let notification = Notification::ContactRequest { actor_id: 1 }; + let notification = Notification::ContactRequest { sender_id: 1 }; assert_eq!(notification.to_proto().content, "{}"); } From 52834dbf210845343ade57b084f3db2b1dc2e8ff Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 17 Oct 2023 11:21:38 -0700 Subject: [PATCH 28/90] Add notifications integration test --- crates/collab/src/tests.rs | 1 + crates/collab/src/tests/notification_tests.rs | 115 ++++++++++++++++++ crates/collab/src/tests/test_server.rs | 7 ++ crates/collab_ui/src/notification_panel.rs | 25 +--- .../notifications/src/notification_store.rs | 36 +++++- 5 files changed, 163 insertions(+), 21 deletions(-) create mode 100644 crates/collab/src/tests/notification_tests.rs diff --git a/crates/collab/src/tests.rs b/crates/collab/src/tests.rs index e78bbe3466..139910e1f6 100644 --- a/crates/collab/src/tests.rs +++ b/crates/collab/src/tests.rs @@ -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; diff --git a/crates/collab/src/tests/notification_tests.rs b/crates/collab/src/tests/notification_tests.rs new file mode 100644 index 0000000000..da94bd6fad --- /dev/null +++ b/crates/collab/src/tests/notification_tests.rs @@ -0,0 +1,115 @@ +use crate::tests::TestServer; +use gpui::{executor::Deterministic, TestAppContext}; +use rpc::Notification; +use std::sync::Arc; + +#[gpui::test] +async fn test_notifications( + deterministic: Arc, + 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; + + // 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); + + 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)); + }); + + // 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(), false, 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(1).unwrap(); + assert_eq!( + entry.notification, + Notification::ChannelInvitation { + channel_id, + channel_name: "the-channel".to_string() + } + ); + 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(1).unwrap(); + assert!(entry.is_read); + assert_eq!(entry.response, Some(true)); + }); +} diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index 2dddd5961b..806b57bb59 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -16,6 +16,7 @@ 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; @@ -46,6 +47,7 @@ pub struct TestClient { pub username: String, pub app_state: Arc, channel_store: ModelHandle, + notification_store: ModelHandle, state: RefCell, } @@ -244,6 +246,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; @@ -449,6 +452,10 @@ impl TestClient { &self.channel_store } + pub fn notification_store(&self) -> &ModelHandle { + &self.notification_store + } + pub fn user_store(&self) -> &ModelHandle { &self.app_state.user_store } diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index 3f1bafb10e..30242d6360 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -386,7 +386,8 @@ impl NotificationPanel { ) { match event { NotificationEvent::NewNotification { entry } => self.add_toast(entry, cx), - NotificationEvent::NotificationRemoved { entry } => self.remove_toast(entry, cx), + NotificationEvent::NotificationRemoved { entry } + | NotificationEvent::NotificationRead { entry } => self.remove_toast(entry, cx), NotificationEvent::NotificationsUpdated { old_range, new_count, @@ -450,25 +451,9 @@ impl NotificationPanel { response: bool, cx: &mut ViewContext, ) { - match notification { - Notification::ContactRequest { - sender_id: actor_id, - } => { - self.user_store - .update(cx, |store, cx| { - store.respond_to_contact_request(actor_id, response, cx) - }) - .detach(); - } - Notification::ChannelInvitation { channel_id, .. } => { - self.channel_store - .update(cx, |store, cx| { - store.respond_to_channel_invite(channel_id, response, cx) - }) - .detach(); - } - _ => {} - } + self.notification_store.update(cx, |store, cx| { + store.respond_to_notification(notification, response, cx); + }); } } diff --git a/crates/notifications/src/notification_store.rs b/crates/notifications/src/notification_store.rs index 43afb8181a..5a1ed2677e 100644 --- a/crates/notifications/src/notification_store.rs +++ b/crates/notifications/src/notification_store.rs @@ -36,6 +36,9 @@ pub enum NotificationEvent { NotificationRemoved { entry: NotificationEntry, }, + NotificationRead { + entry: NotificationEntry, + }, } #[derive(Debug, PartialEq, Eq, Clone)] @@ -272,7 +275,13 @@ impl NotificationStore { if let Some(existing_notification) = cursor.item() { if existing_notification.id == id { - if new_notification.is_none() { + if let Some(new_notification) = &new_notification { + if new_notification.is_read { + cx.emit(NotificationEvent::NotificationRead { + entry: new_notification.clone(), + }); + } + } else { cx.emit(NotificationEvent::NotificationRemoved { entry: existing_notification.clone(), }); @@ -303,6 +312,31 @@ impl NotificationStore { new_count, }); } + + pub fn respond_to_notification( + &mut self, + notification: Notification, + response: bool, + cx: &mut ModelContext, + ) { + match notification { + Notification::ContactRequest { sender_id } => { + self.user_store + .update(cx, |store, cx| { + store.respond_to_contact_request(sender_id, response, cx) + }) + .detach(); + } + Notification::ChannelInvitation { channel_id, .. } => { + self.channel_store + .update(cx, |store, cx| { + store.respond_to_channel_invite(channel_id, response, cx) + }) + .detach(); + } + _ => {} + } + } } impl Entity for NotificationStore { From a874a09b7e3b30696dad650bc997342fd8a53a61 Mon Sep 17 00:00:00 2001 From: KCaverly Date: Tue, 17 Oct 2023 16:21:03 -0400 Subject: [PATCH 29/90] added openai language model tokenizer and LanguageModel trait --- crates/ai/src/ai.rs | 1 + crates/ai/src/models.rs | 49 ++++++++++++++++++++++++++ crates/ai/src/templates/base.rs | 54 ++++++++++++----------------- crates/ai/src/templates/preamble.rs | 42 +++++++++++++++------- 4 files changed, 102 insertions(+), 44 deletions(-) create mode 100644 crates/ai/src/models.rs diff --git a/crates/ai/src/ai.rs b/crates/ai/src/ai.rs index 04e9e14536..f168c15793 100644 --- a/crates/ai/src/ai.rs +++ b/crates/ai/src/ai.rs @@ -1,3 +1,4 @@ pub mod completion; pub mod embedding; +pub mod models; pub mod templates; diff --git a/crates/ai/src/models.rs b/crates/ai/src/models.rs new file mode 100644 index 0000000000..4fe96d44f3 --- /dev/null +++ b/crates/ai/src/models.rs @@ -0,0 +1,49 @@ +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; + fn truncate(&self, content: &str, length: usize) -> anyhow::Result; + fn capacity(&self) -> anyhow::Result; +} + +struct OpenAILanguageModel { + name: String, + bpe: Option, +} + +impl OpenAILanguageModel { + pub fn load(model_name: String) -> Self { + let bpe = tiktoken_rs::get_bpe_from_model(&model_name).log_err(); + OpenAILanguageModel { + name: model_name, + bpe, + } + } +} + +impl LanguageModel for OpenAILanguageModel { + fn name(&self) -> String { + self.name.clone() + } + fn count_tokens(&self, content: &str) -> anyhow::Result { + 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 { + if let Some(bpe) = &self.bpe { + let tokens = bpe.encode_with_special_tokens(content); + bpe.decode(tokens[..length].to_vec()) + } else { + Err(anyhow!("bpe for open ai model was not retrieved")) + } + } + fn capacity(&self) -> anyhow::Result { + anyhow::Ok(tiktoken_rs::model::get_context_size(&self.name)) + } +} diff --git a/crates/ai/src/templates/base.rs b/crates/ai/src/templates/base.rs index 74a4c424ae..b5f9da3586 100644 --- a/crates/ai/src/templates/base.rs +++ b/crates/ai/src/templates/base.rs @@ -1,17 +1,11 @@ -use std::fmt::Write; -use std::{cmp::Reverse, sync::Arc}; +use std::cmp::Reverse; +use std::sync::Arc; use util::ResultExt; +use crate::models::LanguageModel; use crate::templates::repository_context::PromptCodeSnippet; -pub trait LanguageModel { - fn name(&self) -> String; - fn count_tokens(&self, content: &str) -> usize; - fn truncate(&self, content: &str, length: usize) -> String; - fn capacity(&self) -> usize; -} - pub(crate) enum PromptFileType { Text, Code, @@ -73,7 +67,7 @@ impl PromptChain { 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 seperator_tokens = self.args.model.count_tokens(seperator)?; let mut sorted_indices = (0..self.templates.len()).collect::>(); sorted_indices.sort_by_key(|&i| Reverse(&self.templates[i].0)); @@ -81,7 +75,7 @@ impl PromptChain { // If Truncate let mut tokens_outstanding = if truncate { - Some(self.args.model.capacity() - self.args.reserved_tokens) + Some(self.args.model.capacity()? - self.args.reserved_tokens) } else { None }; @@ -111,7 +105,7 @@ impl PromptChain { } let full_prompt = prompts.join(seperator); - let total_token_count = self.args.model.count_tokens(&full_prompt); + let total_token_count = self.args.model.count_tokens(&full_prompt)?; anyhow::Ok((prompts.join(seperator), total_token_count)) } } @@ -131,10 +125,10 @@ pub(crate) mod tests { ) -> anyhow::Result<(String, usize)> { let mut content = "This is a test prompt template".to_string(); - let mut token_count = args.model.count_tokens(&content); + 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); + content = args.model.truncate(&content, max_token_length)?; token_count = max_token_length; } } @@ -152,10 +146,10 @@ pub(crate) mod tests { ) -> 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); + 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); + content = args.model.truncate(&content, max_token_length)?; token_count = max_token_length; } } @@ -169,26 +163,22 @@ pub(crate) mod tests { capacity: usize, } - impl DummyLanguageModel { - fn set_capacity(&mut self, capacity: usize) { - self.capacity = capacity - } - } - impl LanguageModel for DummyLanguageModel { fn name(&self) -> String { "dummy".to_string() } - fn count_tokens(&self, content: &str) -> usize { - content.chars().collect::>().len() + fn count_tokens(&self, content: &str) -> anyhow::Result { + anyhow::Ok(content.chars().collect::>().len()) } - fn truncate(&self, content: &str, length: usize) -> String { - content.chars().collect::>()[..length] - .into_iter() - .collect::() + fn truncate(&self, content: &str, length: usize) -> anyhow::Result { + anyhow::Ok( + content.chars().collect::>()[..length] + .into_iter() + .collect::(), + ) } - fn capacity(&self) -> usize { - self.capacity + fn capacity(&self) -> anyhow::Result { + anyhow::Ok(self.capacity) } } @@ -215,7 +205,7 @@ pub(crate) mod tests { .to_string() ); - assert_eq!(model.count_tokens(&prompt), token_count); + assert_eq!(model.count_tokens(&prompt).unwrap(), token_count); // Testing with Truncation Off // Should ignore capacity and return all prompts @@ -242,7 +232,7 @@ pub(crate) mod tests { .to_string() ); - assert_eq!(model.count_tokens(&prompt), token_count); + assert_eq!(model.count_tokens(&prompt).unwrap(), token_count); // Testing with Truncation Off // Should ignore capacity and return all prompts diff --git a/crates/ai/src/templates/preamble.rs b/crates/ai/src/templates/preamble.rs index b1d33f885e..f395dbf8be 100644 --- a/crates/ai/src/templates/preamble.rs +++ b/crates/ai/src/templates/preamble.rs @@ -4,31 +4,49 @@ use std::fmt::Write; struct EngineerPreamble {} impl PromptTemplate for EngineerPreamble { - fn generate(&self, args: &PromptArguments, max_token_length: Option) -> String { - let mut prompt = String::new(); + fn generate( + &self, + args: &PromptArguments, + max_token_length: Option, + ) -> anyhow::Result<(String, usize)> { + let mut prompts = Vec::new(); match args.get_file_type() { PromptFileType::Code => { - writeln!( - prompt, + prompts.push(format!( "You are an expert {} engineer.", args.language_name.clone().unwrap_or("".to_string()) - ) - .unwrap(); + )); } PromptFileType::Text => { - writeln!(prompt, "You are an expert engineer.").unwrap(); + prompts.push("You are an expert engineer.".to_string()); } } if let Some(project_name) = args.project_name.clone() { - writeln!( - prompt, + prompts.push(format!( "You are currently working inside the '{project_name}' in Zed the code editor." - ) - .unwrap(); + )); } - prompt + 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)) + } } } From 02853bbd606dc87a638bd2ca01a5232203069499 Mon Sep 17 00:00:00 2001 From: KCaverly Date: Tue, 17 Oct 2023 17:29:07 -0400 Subject: [PATCH 30/90] added prompt template for repository context --- crates/ai/src/models.rs | 8 +- crates/ai/src/prompts.rs | 149 ------------------ crates/ai/src/templates/preamble.rs | 6 +- crates/ai/src/templates/repository_context.rs | 47 +++++- crates/assistant/src/assistant_panel.rs | 22 ++- crates/assistant/src/prompts.rs | 87 ++++------ 6 files changed, 96 insertions(+), 223 deletions(-) delete mode 100644 crates/ai/src/prompts.rs diff --git a/crates/ai/src/models.rs b/crates/ai/src/models.rs index 4fe96d44f3..69e73e9b56 100644 --- a/crates/ai/src/models.rs +++ b/crates/ai/src/models.rs @@ -9,16 +9,16 @@ pub trait LanguageModel { fn capacity(&self) -> anyhow::Result; } -struct OpenAILanguageModel { +pub struct OpenAILanguageModel { name: String, bpe: Option, } impl OpenAILanguageModel { - pub fn load(model_name: String) -> Self { - let bpe = tiktoken_rs::get_bpe_from_model(&model_name).log_err(); + pub fn load(model_name: &str) -> Self { + let bpe = tiktoken_rs::get_bpe_from_model(model_name).log_err(); OpenAILanguageModel { - name: model_name, + name: model_name.to_string(), bpe, } } diff --git a/crates/ai/src/prompts.rs b/crates/ai/src/prompts.rs deleted file mode 100644 index 6d2c0629fa..0000000000 --- a/crates/ai/src/prompts.rs +++ /dev/null @@ -1,149 +0,0 @@ -use gpui::{AsyncAppContext, ModelHandle}; -use language::{Anchor, Buffer}; -use std::{fmt::Write, ops::Range, path::PathBuf}; - -pub struct PromptCodeSnippet { - path: Option, - language_name: Option, - content: String, -} - -impl PromptCodeSnippet { - pub fn new(buffer: ModelHandle, range: Range, 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::(); - - let language_name = buffer - .language() - .and_then(|language| Some(language.name().to_string())); - - let file_path = buffer - .file() - .and_then(|file| Some(file.path().to_path_buf())); - - (content, language_name, file_path) - }); - - PromptCodeSnippet { - path: file_path, - language_name, - content, - } - } -} - -impl ToString for PromptCodeSnippet { - fn to_string(&self) -> String { - let path = self - .path - .as_ref() - .and_then(|path| Some(path.to_string_lossy().to_string())) - .unwrap_or("".to_string()); - let language_name = self.language_name.clone().unwrap_or("".to_string()); - let content = self.content.clone(); - - format!("The below code snippet may be relevant from file: {path}\n```{language_name}\n{content}\n```") - } -} - -enum PromptFileType { - Text, - Code, -} - -#[derive(Default)] -struct PromptArguments { - pub language_name: Option, - pub project_name: Option, - pub snippets: Vec, - pub model_name: String, -} - -impl PromptArguments { - pub 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 - } - } -} - -trait PromptTemplate { - fn generate(args: PromptArguments, max_token_length: Option) -> String; -} - -struct EngineerPreamble {} - -impl PromptTemplate for EngineerPreamble { - fn generate(args: PromptArguments, max_token_length: Option) -> String { - let mut prompt = String::new(); - - match args.get_file_type() { - PromptFileType::Code => { - writeln!( - prompt, - "You are an expert {} engineer.", - args.language_name.unwrap_or("".to_string()) - ) - .unwrap(); - } - PromptFileType::Text => { - writeln!(prompt, "You are an expert engineer.").unwrap(); - } - } - - if let Some(project_name) = args.project_name { - writeln!( - prompt, - "You are currently working inside the '{project_name}' in Zed the code editor." - ) - .unwrap(); - } - - prompt - } -} - -struct RepositorySnippets {} - -impl PromptTemplate for RepositorySnippets { - fn generate(args: PromptArguments, max_token_length: Option) -> String { - const MAXIMUM_SNIPPET_TOKEN_COUNT: usize = 500; - let mut template = "You are working inside a large repository, here are a few code snippets that may be useful"; - let mut prompt = String::new(); - - if let Ok(encoding) = tiktoken_rs::get_bpe_from_model(args.model_name.as_str()) { - let default_token_count = - tiktoken_rs::model::get_context_size(args.model_name.as_str()); - let mut remaining_token_count = max_token_length.unwrap_or(default_token_count); - - 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 = encoding - .encode_with_special_tokens(snippet_prompt.as_str()) - .len(); - if token_count <= remaining_token_count { - if token_count < MAXIMUM_SNIPPET_TOKEN_COUNT { - writeln!(prompt, "{snippet_prompt}").unwrap(); - remaining_token_count -= token_count; - template = ""; - } - } else { - break; - } - } - } - - prompt - } -} diff --git a/crates/ai/src/templates/preamble.rs b/crates/ai/src/templates/preamble.rs index f395dbf8be..5834fa1b21 100644 --- a/crates/ai/src/templates/preamble.rs +++ b/crates/ai/src/templates/preamble.rs @@ -1,7 +1,7 @@ use crate::templates::base::{PromptArguments, PromptFileType, PromptTemplate}; use std::fmt::Write; -struct EngineerPreamble {} +pub struct EngineerPreamble {} impl PromptTemplate for EngineerPreamble { fn generate( @@ -14,8 +14,8 @@ impl PromptTemplate for EngineerPreamble { match args.get_file_type() { PromptFileType::Code => { prompts.push(format!( - "You are an expert {} engineer.", - args.language_name.clone().unwrap_or("".to_string()) + "You are an expert {}engineer.", + args.language_name.clone().unwrap_or("".to_string()) + " " )); } PromptFileType::Text => { diff --git a/crates/ai/src/templates/repository_context.rs b/crates/ai/src/templates/repository_context.rs index f9c2253c65..7dd1647c44 100644 --- a/crates/ai/src/templates/repository_context.rs +++ b/crates/ai/src/templates/repository_context.rs @@ -1,8 +1,11 @@ +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, language_name: Option, @@ -17,7 +20,7 @@ impl PromptCodeSnippet { let language_name = buffer .language() - .and_then(|language| Some(language.name().to_string())); + .and_then(|language| Some(language.name().to_string().to_lowercase())); let file_path = buffer .file() @@ -47,3 +50,45 @@ impl ToString for PromptCodeSnippet { 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, + ) -> anyhow::Result<(String, usize)> { + const MAXIMUM_SNIPPET_TOKEN_COUNT: usize = 500; + let mut 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)) + } +} diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index e8edf70498..06de5c135f 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -1,12 +1,15 @@ use crate::{ assistant_settings::{AssistantDockPosition, AssistantSettings, OpenAIModel}, codegen::{self, Codegen, CodegenKind}, - prompts::{generate_content_prompt, PromptCodeSnippet}, + prompts::generate_content_prompt, 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}; @@ -668,14 +671,7 @@ impl AssistantPanel { let snippets = cx.spawn(|_, cx| async move { let mut snippets = Vec::new(); for result in search_results.await { - snippets.push(PromptCodeSnippet::new(result, &cx)); - - // snippets.push(result.buffer.read_with(&cx, |buffer, _| { - // buffer - // .snapshot() - // .text_for_range(result.range) - // .collect::() - // })); + snippets.push(PromptCodeSnippet::new(result.buffer, result.range, &cx)); } snippets }); @@ -717,7 +713,8 @@ impl AssistantPanel { } 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, @@ -729,6 +726,7 @@ impl AssistantPanel { stream: true, }; codegen.update(&mut cx, |codegen, cx| codegen.start(request, cx)); + anyhow::Ok(()) }) .detach(); } diff --git a/crates/assistant/src/prompts.rs b/crates/assistant/src/prompts.rs index 7aafe75920..e33a6e4022 100644 --- a/crates/assistant/src/prompts.rs +++ b/crates/assistant/src/prompts.rs @@ -1,61 +1,15 @@ use crate::codegen::CodegenKind; -use gpui::AsyncAppContext; +use ai::models::{LanguageModel, OpenAILanguageModel}; +use ai::templates::base::{PromptArguments, PromptChain, PromptPriority, PromptTemplate}; +use ai::templates::preamble::EngineerPreamble; +use ai::templates::repository_context::{PromptCodeSnippet, RepositoryContext}; use language::{BufferSnapshot, OffsetRangeExt, ToOffset}; -use semantic_index::SearchResult; use std::cmp::{self, Reverse}; use std::fmt::Write; use std::ops::Range; -use std::path::PathBuf; +use std::sync::Arc; use tiktoken_rs::ChatCompletionRequestMessage; -pub struct PromptCodeSnippet { - path: Option, - language_name: Option, - content: String, -} - -impl PromptCodeSnippet { - pub fn new(search_result: SearchResult, cx: &AsyncAppContext) -> Self { - let (content, language_name, file_path) = - search_result.buffer.read_with(cx, |buffer, _| { - let snapshot = buffer.snapshot(); - let content = snapshot - .text_for_range(search_result.range.clone()) - .collect::(); - - let language_name = buffer - .language() - .and_then(|language| Some(language.name().to_string())); - - let file_path = buffer - .file() - .and_then(|file| Some(file.path().to_path_buf())); - - (content, language_name, file_path) - }); - - PromptCodeSnippet { - path: file_path, - language_name, - content, - } - } -} - -impl ToString for PromptCodeSnippet { - fn to_string(&self) -> String { - let path = self - .path - .as_ref() - .and_then(|path| Some(path.to_string_lossy().to_string())) - .unwrap_or("".to_string()); - let language_name = self.language_name.clone().unwrap_or("".to_string()); - let content = self.content.clone(); - - format!("The below code snippet may be relevant from file: {path}\n```{language_name}\n{content}\n```") - } -} - #[allow(dead_code)] fn summarize(buffer: &BufferSnapshot, selected_range: Range) -> String { #[derive(Debug)] @@ -175,7 +129,32 @@ pub fn generate_content_prompt( kind: CodegenKind, search_results: Vec, model: &str, -) -> String { +) -> anyhow::Result { + // Using new Prompt Templates + let openai_model: Arc = Arc::new(OpenAILanguageModel::load(model)); + let lang_name = if let Some(language_name) = language_name { + Some(language_name.to_string()) + } else { + None + }; + + let args = PromptArguments { + model: openai_model, + language_name: lang_name.clone(), + project_name: None, + snippets: search_results.clone(), + reserved_tokens: 1000, + }; + + let templates: Vec<(PromptPriority, Box)> = vec![ + (PromptPriority::High, Box::new(EngineerPreamble {})), + (PromptPriority::Low, Box::new(RepositoryContext {})), + ]; + let chain = PromptChain::new(args, templates); + + let prompt = chain.generate(true)?; + println!("{:?}", prompt); + const MAXIMUM_SNIPPET_TOKEN_COUNT: usize = 500; const RESERVED_TOKENS_FOR_GENERATION: usize = 1000; @@ -183,7 +162,7 @@ pub fn generate_content_prompt( let range = range.to_offset(buffer); // General Preamble - if let Some(language_name) = language_name { + if let Some(language_name) = language_name.clone() { prompts.push(format!("You're an expert {language_name} engineer.\n")); } else { prompts.push("You're an expert engineer.\n".to_string()); @@ -297,7 +276,7 @@ pub fn generate_content_prompt( } } - prompts.join("\n") + anyhow::Ok(prompts.join("\n")) } #[cfg(test)] From 660021f5e5600b4808d3d11ae1ca985ae0ff57cb Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 17 Oct 2023 15:43:06 -0700 Subject: [PATCH 31/90] Fix more issues with the channels panel * Put the newest notifications at the top * Have at most 1 notification toast, which is non-interactive, but focuses the notification panel on click, and auto-dismisses on a timer. --- crates/channel/src/channel_store.rs | 6 + crates/collab/src/db/queries/channels.rs | 16 +- crates/collab/src/rpc.rs | 16 +- crates/collab/src/tests/notification_tests.rs | 50 ++- crates/collab_ui/src/notification_panel.rs | 326 ++++++++++++------ crates/collab_ui/src/notifications.rs | 111 +----- .../src/notifications/contact_notification.rs | 106 ------ .../notifications/src/notification_store.rs | 33 +- crates/rpc/src/notification.rs | 6 +- 9 files changed, 328 insertions(+), 342 deletions(-) delete mode 100644 crates/collab_ui/src/notifications/contact_notification.rs diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index d8dc7896ea..ae8a797d06 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -213,6 +213,12 @@ impl ChannelStore { 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] { &self.channel_invitations } diff --git a/crates/collab/src/db/queries/channels.rs b/crates/collab/src/db/queries/channels.rs index 745bd6e3ab..d2499ab3ce 100644 --- a/crates/collab/src/db/queries/channels.rs +++ b/crates/collab/src/db/queries/channels.rs @@ -187,6 +187,7 @@ impl Database { rpc::Notification::ChannelInvitation { channel_id: channel_id.to_proto(), channel_name: channel.name, + inviter_id: inviter_id.to_proto(), }, true, &*tx, @@ -276,6 +277,7 @@ impl Database { &rpc::Notification::ChannelInvitation { channel_id: channel_id.to_proto(), channel_name: Default::default(), + inviter_id: Default::default(), }, accept, &*tx, @@ -292,7 +294,7 @@ impl Database { channel_id: ChannelId, member_id: UserId, remover_id: UserId, - ) -> Result<()> { + ) -> Result> { self.transaction(|tx| async move { self.check_user_is_channel_admin(channel_id, remover_id, &*tx) .await?; @@ -310,7 +312,17 @@ impl Database { Err(anyhow!("no such member"))?; } - Ok(()) + Ok(self + .remove_notification( + member_id, + rpc::Notification::ChannelInvitation { + channel_id: channel_id.to_proto(), + channel_name: Default::default(), + inviter_id: Default::default(), + }, + &*tx, + ) + .await?) }) .await } diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 9f3c22ce97..053058e06e 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2331,7 +2331,8 @@ async fn remove_channel_member( let channel_id = ChannelId::from_proto(request.channel_id); let member_id = UserId::from_proto(request.user_id); - db.remove_channel_member(channel_id, member_id, session.user_id) + let removed_notification_id = db + .remove_channel_member(channel_id, member_id, session.user_id) .await?; let mut update = proto::UpdateChannels::default(); @@ -2342,7 +2343,18 @@ async fn remove_channel_member( .await .user_connection_ids(member_id) { - session.peer.send(connection_id, update.clone())?; + session.peer.send(connection_id, update.clone()).trace_err(); + if let Some(notification_id) = removed_notification_id { + session + .peer + .send( + connection_id, + proto::DeleteNotification { + notification_id: notification_id.to_proto(), + }, + ) + .trace_err(); + } } response.send(proto::Ack {})?; diff --git a/crates/collab/src/tests/notification_tests.rs b/crates/collab/src/tests/notification_tests.rs index da94bd6fad..518208c0c7 100644 --- a/crates/collab/src/tests/notification_tests.rs +++ b/crates/collab/src/tests/notification_tests.rs @@ -1,5 +1,7 @@ use crate::tests::TestServer; use gpui::{executor::Deterministic, TestAppContext}; +use notifications::NotificationEvent; +use parking_lot::Mutex; use rpc::Notification; use std::sync::Arc; @@ -14,6 +16,23 @@ async fn test_notifications( 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() @@ -36,6 +55,18 @@ async fn test_notifications( } ); 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); }); @@ -49,6 +80,18 @@ async fn test_notifications( 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. @@ -89,12 +132,13 @@ async fn test_notifications( assert_eq!(store.notification_count(), 2); assert_eq!(store.unread_notification_count(), 1); - let entry = store.notification_at(1).unwrap(); + let entry = store.notification_at(0).unwrap(); assert_eq!( entry.notification, Notification::ChannelInvitation { channel_id, - channel_name: "the-channel".to_string() + channel_name: "the-channel".to_string(), + inviter_id: client_a.id() } ); assert!(!entry.is_read); @@ -108,7 +152,7 @@ async fn test_notifications( assert_eq!(store.notification_count(), 2); assert_eq!(store.unread_notification_count(), 0); - let entry = store.notification_at(1).unwrap(); + let entry = store.notification_at(0).unwrap(); assert!(entry.is_read); assert_eq!(entry.response, Some(true)); }); diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index 30242d6360..93ba05a671 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -1,11 +1,9 @@ use crate::{ - format_timestamp, is_channels_feature_enabled, - notifications::contact_notification::ContactNotification, render_avatar, - NotificationPanelSettings, + format_timestamp, is_channels_feature_enabled, render_avatar, NotificationPanelSettings, }; use anyhow::Result; use channel::ChannelStore; -use client::{Client, Notification, UserStore}; +use client::{Client, Notification, User, UserStore}; use db::kvp::KEY_VALUE_STORE; use futures::StreamExt; use gpui::{ @@ -19,7 +17,7 @@ use notifications::{NotificationEntry, NotificationEvent, NotificationStore}; use project::Fs; use serde::{Deserialize, Serialize}; use settings::SettingsStore; -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; use theme::{IconButton, Theme}; use time::{OffsetDateTime, UtcOffset}; use util::{ResultExt, TryFutureExt}; @@ -28,6 +26,7 @@ use workspace::{ Workspace, }; +const TOAST_DURATION: Duration = Duration::from_secs(5); const NOTIFICATION_PANEL_KEY: &'static str = "NotificationPanel"; pub struct NotificationPanel { @@ -42,6 +41,7 @@ pub struct NotificationPanel { pending_serialization: Task>, subscriptions: Vec, workspace: WeakViewHandle, + current_notification_toast: Option<(u64, Task<()>)>, local_timezone: UtcOffset, has_focus: bool, } @@ -58,7 +58,7 @@ pub enum Event { Dismissed, } -actions!(chat_panel, [ToggleFocus]); +actions!(notification_panel, [ToggleFocus]); pub fn init(_cx: &mut AppContext) {} @@ -69,14 +69,8 @@ impl NotificationPanel { let user_store = workspace.app_state().user_store.clone(); let workspace_handle = workspace.weak_handle(); - let notification_list = - ListState::::new(0, Orientation::Top, 1000., move |this, ix, cx| { - this.render_notification(ix, cx) - }); - cx.add_view(|cx| { let mut status = client.status(); - cx.spawn(|this, mut cx| async move { while let Some(_) = status.next().await { if this @@ -91,6 +85,12 @@ impl NotificationPanel { }) .detach(); + let notification_list = + ListState::::new(0, Orientation::Top, 1000., move |this, ix, cx| { + this.render_notification(ix, cx) + .unwrap_or_else(|| Empty::new().into_any()) + }); + let mut this = Self { fs, client, @@ -102,6 +102,7 @@ impl NotificationPanel { pending_serialization: Task::ready(None), workspace: workspace_handle, has_focus: false, + current_notification_toast: None, subscriptions: Vec::new(), active: false, width: None, @@ -169,73 +170,20 @@ impl NotificationPanel { ); } - fn render_notification(&mut self, ix: usize, cx: &mut ViewContext) -> AnyElement { - self.try_render_notification(ix, cx) - .unwrap_or_else(|| Empty::new().into_any()) - } - - fn try_render_notification( + fn render_notification( &mut self, ix: usize, cx: &mut ViewContext, ) -> Option> { - let notification_store = self.notification_store.read(cx); - let user_store = self.user_store.read(cx); - let channel_store = self.channel_store.read(cx); - let entry = notification_store.notification_at(ix)?; - let notification = entry.notification.clone(); + let entry = self.notification_store.read(cx).notification_at(ix)?; let now = OffsetDateTime::now_utc(); let timestamp = entry.timestamp; - - let icon; - let text; - let actor; - let needs_acceptance; - match notification { - Notification::ContactRequest { sender_id } => { - let requester = user_store.get_cached_user(sender_id)?; - icon = "icons/plus.svg"; - text = format!("{} wants to add you as a contact", requester.github_login); - needs_acceptance = true; - actor = Some(requester); - } - Notification::ContactRequestAccepted { responder_id } => { - let responder = user_store.get_cached_user(responder_id)?; - icon = "icons/plus.svg"; - text = format!("{} accepted your contact invite", responder.github_login); - needs_acceptance = false; - actor = Some(responder); - } - Notification::ChannelInvitation { - ref channel_name, .. - } => { - actor = None; - icon = "icons/hash.svg"; - text = format!("you were invited to join the #{channel_name} channel"); - needs_acceptance = true; - } - 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 = notification_store.channel_message_for_id(message_id)?; - - icon = "icons/conversations.svg"; - text = format!( - "{} mentioned you in the #{} channel:\n{}", - sender.github_login, channel.name, message.body, - ); - needs_acceptance = false; - actor = Some(sender); - } - } + let (actor, text, icon, needs_response) = 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() @@ -276,7 +224,7 @@ impl NotificationPanel { ) .into_any(), ) - } else if needs_acceptance { + } else if needs_response { Some( Flex::row() .with_children([ @@ -336,6 +284,69 @@ impl NotificationPanel { ) } + fn present_notification( + &self, + entry: &NotificationEntry, + cx: &AppContext, + ) -> Option<(Option>, String, &'static str, bool)> { + let user_store = self.user_store.read(cx); + let channel_store = self.channel_store.read(cx); + let icon; + let text; + let actor; + let needs_response; + match entry.notification { + Notification::ContactRequest { sender_id } => { + let requester = user_store.get_cached_user(sender_id)?; + icon = "icons/plus.svg"; + text = format!("{} wants to add you as a contact", requester.github_login); + needs_response = user_store.is_contact_request_pending(&requester); + actor = Some(requester); + } + Notification::ContactRequestAccepted { responder_id } => { + let responder = user_store.get_cached_user(responder_id)?; + icon = "icons/plus.svg"; + text = format!("{} accepted your contact invite", responder.github_login); + needs_response = false; + actor = Some(responder); + } + Notification::ChannelInvitation { + ref channel_name, + channel_id, + inviter_id, + } => { + let inviter = user_store.get_cached_user(inviter_id)?; + 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); + } + 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)?; + icon = "icons/conversations.svg"; + text = format!( + "{} mentioned you in the #{} channel:\n{}", + sender.github_login, channel.name, message.body, + ); + needs_response = false; + actor = Some(sender); + } + } + Some((actor, text, icon, needs_response)) + } + fn render_sign_in_prompt( &self, theme: &Arc, @@ -387,7 +398,7 @@ impl NotificationPanel { match event { NotificationEvent::NewNotification { entry } => self.add_toast(entry, cx), NotificationEvent::NotificationRemoved { entry } - | NotificationEvent::NotificationRead { entry } => self.remove_toast(entry, cx), + | NotificationEvent::NotificationRead { entry } => self.remove_toast(entry.id, cx), NotificationEvent::NotificationsUpdated { old_range, new_count, @@ -399,49 +410,44 @@ impl NotificationPanel { } fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext) { - let id = entry.id as usize; - match entry.notification { - Notification::ContactRequest { - sender_id: actor_id, - } - | Notification::ContactRequestAccepted { - responder_id: actor_id, - } => { - let user_store = self.user_store.clone(); - let Some(user) = user_store.read(cx).get_cached_user(actor_id) else { - return; - }; - self.workspace - .update(cx, |workspace, cx| { - workspace.show_notification(id, cx, |cx| { - cx.add_view(|_| { - ContactNotification::new( - user, - entry.notification.clone(), - user_store, - ) - }) - }) - }) + let Some((actor, text, _, _)) = self.present_notification(entry, cx) else { + return; + }; + + let id = entry.id; + self.current_notification_toast = Some(( + id, + cx.spawn(|this, mut cx| async move { + cx.background().timer(TOAST_DURATION).await; + this.update(&mut cx, |this, cx| this.remove_toast(id, cx)) .ok(); - } - Notification::ChannelInvitation { .. } => {} - Notification::ChannelMessageMention { .. } => {} - } + }), + )); + + self.workspace + .update(cx, |workspace, cx| { + workspace.show_notification(0, cx, |cx| { + let workspace = cx.weak_handle(); + cx.add_view(|_| NotificationToast { + actor, + text, + workspace, + }) + }) + }) + .ok(); } - fn remove_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext) { - let id = entry.id as usize; - match entry.notification { - Notification::ContactRequest { .. } | Notification::ContactRequestAccepted { .. } => { + fn remove_toast(&mut self, notification_id: u64, cx: &mut ViewContext) { + 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::(id, cx) + workspace.dismiss_notification::(0, cx) }) .ok(); } - Notification::ChannelInvitation { .. } => {} - Notification::ChannelMessageMention { .. } => {} } } @@ -582,3 +588,111 @@ fn render_icon_button(style: &IconButton, svg_path: &'static str) -> im .contained() .with_style(style.container) } + +pub struct NotificationToast { + actor: Option>, + text: String, + workspace: WeakViewHandle, +} + +pub enum ToastEvent { + Dismiss, +} + +impl NotificationToast { + fn focus_notification_panel(&self, cx: &mut AppContext) { + let workspace = self.workspace.clone(); + cx.defer(move |cx| { + workspace + .update(cx, |workspace, cx| { + workspace.focus_panel::(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) -> AnyElement { + let user = self.actor.clone(); + let theme = theme::current(cx).clone(); + let theme = &theme.contact_notification; + + MouseEventHandler::new::(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::(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: &::Event) -> bool { + matches!(event, ToastEvent::Dismiss) + } +} diff --git a/crates/collab_ui/src/notifications.rs b/crates/collab_ui/src/notifications.rs index e4456163c6..5c184ec5c8 100644 --- a/crates/collab_ui/src/notifications.rs +++ b/crates/collab_ui/src/notifications.rs @@ -1,120 +1,11 @@ -use client::User; -use gpui::{ - elements::*, - platform::{CursorStyle, MouseButton}, - AnyElement, AppContext, Element, ViewContext, -}; +use gpui::AppContext; use std::sync::Arc; use workspace::AppState; -pub mod contact_notification; pub mod incoming_call_notification; pub mod project_shared_notification; -enum Dismiss {} -enum Button {} - pub fn init(app_state: &Arc, cx: &mut AppContext) { incoming_call_notification::init(app_state, cx); project_shared_notification::init(app_state, cx); } - -pub fn render_user_notification( - user: Arc, - title: &'static str, - body: Option<&'static str>, - on_dismiss: F, - buttons: Vec<(&'static str, Box)>)>, - cx: &mut ViewContext, -) -> AnyElement -where - F: 'static + Fn(&mut V, &mut ViewContext), -{ - 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::(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::(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() -} diff --git a/crates/collab_ui/src/notifications/contact_notification.rs b/crates/collab_ui/src/notifications/contact_notification.rs deleted file mode 100644 index 2e3c3ca58a..0000000000 --- a/crates/collab_ui/src/notifications/contact_notification.rs +++ /dev/null @@ -1,106 +0,0 @@ -use crate::notifications::render_user_notification; -use client::{User, UserStore}; -use gpui::{elements::*, Entity, ModelHandle, View, ViewContext}; -use std::sync::Arc; -use workspace::notifications::Notification; - -pub struct ContactNotification { - user_store: ModelHandle, - user: Arc, - notification: rpc::Notification, -} - -#[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) -> AnyElement { - match self.notification { - rpc::Notification::ContactRequest { .. } => 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, - ), - rpc::Notification::ContactRequestAccepted { .. } => 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: &::Event) -> bool { - matches!(event, Event::Dismiss) - } -} - -impl ContactNotification { - pub fn new( - user: Arc, - notification: rpc::Notification, - user_store: ModelHandle, - ) -> Self { - Self { - user, - notification, - user_store, - } - } - - fn dismiss(&mut self, cx: &mut ViewContext) { - 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.user_store - .update(cx, |store, cx| { - store.respond_to_contact_request(self.user.id, accept, cx) - }) - .detach(); - } -} diff --git a/crates/notifications/src/notification_store.rs b/crates/notifications/src/notification_store.rs index 5a1ed2677e..0ee4ad35f1 100644 --- a/crates/notifications/src/notification_store.rs +++ b/crates/notifications/src/notification_store.rs @@ -25,6 +25,7 @@ pub struct NotificationStore { _subscriptions: Vec, } +#[derive(Clone, PartialEq, Eq, Debug)] pub enum NotificationEvent { NotificationsUpdated { old_range: Range, @@ -118,7 +119,13 @@ impl NotificationStore { self.channel_messages.get(&id) } + // Get the nth newest notification. pub fn notification_at(&self, ix: usize) -> Option<&NotificationEntry> { + let count = self.notifications.summary().count; + if ix >= count { + return None; + } + let ix = count - 1 - ix; let mut cursor = self.notifications.cursor::(); cursor.seek(&Count(ix), Bias::Right, &()); cursor.item() @@ -200,7 +207,9 @@ impl NotificationStore { for entry in ¬ifications { match entry.notification { - Notification::ChannelInvitation { .. } => {} + Notification::ChannelInvitation { inviter_id, .. } => { + user_ids.push(inviter_id); + } Notification::ContactRequest { sender_id: requester_id, } => { @@ -273,8 +282,11 @@ impl NotificationStore { old_range.start = cursor.start().1 .0; } - if let Some(existing_notification) = cursor.item() { - if existing_notification.id == id { + let old_notification = cursor.item(); + if let Some(old_notification) = old_notification { + if old_notification.id == id { + cursor.next(&()); + if let Some(new_notification) = &new_notification { if new_notification.is_read { cx.emit(NotificationEvent::NotificationRead { @@ -283,20 +295,19 @@ impl NotificationStore { } } else { cx.emit(NotificationEvent::NotificationRemoved { - entry: existing_notification.clone(), + entry: old_notification.clone(), }); } - cursor.next(&()); + } + } else if let Some(new_notification) = &new_notification { + if is_new { + cx.emit(NotificationEvent::NewNotification { + entry: new_notification.clone(), + }); } } if let Some(notification) = new_notification { - if is_new { - cx.emit(NotificationEvent::NewNotification { - entry: notification.clone(), - }); - } - new_notifications.push(notification, &()); } } diff --git a/crates/rpc/src/notification.rs b/crates/rpc/src/notification.rs index 06dff82b75..c5476469be 100644 --- a/crates/rpc/src/notification.rs +++ b/crates/rpc/src/notification.rs @@ -30,12 +30,13 @@ pub enum Notification { #[serde(rename = "entity_id")] channel_id: u64, channel_name: String, + inviter_id: u64, }, ChannelMessageMention { - sender_id: u64, - channel_id: u64, #[serde(rename = "entity_id")] message_id: u64, + sender_id: u64, + channel_id: u64, }, } @@ -84,6 +85,7 @@ fn test_notification() { Notification::ChannelInvitation { channel_id: 100, channel_name: "the-channel".into(), + inviter_id: 50, }, Notification::ChannelMessageMention { sender_id: 200, From ee87ac2f9b9f4ea2432b96ea63e8b3cd8b428d1a Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 17 Oct 2023 17:59:42 -0700 Subject: [PATCH 32/90] Start work on chat mentions --- Cargo.lock | 1 + crates/collab/src/db/queries/channels.rs | 18 +- crates/collab_ui/Cargo.toml | 1 + crates/collab_ui/src/chat_panel.rs | 69 +++--- .../src/chat_panel/message_editor.rs | 218 ++++++++++++++++++ crates/rpc/proto/zed.proto | 7 +- crates/rpc/src/proto.rs | 2 + crates/theme/src/theme.rs | 1 + styles/src/style_tree/chat_panel.ts | 1 + 9 files changed, 271 insertions(+), 47 deletions(-) create mode 100644 crates/collab_ui/src/chat_panel/message_editor.rs diff --git a/Cargo.lock b/Cargo.lock index a2fc2bf2d8..ce517efd09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1558,6 +1558,7 @@ dependencies = [ "fuzzy", "gpui", "language", + "lazy_static", "log", "menu", "notifications", diff --git a/crates/collab/src/db/queries/channels.rs b/crates/collab/src/db/queries/channels.rs index d2499ab3ce..1ca38b2e3c 100644 --- a/crates/collab/src/db/queries/channels.rs +++ b/crates/collab/src/db/queries/channels.rs @@ -552,7 +552,8 @@ impl Database { user_id: UserId, ) -> Result> { self.transaction(|tx| async move { - self.check_user_is_channel_admin(channel_id, user_id, &*tx) + let user_membership = self + .check_user_is_channel_member(channel_id, user_id, &*tx) .await?; #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] @@ -613,6 +614,14 @@ impl Database { }); } + // If the user is not an admin, don't give them all of the details + if !user_membership.admin { + rows.retain_mut(|row| { + row.admin = false; + row.kind != proto::channel_member::Kind::Invitee as i32 + }); + } + Ok(rows) }) .await @@ -644,9 +653,9 @@ impl Database { channel_id: ChannelId, user_id: UserId, tx: &DatabaseTransaction, - ) -> Result<()> { + ) -> Result { let channel_ids = self.get_channel_ancestors(channel_id, tx).await?; - channel_member::Entity::find() + Ok(channel_member::Entity::find() .filter( channel_member::Column::ChannelId .is_in(channel_ids) @@ -654,8 +663,7 @@ impl Database { ) .one(&*tx) .await? - .ok_or_else(|| anyhow!("user is not a channel member or channel does not exist"))?; - Ok(()) + .ok_or_else(|| anyhow!("user is not a channel member or channel does not exist"))?) } pub async fn check_user_is_channel_admin( diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index 4a0f8c5e8b..697faace80 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -54,6 +54,7 @@ zed-actions = {path = "../zed-actions"} anyhow.workspace = true futures.workspace = true +lazy_static.workspace = true log.workspace = true schemars.workspace = true postage.workspace = true diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index d58a406d78..28bfe62109 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -18,8 +18,9 @@ use gpui::{ 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}; @@ -33,6 +34,8 @@ use workspace::{ Workspace, }; +mod message_editor; + const MESSAGE_LOADING_THRESHOLD: usize = 50; const CHAT_PANEL_KEY: &'static str = "ChatPanel"; @@ -42,7 +45,7 @@ pub struct ChatPanel { languages: Arc, active_chat: Option<(ModelHandle, Subscription)>, message_list: ListState, - input_editor: ViewHandle, + input_editor: ViewHandle, channel_select: ViewHandle