@@ -0,0 +1,356 @@
+use crate::TestServer;
+use collab_ui::CollabPanel;
+use collab_ui::collab_panel::{MoveChannelDown, MoveChannelUp, ToggleSelectedChannelFavorite};
+use gpui::TestAppContext;
+use menu::{SelectNext, SelectPrevious};
+
+#[gpui::test]
+async fn test_reorder_favorite_channels_independently_of_channels(cx: &mut TestAppContext) {
+ let (server, client) = TestServer::start1(cx).await;
+ let root = server
+ .make_channel("root", None, (&client, cx), &mut [])
+ .await;
+ let _ = server
+ .make_channel("channel-a", Some(root), (&client, cx), &mut [])
+ .await;
+ let _ = server
+ .make_channel("channel-b", Some(root), (&client, cx), &mut [])
+ .await;
+ let _ = server
+ .make_channel("channel-c", Some(root), (&client, cx), &mut [])
+ .await;
+
+ let (workspace, cx) = client.build_test_workspace(cx).await;
+ let panel = workspace.update_in(cx, |workspace, window, cx| {
+ let panel = CollabPanel::new(workspace, window, cx);
+ workspace.add_panel(panel.clone(), window, cx);
+ panel
+ });
+ cx.run_until_parked();
+
+ // Verify initial state.
+ assert_eq!(
+ panel.read_with(cx, |panel, _| panel.entries_as_strings()),
+ &[
+ "[Channels]",
+ " v root",
+ " #️⃣ channel-a",
+ " #️⃣ channel-b",
+ " #️⃣ channel-c",
+ "[Contacts]",
+ ]
+ );
+
+ // Select channel-b.
+ panel.update_in(cx, |panel, window, cx| {
+ panel.select_next(&SelectNext, window, cx);
+ panel.select_next(&SelectNext, window, cx);
+ panel.select_next(&SelectNext, window, cx);
+ panel.select_next(&SelectNext, window, cx);
+ });
+ assert_eq!(
+ panel.read_with(cx, |panel, _| panel.entries_as_strings()),
+ &[
+ "[Channels]",
+ " v root",
+ " #️⃣ channel-a",
+ " #️⃣ channel-b <== selected",
+ " #️⃣ channel-c",
+ "[Contacts]",
+ ]
+ );
+
+ // Favorite channel-b.
+ panel.update_in(cx, |panel, window, cx| {
+ panel.toggle_selected_channel_favorite(&ToggleSelectedChannelFavorite, window, cx);
+ });
+ assert_eq!(
+ panel.read_with(cx, |panel, _| panel.entries_as_strings()),
+ &[
+ "[Favorites]",
+ " #️⃣ channel-b",
+ "[Channels]",
+ " v root",
+ " #️⃣ channel-a",
+ " #️⃣ channel-b <== selected",
+ " #️⃣ channel-c",
+ "[Contacts]",
+ ]
+ );
+
+ // Select channel-c.
+ panel.update_in(cx, |panel, window, cx| {
+ panel.select_next(&SelectNext, window, cx);
+ });
+ // Favorite channel-c.
+ panel.update_in(cx, |panel, window, cx| {
+ panel.toggle_selected_channel_favorite(&ToggleSelectedChannelFavorite, window, cx);
+ });
+ assert_eq!(
+ panel.read_with(cx, |panel, _| panel.entries_as_strings()),
+ &[
+ "[Favorites]",
+ " #️⃣ channel-b",
+ " #️⃣ channel-c",
+ "[Channels]",
+ " v root",
+ " #️⃣ channel-a",
+ " #️⃣ channel-b",
+ " #️⃣ channel-c <== selected",
+ "[Contacts]",
+ ]
+ );
+
+ // Navigate up to favorite channel-b .
+ panel.update_in(cx, |panel, window, cx| {
+ panel.select_previous(&SelectPrevious, window, cx);
+ panel.select_previous(&SelectPrevious, window, cx);
+ panel.select_previous(&SelectPrevious, window, cx);
+ panel.select_previous(&SelectPrevious, window, cx);
+ panel.select_previous(&SelectPrevious, window, cx);
+ panel.select_previous(&SelectPrevious, window, cx);
+ });
+ assert_eq!(
+ panel.read_with(cx, |panel, _| panel.entries_as_strings()),
+ &[
+ "[Favorites]",
+ " #️⃣ channel-b <== selected",
+ " #️⃣ channel-c",
+ "[Channels]",
+ " v root",
+ " #️⃣ channel-a",
+ " #️⃣ channel-b",
+ " #️⃣ channel-c",
+ "[Contacts]",
+ ]
+ );
+
+ // Move favorite channel-b down.
+ // The Channels section should remain unchanged
+ panel.update_in(cx, |panel, window, cx| {
+ panel.move_channel_down(&MoveChannelDown, window, cx);
+ });
+ assert_eq!(
+ panel.read_with(cx, |panel, _| panel.entries_as_strings()),
+ &[
+ "[Favorites]",
+ " #️⃣ channel-c",
+ " #️⃣ channel-b <== selected",
+ "[Channels]",
+ " v root",
+ " #️⃣ channel-a",
+ " #️⃣ channel-b",
+ " #️⃣ channel-c",
+ "[Contacts]",
+ ]
+ );
+
+ // Move favorite channel-b down again when it's already last (should be no-op).
+ panel.update_in(cx, |panel, window, cx| {
+ panel.move_channel_down(&MoveChannelDown, window, cx);
+ });
+ assert_eq!(
+ panel.read_with(cx, |panel, _| panel.entries_as_strings()),
+ &[
+ "[Favorites]",
+ " #️⃣ channel-c",
+ " #️⃣ channel-b <== selected",
+ "[Channels]",
+ " v root",
+ " #️⃣ channel-a",
+ " #️⃣ channel-b",
+ " #️⃣ channel-c",
+ "[Contacts]",
+ ]
+ );
+
+ // Move favorite channel-b back up.
+ // The Channels section should remain unchanged.
+ panel.update_in(cx, |panel, window, cx| {
+ panel.move_channel_up(&MoveChannelUp, window, cx);
+ });
+ assert_eq!(
+ panel.read_with(cx, |panel, _| panel.entries_as_strings()),
+ &[
+ "[Favorites]",
+ " #️⃣ channel-b <== selected",
+ " #️⃣ channel-c",
+ "[Channels]",
+ " v root",
+ " #️⃣ channel-a",
+ " #️⃣ channel-b",
+ " #️⃣ channel-c",
+ "[Contacts]",
+ ]
+ );
+
+ // Move favorite channel-b up again when it's already first (should be no-op).
+ panel.update_in(cx, |panel, window, cx| {
+ panel.move_channel_up(&MoveChannelUp, window, cx);
+ });
+ assert_eq!(
+ panel.read_with(cx, |panel, _| panel.entries_as_strings()),
+ &[
+ "[Favorites]",
+ " #️⃣ channel-b <== selected",
+ " #️⃣ channel-c",
+ "[Channels]",
+ " v root",
+ " #️⃣ channel-a",
+ " #️⃣ channel-b",
+ " #️⃣ channel-c",
+ "[Contacts]",
+ ]
+ );
+
+ // Unfavorite channel-b.
+ // Selection should move to the next favorite (channel-c).
+ panel.update_in(cx, |panel, window, cx| {
+ panel.toggle_selected_channel_favorite(&ToggleSelectedChannelFavorite, window, cx);
+ });
+ assert_eq!(
+ panel.read_with(cx, |panel, _| panel.entries_as_strings()),
+ &[
+ "[Favorites]",
+ " #️⃣ channel-c <== selected",
+ "[Channels]",
+ " v root",
+ " #️⃣ channel-a",
+ " #️⃣ channel-b",
+ " #️⃣ channel-c",
+ "[Contacts]",
+ ]
+ );
+
+ // Unfavorite channel-c.
+ // Favorites section should disappear entirely.
+ // Selection should move to the next available item.
+ panel.update_in(cx, |panel, window, cx| {
+ panel.toggle_selected_channel_favorite(&ToggleSelectedChannelFavorite, window, cx);
+ });
+ assert_eq!(
+ panel.read_with(cx, |panel, _| panel.entries_as_strings()),
+ &[
+ "[Channels]",
+ " v root <== selected",
+ " #️⃣ channel-a",
+ " #️⃣ channel-b",
+ " #️⃣ channel-c",
+ "[Contacts]",
+ ]
+ );
+}
+
+#[gpui::test]
+async fn test_reorder_channels_independently_of_favorites(cx: &mut TestAppContext) {
+ let (server, client) = TestServer::start1(cx).await;
+ let root = server
+ .make_channel("root", None, (&client, cx), &mut [])
+ .await;
+ let _ = server
+ .make_channel("channel-a", Some(root), (&client, cx), &mut [])
+ .await;
+ let _ = server
+ .make_channel("channel-b", Some(root), (&client, cx), &mut [])
+ .await;
+ let _ = server
+ .make_channel("channel-c", Some(root), (&client, cx), &mut [])
+ .await;
+
+ let (workspace, cx) = client.build_test_workspace(cx).await;
+ let panel = workspace.update_in(cx, |workspace, window, cx| {
+ let panel = CollabPanel::new(workspace, window, cx);
+ workspace.add_panel(panel.clone(), window, cx);
+ panel
+ });
+ cx.run_until_parked();
+
+ // Select channel-a.
+ panel.update_in(cx, |panel, window, cx| {
+ panel.select_next(&SelectNext, window, cx);
+ panel.select_next(&SelectNext, window, cx);
+ panel.select_next(&SelectNext, window, cx);
+ });
+ assert_eq!(
+ panel.read_with(cx, |panel, _| panel.entries_as_strings()),
+ &[
+ "[Channels]",
+ " v root",
+ " #️⃣ channel-a <== selected",
+ " #️⃣ channel-b",
+ " #️⃣ channel-c",
+ "[Contacts]",
+ ]
+ );
+
+ // Favorite channel-a.
+ panel.update_in(cx, |panel, window, cx| {
+ panel.toggle_selected_channel_favorite(&ToggleSelectedChannelFavorite, window, cx);
+ });
+
+ // Select channel-b.
+ // Favorite channel-b.
+ panel.update_in(cx, |panel, window, cx| {
+ panel.select_next(&SelectNext, window, cx);
+ panel.toggle_selected_channel_favorite(&ToggleSelectedChannelFavorite, window, cx);
+ });
+ cx.run_until_parked();
+
+ assert_eq!(
+ panel.read_with(cx, |panel, _| panel.entries_as_strings()),
+ &[
+ "[Favorites]",
+ " #️⃣ channel-a",
+ " #️⃣ channel-b",
+ "[Channels]",
+ " v root",
+ " #️⃣ channel-a",
+ " #️⃣ channel-b <== selected",
+ " #️⃣ channel-c",
+ "[Contacts]",
+ ]
+ );
+
+ // Select channel-a in the Channels section.
+ panel.update_in(cx, |panel, window, cx| {
+ panel.select_previous(&SelectPrevious, window, cx);
+ });
+ assert_eq!(
+ panel.read_with(cx, |panel, _| panel.entries_as_strings()),
+ &[
+ "[Favorites]",
+ " #️⃣ channel-a",
+ " #️⃣ channel-b",
+ "[Channels]",
+ " v root",
+ " #️⃣ channel-a <== selected",
+ " #️⃣ channel-b",
+ " #️⃣ channel-c",
+ "[Contacts]",
+ ]
+ );
+
+ // Move channel-a down.
+ // The Favorites section should remain unchanged.
+ // Selection should remain on channel-a in the Channels section,
+ // not jump to channel-a in Favorites.
+ panel.update_in(cx, |panel, window, cx| {
+ panel.move_channel_down(&MoveChannelDown, window, cx);
+ });
+ cx.run_until_parked();
+
+ assert_eq!(
+ panel.read_with(cx, |panel, _| panel.entries_as_strings()),
+ &[
+ "[Favorites]",
+ " #️⃣ channel-a",
+ " #️⃣ channel-b",
+ "[Channels]",
+ " v root",
+ " #️⃣ channel-b",
+ " #️⃣ channel-a <== selected",
+ " #️⃣ channel-c",
+ "[Contacts]",
+ ]
+ );
+}
@@ -23,7 +23,7 @@ use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrevious};
use project::{Fs, Project};
use rpc::{
ErrorCode, ErrorExt,
- proto::{self, ChannelVisibility, PeerId},
+ proto::{self, ChannelVisibility, PeerId, reorder_channel::Direction},
};
use serde::{Deserialize, Serialize};
use settings::Settings;
@@ -306,6 +306,7 @@ enum ListEntry {
channel: Arc<Channel>,
depth: usize,
has_children: bool,
+ is_favorite: bool,
// `None` when the channel is a parent of a matched channel.
string_match: Option<StringMatch>,
},
@@ -727,6 +728,7 @@ impl CollabPanel {
channel: (*channel).clone(),
depth: 0,
has_children: false,
+ is_favorite: true,
string_match: matches_by_candidate.get(&ix).cloned().cloned(),
});
}
@@ -827,6 +829,7 @@ impl CollabPanel {
channel: channel.clone(),
depth,
has_children: false,
+ is_favorite: false,
string_match: matches_by_id.get(&channel.id).map(|mat| (*mat).clone()),
});
self.entries
@@ -843,6 +846,7 @@ impl CollabPanel {
channel: channel.clone(),
depth,
has_children,
+ is_favorite: false,
string_match: matches_by_id.get(&channel.id).map(|mat| (*mat).clone()),
});
}
@@ -994,13 +998,22 @@ impl CollabPanel {
if select_same_item {
if let Some(prev_selected_entry) = prev_selected_entry {
- self.selection.take();
+ let prev_selection = self.selection.take();
for (ix, entry) in self.entries.iter().enumerate() {
if *entry == prev_selected_entry {
self.selection = Some(ix);
break;
}
}
+ if self.selection.is_none() {
+ self.selection = prev_selection.and_then(|prev_ix| {
+ if self.entries.is_empty() {
+ None
+ } else {
+ Some(prev_ix.min(self.entries.len() - 1))
+ }
+ });
+ }
}
} else {
self.selection = self.selection.and_then(|prev_selection| {
@@ -1654,7 +1667,7 @@ impl CollabPanel {
self.update_entries(false, cx);
}
- fn select_next(&mut self, _: &SelectNext, _: &mut Window, cx: &mut Context<Self>) {
+ pub fn select_next(&mut self, _: &SelectNext, _: &mut Window, cx: &mut Context<Self>) {
let ix = self.selection.map_or(0, |ix| ix + 1);
if ix < self.entries.len() {
self.selection = Some(ix);
@@ -1666,7 +1679,7 @@ impl CollabPanel {
cx.notify();
}
- fn select_previous(&mut self, _: &SelectPrevious, _: &mut Window, cx: &mut Context<Self>) {
+ pub fn select_previous(&mut self, _: &SelectPrevious, _: &mut Window, cx: &mut Context<Self>) {
let ix = self.selection.take().unwrap_or(0);
if ix > 0 {
self.selection = Some(ix - 1);
@@ -1922,7 +1935,7 @@ impl CollabPanel {
self.collapsed_channels.binary_search(&channel_id).is_ok()
}
- fn toggle_favorite_channel(&mut self, channel_id: ChannelId, cx: &mut Context<Self>) {
+ pub fn toggle_favorite_channel(&mut self, channel_id: ChannelId, cx: &mut Context<Self>) {
self.channel_store.update(cx, |store, cx| {
store.toggle_favorite_channel(channel_id, cx);
});
@@ -1934,6 +1947,12 @@ impl CollabPanel {
}
fn persist_favorites(&mut self, cx: &mut Context<Self>) {
+ // GlobalKeyValueStore uses a sqlez worker thread that the test
+ // scheduler can't control, causing non-determinism failures.
+ if cfg!(any(test, feature = "test-support")) {
+ return;
+ }
+
let favorite_ids: Vec<u64> = self
.channel_store
.read(cx)
@@ -2069,7 +2088,7 @@ impl CollabPanel {
}
}
- fn toggle_selected_channel_favorite(
+ pub fn toggle_selected_channel_favorite(
&mut self,
_: &ToggleSelectedChannelFavorite,
_window: &mut Window,
@@ -2160,33 +2179,79 @@ impl CollabPanel {
})
}
- fn move_channel_up(&mut self, _: &MoveChannelUp, window: &mut Window, cx: &mut Context<Self>) {
- if let Some(channel) = self.selected_channel() {
- self.channel_store.update(cx, |store, cx| {
- store
- .reorder_channel(channel.id, proto::reorder_channel::Direction::Up, cx)
- .detach_and_prompt_err("Failed to move channel up", window, cx, |_, _, _| None)
- });
- }
+ pub fn move_channel_up(
+ &mut self,
+ _: &MoveChannelUp,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.reorder_selected_channel(Direction::Up, window, cx);
}
- fn move_channel_down(
+ pub fn move_channel_down(
&mut self,
_: &MoveChannelDown,
window: &mut Window,
cx: &mut Context<Self>,
) {
- if let Some(channel) = self.selected_channel() {
+ self.reorder_selected_channel(Direction::Down, window, cx);
+ }
+
+ fn reorder_selected_channel(
+ &mut self,
+ direction: Direction,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ if let Some(channel) = self.selected_channel().cloned() {
+ if self.selected_entry_is_favorite() {
+ self.reorder_favorite(channel.id, direction, cx);
+ return;
+ }
+
self.channel_store.update(cx, |store, cx| {
store
- .reorder_channel(channel.id, proto::reorder_channel::Direction::Down, cx)
- .detach_and_prompt_err("Failed to move channel down", window, cx, |_, _, _| {
- None
- })
+ .reorder_channel(channel.id, direction, cx)
+ .detach_and_prompt_err(
+ match direction {
+ Direction::Up => "Failed to move channel up",
+ Direction::Down => "Failed to move channel down",
+ },
+ window,
+ cx,
+ |_, _, _| None,
+ )
});
}
}
+ pub fn reorder_favorite(
+ &mut self,
+ channel_id: ChannelId,
+ direction: Direction,
+ cx: &mut Context<Self>,
+ ) {
+ self.channel_store.update(cx, |store, cx| {
+ let favorite_ids = store.favorite_channel_ids();
+ let Some(channel_index) = favorite_ids.iter().position(|id| *id == channel_id) else {
+ return;
+ };
+ let target_channel_index = match direction {
+ Direction::Up => channel_index.checked_sub(1),
+ Direction::Down => {
+ let next = channel_index + 1;
+ (next < favorite_ids.len()).then_some(next)
+ }
+ };
+ if let Some(target_channel_index) = target_channel_index {
+ let mut new_ids = favorite_ids.to_vec();
+ new_ids.swap(channel_index, target_channel_index);
+ store.set_favorite_channel_ids(new_ids, cx);
+ }
+ });
+ self.persist_favorites(cx);
+ }
+
fn open_channel_notes(
&mut self,
channel_id: ChannelId,
@@ -2255,6 +2320,20 @@ impl CollabPanel {
})
}
+ fn selected_entry_is_favorite(&self) -> bool {
+ self.selection
+ .and_then(|ix| self.entries.get(ix))
+ .is_some_and(|entry| {
+ matches!(
+ entry,
+ ListEntry::Channel {
+ is_favorite: true,
+ ..
+ }
+ )
+ })
+ }
+
fn selected_contact(&self) -> Option<Arc<Contact>> {
self.selection
.and_then(|ix| self.entries.get(ix))
@@ -2552,6 +2631,7 @@ impl CollabPanel {
depth,
has_children,
string_match,
+ ..
} => self
.render_channel(
channel,
@@ -3445,13 +3525,17 @@ impl PartialEq for ListEntry {
}
}
ListEntry::Channel {
- channel: channel_1, ..
+ channel: channel_1,
+ is_favorite: is_favorite_1,
+ ..
} => {
if let ListEntry::Channel {
- channel: channel_2, ..
+ channel: channel_2,
+ is_favorite: is_favorite_2,
+ ..
} = other
{
- return channel_1.id == channel_2.id;
+ return channel_1.id == channel_2.id && is_favorite_1 == is_favorite_2;
}
}
ListEntry::ChannelNotes { channel_id } => {
@@ -3557,3 +3641,91 @@ impl Render for JoinChannelTooltip {
})
}
}
+
+#[cfg(any(test, feature = "test-support"))]
+impl CollabPanel {
+ pub fn entries_as_strings(&self) -> Vec<String> {
+ let mut string_entries = Vec::new();
+ for (index, entry) in self.entries.iter().enumerate() {
+ let selected_marker = if self.selection == Some(index) {
+ " <== selected"
+ } else {
+ ""
+ };
+ match entry {
+ ListEntry::Header(section) => {
+ let name = match section {
+ Section::ActiveCall => "Active Call",
+ Section::FavoriteChannels => "Favorites",
+ Section::Channels => "Channels",
+ Section::ChannelInvites => "Channel Invites",
+ Section::ContactRequests => "Contact Requests",
+ Section::Contacts => "Contacts",
+ Section::Online => "Online",
+ Section::Offline => "Offline",
+ };
+ string_entries.push(format!("[{name}]"));
+ }
+ ListEntry::Channel {
+ channel,
+ depth,
+ has_children,
+ ..
+ } => {
+ let indent = " ".repeat(*depth + 1);
+ let icon = if *has_children {
+ "v "
+ } else if channel.visibility == proto::ChannelVisibility::Public {
+ "🛜 "
+ } else {
+ "#️⃣ "
+ };
+ string_entries.push(format!("{indent}{icon}{}{selected_marker}", channel.name));
+ }
+ ListEntry::ChannelNotes { .. } => {
+ string_entries.push(format!(" (notes){selected_marker}"));
+ }
+ ListEntry::ChannelEditor { depth } => {
+ let indent = " ".repeat(*depth + 1);
+ string_entries.push(format!("{indent}[editor]{selected_marker}"));
+ }
+ ListEntry::ChannelInvite(channel) => {
+ string_entries.push(format!(" (invite) #{}{selected_marker}", channel.name));
+ }
+ ListEntry::CallParticipant { user, .. } => {
+ string_entries.push(format!(" {}{selected_marker}", user.github_login));
+ }
+ ListEntry::ParticipantProject {
+ worktree_root_names,
+ ..
+ } => {
+ string_entries.push(format!(
+ " {}{selected_marker}",
+ worktree_root_names.join(", ")
+ ));
+ }
+ ListEntry::ParticipantScreen { .. } => {
+ string_entries.push(format!(" (screen){selected_marker}"));
+ }
+ ListEntry::IncomingRequest(user) => {
+ string_entries.push(format!(
+ " (incoming) {}{selected_marker}",
+ user.github_login
+ ));
+ }
+ ListEntry::OutgoingRequest(user) => {
+ string_entries.push(format!(
+ " (outgoing) {}{selected_marker}",
+ user.github_login
+ ));
+ }
+ ListEntry::Contact { contact, .. } => {
+ string_entries
+ .push(format!(" {}{selected_marker}", contact.user.github_login));
+ }
+ ListEntry::ContactPlaceholder => {}
+ }
+ }
+ string_entries
+ }
+}