Add channel reordering functionality (#31833)
Release Notes: - Added channel reordering for administrators (use `cmd-up` and `cmd-down` on macOS or `ctrl-up` `ctrl-down` on Linux to move channels up or down within their parent) ## Summary This PR introduces the ability for channel administrators to reorder channels within their parent context, providing better organizational control over channel hierarchies. Users can now move channels up or down relative to their siblings using keyboard shortcuts. ## Problem Previously, channels were displayed in alphabetical order with no way to customize their arrangement. This made it difficult for teams to organize channels in a logical order that reflected their workflow or importance, forcing users to prefix channel names with numbers or special characters as a workaround. ## Solution The implementation adds a persistent `channel_order` field to channels that determines their display order within their parent. Channels with the same parent are sorted by this field rather than alphabetically. ## Implementation Details ### Database Schema Added a new column and index to support efficient ordering: ```sql -- crates/collab/migrations/20250530175450_add_channel_order.sql ALTER TABLE channels ADD COLUMN channel_order INTEGER NOT NULL DEFAULT 1; CREATE INDEX CONCURRENTLY "index_channels_on_parent_path_and_order" ON "channels" ("parent_path", "channel_order"); ``` ### RPC Protocol Extended the channel proto with ordering support: ```proto // crates/proto/proto/channel.proto message Channel { uint64 id = 1; string name = 2; ChannelVisibility visibility = 3; int32 channel_order = 4; repeated uint64 parent_path = 5; } message ReorderChannel { uint64 channel_id = 1; enum Direction { Up = 0; Down = 1; } Direction direction = 2; } ``` ### Server-side Logic The reordering is handled by swapping `channel_order` values between adjacent channels: ```rust // crates/collab/src/db/queries/channels.rs pub async fn reorder_channel( &self, channel_id: ChannelId, direction: proto::reorder_channel::Direction, user_id: UserId, ) -> Result<Vec<Channel>> { // Find the sibling channel to swap with let sibling_channel = match direction { proto::reorder_channel::Direction::Up => { // Find channel with highest order less than current channel::Entity::find() .filter( channel::Column::ParentPath .eq(&channel.parent_path) .and(channel::Column::ChannelOrder.lt(channel.channel_order)), ) .order_by_desc(channel::Column::ChannelOrder) .one(&*tx) .await? } // Similar logic for Down... }; // Swap the channel_order values let temp_order = channel.channel_order; channel.channel_order = sibling_channel.channel_order; sibling_channel.channel_order = temp_order; } ``` ### Client-side Sorting Optimized the sorting algorithm to avoid O(n²) complexity: ```rust // crates/collab/src/db/queries/channels.rs // Pre-compute sort keys for efficient O(n log n) sorting let mut channels_with_keys: Vec<(Vec<i32>, Channel)> = channels .into_iter() .map(|channel| { let mut sort_key = Vec::with_capacity(channel.parent_path.len() + 1); // Build sort key from parent path orders for parent_id in &channel.parent_path { sort_key.push(channel_order_map.get(parent_id).copied().unwrap_or(i32::MAX)); } sort_key.push(channel.channel_order); (sort_key, channel) }) .collect(); channels_with_keys.sort_by(|a, b| a.0.cmp(&b.0)); ``` ### User Interface Added keyboard shortcuts and proper context handling: ```json // assets/keymaps/default-macos.json { "context": "CollabPanel && not_editing", "bindings": { "cmd-up": "collab_panel::MoveChannelUp", "cmd-down": "collab_panel::MoveChannelDown" } } ``` The CollabPanel now properly sets context to distinguish between editing and navigation modes: ```rust // crates/collab_ui/src/collab_panel.rs fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext { let mut dispatch_context = KeyContext::new_with_defaults(); dispatch_context.add("CollabPanel"); dispatch_context.add("menu"); let identifier = if self.channel_name_editor.focus_handle(cx).is_focused(window) { "editing" } else { "not_editing" }; dispatch_context.add(identifier); dispatch_context } ``` ## Testing Comprehensive tests were added to verify: - Basic reordering functionality (up/down movement) - Boundary conditions (first/last channels) - Permission checks (non-admins cannot reorder) - Ordering persistence across server restarts - Correct broadcasting of changes to channel members ## Migration Strategy Existing channels are assigned initial `channel_order` values based on their current alphabetical sorting to maintain the familiar order users expect: ```sql UPDATE channels SET channel_order = ( SELECT ROW_NUMBER() OVER ( PARTITION BY parent_path ORDER BY name, id ) FROM channels c2 WHERE c2.id = channels.id ); ``` ## Future Enhancements While this PR provides basic reordering functionality, potential future improvements could include: - Drag-and-drop reordering in the UI - Bulk reordering operations - Custom sorting strategies (by activity, creation date, etc.) ## Checklist - [x] Database migration included - [x] Tests added for new functionality - [x] Keybindings work on macOS and Linux - [x] Permissions properly enforced - [x] Error handling implemented throughout - [x] Manual testing completed - [x] Documentation updated --------- Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
This commit is contained in:
parent
c3653f4cb1
commit
0a2186c87b
18 changed files with 786 additions and 70 deletions
6
.rules
6
.rules
|
@ -5,6 +5,12 @@
|
|||
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
|
||||
* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors.
|
||||
* Be careful with operations like indexing which may panic if the indexes are out of bounds.
|
||||
* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately:
|
||||
- Propagate errors with `?` when the calling function should handle them
|
||||
- Use `.log_err()` or similar when you need to ignore errors but want visibility
|
||||
- Use explicit error handling with `match` or `if let Err(...)` when you need custom logic
|
||||
- Example: avoid `let _ = client.request(...).await?;` - use `client.request(...).await?;` instead
|
||||
* When implementing async operations that may fail, ensure errors propagate to the UI layer so users get meaningful feedback.
|
||||
* Never create files with `mod.rs` paths - prefer `src/some_module.rs` instead of `src/some_module/mod.rs`.
|
||||
|
||||
# GPUI
|
||||
|
|
|
@ -911,7 +911,9 @@
|
|||
"context": "CollabPanel && not_editing",
|
||||
"bindings": {
|
||||
"ctrl-backspace": "collab_panel::Remove",
|
||||
"space": "menu::Confirm"
|
||||
"space": "menu::Confirm",
|
||||
"ctrl-up": "collab_panel::MoveChannelUp",
|
||||
"ctrl-down": "collab_panel::MoveChannelDown"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -967,7 +967,9 @@
|
|||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"ctrl-backspace": "collab_panel::Remove",
|
||||
"space": "menu::Confirm"
|
||||
"space": "menu::Confirm",
|
||||
"cmd-up": "collab_panel::MoveChannelUp",
|
||||
"cmd-down": "collab_panel::MoveChannelDown"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -56,6 +56,7 @@ pub struct Channel {
|
|||
pub name: SharedString,
|
||||
pub visibility: proto::ChannelVisibility,
|
||||
pub parent_path: Vec<ChannelId>,
|
||||
pub channel_order: i32,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
|
@ -614,7 +615,24 @@ impl ChannelStore {
|
|||
to: to.0,
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reorder_channel(
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
direction: proto::reorder_channel::Direction,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
let client = self.client.clone();
|
||||
cx.spawn(async move |_, _| {
|
||||
client
|
||||
.request(proto::ReorderChannel {
|
||||
channel_id: channel_id.0,
|
||||
direction: direction.into(),
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
@ -1027,6 +1045,18 @@ impl ChannelStore {
|
|||
});
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn reset(&mut self) {
|
||||
self.channel_invitations.clear();
|
||||
self.channel_index.clear();
|
||||
self.channel_participants.clear();
|
||||
self.outgoing_invites.clear();
|
||||
self.opened_buffers.clear();
|
||||
self.opened_chats.clear();
|
||||
self.disconnect_channel_buffers_task = None;
|
||||
self.channel_states.clear();
|
||||
}
|
||||
|
||||
pub(crate) fn update_channels(
|
||||
&mut self,
|
||||
payload: proto::UpdateChannels,
|
||||
|
@ -1051,6 +1081,7 @@ impl ChannelStore {
|
|||
visibility: channel.visibility(),
|
||||
name: channel.name.into(),
|
||||
parent_path: channel.parent_path.into_iter().map(ChannelId).collect(),
|
||||
channel_order: channel.channel_order,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
|
|
@ -61,11 +61,13 @@ impl ChannelPathsInsertGuard<'_> {
|
|||
|
||||
ret = existing_channel.visibility != channel_proto.visibility()
|
||||
|| existing_channel.name != channel_proto.name
|
||||
|| existing_channel.parent_path != parent_path;
|
||||
|| existing_channel.parent_path != parent_path
|
||||
|| existing_channel.channel_order != channel_proto.channel_order;
|
||||
|
||||
existing_channel.visibility = channel_proto.visibility();
|
||||
existing_channel.name = channel_proto.name.into();
|
||||
existing_channel.parent_path = parent_path;
|
||||
existing_channel.channel_order = channel_proto.channel_order;
|
||||
} else {
|
||||
self.channels_by_id.insert(
|
||||
ChannelId(channel_proto.id),
|
||||
|
@ -74,6 +76,7 @@ impl ChannelPathsInsertGuard<'_> {
|
|||
visibility: channel_proto.visibility(),
|
||||
name: channel_proto.name.into(),
|
||||
parent_path,
|
||||
channel_order: channel_proto.channel_order,
|
||||
}),
|
||||
);
|
||||
self.insert_root(ChannelId(channel_proto.id));
|
||||
|
@ -100,17 +103,18 @@ impl Drop for ChannelPathsInsertGuard<'_> {
|
|||
fn channel_path_sorting_key(
|
||||
id: ChannelId,
|
||||
channels_by_id: &BTreeMap<ChannelId, Arc<Channel>>,
|
||||
) -> impl Iterator<Item = (&str, ChannelId)> {
|
||||
let (parent_path, name) = channels_by_id
|
||||
.get(&id)
|
||||
.map_or((&[] as &[_], None), |channel| {
|
||||
(
|
||||
channel.parent_path.as_slice(),
|
||||
Some((channel.name.as_ref(), channel.id)),
|
||||
)
|
||||
});
|
||||
) -> impl Iterator<Item = (i32, ChannelId)> {
|
||||
let (parent_path, order_and_id) =
|
||||
channels_by_id
|
||||
.get(&id)
|
||||
.map_or((&[] as &[_], None), |channel| {
|
||||
(
|
||||
channel.parent_path.as_slice(),
|
||||
Some((channel.channel_order, channel.id)),
|
||||
)
|
||||
});
|
||||
parent_path
|
||||
.iter()
|
||||
.filter_map(|id| Some((channels_by_id.get(id)?.name.as_ref(), *id)))
|
||||
.chain(name)
|
||||
.filter_map(|id| Some((channels_by_id.get(id)?.channel_order, *id)))
|
||||
.chain(order_and_id)
|
||||
}
|
||||
|
|
|
@ -21,12 +21,14 @@ fn test_update_channels(cx: &mut App) {
|
|||
name: "b".to_string(),
|
||||
visibility: proto::ChannelVisibility::Members as i32,
|
||||
parent_path: Vec::new(),
|
||||
channel_order: 1,
|
||||
},
|
||||
proto::Channel {
|
||||
id: 2,
|
||||
name: "a".to_string(),
|
||||
visibility: proto::ChannelVisibility::Members as i32,
|
||||
parent_path: Vec::new(),
|
||||
channel_order: 2,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
|
@ -37,8 +39,8 @@ fn test_update_channels(cx: &mut App) {
|
|||
&channel_store,
|
||||
&[
|
||||
//
|
||||
(0, "a".to_string()),
|
||||
(0, "b".to_string()),
|
||||
(0, "a".to_string()),
|
||||
],
|
||||
cx,
|
||||
);
|
||||
|
@ -52,12 +54,14 @@ fn test_update_channels(cx: &mut App) {
|
|||
name: "x".to_string(),
|
||||
visibility: proto::ChannelVisibility::Members as i32,
|
||||
parent_path: vec![1],
|
||||
channel_order: 1,
|
||||
},
|
||||
proto::Channel {
|
||||
id: 4,
|
||||
name: "y".to_string(),
|
||||
visibility: proto::ChannelVisibility::Members as i32,
|
||||
parent_path: vec![2],
|
||||
channel_order: 1,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
|
@ -67,15 +71,111 @@ fn test_update_channels(cx: &mut App) {
|
|||
assert_channels(
|
||||
&channel_store,
|
||||
&[
|
||||
(0, "a".to_string()),
|
||||
(1, "y".to_string()),
|
||||
(0, "b".to_string()),
|
||||
(1, "x".to_string()),
|
||||
(0, "a".to_string()),
|
||||
(1, "y".to_string()),
|
||||
],
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_update_channels_order_independent(cx: &mut App) {
|
||||
/// Based on: https://stackoverflow.com/a/59939809
|
||||
fn unique_permutations<T: Clone>(items: Vec<T>) -> Vec<Vec<T>> {
|
||||
if items.len() == 1 {
|
||||
vec![items]
|
||||
} else {
|
||||
let mut output: Vec<Vec<T>> = vec![];
|
||||
|
||||
for (ix, first) in items.iter().enumerate() {
|
||||
let mut remaining_elements = items.clone();
|
||||
remaining_elements.remove(ix);
|
||||
for mut permutation in unique_permutations(remaining_elements) {
|
||||
permutation.insert(0, first.clone());
|
||||
output.push(permutation);
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
let test_data = vec![
|
||||
proto::Channel {
|
||||
id: 6,
|
||||
name: "β".to_string(),
|
||||
visibility: proto::ChannelVisibility::Members as i32,
|
||||
parent_path: vec![1, 3],
|
||||
channel_order: 1,
|
||||
},
|
||||
proto::Channel {
|
||||
id: 5,
|
||||
name: "α".to_string(),
|
||||
visibility: proto::ChannelVisibility::Members as i32,
|
||||
parent_path: vec![1],
|
||||
channel_order: 2,
|
||||
},
|
||||
proto::Channel {
|
||||
id: 3,
|
||||
name: "x".to_string(),
|
||||
visibility: proto::ChannelVisibility::Members as i32,
|
||||
parent_path: vec![1],
|
||||
channel_order: 1,
|
||||
},
|
||||
proto::Channel {
|
||||
id: 4,
|
||||
name: "y".to_string(),
|
||||
visibility: proto::ChannelVisibility::Members as i32,
|
||||
parent_path: vec![2],
|
||||
channel_order: 1,
|
||||
},
|
||||
proto::Channel {
|
||||
id: 1,
|
||||
name: "b".to_string(),
|
||||
visibility: proto::ChannelVisibility::Members as i32,
|
||||
parent_path: Vec::new(),
|
||||
channel_order: 1,
|
||||
},
|
||||
proto::Channel {
|
||||
id: 2,
|
||||
name: "a".to_string(),
|
||||
visibility: proto::ChannelVisibility::Members as i32,
|
||||
parent_path: Vec::new(),
|
||||
channel_order: 2,
|
||||
},
|
||||
];
|
||||
|
||||
let channel_store = init_test(cx);
|
||||
let permutations = unique_permutations(test_data);
|
||||
|
||||
for test_instance in permutations {
|
||||
channel_store.update(cx, |channel_store, _| channel_store.reset());
|
||||
|
||||
update_channels(
|
||||
&channel_store,
|
||||
proto::UpdateChannels {
|
||||
channels: test_instance,
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
);
|
||||
|
||||
assert_channels(
|
||||
&channel_store,
|
||||
&[
|
||||
(0, "b".to_string()),
|
||||
(1, "x".to_string()),
|
||||
(2, "β".to_string()),
|
||||
(1, "α".to_string()),
|
||||
(0, "a".to_string()),
|
||||
(1, "y".to_string()),
|
||||
],
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_dangling_channel_paths(cx: &mut App) {
|
||||
let channel_store = init_test(cx);
|
||||
|
@ -89,18 +189,21 @@ fn test_dangling_channel_paths(cx: &mut App) {
|
|||
name: "a".to_string(),
|
||||
visibility: proto::ChannelVisibility::Members as i32,
|
||||
parent_path: vec![],
|
||||
channel_order: 1,
|
||||
},
|
||||
proto::Channel {
|
||||
id: 1,
|
||||
name: "b".to_string(),
|
||||
visibility: proto::ChannelVisibility::Members as i32,
|
||||
parent_path: vec![0],
|
||||
channel_order: 1,
|
||||
},
|
||||
proto::Channel {
|
||||
id: 2,
|
||||
name: "c".to_string(),
|
||||
visibility: proto::ChannelVisibility::Members as i32,
|
||||
parent_path: vec![0, 1],
|
||||
channel_order: 1,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
|
@ -147,6 +250,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
|
|||
name: "the-channel".to_string(),
|
||||
visibility: proto::ChannelVisibility::Members as i32,
|
||||
parent_path: vec![],
|
||||
channel_order: 1,
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
|
|
|
@ -266,11 +266,14 @@ CREATE TABLE "channels" (
|
|||
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"visibility" VARCHAR NOT NULL,
|
||||
"parent_path" TEXT NOT NULL,
|
||||
"requires_zed_cla" BOOLEAN NOT NULL DEFAULT FALSE
|
||||
"requires_zed_cla" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
"channel_order" INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE INDEX "index_channels_on_parent_path" ON "channels" ("parent_path");
|
||||
|
||||
CREATE INDEX "index_channels_on_parent_path_and_order" ON "channels" ("parent_path", "channel_order");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "channel_chat_participants" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"user_id" INTEGER NOT NULL REFERENCES users (id),
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
-- Add channel_order column to channels table with default value
|
||||
ALTER TABLE channels ADD COLUMN channel_order INTEGER NOT NULL DEFAULT 1;
|
||||
|
||||
-- Update channel_order for existing channels using ROW_NUMBER for deterministic ordering
|
||||
UPDATE channels
|
||||
SET channel_order = (
|
||||
SELECT ROW_NUMBER() OVER (
|
||||
PARTITION BY parent_path
|
||||
ORDER BY name, id
|
||||
)
|
||||
FROM channels c2
|
||||
WHERE c2.id = channels.id
|
||||
);
|
||||
|
||||
-- Create index for efficient ordering queries
|
||||
CREATE INDEX "index_channels_on_parent_path_and_order" ON "channels" ("parent_path", "channel_order");
|
|
@ -582,6 +582,7 @@ pub struct Channel {
|
|||
pub visibility: ChannelVisibility,
|
||||
/// parent_path is the channel ids from the root to this one (not including this one)
|
||||
pub parent_path: Vec<ChannelId>,
|
||||
pub channel_order: i32,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
|
@ -591,6 +592,7 @@ impl Channel {
|
|||
visibility: value.visibility,
|
||||
name: value.clone().name,
|
||||
parent_path: value.ancestors().collect(),
|
||||
channel_order: value.channel_order,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -600,8 +602,13 @@ impl Channel {
|
|||
name: self.name.clone(),
|
||||
visibility: self.visibility.into(),
|
||||
parent_path: self.parent_path.iter().map(|c| c.to_proto()).collect(),
|
||||
channel_order: self.channel_order,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn root_id(&self) -> ChannelId {
|
||||
self.parent_path.first().copied().unwrap_or(self.id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Hash)]
|
||||
|
|
|
@ -4,7 +4,7 @@ use rpc::{
|
|||
ErrorCode, ErrorCodeExt,
|
||||
proto::{ChannelBufferVersion, VectorClockEntry, channel_member::Kind},
|
||||
};
|
||||
use sea_orm::{DbBackend, TryGetableMany};
|
||||
use sea_orm::{ActiveValue, DbBackend, TryGetableMany};
|
||||
|
||||
impl Database {
|
||||
#[cfg(test)]
|
||||
|
@ -59,16 +59,32 @@ impl Database {
|
|||
parent = Some(parent_channel);
|
||||
}
|
||||
|
||||
let parent_path = parent
|
||||
.as_ref()
|
||||
.map_or(String::new(), |parent| parent.path());
|
||||
|
||||
// Find the maximum channel_order among siblings to set the new channel at the end
|
||||
let max_order = if parent_path.is_empty() {
|
||||
0
|
||||
} else {
|
||||
max_order(&parent_path, &tx).await?
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"Creating channel '{}' with parent_path='{}', max_order={}, new_order={}",
|
||||
name,
|
||||
parent_path,
|
||||
max_order,
|
||||
max_order + 1
|
||||
);
|
||||
|
||||
let channel = channel::ActiveModel {
|
||||
id: ActiveValue::NotSet,
|
||||
name: ActiveValue::Set(name.to_string()),
|
||||
visibility: ActiveValue::Set(ChannelVisibility::Members),
|
||||
parent_path: ActiveValue::Set(
|
||||
parent
|
||||
.as_ref()
|
||||
.map_or(String::new(), |parent| parent.path()),
|
||||
),
|
||||
parent_path: ActiveValue::Set(parent_path),
|
||||
requires_zed_cla: ActiveValue::NotSet,
|
||||
channel_order: ActiveValue::Set(max_order + 1),
|
||||
}
|
||||
.insert(&*tx)
|
||||
.await?;
|
||||
|
@ -531,11 +547,7 @@ impl Database {
|
|||
.get_channel_descendants_excluding_self(channels.iter(), tx)
|
||||
.await?;
|
||||
|
||||
for channel in channels {
|
||||
if let Err(ix) = descendants.binary_search_by_key(&channel.path(), |c| c.path()) {
|
||||
descendants.insert(ix, channel);
|
||||
}
|
||||
}
|
||||
descendants.extend(channels);
|
||||
|
||||
let roles_by_channel_id = channel_memberships
|
||||
.iter()
|
||||
|
@ -952,11 +964,14 @@ impl Database {
|
|||
}
|
||||
|
||||
let root_id = channel.root_id();
|
||||
let new_parent_path = new_parent.path();
|
||||
let old_path = format!("{}{}/", channel.parent_path, channel.id);
|
||||
let new_path = format!("{}{}/", new_parent.path(), channel.id);
|
||||
let new_path = format!("{}{}/", &new_parent_path, channel.id);
|
||||
let new_order = max_order(&new_parent_path, &tx).await? + 1;
|
||||
|
||||
let mut model = channel.into_active_model();
|
||||
model.parent_path = ActiveValue::Set(new_parent.path());
|
||||
model.channel_order = ActiveValue::Set(new_order);
|
||||
let channel = model.update(&*tx).await?;
|
||||
|
||||
let descendent_ids =
|
||||
|
@ -986,6 +1001,137 @@ impl Database {
|
|||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn reorder_channel(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
direction: proto::reorder_channel::Direction,
|
||||
user_id: UserId,
|
||||
) -> Result<Vec<Channel>> {
|
||||
self.transaction(|tx| async move {
|
||||
let mut channel = self.get_channel_internal(channel_id, &tx).await?;
|
||||
|
||||
if channel.is_root() {
|
||||
log::info!("Skipping reorder of root channel {}", channel.id,);
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Reordering channel {} (parent_path: '{}', order: {})",
|
||||
channel.id,
|
||||
channel.parent_path,
|
||||
channel.channel_order
|
||||
);
|
||||
|
||||
// Check if user is admin of the channel
|
||||
self.check_user_is_channel_admin(&channel, user_id, &tx)
|
||||
.await?;
|
||||
|
||||
// Find the sibling channel to swap with
|
||||
let sibling_channel = match direction {
|
||||
proto::reorder_channel::Direction::Up => {
|
||||
log::info!(
|
||||
"Looking for sibling with parent_path='{}' and order < {}",
|
||||
channel.parent_path,
|
||||
channel.channel_order
|
||||
);
|
||||
// Find channel with highest order less than current
|
||||
channel::Entity::find()
|
||||
.filter(
|
||||
channel::Column::ParentPath
|
||||
.eq(&channel.parent_path)
|
||||
.and(channel::Column::ChannelOrder.lt(channel.channel_order)),
|
||||
)
|
||||
.order_by_desc(channel::Column::ChannelOrder)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
}
|
||||
proto::reorder_channel::Direction::Down => {
|
||||
log::info!(
|
||||
"Looking for sibling with parent_path='{}' and order > {}",
|
||||
channel.parent_path,
|
||||
channel.channel_order
|
||||
);
|
||||
// Find channel with lowest order greater than current
|
||||
channel::Entity::find()
|
||||
.filter(
|
||||
channel::Column::ParentPath
|
||||
.eq(&channel.parent_path)
|
||||
.and(channel::Column::ChannelOrder.gt(channel.channel_order)),
|
||||
)
|
||||
.order_by_asc(channel::Column::ChannelOrder)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
let mut sibling_channel = match sibling_channel {
|
||||
Some(sibling) => {
|
||||
log::info!(
|
||||
"Found sibling {} (parent_path: '{}', order: {})",
|
||||
sibling.id,
|
||||
sibling.parent_path,
|
||||
sibling.channel_order
|
||||
);
|
||||
sibling
|
||||
}
|
||||
None => {
|
||||
log::warn!("No sibling found to swap with");
|
||||
// No sibling to swap with
|
||||
return Ok(vec![]);
|
||||
}
|
||||
};
|
||||
|
||||
let current_order = channel.channel_order;
|
||||
let sibling_order = sibling_channel.channel_order;
|
||||
|
||||
channel::ActiveModel {
|
||||
id: ActiveValue::Unchanged(sibling_channel.id),
|
||||
channel_order: ActiveValue::Set(current_order),
|
||||
..Default::default()
|
||||
}
|
||||
.update(&*tx)
|
||||
.await?;
|
||||
sibling_channel.channel_order = current_order;
|
||||
|
||||
channel::ActiveModel {
|
||||
id: ActiveValue::Unchanged(channel.id),
|
||||
channel_order: ActiveValue::Set(sibling_order),
|
||||
..Default::default()
|
||||
}
|
||||
.update(&*tx)
|
||||
.await?;
|
||||
channel.channel_order = sibling_order;
|
||||
|
||||
log::info!(
|
||||
"Reorder complete. Swapped channels {} and {}",
|
||||
channel.id,
|
||||
sibling_channel.id
|
||||
);
|
||||
|
||||
let swapped_channels = vec![
|
||||
Channel::from_model(channel),
|
||||
Channel::from_model(sibling_channel),
|
||||
];
|
||||
|
||||
Ok(swapped_channels)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
async fn max_order(parent_path: &str, tx: &TransactionHandle) -> Result<i32> {
|
||||
let max_order = channel::Entity::find()
|
||||
.filter(channel::Column::ParentPath.eq(parent_path))
|
||||
.select_only()
|
||||
.column_as(channel::Column::ChannelOrder.max(), "max_order")
|
||||
.into_tuple::<Option<i32>>()
|
||||
.one(&**tx)
|
||||
.await?
|
||||
.flatten()
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(max_order)
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
||||
|
|
|
@ -10,6 +10,9 @@ pub struct Model {
|
|||
pub visibility: ChannelVisibility,
|
||||
pub parent_path: String,
|
||||
pub requires_zed_cla: bool,
|
||||
/// The order of this channel relative to its siblings within the same parent.
|
||||
/// Lower values appear first. Channels are sorted by parent_path first, then by channel_order.
|
||||
pub channel_order: i32,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
|
|
|
@ -172,16 +172,40 @@ impl Drop for TestDb {
|
|||
}
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_channel_tree_matches(actual: Vec<Channel>, expected: Vec<Channel>) {
|
||||
let expected_channels = expected.into_iter().collect::<HashSet<_>>();
|
||||
let actual_channels = actual.into_iter().collect::<HashSet<_>>();
|
||||
pretty_assertions::assert_eq!(expected_channels, actual_channels);
|
||||
}
|
||||
|
||||
fn channel_tree(channels: &[(ChannelId, &[ChannelId], &'static str)]) -> Vec<Channel> {
|
||||
channels
|
||||
.iter()
|
||||
.map(|(id, parent_path, name)| Channel {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let mut result = Vec::new();
|
||||
let mut order_by_parent: HashMap<Vec<ChannelId>, i32> = HashMap::new();
|
||||
|
||||
for (id, parent_path, name) in channels {
|
||||
let parent_key = parent_path.to_vec();
|
||||
let order = if parent_key.is_empty() {
|
||||
1
|
||||
} else {
|
||||
*order_by_parent
|
||||
.entry(parent_key.clone())
|
||||
.and_modify(|e| *e += 1)
|
||||
.or_insert(1)
|
||||
};
|
||||
|
||||
result.push(Channel {
|
||||
id: *id,
|
||||
name: name.to_string(),
|
||||
visibility: ChannelVisibility::Members,
|
||||
parent_path: parent_path.to_vec(),
|
||||
})
|
||||
.collect()
|
||||
parent_path: parent_key,
|
||||
channel_order: order,
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
static GITHUB_USER_ID: AtomicI32 = AtomicI32::new(5);
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
use crate::{
|
||||
db::{
|
||||
Channel, ChannelId, ChannelRole, Database, NewUserParams, RoomId, UserId,
|
||||
tests::{channel_tree, new_test_connection, new_test_user},
|
||||
tests::{assert_channel_tree_matches, channel_tree, new_test_connection, new_test_user},
|
||||
},
|
||||
test_both_dbs,
|
||||
};
|
||||
use rpc::{
|
||||
ConnectionId,
|
||||
proto::{self},
|
||||
proto::{self, reorder_channel},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use std::{collections::HashSet, sync::Arc};
|
||||
|
||||
test_both_dbs!(test_channels, test_channels_postgres, test_channels_sqlite);
|
||||
|
||||
|
@ -59,28 +59,28 @@ async fn test_channels(db: &Arc<Database>) {
|
|||
.unwrap();
|
||||
|
||||
let result = db.get_channels_for_user(a_id).await.unwrap();
|
||||
assert_eq!(
|
||||
assert_channel_tree_matches(
|
||||
result.channels,
|
||||
channel_tree(&[
|
||||
(zed_id, &[], "zed"),
|
||||
(crdb_id, &[zed_id], "crdb"),
|
||||
(livestreaming_id, &[zed_id], "livestreaming",),
|
||||
(livestreaming_id, &[zed_id], "livestreaming"),
|
||||
(replace_id, &[zed_id], "replace"),
|
||||
(rust_id, &[], "rust"),
|
||||
(cargo_id, &[rust_id], "cargo"),
|
||||
(cargo_ra_id, &[rust_id, cargo_id], "cargo-ra",)
|
||||
],)
|
||||
(cargo_ra_id, &[rust_id, cargo_id], "cargo-ra"),
|
||||
]),
|
||||
);
|
||||
|
||||
let result = db.get_channels_for_user(b_id).await.unwrap();
|
||||
assert_eq!(
|
||||
assert_channel_tree_matches(
|
||||
result.channels,
|
||||
channel_tree(&[
|
||||
(zed_id, &[], "zed"),
|
||||
(crdb_id, &[zed_id], "crdb"),
|
||||
(livestreaming_id, &[zed_id], "livestreaming",),
|
||||
(replace_id, &[zed_id], "replace")
|
||||
],)
|
||||
(livestreaming_id, &[zed_id], "livestreaming"),
|
||||
(replace_id, &[zed_id], "replace"),
|
||||
]),
|
||||
);
|
||||
|
||||
// Update member permissions
|
||||
|
@ -94,14 +94,14 @@ async fn test_channels(db: &Arc<Database>) {
|
|||
assert!(set_channel_admin.is_ok());
|
||||
|
||||
let result = db.get_channels_for_user(b_id).await.unwrap();
|
||||
assert_eq!(
|
||||
assert_channel_tree_matches(
|
||||
result.channels,
|
||||
channel_tree(&[
|
||||
(zed_id, &[], "zed"),
|
||||
(crdb_id, &[zed_id], "crdb"),
|
||||
(livestreaming_id, &[zed_id], "livestreaming",),
|
||||
(replace_id, &[zed_id], "replace")
|
||||
],)
|
||||
(livestreaming_id, &[zed_id], "livestreaming"),
|
||||
(replace_id, &[zed_id], "replace"),
|
||||
]),
|
||||
);
|
||||
|
||||
// Remove a single channel
|
||||
|
@ -313,8 +313,8 @@ async fn test_channel_renames(db: &Arc<Database>) {
|
|||
|
||||
test_both_dbs!(
|
||||
test_db_channel_moving,
|
||||
test_channels_moving_postgres,
|
||||
test_channels_moving_sqlite
|
||||
test_db_channel_moving_postgres,
|
||||
test_db_channel_moving_sqlite
|
||||
);
|
||||
|
||||
async fn test_db_channel_moving(db: &Arc<Database>) {
|
||||
|
@ -343,16 +343,14 @@ async fn test_db_channel_moving(db: &Arc<Database>) {
|
|||
.await
|
||||
.unwrap();
|
||||
|
||||
let livestreaming_dag_id = db
|
||||
.create_sub_channel("livestreaming_dag", livestreaming_id, a_id)
|
||||
let livestreaming_sub_id = db
|
||||
.create_sub_channel("livestreaming_sub", livestreaming_id, a_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// ========================================================================
|
||||
// sanity check
|
||||
// Initial DAG:
|
||||
// /- gpui2
|
||||
// zed -- crdb - livestreaming - livestreaming_dag
|
||||
// zed -- crdb - livestreaming - livestreaming_sub
|
||||
let result = db.get_channels_for_user(a_id).await.unwrap();
|
||||
assert_channel_tree(
|
||||
result.channels,
|
||||
|
@ -360,10 +358,242 @@ async fn test_db_channel_moving(db: &Arc<Database>) {
|
|||
(zed_id, &[]),
|
||||
(crdb_id, &[zed_id]),
|
||||
(livestreaming_id, &[zed_id, crdb_id]),
|
||||
(livestreaming_dag_id, &[zed_id, crdb_id, livestreaming_id]),
|
||||
(livestreaming_sub_id, &[zed_id, crdb_id, livestreaming_id]),
|
||||
(gpui2_id, &[zed_id]),
|
||||
],
|
||||
);
|
||||
|
||||
// Check that we can do a simple leaf -> leaf move
|
||||
db.move_channel(livestreaming_sub_id, crdb_id, a_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// /- gpui2
|
||||
// zed -- crdb -- livestreaming
|
||||
// \- livestreaming_sub
|
||||
let result = db.get_channels_for_user(a_id).await.unwrap();
|
||||
assert_channel_tree(
|
||||
result.channels,
|
||||
&[
|
||||
(zed_id, &[]),
|
||||
(crdb_id, &[zed_id]),
|
||||
(livestreaming_id, &[zed_id, crdb_id]),
|
||||
(livestreaming_sub_id, &[zed_id, crdb_id]),
|
||||
(gpui2_id, &[zed_id]),
|
||||
],
|
||||
);
|
||||
|
||||
// Check that we can move a whole subtree at once
|
||||
db.move_channel(crdb_id, gpui2_id, a_id).await.unwrap();
|
||||
|
||||
// zed -- gpui2 -- crdb -- livestreaming
|
||||
// \- livestreaming_sub
|
||||
let result = db.get_channels_for_user(a_id).await.unwrap();
|
||||
assert_channel_tree(
|
||||
result.channels,
|
||||
&[
|
||||
(zed_id, &[]),
|
||||
(gpui2_id, &[zed_id]),
|
||||
(crdb_id, &[zed_id, gpui2_id]),
|
||||
(livestreaming_id, &[zed_id, gpui2_id, crdb_id]),
|
||||
(livestreaming_sub_id, &[zed_id, gpui2_id, crdb_id]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
test_both_dbs!(
|
||||
test_channel_reordering,
|
||||
test_channel_reordering_postgres,
|
||||
test_channel_reordering_sqlite
|
||||
);
|
||||
|
||||
async fn test_channel_reordering(db: &Arc<Database>) {
|
||||
let admin_id = db
|
||||
.create_user(
|
||||
"admin@example.com",
|
||||
None,
|
||||
false,
|
||||
NewUserParams {
|
||||
github_login: "admin".into(),
|
||||
github_user_id: 1,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.user_id;
|
||||
|
||||
let user_id = db
|
||||
.create_user(
|
||||
"user@example.com",
|
||||
None,
|
||||
false,
|
||||
NewUserParams {
|
||||
github_login: "user".into(),
|
||||
github_user_id: 2,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.user_id;
|
||||
|
||||
// Create a root channel with some sub-channels
|
||||
let root_id = db.create_root_channel("root", admin_id).await.unwrap();
|
||||
|
||||
// Invite user to root channel so they can see the sub-channels
|
||||
db.invite_channel_member(root_id, user_id, admin_id, ChannelRole::Member)
|
||||
.await
|
||||
.unwrap();
|
||||
db.respond_to_channel_invite(root_id, user_id, true)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let alpha_id = db
|
||||
.create_sub_channel("alpha", root_id, admin_id)
|
||||
.await
|
||||
.unwrap();
|
||||
let beta_id = db
|
||||
.create_sub_channel("beta", root_id, admin_id)
|
||||
.await
|
||||
.unwrap();
|
||||
let gamma_id = db
|
||||
.create_sub_channel("gamma", root_id, admin_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Initial order should be: root, alpha (order=1), beta (order=2), gamma (order=3)
|
||||
let result = db.get_channels_for_user(admin_id).await.unwrap();
|
||||
assert_channel_tree_order(
|
||||
result.channels,
|
||||
&[
|
||||
(root_id, &[], 1),
|
||||
(alpha_id, &[root_id], 1),
|
||||
(beta_id, &[root_id], 2),
|
||||
(gamma_id, &[root_id], 3),
|
||||
],
|
||||
);
|
||||
|
||||
// Test moving beta up (should swap with alpha)
|
||||
let updated_channels = db
|
||||
.reorder_channel(beta_id, reorder_channel::Direction::Up, admin_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify that beta and alpha were returned as updated
|
||||
assert_eq!(updated_channels.len(), 2);
|
||||
let updated_ids: std::collections::HashSet<_> = updated_channels.iter().map(|c| c.id).collect();
|
||||
assert!(updated_ids.contains(&alpha_id));
|
||||
assert!(updated_ids.contains(&beta_id));
|
||||
|
||||
// Now order should be: root, beta (order=1), alpha (order=2), gamma (order=3)
|
||||
let result = db.get_channels_for_user(admin_id).await.unwrap();
|
||||
assert_channel_tree_order(
|
||||
result.channels,
|
||||
&[
|
||||
(root_id, &[], 1),
|
||||
(beta_id, &[root_id], 1),
|
||||
(alpha_id, &[root_id], 2),
|
||||
(gamma_id, &[root_id], 3),
|
||||
],
|
||||
);
|
||||
|
||||
// Test moving gamma down (should be no-op since it's already last)
|
||||
let updated_channels = db
|
||||
.reorder_channel(gamma_id, reorder_channel::Direction::Down, admin_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Should return just nothing
|
||||
assert_eq!(updated_channels.len(), 0);
|
||||
|
||||
// Test moving alpha down (should swap with gamma)
|
||||
let updated_channels = db
|
||||
.reorder_channel(alpha_id, reorder_channel::Direction::Down, admin_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify that alpha and gamma were returned as updated
|
||||
assert_eq!(updated_channels.len(), 2);
|
||||
let updated_ids: std::collections::HashSet<_> = updated_channels.iter().map(|c| c.id).collect();
|
||||
assert!(updated_ids.contains(&alpha_id));
|
||||
assert!(updated_ids.contains(&gamma_id));
|
||||
|
||||
// Now order should be: root, beta (order=1), gamma (order=2), alpha (order=3)
|
||||
let result = db.get_channels_for_user(admin_id).await.unwrap();
|
||||
assert_channel_tree_order(
|
||||
result.channels,
|
||||
&[
|
||||
(root_id, &[], 1),
|
||||
(beta_id, &[root_id], 1),
|
||||
(gamma_id, &[root_id], 2),
|
||||
(alpha_id, &[root_id], 3),
|
||||
],
|
||||
);
|
||||
|
||||
// Test that non-admin cannot reorder
|
||||
let reorder_result = db
|
||||
.reorder_channel(beta_id, reorder_channel::Direction::Up, user_id)
|
||||
.await;
|
||||
assert!(reorder_result.is_err());
|
||||
|
||||
// Test moving beta up (should be no-op since it's already first)
|
||||
let updated_channels = db
|
||||
.reorder_channel(beta_id, reorder_channel::Direction::Up, admin_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Should return nothing
|
||||
assert_eq!(updated_channels.len(), 0);
|
||||
|
||||
// Adding a channel to an existing ordering should add it to the end
|
||||
let delta_id = db
|
||||
.create_sub_channel("delta", root_id, admin_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = db.get_channels_for_user(admin_id).await.unwrap();
|
||||
assert_channel_tree_order(
|
||||
result.channels,
|
||||
&[
|
||||
(root_id, &[], 1),
|
||||
(beta_id, &[root_id], 1),
|
||||
(gamma_id, &[root_id], 2),
|
||||
(alpha_id, &[root_id], 3),
|
||||
(delta_id, &[root_id], 4),
|
||||
],
|
||||
);
|
||||
|
||||
// And moving a channel into an existing ordering should add it to the end
|
||||
let eta_id = db
|
||||
.create_sub_channel("eta", delta_id, admin_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = db.get_channels_for_user(admin_id).await.unwrap();
|
||||
assert_channel_tree_order(
|
||||
result.channels,
|
||||
&[
|
||||
(root_id, &[], 1),
|
||||
(beta_id, &[root_id], 1),
|
||||
(gamma_id, &[root_id], 2),
|
||||
(alpha_id, &[root_id], 3),
|
||||
(delta_id, &[root_id], 4),
|
||||
(eta_id, &[root_id, delta_id], 1),
|
||||
],
|
||||
);
|
||||
|
||||
db.move_channel(eta_id, root_id, admin_id).await.unwrap();
|
||||
let result = db.get_channels_for_user(admin_id).await.unwrap();
|
||||
assert_channel_tree_order(
|
||||
result.channels,
|
||||
&[
|
||||
(root_id, &[], 1),
|
||||
(beta_id, &[root_id], 1),
|
||||
(gamma_id, &[root_id], 2),
|
||||
(alpha_id, &[root_id], 3),
|
||||
(delta_id, &[root_id], 4),
|
||||
(eta_id, &[root_id], 5),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
test_both_dbs!(
|
||||
|
@ -422,6 +652,20 @@ async fn test_db_channel_moving_bugs(db: &Arc<Database>) {
|
|||
(livestreaming_id, &[zed_id, projects_id]),
|
||||
],
|
||||
);
|
||||
|
||||
// Can't un-root a root channel
|
||||
db.move_channel(zed_id, livestreaming_id, user_id)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let result = db.get_channels_for_user(user_id).await.unwrap();
|
||||
assert_channel_tree(
|
||||
result.channels,
|
||||
&[
|
||||
(zed_id, &[]),
|
||||
(projects_id, &[zed_id]),
|
||||
(livestreaming_id, &[zed_id, projects_id]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
test_both_dbs!(
|
||||
|
@ -745,10 +989,29 @@ fn assert_channel_tree(actual: Vec<Channel>, expected: &[(ChannelId, &[ChannelId
|
|||
let actual = actual
|
||||
.iter()
|
||||
.map(|channel| (channel.id, channel.parent_path.as_slice()))
|
||||
.collect::<Vec<_>>();
|
||||
pretty_assertions::assert_eq!(
|
||||
actual,
|
||||
expected.to_vec(),
|
||||
"wrong channel ids and parent paths"
|
||||
);
|
||||
.collect::<HashSet<_>>();
|
||||
let expected = expected
|
||||
.iter()
|
||||
.map(|(id, parents)| (*id, *parents))
|
||||
.collect::<HashSet<_>>();
|
||||
pretty_assertions::assert_eq!(actual, expected, "wrong channel ids and parent paths");
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_channel_tree_order(actual: Vec<Channel>, expected: &[(ChannelId, &[ChannelId], i32)]) {
|
||||
let actual = actual
|
||||
.iter()
|
||||
.map(|channel| {
|
||||
(
|
||||
channel.id,
|
||||
channel.parent_path.as_slice(),
|
||||
channel.channel_order,
|
||||
)
|
||||
})
|
||||
.collect::<HashSet<_>>();
|
||||
let expected = expected
|
||||
.iter()
|
||||
.map(|(id, parents, order)| (*id, *parents, *order))
|
||||
.collect::<HashSet<_>>();
|
||||
pretty_assertions::assert_eq!(actual, expected, "wrong channel ids and parent paths");
|
||||
}
|
||||
|
|
|
@ -384,6 +384,7 @@ impl Server {
|
|||
.add_request_handler(get_notifications)
|
||||
.add_request_handler(mark_notification_as_read)
|
||||
.add_request_handler(move_channel)
|
||||
.add_request_handler(reorder_channel)
|
||||
.add_request_handler(follow)
|
||||
.add_message_handler(unfollow)
|
||||
.add_message_handler(update_followers)
|
||||
|
@ -3220,6 +3221,51 @@ async fn move_channel(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn reorder_channel(
|
||||
request: proto::ReorderChannel,
|
||||
response: Response<proto::ReorderChannel>,
|
||||
session: Session,
|
||||
) -> Result<()> {
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
let direction = request.direction();
|
||||
|
||||
let updated_channels = session
|
||||
.db()
|
||||
.await
|
||||
.reorder_channel(channel_id, direction, session.user_id())
|
||||
.await?;
|
||||
|
||||
if let Some(root_id) = updated_channels.first().map(|channel| channel.root_id()) {
|
||||
let connection_pool = session.connection_pool().await;
|
||||
for (connection_id, role) in connection_pool.channel_connection_ids(root_id) {
|
||||
let channels = updated_channels
|
||||
.iter()
|
||||
.filter_map(|channel| {
|
||||
if role.can_see_channel(channel.visibility) {
|
||||
Some(channel.to_proto())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if channels.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let update = proto::UpdateChannels {
|
||||
channels,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
session.peer.send(connection_id, update.clone())?;
|
||||
}
|
||||
}
|
||||
|
||||
response.send(Ack {})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the list of channel members
|
||||
async fn get_channel_members(
|
||||
request: proto::GetChannelMembers,
|
||||
|
|
|
@ -14,9 +14,9 @@ use fuzzy::{StringMatchCandidate, match_strings};
|
|||
use gpui::{
|
||||
AnyElement, App, AsyncWindowContext, Bounds, ClickEvent, ClipboardItem, Context, DismissEvent,
|
||||
Div, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, InteractiveElement, IntoElement,
|
||||
ListOffset, ListState, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render,
|
||||
SharedString, Styled, Subscription, Task, TextStyle, WeakEntity, Window, actions, anchored,
|
||||
canvas, deferred, div, fill, list, point, prelude::*, px,
|
||||
KeyContext, ListOffset, ListState, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel,
|
||||
Render, SharedString, Styled, Subscription, Task, TextStyle, WeakEntity, Window, actions,
|
||||
anchored, canvas, deferred, div, fill, list, point, prelude::*, px,
|
||||
};
|
||||
use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrevious};
|
||||
use project::{Fs, Project};
|
||||
|
@ -52,6 +52,8 @@ actions!(
|
|||
StartMoveChannel,
|
||||
MoveSelected,
|
||||
InsertSpace,
|
||||
MoveChannelUp,
|
||||
MoveChannelDown,
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -1961,6 +1963,33 @@ 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)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn move_channel_down(
|
||||
&mut self,
|
||||
_: &MoveChannelDown,
|
||||
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::Down, cx)
|
||||
.detach_and_prompt_err("Failed to move channel down", window, cx, |_, _, _| {
|
||||
None
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn open_channel_notes(
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
|
@ -1974,7 +2003,7 @@ impl CollabPanel {
|
|||
|
||||
fn show_inline_context_menu(
|
||||
&mut self,
|
||||
_: &menu::SecondaryConfirm,
|
||||
_: &Secondary,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
|
@ -2003,6 +2032,21 @@ impl CollabPanel {
|
|||
}
|
||||
}
|
||||
|
||||
fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
|
||||
let mut dispatch_context = KeyContext::new_with_defaults();
|
||||
dispatch_context.add("CollabPanel");
|
||||
dispatch_context.add("menu");
|
||||
|
||||
let identifier = if self.channel_name_editor.focus_handle(cx).is_focused(window) {
|
||||
"editing"
|
||||
} else {
|
||||
"not_editing"
|
||||
};
|
||||
|
||||
dispatch_context.add(identifier);
|
||||
dispatch_context
|
||||
}
|
||||
|
||||
fn selected_channel(&self) -> Option<&Arc<Channel>> {
|
||||
self.selection
|
||||
.and_then(|ix| self.entries.get(ix))
|
||||
|
@ -2965,7 +3009,7 @@ fn render_tree_branch(
|
|||
impl Render for CollabPanel {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.key_context("CollabPanel")
|
||||
.key_context(self.dispatch_context(window, cx))
|
||||
.on_action(cx.listener(CollabPanel::cancel))
|
||||
.on_action(cx.listener(CollabPanel::select_next))
|
||||
.on_action(cx.listener(CollabPanel::select_previous))
|
||||
|
@ -2977,6 +3021,8 @@ impl Render for CollabPanel {
|
|||
.on_action(cx.listener(CollabPanel::collapse_selected_channel))
|
||||
.on_action(cx.listener(CollabPanel::expand_selected_channel))
|
||||
.on_action(cx.listener(CollabPanel::start_move_selected_channel))
|
||||
.on_action(cx.listener(CollabPanel::move_channel_up))
|
||||
.on_action(cx.listener(CollabPanel::move_channel_down))
|
||||
.track_focus(&self.focus_handle(cx))
|
||||
.size_full()
|
||||
.child(if self.user_store.read(cx).current_user().is_none() {
|
||||
|
|
|
@ -8,6 +8,7 @@ message Channel {
|
|||
uint64 id = 1;
|
||||
string name = 2;
|
||||
ChannelVisibility visibility = 3;
|
||||
int32 channel_order = 4;
|
||||
repeated uint64 parent_path = 5;
|
||||
}
|
||||
|
||||
|
@ -207,6 +208,15 @@ message MoveChannel {
|
|||
uint64 to = 2;
|
||||
}
|
||||
|
||||
message ReorderChannel {
|
||||
uint64 channel_id = 1;
|
||||
enum Direction {
|
||||
Up = 0;
|
||||
Down = 1;
|
||||
}
|
||||
Direction direction = 2;
|
||||
}
|
||||
|
||||
message JoinChannelBuffer {
|
||||
uint64 channel_id = 1;
|
||||
}
|
||||
|
|
|
@ -190,6 +190,7 @@ message Envelope {
|
|||
GetChannelMessagesById get_channel_messages_by_id = 144;
|
||||
|
||||
MoveChannel move_channel = 147;
|
||||
ReorderChannel reorder_channel = 349;
|
||||
SetChannelVisibility set_channel_visibility = 148;
|
||||
|
||||
AddNotification add_notification = 149;
|
||||
|
|
|
@ -176,6 +176,7 @@ messages!(
|
|||
(LspExtClearFlycheck, Background),
|
||||
(MarkNotificationRead, Foreground),
|
||||
(MoveChannel, Foreground),
|
||||
(ReorderChannel, Foreground),
|
||||
(MultiLspQuery, Background),
|
||||
(MultiLspQueryResponse, Background),
|
||||
(OnTypeFormatting, Background),
|
||||
|
@ -389,6 +390,7 @@ request_messages!(
|
|||
(RemoveContact, Ack),
|
||||
(RenameChannel, RenameChannelResponse),
|
||||
(RenameProjectEntry, ProjectEntryResponse),
|
||||
(ReorderChannel, Ack),
|
||||
(RequestContact, Ack),
|
||||
(
|
||||
ResolveCompletionDocumentation,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue