Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion cmux-tui/crates/cmux-tui-core/src/server/personal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,10 @@ pub(super) fn delete_group(mux: &Mux, client: u64, id: &str) -> anyhow::Result<V
"workspace_group.delete",
None,
&Mux::ordinary_resource_selectors(),
crate::state::personal::PersonalChange::GroupDelete { group: id.to_string() },
crate::state::personal::PersonalChange::GroupDelete {
group: id.to_string(),
merge_into: None,
},
)?;
let ungrouped = commit
.result
Expand Down
2 changes: 2 additions & 0 deletions cmux-tui/crates/cmux-tui-core/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ mod group_delete_tests;
#[cfg(test)]
mod group_icon_tests;
#[cfg(test)]
mod group_merge_tests;
#[cfg(test)]
mod group_pin_tests;
pub(crate) mod home;
pub(crate) mod home_store;
Expand Down
95 changes: 95 additions & 0 deletions cmux-tui/crates/cmux-tui-core/src/state/group_merge_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//! `workspace_group.delete {merge_into}`: a duplicate group's members join
//! the group it duplicates, in their order, and the duplicate goes without a
//! closed-history record, since nothing of it is lost. `cmux workspace group
//! tidy` drives it (Leo, 2026-11-02: fifteen "Leads" groups of one row each).

use serde_json::json;

use super::tests::{Session, empty_workspace, error_code, mutate, read, send};
use crate::mux::*;
use crate::state::prelude::*;

fn group(mux: &Arc<Mux>, name: &str, key: &str) -> String {
mutate(mux, "workspace_group.create", json!({"name": name}), key)["id"]
.as_str()
.unwrap()
.to_string()
}

/// The workspaces of group `group`, in the personal order.
fn members(mux: &Arc<Mux>, group: &str) -> Vec<Value> {
let mut placements: Vec<Value> = read(mux, "workspace.placement.list", json!({}))
.as_array()
.unwrap()
.iter()
.filter(|placement| placement["group_id"] == group)
.cloned()
.collect();
placements.sort_by_key(|placement| placement["index"].as_u64());
placements.iter().map(|placement| placement["workspace"]["workspace_id"].clone()).collect()
}

#[test]
fn a_merged_duplicate_hands_its_members_over_and_leaves_no_record() {
let session = Session::new("group-merge");
let mux = session.open();
let a = empty_workspace(&mux, "a");
let b = empty_workspace(&mux, "b");
let c = empty_workspace(&mux, "c");
let leads = group(&mux, "Leads", "leads");
let again = group(&mux, "Leads", "leads-again");
mutate(&mux, "workspace.place", json!({"workspace": a, "group": leads}), "a");
mutate(&mux, "workspace.place", json!({"workspace": b, "group": again}), "b");
mutate(&mux, "workspace.place", json!({"workspace": c, "group": leads}), "c");

let merged = mutate(
&mux,
"workspace_group.delete",
json!({"workspace_group": again, "merge_into": leads}),
"merge",
);

assert_eq!(merged["merged_into"], leads.as_str(), "{merged}");
assert_eq!(members(&mux, &leads), [json!(a), json!(b), json!(c)], "order kept");
let groups = read(&mux, "workspace_group.list", json!({}));
assert_eq!(groups.as_array().unwrap().len(), 1, "{groups}");
let closed = read(&mux, "closed.list", json!({}));
assert_eq!(closed.as_array().unwrap().len(), 0, "a merge loses nothing: {closed}");
mux.shutdown();
}

#[test]
fn a_merge_stays_in_one_room_and_needs_its_target() {
let session = Session::new("group-merge-room");
let mux = session.open();
let side = mutate(&mux, "room.create", json!({"name": "Side"}), "side");
let here = group(&mux, "Leads", "here");
let there = mutate(
&mux,
"workspace_group.create",
json!({"name": "Leads", "room": side["id"]}),
"there",
)["id"]
.as_str()
.unwrap()
.to_string();
let across = send(
&mux,
"workspace_group.delete",
json!({"workspace_group": there, "merge_into": here}),
Some("across"),
);
assert!(across.is_err(), "another room's group is not a merge target: {across:?}");
assert_eq!(
error_code(send(
&mux,
"workspace_group.delete",
json!({"workspace_group": there, "merge_into": "grp_missing"}),
Some("missing"),
)),
"resource.not_found"
);
let groups = read(&mux, "workspace_group.list", json!({}));
assert_eq!(groups.as_array().unwrap().len(), 2, "nothing deleted: {groups}");
mux.shutdown();
}
24 changes: 23 additions & 1 deletion cmux-tui/crates/cmux-tui-core/src/state/personal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ pub(crate) enum PersonalChange {
},
GroupDelete {
group: String,
/// Its members join this group of the same room first, and the
/// deleted group keeps no closed-history record: nothing of it is
/// lost (a duplicate's tidy, `cmux workspace group tidy`). Omitted
/// when absent, so older mutation fingerprints keep their shape.
#[serde(skip_serializing_if = "Option::is_none")]
merge_into: Option<String>,
},
GroupMove {
group: String,
Expand Down Expand Up @@ -332,13 +338,29 @@ fn apply_personal_change(
}
Ok(StateChanges::new(value, changes))
}
PersonalChange::GroupDelete { group } => {
PersonalChange::GroupDelete { group, merge_into } => {
// RECOVERABLE-BY-DEFAULT: the group is one closed-history record
// with no member (its workspaces stay open), so Reopen Closed
// forms it again, also after a restart.
if personal::workspace_group_snapshot(tx, &group)?.is_none() {
return Err(state_not_found("workspace_group", &group));
}
if let Some(into) = merge_into {
if personal::workspace_group_snapshot(tx, &into)?.is_none() {
return Err(state_not_found("workspace_group", &into));
}
let moved = personal::merge_group(tx, &group, &into)
.map_err(|error| typed(error, "workspace_group", &into))?;
personal::delete_group(tx, &group)
.map_err(|error| typed(error, "workspace_group", &group))?;
let mut changes = vec![state_delete("workspace_group", &group)];
changes.extend(all_groups(tx)?);
changes.extend(all_placements(tx)?);
return Ok(StateChanges::new(
serde_json::json!({"id": group, "merged_into": into, "moved": refs(tx, &moved)?}),
changes,
));
}
let archive = group_archive::snapshot_group(tx, &group)?;
pend_group(tx, &new_closed_id(), &serde_json::json!({"group": archive}))?;
let ungrouped = personal::delete_group(tx, &group)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,14 @@ pub(crate) fn delete_group(
WorkspaceRegistry::delete_personal_group_in(tx, id)
}

pub(crate) fn merge_group(
tx: &Transaction<'_>,
from: &str,
into: &str,
) -> anyhow::Result<Vec<(String, String)>> {
WorkspaceRegistry::merge_personal_group_in(tx, from, into)
}

pub(crate) fn move_group(
tx: &Transaction<'_>,
id: &str,
Expand Down
4 changes: 3 additions & 1 deletion cmux-tui/crates/cmux-tui-core/src/state/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1008,7 +1008,9 @@ fn personal_change(
pinned: fields.get("pinned").and_then(Value::as_bool),
}
}
Op::WorkspaceGroupDelete => PersonalChange::GroupDelete { group: group() },
Op::WorkspaceGroupDelete => {
PersonalChange::GroupDelete { group: group(), merge_into: string(fields, "merge_into") }
}
Op::WorkspaceGroupMove => {
PersonalChange::GroupMove { group: group(), index: index(fields, "index").unwrap_or(0) }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,48 @@ impl WorkspaceRegistry {
Ok(members)
}

/// Moves every member of group `from` into group `into` of the same room, keeping
/// each member's place in the personal order. Returns the members moved.
pub(crate) fn merge_personal_group_in(
tx: &Transaction<'_>,
from: &str,
into: &str,
) -> anyhow::Result<Vec<(String, String)>> {
validate_workspace_group_id(from)?;
validate_workspace_group_id(into)?;
anyhow::ensure!(from != into, "group {from} cannot merge into itself");
let source =
read_group(tx, from)?.ok_or_else(|| anyhow::anyhow!("unknown personal group {from}"))?;
let target =
read_group(tx, into)?.ok_or_else(|| anyhow::anyhow!("unknown personal group {into}"))?;
anyhow::ensure!(
source.profile == target.profile,
"group {from} is in room {} and group {into} in room {}: a merge stays in one room",
source.profile,
target.profile
);
let members = {
let mut statement = tx.prepare(
"SELECT session_id, workspace_key FROM personal_workspaces WHERE group_id = ?1
ORDER BY position ASC",
)?;
statement
.query_map([from], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))?
.collect::<Result<Vec<_>, _>>()?
};
tx.execute(
"UPDATE personal_workspaces SET group_id = ?2 WHERE group_id = ?1",
params![from, into],
)?;
commit_personal(
tx,
"personal.group.merged",
vec![subject("personal_group", from), subject("personal_group", into)],
&json!({"group_id": from, "into": into, "moved": members}),
)?;
Ok(members)
}

pub(crate) fn move_personal_group_in(
tx: &Transaction<'_>,
id: &str,
Expand Down
3 changes: 3 additions & 0 deletions cmux-tui/crates/cmux-tui/src/cli/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2984,6 +2984,9 @@ mod tests {
let group = protocol(&["workspace", "group", "create", "--name", "Work"]);
assert_eq!(operation(&group), "workspace_group.create");
assert_eq!(group.params["name"], "Work");
let merge = protocol(&["workspace", "group", "grp_b", "delete", "--merge-into", "grp_a"]);
assert_eq!(operation(&merge), "workspace_group.delete");
assert_eq!(merge.params["merge_into"], "grp_a");
let remove = protocol(&[
"workspace",
"group",
Expand Down
1 change: 1 addition & 0 deletions cmux-tui/crates/cmux-tui/src/cli/command/cases.rs
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,7 @@ fn state_resource_cases<'a>(
"workspace_group.update",
),
(vec!["workspace", "group", "g", "delete"], "workspace_group.delete"),
(vec!["workspace", "group", "g", "delete", "--merge-into", "k"], "workspace_group.delete"),
(vec!["workspace", "group", "g", "move", "--index", "1"], "workspace_group.move"),
(vec!["tab", tab, "pin"], "tab.pin"),
(vec!["tab", tab, "unpin"], "tab.unpin"),
Expand Down
8 changes: 7 additions & 1 deletion cmux-tui/crates/cmux-tui/src/cli/command/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,13 @@ pub(super) fn parse_workspace_group(
top_index(&mut params, flags)?;
Op::WorkspaceGroupUpdate
}
"delete" => Op::WorkspaceGroupDelete,
"delete" => {
// A duplicate's tidy: its members join that group first.
if let Some(into) = flags.take("merge-into") {
params.state("merge_into", &into, Op::WorkspaceGroupList)?;
}
Op::WorkspaceGroupDelete
}
"move" => {
required_index(&mut params, flags)?;
Op::WorkspaceGroupMove
Expand Down
2 changes: 1 addition & 1 deletion cmux-tui/crates/cmux-tui/src/cli/topology_help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ USAGE
cmux workspace group <group> update [--name <value>] [--color <value>|--clear-color]
[--icon <icon>|--clear-icon] [--pinned true|false]
[--room <room>] [--collapse|--expand] [--top-index <n>|--clear-top-index]
cmux workspace group <group> delete
cmux workspace group <group> delete [--merge-into <group>]
cmux workspace group <group> move --index <n>
cmux workspace group <group> add --workspace <selector> [--index <n>]
cmux workspace group remove --workspace <selector>
Expand Down
Loading