diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 2211f9563d..14b9d01b21 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -585,6 +585,14 @@ "space": "menu::Confirm" } }, + { + "context": "CollabPanel > Editor", + "bindings": { + "cmd-c": "collab_panel::StartLinkChannel", + "cmd-x": "collab_panel::StartMoveChannel", + "cmd-v": "collab_panel::MoveOrLinkToSelected" + } + }, { "context": "ChannelModal", "bindings": { diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index 5e2e105165..ee1929f720 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -146,17 +146,26 @@ impl ChannelStore { }) } + /// Returns the number of unique channels in the store pub fn channel_count(&self) -> usize { - self.channel_index.len() + self.channel_index.by_id().len() } + /// Returns the index of a channel ID in the list of unique channels pub fn index_of_channel(&self, channel_id: ChannelId) -> Option { self.channel_index - .iter() - .position(|path| path.ends_with(&[channel_id])) + .by_id() + .keys() + .position(|id| *id == channel_id) } - pub fn channels(&self) -> impl '_ + Iterator)> { + /// Returns an iterator over all unique channels + pub fn channels(&self) -> impl '_ + Iterator> { + self.channel_index.by_id().values() + } + + /// Iterate over all entries in the channel DAG + pub fn channel_dag_entries(&self) -> impl '_ + Iterator)> { self.channel_index.iter().map(move |path| { let id = path.last().unwrap(); let channel = self.channel_for_id(*id).unwrap(); @@ -164,7 +173,7 @@ impl ChannelStore { }) } - pub fn channel_at_index(&self, ix: usize) -> Option<(&Arc, &ChannelPath)> { + pub fn channel_dag_entry_at(&self, ix: usize) -> Option<(&Arc, &ChannelPath)> { let path = self.channel_index.get(ix)?; let id = path.last().unwrap(); let channel = self.channel_for_id(*id).unwrap(); @@ -172,6 +181,10 @@ impl ChannelStore { Some((channel, path)) } + pub fn channel_at(&self, ix: usize) -> Option<&Arc> { + self.channel_index.by_id().values().nth(ix) + } + pub fn channel_invitations(&self) -> &[Arc] { &self.channel_invitations } diff --git a/crates/channel/src/channel_store/channel_index.rs b/crates/channel/src/channel_store/channel_index.rs index 95e750aad0..d0c49dc298 100644 --- a/crates/channel/src/channel_store/channel_index.rs +++ b/crates/channel/src/channel_store/channel_index.rs @@ -1,7 +1,7 @@ use std::{ops::Deref, sync::Arc}; use crate::{Channel, ChannelId}; -use collections::HashMap; +use collections::BTreeMap; use rpc::proto; use super::ChannelPath; @@ -9,11 +9,11 @@ use super::ChannelPath; #[derive(Default, Debug)] pub struct ChannelIndex { paths: Vec, - channels_by_id: HashMap>, + channels_by_id: BTreeMap>, } impl ChannelIndex { - pub fn by_id(&self) -> &HashMap> { + pub fn by_id(&self) -> &BTreeMap> { &self.channels_by_id } @@ -53,7 +53,7 @@ impl Deref for ChannelIndex { #[derive(Debug)] pub struct ChannelPathsInsertGuard<'a> { paths: &'a mut Vec, - channels_by_id: &'a mut HashMap>, + channels_by_id: &'a mut BTreeMap>, } impl<'a> ChannelPathsInsertGuard<'a> { @@ -155,7 +155,7 @@ impl<'a> Drop for ChannelPathsInsertGuard<'a> { fn channel_path_sorting_key<'a>( path: &'a [ChannelId], - channels_by_id: &'a HashMap>, + channels_by_id: &'a BTreeMap>, ) -> impl 'a + Iterator> { path.iter() .map(|id| Some(channels_by_id.get(id)?.name.as_str())) diff --git a/crates/channel/src/channel_store_tests.rs b/crates/channel/src/channel_store_tests.rs index 775bf29425..41acafa3a3 100644 --- a/crates/channel/src/channel_store_tests.rs +++ b/crates/channel/src/channel_store_tests.rs @@ -181,7 +181,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) { // Join a channel and populate its existing messages. let channel = channel_store.update(cx, |store, cx| { - let channel_id = store.channels().next().unwrap().1.id; + let channel_id = store.channel_dag_entries().next().unwrap().1.id; store.open_channel_chat(channel_id, cx) }); let join_channel = server.receive::().await.unwrap(); @@ -363,7 +363,7 @@ fn assert_channels( ) { let actual = channel_store.read_with(cx, |store, _| { store - .channels() + .channel_dag_entries() .map(|(depth, channel)| { ( depth, diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index 906461a10e..6b300282a1 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -56,7 +56,10 @@ async fn test_core_channels( ); client_b.channel_store().read_with(cx_b, |channels, _| { - assert!(channels.channels().collect::>().is_empty()) + assert!(channels + .channel_dag_entries() + .collect::>() + .is_empty()) }); // Invite client B to channel A as client A. @@ -1170,7 +1173,7 @@ fn assert_channels( ) { let actual = channel_store.read_with(cx, |store, _| { store - .channels() + .channel_dag_entries() .map(|(depth, channel)| ExpectedChannel { depth, name: channel.name.clone(), @@ -1192,7 +1195,7 @@ fn assert_channels_list_shape( let actual = channel_store.read_with(cx, |store, _| { store - .channels() + .channel_dag_entries() .map(|(depth, channel)| (channel.id, depth)) .collect::>() }); diff --git a/crates/collab/src/tests/random_channel_buffer_tests.rs b/crates/collab/src/tests/random_channel_buffer_tests.rs index a60d3d7d7d..2950922e7c 100644 --- a/crates/collab/src/tests/random_channel_buffer_tests.rs +++ b/crates/collab/src/tests/random_channel_buffer_tests.rs @@ -86,7 +86,7 @@ impl RandomizedTest for RandomChannelBufferTest { match rng.gen_range(0..100_u32) { 0..=29 => { let channel_name = client.channel_store().read_with(cx, |store, cx| { - store.channels().find_map(|(_, channel)| { + store.channel_dag_entries().find_map(|(_, channel)| { if store.has_open_channel_buffer(channel.id, cx) { None } else { @@ -133,7 +133,7 @@ impl RandomizedTest for RandomChannelBufferTest { ChannelBufferOperation::JoinChannelNotes { channel_name } => { let buffer = client.channel_store().update(cx, |store, cx| { let channel_id = store - .channels() + .channel_dag_entries() .find(|(_, c)| c.name == channel_name) .unwrap() .1 diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index 4200ada36b..082702feda 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -166,8 +166,8 @@ impl ChatPanel { let selected_channel_id = this .channel_store .read(cx) - .channel_at_index(selected_ix) - .map(|e| e.0.id); + .channel_at(selected_ix) + .map(|e| e.id); if let Some(selected_channel_id) = selected_channel_id { this.select_channel(selected_channel_id, cx) .detach_and_log_err(cx); @@ -391,7 +391,7 @@ impl ChatPanel { (ItemType::Unselected, true) => &theme.channel_select.hovered_item, }; - let channel = &channel_store.read(cx).channel_at_index(ix).unwrap().0; + let channel = &channel_store.read(cx).channel_at(ix).unwrap(); let channel_id = channel.id; let mut row = Flex::row() diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 093164e3c2..4bc4e91ae4 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -23,9 +23,9 @@ use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ actions, elements::{ - Canvas, ChildView, Component, Empty, Flex, Image, Label, List, ListOffset, ListState, - MouseEventHandler, Orientation, OverlayPositionMode, Padding, ParentElement, SafeStylable, - Stack, Svg, + Canvas, ChildView, Component, ContainerStyle, Empty, Flex, Image, Label, List, ListOffset, + ListState, MouseEventHandler, Orientation, OverlayPositionMode, Padding, ParentElement, + SafeStylable, Stack, Svg, }, fonts::TextStyle, geometry::{ @@ -42,7 +42,7 @@ use project::{Fs, Project}; use serde_derive::{Deserialize, Serialize}; use settings::SettingsStore; use std::{borrow::Cow, hash::Hash, mem, sync::Arc}; -use theme::{components::ComponentExt, IconButton}; +use theme::{components::ComponentExt, IconButton, Interactive}; use util::{iife, ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel}, @@ -65,6 +65,11 @@ struct RenameChannel { location: ChannelPath, } +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +struct ToggleSelectedIx { + ix: usize, +} + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] struct RemoveChannel { channel_id: ChannelId, @@ -96,7 +101,13 @@ struct OpenChannelBuffer { } #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -struct StartMoveChannel { +struct StartMoveChannelFor { + channel_id: ChannelId, + parent_id: Option, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +struct StartLinkChannelFor { channel_id: ChannelId, parent_id: Option, } @@ -126,7 +137,10 @@ actions!( Remove, Secondary, CollapseSelectedChannel, - ExpandSelectedChannel + ExpandSelectedChannel, + StartMoveChannel, + StartLinkChannel, + MoveOrLinkToSelected, ] ); @@ -143,12 +157,27 @@ impl_actions!( JoinChannelCall, OpenChannelBuffer, LinkChannel, - StartMoveChannel, + StartMoveChannelFor, + StartLinkChannelFor, MoveChannel, - UnlinkChannel + UnlinkChannel, + ToggleSelectedIx ] ); +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +struct ChannelMoveClipboard { + channel_id: ChannelId, + parent_id: Option, + intent: ClipboardIntent, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum ClipboardIntent { + Move, + Link, +} + const COLLABORATION_PANEL_KEY: &'static str = "CollaborationPanel"; pub fn init(cx: &mut AppContext) { @@ -175,17 +204,99 @@ pub fn init(cx: &mut AppContext) { cx.add_action(CollabPanel::open_channel_notes); cx.add_action( - |panel: &mut CollabPanel, action: &StartMoveChannel, _: &mut ViewContext| { - panel.channel_move = Some(*action); + |panel: &mut CollabPanel, action: &ToggleSelectedIx, cx: &mut ViewContext| { + if panel.selection.take() != Some(action.ix) { + panel.selection = Some(action.ix) + } + + cx.notify(); + }, + ); + + cx.add_action( + |panel: &mut CollabPanel, + action: &StartMoveChannelFor, + _: &mut ViewContext| { + panel.channel_clipboard = Some(ChannelMoveClipboard { + channel_id: action.channel_id, + parent_id: action.parent_id, + intent: ClipboardIntent::Move, + }); + }, + ); + + cx.add_action( + |panel: &mut CollabPanel, + action: &StartLinkChannelFor, + _: &mut ViewContext| { + panel.channel_clipboard = Some(ChannelMoveClipboard { + channel_id: action.channel_id, + parent_id: action.parent_id, + intent: ClipboardIntent::Link, + }) + }, + ); + + cx.add_action( + |panel: &mut CollabPanel, _: &StartMoveChannel, _: &mut ViewContext| { + if let Some((_, path)) = panel.selected_channel() { + panel.channel_clipboard = Some(ChannelMoveClipboard { + channel_id: path.channel_id(), + parent_id: path.parent_id(), + intent: ClipboardIntent::Move, + }) + } + }, + ); + + cx.add_action( + |panel: &mut CollabPanel, _: &StartLinkChannel, _: &mut ViewContext| { + if let Some((_, path)) = panel.selected_channel() { + panel.channel_clipboard = Some(ChannelMoveClipboard { + channel_id: path.channel_id(), + parent_id: path.parent_id(), + intent: ClipboardIntent::Link, + }) + } + }, + ); + + cx.add_action( + |panel: &mut CollabPanel, _: &MoveOrLinkToSelected, cx: &mut ViewContext| { + let clipboard = panel.channel_clipboard.take(); + if let Some(((selected_channel, _), clipboard)) = + panel.selected_channel().zip(clipboard) + { + match clipboard.intent { + ClipboardIntent::Move if clipboard.parent_id.is_some() => { + let parent_id = clipboard.parent_id.unwrap(); + panel.channel_store.update(cx, |channel_store, cx| { + channel_store + .move_channel( + clipboard.channel_id, + parent_id, + selected_channel.id, + cx, + ) + .detach_and_log_err(cx) + }) + } + _ => panel.channel_store.update(cx, |channel_store, cx| { + channel_store + .link_channel(clipboard.channel_id, selected_channel.id, cx) + .detach_and_log_err(cx) + }), + } + } }, ); cx.add_action( |panel: &mut CollabPanel, action: &LinkChannel, cx: &mut ViewContext| { - if let Some(move_start) = panel.channel_move.take() { + if let Some(clipboard) = panel.channel_clipboard.take() { panel.channel_store.update(cx, |channel_store, cx| { channel_store - .link_channel(move_start.channel_id, action.to, cx) + .link_channel(clipboard.channel_id, action.to, cx) .detach_and_log_err(cx) }) } @@ -194,15 +305,15 @@ pub fn init(cx: &mut AppContext) { cx.add_action( |panel: &mut CollabPanel, action: &MoveChannel, cx: &mut ViewContext| { - if let Some(move_start) = panel.channel_move.take() { + if let Some(clipboard) = panel.channel_clipboard.take() { panel.channel_store.update(cx, |channel_store, cx| { - if let Some(parent) = move_start.parent_id { + if let Some(parent) = clipboard.parent_id { channel_store - .move_channel(move_start.channel_id, parent, action.to, cx) + .move_channel(clipboard.channel_id, parent, action.to, cx) .detach_and_log_err(cx) } else { channel_store - .link_channel(move_start.channel_id, action.to, cx) + .link_channel(clipboard.channel_id, action.to, cx) .detach_and_log_err(cx) } }) @@ -246,7 +357,7 @@ pub struct CollabPanel { width: Option, fs: Arc, has_focus: bool, - channel_move: Option, + channel_clipboard: Option, pending_serialization: Task>, context_menu: ViewHandle, filter_editor: ViewHandle, @@ -444,6 +555,7 @@ impl CollabPanel { path.to_owned(), &theme.collab_panel, is_selected, + ix, cx, ); @@ -510,7 +622,7 @@ impl CollabPanel { let mut this = Self { width: None, has_focus: false, - channel_move: None, + channel_clipboard: None, fs: workspace.app_state().fs.clone(), pending_serialization: Task::ready(None), context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)), @@ -776,16 +888,13 @@ impl CollabPanel { if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() { self.match_candidates.clear(); self.match_candidates - .extend( - channel_store - .channels() - .enumerate() - .map(|(ix, (_, channel))| StringMatchCandidate { - id: ix, - string: channel.name.clone(), - char_bag: channel.name.chars().collect(), - }), - ); + .extend(channel_store.channel_dag_entries().enumerate().map( + |(ix, (_, channel))| StringMatchCandidate { + id: ix, + string: channel.name.clone(), + char_bag: channel.name.chars().collect(), + }, + )); let matches = executor.block(match_strings( &self.match_candidates, &query, @@ -801,7 +910,9 @@ impl CollabPanel { } let mut collapse_depth = None; for mat in matches { - let (channel, path) = channel_store.channel_at_index(mat.candidate_id).unwrap(); + let (channel, path) = channel_store + .channel_dag_entry_at(mat.candidate_id) + .unwrap(); let depth = path.len() - 1; if collapse_depth.is_none() && self.is_channel_collapsed(path) { @@ -1627,7 +1738,7 @@ impl CollabPanel { .constrained() .with_height(theme.collab_panel.row_height) .contained() - .with_style(gpui::elements::ContainerStyle { + .with_style(ContainerStyle { background_color: Some(theme.editor.background), ..*theme.collab_panel.contact_row.default_style() }) @@ -1645,11 +1756,13 @@ impl CollabPanel { path: ChannelPath, theme: &theme::CollabPanel, is_selected: bool, + ix: usize, cx: &mut ViewContext, ) -> AnyElement { let channel_id = channel.id; let has_children = self.channel_store.read(cx).has_children(channel_id); - + let other_selected = + self.selected_channel().map(|channel| channel.0.id) == Some(channel.id); let disclosed = has_children.then(|| !self.collapsed_channels.binary_search(&path).is_ok()); let is_active = iife!({ @@ -1683,6 +1796,20 @@ impl CollabPanel { MouseEventHandler::new::(path.unique_id() as usize, cx, |state, cx| { let row_hovered = state.hovered(); + let mut select_state = |interactive: &Interactive| { + if state.clicked() == Some(MouseButton::Left) && interactive.clicked.is_some() { + interactive.clicked.as_ref().unwrap().clone() + } else if state.hovered() || other_selected { + interactive + .hovered + .as_ref() + .unwrap_or(&interactive.default) + .clone() + } else { + interactive.default.clone() + } + }; + Flex::::row() .with_child( Svg::new("icons/hash.svg") @@ -1760,11 +1887,11 @@ impl CollabPanel { .constrained() .with_height(theme.row_height) .contained() - .with_style( - *theme + .with_style(select_state( + theme .channel_row - .style_for(is_selected || is_active || is_dragged_over, state), - ) + .in_state(is_selected || is_active || is_dragged_over), + )) .with_padding_left( theme.channel_row.default_style().padding.left + theme.channel_indent * depth as f32, @@ -1778,7 +1905,7 @@ impl CollabPanel { .on_click(MouseButton::Right, { let path = path.clone(); move |e, this, cx| { - this.deploy_channel_context_menu(Some(e.position), &path, cx); + this.deploy_channel_context_menu(Some(e.position), &path, ix, cx); } }) .on_up(MouseButton::Left, move |e, this, cx| { @@ -2119,15 +2246,34 @@ impl CollabPanel { .into_any() } + fn has_subchannels(&self, ix: usize) -> bool { + self.entries + .get(ix) + .zip(self.entries.get(ix + 1)) + .map(|entries| match entries { + ( + ListEntry::Channel { + path: this_path, .. + }, + ListEntry::Channel { + path: next_path, .. + }, + ) => next_path.starts_with(this_path), + _ => false, + }) + .unwrap_or(false) + } + fn deploy_channel_context_menu( &mut self, position: Option, path: &ChannelPath, + ix: usize, cx: &mut ViewContext, ) { self.context_menu_on_selected = position.is_none(); - let channel_name = self.channel_move.as_ref().and_then(|channel| { + let channel_name = self.channel_clipboard.as_ref().and_then(|channel| { let channel_name = self .channel_store .read(cx) @@ -2145,42 +2291,37 @@ impl CollabPanel { let mut items = Vec::new(); - if let Some(channel_name) = channel_name { - items.push(ContextMenuItem::action( - format!("Move '#{}' here", channel_name), - MoveChannel { - to: path.channel_id(), - }, - )); - items.push(ContextMenuItem::action( - format!("Link '#{}' here", channel_name), - LinkChannel { - to: path.channel_id(), - }, - )); - items.push(ContextMenuItem::Separator) - } - - let expand_action_name = if self.is_channel_collapsed(&path) { - "Expand Subchannels" + let select_action_name = if self.selection == Some(ix) { + "Unselect" } else { - "Collapse Subchannels" + "Select" }; - items.extend([ - ContextMenuItem::action( + items.push(ContextMenuItem::action( + select_action_name, + ToggleSelectedIx { ix }, + )); + + if self.has_subchannels(ix) { + let expand_action_name = if self.is_channel_collapsed(&path) { + "Expand Subchannels" + } else { + "Collapse Subchannels" + }; + items.push(ContextMenuItem::action( expand_action_name, ToggleCollapse { location: path.clone(), }, - ), - ContextMenuItem::action( - "Open Notes", - OpenChannelBuffer { - channel_id: path.channel_id(), - }, - ), - ]); + )); + } + + items.push(ContextMenuItem::action( + "Open Notes", + OpenChannelBuffer { + channel_id: path.channel_id(), + }, + )); if self.channel_store.read(cx).is_user_admin(path.channel_id()) { let parent_id = path.parent_id(); @@ -2212,13 +2353,38 @@ impl CollabPanel { )); } - items.extend([ContextMenuItem::action( - "Move this channel", - StartMoveChannel { - channel_id: path.channel_id(), - parent_id, - }, - )]); + items.extend([ + ContextMenuItem::action( + "Move this channel", + StartMoveChannelFor { + channel_id: path.channel_id(), + parent_id, + }, + ), + ContextMenuItem::action( + "Link this channel", + StartLinkChannelFor { + channel_id: path.channel_id(), + parent_id, + }, + ), + ]); + + if let Some(channel_name) = channel_name { + items.push(ContextMenuItem::Separator); + items.push(ContextMenuItem::action( + format!("Move '#{}' here", channel_name), + MoveChannel { + to: path.channel_id(), + }, + )); + items.push(ContextMenuItem::action( + format!("Link '#{}' here", channel_name), + LinkChannel { + to: path.channel_id(), + }, + )); + } items.extend([ ContextMenuItem::Separator, @@ -2598,7 +2764,7 @@ impl CollabPanel { return; }; - self.deploy_channel_context_menu(None, &path.to_owned(), cx); + self.deploy_channel_context_menu(None, &path.to_owned(), self.selection.unwrap(), cx); } fn selected_channel(&self) -> Option<(&Arc, &ChannelPath)> {