1use agentmux_common::ipc::{ErrorCode, Event};
5
6use crate::state::State;
7
8
9use crate::state::TabRecord;
10
11pub(super) fn handle_create_tab(state: &mut State, workspace_id: String, name: String) -> Vec<Event> {
21 let Some(workspace_record) = state.workspaces.get(&workspace_id) else {
22 let v = state.bump_version();
23 return vec![Event::Error {
24 code: ErrorCode::InvalidCommand,
25 message: format!("CreateTab: workspace not found: {}", workspace_id),
26 fatal: false,
27 version: v,
28 }];
29 };
30 let resolved_name = if name.is_empty() {
37 format!("Tab {}", workspace_record.tab_ids.len() + 1)
38 } else {
39 name
40 };
41 let tab_id = uuid::Uuid::new_v4().to_string();
42 state.tabs.insert(
43 tab_id.clone(),
44 TabRecord {
45 tab_id: tab_id.clone(),
46 workspace_id: workspace_id.clone(),
47 name: resolved_name.clone(),
48 block_ids: Vec::new(),
49 focused_node_id: String::new(),
50 magnified_node_id: String::new(),
51 rootnode: None,
52 },
53 );
54 let workspace = state.workspaces.get_mut(&workspace_id).expect("checked");
55 workspace.tab_ids.push(tab_id.clone());
56 let activated = if workspace.active_tab_id.is_none() {
57 workspace.active_tab_id = Some(tab_id.clone());
58 true
59 } else {
60 false
61 };
62 let mut events = Vec::with_capacity(2);
63 let v = state.bump_version();
64 events.push(Event::TabCreated {
65 workspace_id: workspace_id.clone(),
66 tab_id: tab_id.clone(),
67 name: resolved_name,
68 version: v,
69 });
70 if activated {
71 let v2 = state.bump_version();
72 events.push(Event::ActiveTabChanged {
73 workspace_id,
74 tab_id: Some(tab_id),
75 version: v2,
76 });
77 }
78 events
79}
80
81pub(super) fn handle_delete_tab(
88 state: &mut State,
89 workspace_id: String,
90 tab_id: String,
91 force: bool,
92) -> Vec<Event> {
93 let Some(workspace) = state.workspaces.get_mut(&workspace_id) else {
94 return Vec::new();
95 };
96 let Some(pos) = workspace.tab_ids.iter().position(|t| t == &tab_id) else {
97 return Vec::new();
98 };
99 if !force && workspace.tab_ids.len() <= 1 {
115 let v = state.bump_version();
116 return vec![Event::Error {
117 code: ErrorCode::InvalidCommand,
118 message: format!(
119 "DeleteTab: refusing to delete the last tab in workspace {} (would leave empty workspace; pass force=true for compensation paths)",
120 workspace_id,
121 ),
122 fatal: false,
123 version: v,
124 }];
125 }
126 workspace.tab_ids.remove(pos);
127 let active_changed = if workspace.active_tab_id.as_deref() == Some(tab_id.as_str()) {
128 let new_active = workspace
129 .tab_ids
130 .get(pos)
131 .or_else(|| pos.checked_sub(1).and_then(|i| workspace.tab_ids.get(i)))
132 .cloned();
133 workspace.active_tab_id = new_active.clone();
134 Some(new_active)
135 } else {
136 None
137 };
138 let removed_tab = state.tabs.remove(&tab_id);
139 let cascaded_block_ids: Vec<String> = removed_tab
147 .as_ref()
148 .map(|t| t.block_ids.clone())
149 .unwrap_or_default();
150 if let Some(tab) = &removed_tab {
151 for block_id in &tab.block_ids {
152 state.blocks.remove(block_id);
153 }
154 }
155 let mut events = Vec::with_capacity(2);
156 let v = state.bump_version();
157 events.push(Event::TabDeleted {
158 workspace_id: workspace_id.clone(),
159 tab_id,
160 block_ids: cascaded_block_ids,
161 version: v,
162 });
163 if let Some(new_active) = active_changed {
164 let v2 = state.bump_version();
165 events.push(Event::ActiveTabChanged {
166 workspace_id,
167 tab_id: new_active,
168 version: v2,
169 });
170 }
171 events
172}
173
174pub(super) fn handle_set_active_tab(
178 state: &mut State,
179 workspace_id: String,
180 tab_id: String,
181) -> Vec<Event> {
182 let Some(workspace) = state.workspaces.get_mut(&workspace_id) else {
183 let v = state.bump_version();
184 return vec![Event::Error {
185 code: ErrorCode::InvalidCommand,
186 message: format!("SetActiveTab: workspace not found: {}", workspace_id),
187 fatal: false,
188 version: v,
189 }];
190 };
191 if !workspace.tab_ids.iter().any(|t| t == &tab_id) {
192 let v = state.bump_version();
193 return vec![Event::Error {
194 code: ErrorCode::InvalidCommand,
195 message: format!(
196 "SetActiveTab: tab {} not in workspace {}",
197 tab_id, workspace_id
198 ),
199 fatal: false,
200 version: v,
201 }];
202 }
203 if workspace.active_tab_id.as_deref() == Some(tab_id.as_str()) {
204 return Vec::new();
205 }
206 workspace.active_tab_id = Some(tab_id.clone());
207 let v = state.bump_version();
208 vec![Event::ActiveTabChanged {
209 workspace_id,
210 tab_id: Some(tab_id),
211 version: v,
212 }]
213}
214
215pub(super) fn handle_reorder_tab(
220 state: &mut State,
221 workspace_id: String,
222 tab_id: String,
223 new_index: u32,
224) -> Vec<Event> {
225 let Some(workspace) = state.workspaces.get_mut(&workspace_id) else {
226 let v = state.bump_version();
227 return vec![Event::Error {
228 code: ErrorCode::InvalidCommand,
229 message: format!("ReorderTab: workspace not found: {}", workspace_id),
230 fatal: false,
231 version: v,
232 }];
233 };
234 let Some(current_pos) = workspace.tab_ids.iter().position(|t| t == &tab_id) else {
235 let v = state.bump_version();
236 return vec![Event::Error {
237 code: ErrorCode::InvalidCommand,
238 message: format!(
239 "ReorderTab: tab {} not in workspace {}",
240 tab_id, workspace_id
241 ),
242 fatal: false,
243 version: v,
244 }];
245 };
246 let len = workspace.tab_ids.len();
247 let target = (new_index as usize).min(len.saturating_sub(1));
248 if current_pos == target {
249 return Vec::new();
250 }
251 let id = workspace.tab_ids.remove(current_pos);
252 workspace.tab_ids.insert(target, id);
253 let v = state.bump_version();
254 vec![Event::TabReordered {
255 workspace_id,
256 tab_id,
257 new_index: target as u32,
258 version: v,
259 }]
260}
261
262pub(super) fn handle_reorder_tabs_bulk(
267 state: &mut State,
268 workspace_id: String,
269 tab_ids: Vec<String>,
270) -> Vec<Event> {
271 let Some(current) = state.workspaces.get(&workspace_id).map(|w| w.tab_ids.clone()) else {
283 let v = state.bump_version();
284 return vec![Event::Error {
285 code: ErrorCode::InvalidCommand,
286 message: format!("ReorderTabsBulk: workspace not found: {}", workspace_id),
287 fatal: false,
288 version: v,
289 }];
290 };
291 {
292 let mut seen: std::collections::HashSet<&String> =
293 std::collections::HashSet::with_capacity(tab_ids.len());
294 for id in &tab_ids {
295 if !seen.insert(id) {
296 let v = state.bump_version();
297 return vec![Event::Error {
298 code: ErrorCode::InvalidCommand,
299 message: format!(
300 "ReorderTabsBulk: tab_ids contains duplicate entry: {}",
301 id
302 ),
303 fatal: false,
304 version: v,
305 }];
306 }
307 }
308 }
309 if current.len() != tab_ids.len()
310 || !current.iter().all(|id| tab_ids.contains(id))
311 {
312 let v = state.bump_version();
313 return vec![Event::Error {
314 code: ErrorCode::InvalidCommand,
315 message: format!(
316 "ReorderTabsBulk: tab_ids must be a permutation of the workspace's current tabs (got {:?}, expected a permutation of {:?})",
317 tab_ids, current
318 ),
319 fatal: false,
320 version: v,
321 }];
322 }
323 if current == tab_ids {
324 return Vec::new();
325 }
326 state.workspaces.get_mut(&workspace_id).expect("checked above").tab_ids = tab_ids.clone();
327 let v = state.bump_version();
328 vec![Event::TabsReorderedBulk {
329 workspace_id,
330 tab_ids,
331 version: v,
332 }]
333}
334
335pub(super) fn handle_move_tab(
350 state: &mut State,
351 tab_id: String,
352 src_workspace_id: String,
353 dst_workspace_id: String,
354 dst_index: u32,
355) -> Vec<Event> {
356 if src_workspace_id == dst_workspace_id {
357 let v = state.bump_version();
358 return vec![Event::Error {
359 code: ErrorCode::InvalidCommand,
360 message: "MoveTab: src and dst workspaces are identical; use ReorderTab".into(),
361 fatal: false,
362 version: v,
363 }];
364 }
365 if !state.workspaces.contains_key(&src_workspace_id) {
373 let v = state.bump_version();
374 return vec![Event::Error {
375 code: ErrorCode::InvalidCommand,
376 message: format!("MoveTab: src workspace not found: {}", src_workspace_id),
377 fatal: false,
378 version: v,
379 }];
380 }
381 if !state.workspaces.contains_key(&dst_workspace_id) {
382 let v = state.bump_version();
383 return vec![Event::Error {
384 code: ErrorCode::InvalidCommand,
385 message: format!("MoveTab: dst workspace not found: {}", dst_workspace_id),
386 fatal: false,
387 version: v,
388 }];
389 }
390 let tab_workspace_id: Option<String> =
391 state.tabs.get(&tab_id).map(|t| t.workspace_id.clone());
392 match tab_workspace_id {
393 None => {
394 let v = state.bump_version();
395 return vec![Event::Error {
396 code: ErrorCode::InvalidCommand,
397 message: format!("MoveTab: tab not found in state: {}", tab_id),
398 fatal: false,
399 version: v,
400 }];
401 }
402 Some(actual_ws) if actual_ws != src_workspace_id => {
403 let v = state.bump_version();
404 return vec![Event::Error {
405 code: ErrorCode::InvalidCommand,
406 message: format!(
407 "MoveTab: workspace_id mismatch — tab {} belongs to {}, not {}",
408 tab_id, actual_ws, src_workspace_id
409 ),
410 fatal: false,
411 version: v,
412 }];
413 }
414 Some(_) => {}
415 }
416
417 let new_src_active_tab_id: Option<String> = {
419 let src = state.workspaces.get_mut(&src_workspace_id).expect("checked");
420 src.tab_ids.retain(|id| id != &tab_id);
421 if src.active_tab_id.as_deref() == Some(tab_id.as_str()) {
422 src.active_tab_id = src.tab_ids.first().cloned();
423 }
424 src.active_tab_id.clone()
425 };
426
427 let final_dst_index: u32 = {
433 let dst = state.workspaces.get_mut(&dst_workspace_id).expect("checked");
434 let clamped = (dst_index as usize).min(dst.tab_ids.len());
435 dst.tab_ids.insert(clamped, tab_id.clone());
436 dst.active_tab_id = Some(tab_id.clone());
437 clamped as u32
438 };
439
440 state
442 .tabs
443 .get_mut(&tab_id)
444 .expect("checked")
445 .workspace_id = dst_workspace_id.clone();
446
447 let v = state.bump_version();
448 vec![Event::TabMoved {
449 tab_id: tab_id.clone(),
450 src_workspace_id,
451 dst_workspace_id,
452 dst_index: final_dst_index,
453 new_src_active_tab_id,
454 new_dst_active_tab_id: Some(tab_id),
455 version: v,
456 }]
457}
458
459pub(super) fn handle_rename_tab(state: &mut State, tab_id: String, name: String) -> Vec<Event> {
462 let Some(tab) = state.tabs.get_mut(&tab_id) else {
463 let v = state.bump_version();
464 return vec![Event::Error {
465 code: ErrorCode::InvalidCommand,
466 message: format!("RenameTab: tab not found: {}", tab_id),
467 fatal: false,
468 version: v,
469 }];
470 };
471 if tab.name == name {
472 return Vec::new();
473 }
474 tab.name = name.clone();
475 let v = state.bump_version();
476 vec![Event::TabRenamed {
477 tab_id,
478 name,
479 version: v,
480 }]
481}
482
483pub(super) fn handle_update_tab_meta(
486 state: &mut State,
487 tab_id: String,
488 meta_patch: serde_json::Value,
489) -> Vec<Event> {
490 if !state.tabs.contains_key(&tab_id) {
491 let v = state.bump_version();
492 return vec![Event::Error {
493 code: ErrorCode::InvalidCommand,
494 message: format!("UpdateTabMeta: tab not found: {}", tab_id),
495 fatal: false,
496 version: v,
497 }];
498 }
499 let v = state.bump_version();
500 vec![Event::TabMetaUpdated {
501 tab_id,
502 meta_patch,
503 version: v,
504 }]
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use crate::reducer::test_support::*;
511 use crate::reducer::update;
512 use agentmux_common::ipc::Command;
513
514 #[test]
515 fn create_tab_validates_workspace_exists() {
516 let mut state = State::default();
517 let events = update(
518 &mut state,
519 Command::CreateTab {
520 workspace_id: "no-such-ws".into(),
521 name: "t1".into(),
522 },
523 &ctx(1),
524 );
525 assert!(matches!(&events[0], Event::Error { .. }));
526 assert!(state.tabs.is_empty());
527 }
528
529 #[test]
530 fn create_tab_first_tab_becomes_active() {
531 let mut state = State::default();
532 let ws_id = create_workspace(&mut state, "w");
533 let events = update(
534 &mut state,
535 Command::CreateTab {
536 workspace_id: ws_id.clone(),
537 name: "t1".into(),
538 },
539 &ctx(1),
540 );
541 assert!(matches!(&events[0], Event::TabCreated { .. }));
543 assert!(matches!(&events[1], Event::ActiveTabChanged { .. }));
544 let workspace = &state.workspaces[&ws_id];
545 assert_eq!(workspace.tab_ids.len(), 1);
546 assert_eq!(workspace.active_tab_id, Some(workspace.tab_ids[0].clone()));
547 assert_eq!(state.tabs.len(), 1);
548 }
549
550 #[test]
551 fn create_tab_second_tab_does_not_steal_active() {
552 let mut state = State::default();
553 let ws_id = create_workspace(&mut state, "w");
554 let _ = update(
555 &mut state,
556 Command::CreateTab {
557 workspace_id: ws_id.clone(),
558 name: "t1".into(),
559 },
560 &ctx(1),
561 );
562 let first_active = state.workspaces[&ws_id].active_tab_id.clone();
563 let events = update(
564 &mut state,
565 Command::CreateTab {
566 workspace_id: ws_id.clone(),
567 name: "t2".into(),
568 },
569 &ctx(2),
570 );
571 assert!(matches!(&events[0], Event::TabCreated { .. }));
573 assert_eq!(events.len(), 1);
574 assert_eq!(state.workspaces[&ws_id].active_tab_id, first_active);
575 assert_eq!(state.workspaces[&ws_id].tab_ids.len(), 2);
576 }
577
578 #[test]
579 fn delete_tab_removes_from_state_and_workspace_list() {
580 let mut state = State::default();
581 let ws_id = create_workspace(&mut state, "w");
582 let _ = update(
583 &mut state,
584 Command::CreateTab {
585 workspace_id: ws_id.clone(),
586 name: "t1".into(),
587 },
588 &ctx(1),
589 );
590 let _ = update(
591 &mut state,
592 Command::CreateTab {
593 workspace_id: ws_id.clone(),
594 name: "t2".into(),
595 },
596 &ctx(2),
597 );
598 let tab2_id = state.workspaces[&ws_id].tab_ids[1].clone();
599 let events = update(
600 &mut state,
601 Command::DeleteTab {
602 workspace_id: ws_id.clone(),
603 tab_id: tab2_id.clone(),
604 force: false,
605 },
606 &ctx(3),
607 );
608 assert!(matches!(&events[0], Event::TabDeleted { .. }));
609 assert_eq!(events.len(), 1);
611 assert!(!state.tabs.contains_key(&tab2_id));
612 assert_eq!(state.workspaces[&ws_id].tab_ids.len(), 1);
613 }
614
615 #[test]
616 fn delete_active_tab_promotes_neighbor() {
617 let mut state = State::default();
618 let ws_id = create_workspace(&mut state, "w");
619 let _ = update(
620 &mut state,
621 Command::CreateTab {
622 workspace_id: ws_id.clone(),
623 name: "t1".into(),
624 },
625 &ctx(1),
626 );
627 let _ = update(
628 &mut state,
629 Command::CreateTab {
630 workspace_id: ws_id.clone(),
631 name: "t2".into(),
632 },
633 &ctx(2),
634 );
635 let tab1_id = state.workspaces[&ws_id].tab_ids[0].clone();
636 let tab2_id = state.workspaces[&ws_id].tab_ids[1].clone();
637 let events = update(
639 &mut state,
640 Command::DeleteTab {
641 workspace_id: ws_id.clone(),
642 tab_id: tab1_id.clone(),
643 force: false,
644 },
645 &ctx(3),
646 );
647 assert!(matches!(&events[0], Event::TabDeleted { .. }));
648 assert!(matches!(
649 &events[1],
650 Event::ActiveTabChanged { tab_id: Some(_), .. }
651 ));
652 assert_eq!(state.workspaces[&ws_id].active_tab_id, Some(tab2_id));
654 }
655
656 #[test]
657 fn delete_last_tab_clears_active_to_none() {
658 let mut state = State::default();
664 let ws_id = create_workspace(&mut state, "w");
665 let _ = update(
666 &mut state,
667 Command::CreateTab {
668 workspace_id: ws_id.clone(),
669 name: "t1".into(),
670 },
671 &ctx(1),
672 );
673 let tab1_id = state.workspaces[&ws_id].tab_ids[0].clone();
674 let events = update(
675 &mut state,
676 Command::DeleteTab {
677 workspace_id: ws_id.clone(),
678 tab_id: tab1_id,
679 force: true,
684 },
685 &ctx(2),
686 );
687 assert!(matches!(&events[0], Event::TabDeleted { .. }));
688 assert!(matches!(
689 &events[1],
690 Event::ActiveTabChanged { tab_id: None, .. }
691 ));
692 assert_eq!(state.workspaces[&ws_id].active_tab_id, None);
693 assert!(state.tabs.is_empty());
694 }
695
696 #[test]
697 fn delete_unknown_tab_silent_no_op() {
698 let mut state = State::default();
699 let ws_id = create_workspace(&mut state, "w");
700 let events = update(
701 &mut state,
702 Command::DeleteTab {
703 workspace_id: ws_id,
704 tab_id: "ghost".into(),
705 force: false,
706 },
707 &ctx(1),
708 );
709 assert!(events.is_empty());
710 }
711
712 #[test]
713 fn set_active_tab_validates_workspace_and_tab() {
714 let mut state = State::default();
715 let ws_id = create_workspace(&mut state, "w");
716 let _ = update(
717 &mut state,
718 Command::CreateTab {
719 workspace_id: ws_id.clone(),
720 name: "t1".into(),
721 },
722 &ctx(1),
723 );
724 let events = update(
726 &mut state,
727 Command::SetActiveTab {
728 workspace_id: "no-such".into(),
729 tab_id: "x".into(),
730 },
731 &ctx(2),
732 );
733 assert!(matches!(&events[0], Event::Error { .. }));
734 let events = update(
736 &mut state,
737 Command::SetActiveTab {
738 workspace_id: ws_id,
739 tab_id: "ghost".into(),
740 },
741 &ctx(3),
742 );
743 assert!(matches!(&events[0], Event::Error { .. }));
744 }
745
746 #[test]
747 fn set_active_tab_idempotent_no_event_when_already_active() {
748 let mut state = State::default();
749 let ws_id = create_workspace(&mut state, "w");
750 let _ = update(
751 &mut state,
752 Command::CreateTab {
753 workspace_id: ws_id.clone(),
754 name: "t1".into(),
755 },
756 &ctx(1),
757 );
758 let tab_id = state.workspaces[&ws_id].tab_ids[0].clone();
759 let events = update(
761 &mut state,
762 Command::SetActiveTab {
763 workspace_id: ws_id,
764 tab_id,
765 },
766 &ctx(2),
767 );
768 assert!(events.is_empty());
769 }
770
771 #[test]
772 fn reorder_tab_moves_to_new_index() {
773 let mut state = State::default();
774 let ws_id = create_workspace(&mut state, "w");
775 for i in 1..=3 {
776 let _ = update(
777 &mut state,
778 Command::CreateTab {
779 workspace_id: ws_id.clone(),
780 name: format!("t{}", i),
781 },
782 &ctx(i),
783 );
784 }
785 let original = state.workspaces[&ws_id].tab_ids.clone();
786 let events = update(
788 &mut state,
789 Command::ReorderTab {
790 workspace_id: ws_id.clone(),
791 tab_id: original[0].clone(),
792 new_index: 2,
793 },
794 &ctx(10),
795 );
796 assert!(matches!(&events[0], Event::TabReordered { .. }));
797 let after = &state.workspaces[&ws_id].tab_ids;
798 assert_eq!(after[0], original[1]);
799 assert_eq!(after[1], original[2]);
800 assert_eq!(after[2], original[0]);
801 }
802
803 #[test]
804 fn reorder_tab_clamps_to_last_index() {
805 let mut state = State::default();
806 let ws_id = create_workspace(&mut state, "w");
807 let _ = update(
808 &mut state,
809 Command::CreateTab {
810 workspace_id: ws_id.clone(),
811 name: "t1".into(),
812 },
813 &ctx(1),
814 );
815 let _ = update(
816 &mut state,
817 Command::CreateTab {
818 workspace_id: ws_id.clone(),
819 name: "t2".into(),
820 },
821 &ctx(2),
822 );
823 let original = state.workspaces[&ws_id].tab_ids.clone();
824 let events = update(
826 &mut state,
827 Command::ReorderTab {
828 workspace_id: ws_id.clone(),
829 tab_id: original[0].clone(),
830 new_index: 99,
831 },
832 &ctx(3),
833 );
834 if let Event::TabReordered { new_index, .. } = &events[0] {
835 assert_eq!(*new_index, 1);
836 } else {
837 panic!("expected TabReordered");
838 }
839 }
840
841 #[test]
842 fn reorder_tab_already_at_position_no_op() {
843 let mut state = State::default();
844 let ws_id = create_workspace(&mut state, "w");
845 let _ = update(
846 &mut state,
847 Command::CreateTab {
848 workspace_id: ws_id.clone(),
849 name: "t1".into(),
850 },
851 &ctx(1),
852 );
853 let tab_id = state.workspaces[&ws_id].tab_ids[0].clone();
854 let events = update(
855 &mut state,
856 Command::ReorderTab {
857 workspace_id: ws_id,
858 tab_id,
859 new_index: 0,
860 },
861 &ctx(2),
862 );
863 assert!(events.is_empty());
864 }
865
866 #[test]
867 fn reorder_tab_validates_workspace_and_tab() {
868 let mut state = State::default();
869 let events = update(
870 &mut state,
871 Command::ReorderTab {
872 workspace_id: "no-such-ws".into(),
873 tab_id: "x".into(),
874 new_index: 0,
875 },
876 &ctx(1),
877 );
878 assert!(matches!(&events[0], Event::Error { .. }));
879 let ws_id = create_workspace(&mut state, "w");
880 let events = update(
881 &mut state,
882 Command::ReorderTab {
883 workspace_id: ws_id,
884 tab_id: "ghost".into(),
885 new_index: 0,
886 },
887 &ctx(2),
888 );
889 assert!(matches!(&events[0], Event::Error { .. }));
890 }
891
892 #[test]
900 fn reorder_tabs_bulk_rejects_unknown_ids() {
901 let mut state = State::default();
902 let ws_id = create_workspace(&mut state, "w");
903 let known = create_tab(&mut state, &ws_id, "known");
904 let unknown = "unknown-tab".to_string();
905 let events = update(
906 &mut state,
907 Command::ReorderTabsBulk {
908 workspace_id: ws_id.clone(),
909 tab_ids: vec![unknown.clone(), known.clone()],
910 },
911 &ctx(99),
912 );
913 match &events[0] {
914 Event::Error { code, message, .. } => {
915 assert_eq!(*code, ErrorCode::InvalidCommand);
916 assert!(
917 message.contains("permutation"),
918 "error should mention permutation, got: {}",
919 message
920 );
921 }
922 other => panic!("expected Error event, got {:?}", other),
923 }
924 let ws = state.workspaces.get(&ws_id).expect("ws still present");
926 assert_eq!(ws.tab_ids, vec![known]);
927 }
928
929 #[test]
932 fn reorder_tabs_bulk_accepts_permutation_of_known_ids() {
933 let mut state = State::default();
934 let ws_id = create_workspace(&mut state, "w");
935 let t1 = create_tab(&mut state, &ws_id, "t1");
936 let t2 = create_tab(&mut state, &ws_id, "t2");
937 let events = update(
938 &mut state,
939 Command::ReorderTabsBulk {
940 workspace_id: ws_id.clone(),
941 tab_ids: vec![t2.clone(), t1.clone()],
942 },
943 &ctx(99),
944 );
945 assert!(
946 matches!(&events[0], Event::TabsReorderedBulk { .. }),
947 "expected TabsReorderedBulk, got {:?}",
948 events.first()
949 );
950 let ws = state.workspaces.get(&ws_id).expect("ws still present");
951 assert_eq!(ws.tab_ids, vec![t2, t1]);
952 }
953
954 #[test]
957 fn reorder_tabs_bulk_rejects_missing_known_id() {
958 let mut state = State::default();
959 let ws_id = create_workspace(&mut state, "w");
960 let t1 = create_tab(&mut state, &ws_id, "t1");
961 let _t2 = create_tab(&mut state, &ws_id, "t2");
962 let events = update(
963 &mut state,
964 Command::ReorderTabsBulk {
965 workspace_id: ws_id,
966 tab_ids: vec![t1],
967 },
968 &ctx(99),
969 );
970 assert!(matches!(&events[0], Event::Error { .. }));
971 }
972
973 #[test]
977 fn reorder_tabs_bulk_rejects_duplicates() {
978 let mut state = State::default();
979 let ws_id = create_workspace(&mut state, "w");
980 let t1 = create_tab(&mut state, &ws_id, "t1");
981 let events = update(
982 &mut state,
983 Command::ReorderTabsBulk {
984 workspace_id: ws_id,
985 tab_ids: vec![t1.clone(), t1.clone()],
986 },
987 &ctx(2),
988 );
989 match &events[0] {
990 Event::Error { code, message, .. } => {
991 assert_eq!(*code, ErrorCode::InvalidCommand);
992 assert!(
993 message.contains("duplicate"),
994 "error should mention duplicate, got: {}",
995 message
996 );
997 }
998 other => panic!("expected Error event, got {:?}", other),
999 }
1000 }
1001
1002 #[test]
1003 fn reorder_tabs_bulk_validates_workspace() {
1004 let mut state = State::default();
1005 let events = update(
1006 &mut state,
1007 Command::ReorderTabsBulk {
1008 workspace_id: "no-such-ws".into(),
1009 tab_ids: vec!["a".into()],
1010 },
1011 &ctx(1),
1012 );
1013 assert!(matches!(&events[0], Event::Error { .. }));
1014 }
1015
1016 #[test]
1022 fn delete_tab_emits_cascaded_block_ids() {
1023 let mut state = State::default();
1024 let ws_id = create_workspace(&mut state, "w");
1025 let _ = update(
1026 &mut state,
1027 Command::CreateTab { workspace_id: ws_id.clone(), name: "t1".into() },
1028 &ctx(1),
1029 );
1030 let tab1_id = state.workspaces[&ws_id].tab_ids[0].clone();
1031 let _ = update(
1033 &mut state,
1034 Command::CreateTab { workspace_id: ws_id.clone(), name: "t2".into() },
1035 &ctx(2),
1036 );
1037 let block1 = match &update(
1038 &mut state,
1039 Command::CreateBlock { tab_id: tab1_id.clone(), meta: serde_json::Value::Null },
1040 &ctx(3),
1041 )[0] {
1042 Event::BlockCreated { block_id, .. } => block_id.clone(),
1043 other => panic!("expected BlockCreated, got {:?}", other),
1044 };
1045 let block2 = match &update(
1046 &mut state,
1047 Command::CreateBlock { tab_id: tab1_id.clone(), meta: serde_json::Value::Null },
1048 &ctx(4),
1049 )[0] {
1050 Event::BlockCreated { block_id, .. } => block_id.clone(),
1051 other => panic!("expected BlockCreated, got {:?}", other),
1052 };
1053 let events = update(
1054 &mut state,
1055 Command::DeleteTab { workspace_id: ws_id, tab_id: tab1_id, force: false },
1056 &ctx(5),
1057 );
1058 match &events[0] {
1059 Event::TabDeleted { block_ids, .. } => {
1060 assert_eq!(block_ids.len(), 2);
1061 assert!(block_ids.contains(&block1));
1062 assert!(block_ids.contains(&block2));
1063 }
1064 other => panic!("expected TabDeleted, got {:?}", other),
1065 }
1066 }
1067
1068 #[test]
1069 fn delete_tab_with_zero_blocks_emits_empty_block_ids() {
1070 let mut state = State::default();
1071 let ws_id = create_workspace(&mut state, "w");
1072 let _ = update(
1073 &mut state,
1074 Command::CreateTab { workspace_id: ws_id.clone(), name: "t1".into() },
1075 &ctx(1),
1076 );
1077 let tab1_id = state.workspaces[&ws_id].tab_ids[0].clone();
1078 let _ = update(
1079 &mut state,
1080 Command::CreateTab { workspace_id: ws_id.clone(), name: "t2".into() },
1081 &ctx(2),
1082 );
1083 let events = update(
1084 &mut state,
1085 Command::DeleteTab { workspace_id: ws_id, tab_id: tab1_id, force: false },
1086 &ctx(3),
1087 );
1088 match &events[0] {
1089 Event::TabDeleted { block_ids, .. } => assert!(block_ids.is_empty()),
1090 other => panic!("expected TabDeleted, got {:?}", other),
1091 }
1092 }
1093
1094 #[test]
1099 fn create_tab_auto_generates_tab_n_when_name_empty() {
1100 let mut state = State::default();
1101 let ws_id = create_workspace(&mut state, "w");
1102 let events = update(
1103 &mut state,
1104 Command::CreateTab {
1105 workspace_id: ws_id.clone(),
1106 name: String::new(),
1107 },
1108 &ctx(2),
1109 );
1110 match &events[0] {
1111 Event::TabCreated { name, tab_id, .. } => {
1112 assert_eq!(name, "Tab 1", "first tab in fresh workspace");
1113 assert_eq!(state.tabs[tab_id].name, "Tab 1");
1114 }
1115 other => panic!("expected TabCreated, got {:?}", other),
1116 }
1117 let events = update(
1119 &mut state,
1120 Command::CreateTab {
1121 workspace_id: ws_id.clone(),
1122 name: String::new(),
1123 },
1124 &ctx(3),
1125 );
1126 match &events[0] {
1127 Event::TabCreated { name, .. } => assert_eq!(name, "Tab 2"),
1128 other => panic!("expected TabCreated, got {:?}", other),
1129 }
1130 let events = update(
1132 &mut state,
1133 Command::CreateTab {
1134 workspace_id: ws_id,
1135 name: "my custom tab".into(),
1136 },
1137 &ctx(4),
1138 );
1139 match &events[0] {
1140 Event::TabCreated { name, .. } => assert_eq!(name, "my custom tab"),
1141 other => panic!("expected TabCreated, got {:?}", other),
1142 }
1143 }
1144
1145 #[test]
1146 fn delete_tab_cascades_blocks() {
1147 let mut state = State::default();
1148 let ws_id = create_workspace(&mut state, "w");
1149 let tab_id = create_tab(&mut state, &ws_id, "t");
1150 let _ = update(
1151 &mut state,
1152 Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null },
1153 &ctx(2),
1154 );
1155 let _ = update(
1156 &mut state,
1157 Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null },
1158 &ctx(3),
1159 );
1160 assert_eq!(state.blocks.len(), 2);
1161 let _ = update(
1162 &mut state,
1163 Command::DeleteTab {
1164 workspace_id: ws_id,
1165 tab_id,
1166 force: true,
1169 },
1170 &ctx(4),
1171 );
1172 assert!(state.blocks.is_empty());
1173 }
1174
1175 #[test]
1178 fn move_tab_cross_workspace_updates_lists_and_parent() {
1179 let mut state = State::default();
1180 let src = create_workspace(&mut state, "src");
1181 let dst = create_workspace(&mut state, "dst");
1182 let t1 = create_tab(&mut state, &src, "t1");
1183 let t2 = create_tab(&mut state, &src, "t2");
1184 let dst_existing = create_tab(&mut state, &dst, "existing");
1185 let events = update(
1186 &mut state,
1187 Command::MoveTab {
1188 tab_id: t1.clone(),
1189 src_workspace_id: src.clone(),
1190 dst_workspace_id: dst.clone(),
1191 dst_index: 0,
1192 },
1193 &ctx(99),
1194 );
1195 match &events[0] {
1196 Event::TabMoved {
1197 tab_id,
1198 src_workspace_id,
1199 dst_workspace_id,
1200 dst_index,
1201 new_src_active_tab_id,
1202 ..
1203 } => {
1204 assert_eq!(tab_id, &t1);
1205 assert_eq!(src_workspace_id, &src);
1206 assert_eq!(dst_workspace_id, &dst);
1207 assert_eq!(*dst_index, 0);
1208 assert_eq!(new_src_active_tab_id, &Some(t2.clone()));
1209 }
1210 other => panic!("expected TabMoved, got {:?}", other),
1211 }
1212 assert_eq!(state.workspaces[&src].tab_ids, vec![t2.clone()]);
1213 assert_eq!(state.workspaces[&dst].tab_ids, vec![t1.clone(), dst_existing]);
1214 assert_eq!(state.tabs[&t1].workspace_id, dst);
1215 assert_eq!(state.workspaces[&src].active_tab_id, Some(t2));
1216 }
1217
1218 #[test]
1219 fn move_tab_clamps_dst_index_to_dst_length() {
1220 let mut state = State::default();
1221 let src = create_workspace(&mut state, "src");
1222 let dst = create_workspace(&mut state, "dst");
1223 let t1 = create_tab(&mut state, &src, "t1");
1224 let _ = create_tab(&mut state, &src, "filler");
1225 let events = update(
1226 &mut state,
1227 Command::MoveTab {
1228 tab_id: t1.clone(),
1229 src_workspace_id: src,
1230 dst_workspace_id: dst.clone(),
1231 dst_index: 999,
1232 },
1233 &ctx(2),
1234 );
1235 match &events[0] {
1236 Event::TabMoved { dst_index, .. } => assert_eq!(*dst_index, 0),
1237 other => panic!("expected TabMoved, got {:?}", other),
1238 }
1239 assert_eq!(state.workspaces[&dst].tab_ids, vec![t1]);
1240 }
1241
1242 #[test]
1243 fn move_tab_src_active_clears_when_workspace_empties() {
1244 let mut state = State::default();
1245 let src = create_workspace(&mut state, "src");
1246 let dst = create_workspace(&mut state, "dst");
1247 let only_tab = create_tab(&mut state, &src, "only");
1248 let events = update(
1249 &mut state,
1250 Command::MoveTab {
1251 tab_id: only_tab,
1252 src_workspace_id: src.clone(),
1253 dst_workspace_id: dst,
1254 dst_index: 0,
1255 },
1256 &ctx(2),
1257 );
1258 match &events[0] {
1259 Event::TabMoved {
1260 new_src_active_tab_id,
1261 ..
1262 } => assert_eq!(new_src_active_tab_id, &None),
1263 other => panic!("expected TabMoved, got {:?}", other),
1264 }
1265 assert_eq!(state.workspaces[&src].active_tab_id, None);
1266 assert!(state.workspaces[&src].tab_ids.is_empty());
1267 }
1268
1269 #[test]
1270 fn move_tab_rejects_same_workspace() {
1271 let mut state = State::default();
1272 let ws = create_workspace(&mut state, "w");
1273 let t1 = create_tab(&mut state, &ws, "t1");
1274 let events = update(
1275 &mut state,
1276 Command::MoveTab {
1277 tab_id: t1,
1278 src_workspace_id: ws.clone(),
1279 dst_workspace_id: ws,
1280 dst_index: 0,
1281 },
1282 &ctx(2),
1283 );
1284 assert!(matches!(&events[0], Event::Error { .. }));
1285 }
1286
1287 #[test]
1288 fn move_tab_rejects_unknown_src_or_dst_or_tab() {
1289 let mut state = State::default();
1290 let src = create_workspace(&mut state, "src");
1291 let dst = create_workspace(&mut state, "dst");
1292 let t1 = create_tab(&mut state, &src, "t1");
1293
1294 let events = update(
1295 &mut state,
1296 Command::MoveTab {
1297 tab_id: t1.clone(),
1298 src_workspace_id: "no-such-src".into(),
1299 dst_workspace_id: dst.clone(),
1300 dst_index: 0,
1301 },
1302 &ctx(2),
1303 );
1304 assert!(matches!(&events[0], Event::Error { .. }));
1305
1306 let events = update(
1307 &mut state,
1308 Command::MoveTab {
1309 tab_id: t1.clone(),
1310 src_workspace_id: src.clone(),
1311 dst_workspace_id: "no-such-dst".into(),
1312 dst_index: 0,
1313 },
1314 &ctx(3),
1315 );
1316 assert!(matches!(&events[0], Event::Error { .. }));
1317
1318 let events = update(
1324 &mut state,
1325 Command::MoveTab {
1326 tab_id: "ghost-tab".into(),
1327 src_workspace_id: src,
1328 dst_workspace_id: dst,
1329 dst_index: 0,
1330 },
1331 &ctx(4),
1332 );
1333 assert!(matches!(&events[0], Event::Error { .. }));
1334 }
1335
1336 #[test]
1341 fn move_tab_unknown_tab_rejects() {
1342 let mut state = State::default();
1343 let src = create_workspace(&mut state, "src");
1344 let dst = create_workspace(&mut state, "dst");
1345 let unknown_id = "unknown-tab-xyz".to_string();
1346 assert!(!state.tabs.contains_key(&unknown_id));
1347 let events = update(
1348 &mut state,
1349 Command::MoveTab {
1350 tab_id: unknown_id.clone(),
1351 src_workspace_id: src,
1352 dst_workspace_id: dst,
1353 dst_index: 0,
1354 },
1355 &ctx(2),
1356 );
1357 match &events[0] {
1358 Event::Error { code, message, .. } => {
1359 assert_eq!(*code, ErrorCode::InvalidCommand);
1360 assert!(
1361 message.contains("tab not found"),
1362 "error should mention `tab not found`, got: {}",
1363 message
1364 );
1365 }
1366 other => panic!("expected Error event, got {:?}", other),
1367 }
1368 assert!(!state.tabs.contains_key(&unknown_id));
1370 }
1371
1372 #[test]
1378 fn move_tab_wrong_workspace_rejects() {
1379 let mut state = State::default();
1380 let real_src = create_workspace(&mut state, "real_src");
1381 let dst = create_workspace(&mut state, "dst");
1382 let other = create_workspace(&mut state, "other");
1383 let t1 = create_tab(&mut state, &real_src, "t1");
1384 let filler = create_tab(&mut state, &other, "filler");
1385 let events = update(
1388 &mut state,
1389 Command::MoveTab {
1390 tab_id: t1.clone(),
1391 src_workspace_id: other.clone(),
1392 dst_workspace_id: dst.clone(),
1393 dst_index: 0,
1394 },
1395 &ctx(2),
1396 );
1397 match &events[0] {
1398 Event::Error { code, message, .. } => {
1399 assert_eq!(*code, ErrorCode::InvalidCommand);
1400 assert!(
1401 message.contains("workspace_id mismatch"),
1402 "error should mention `workspace_id mismatch`, got: {}",
1403 message
1404 );
1405 }
1406 other => panic!("expected Error event, got {:?}", other),
1407 }
1408 assert_eq!(state.tabs[&t1].workspace_id, real_src);
1411 assert_eq!(state.workspaces[&real_src].tab_ids, vec![t1]);
1412 assert_eq!(state.workspaces[&other].tab_ids, vec![filler]);
1413 assert!(state.workspaces[&dst].tab_ids.is_empty());
1414 }
1415}