1use agentmux_common::ipc::{ErrorCode, Event};
5
6use crate::state::State;
7
8
9use crate::state::BlockRecord;
10
11pub(super) fn handle_create_block(
19 state: &mut State,
20 tab_id: String,
21 meta: serde_json::Value,
22) -> Vec<Event> {
23 if !state.tabs.contains_key(&tab_id) {
24 let v = state.bump_version();
25 return vec![Event::Error {
26 code: ErrorCode::InvalidCommand,
27 message: format!("CreateBlock: tab not found: {}", tab_id),
28 fatal: false,
29 version: v,
30 }];
31 }
32 let block_id = uuid::Uuid::new_v4().to_string();
33 state.blocks.insert(
34 block_id.clone(),
35 BlockRecord {
36 block_id: block_id.clone(),
37 tab_id: tab_id.clone(),
38 },
39 );
40 let tab = state.tabs.get_mut(&tab_id).expect("checked");
41 tab.block_ids.push(block_id.clone());
42 let v = state.bump_version();
43 vec![Event::BlockCreated {
44 tab_id,
45 block_id,
46 meta,
47 version: v,
48 }]
49}
50
51pub(super) fn handle_delete_block(state: &mut State, tab_id: String, block_id: String) -> Vec<Event> {
54 let Some(tab) = state.tabs.get_mut(&tab_id) else {
55 return Vec::new();
56 };
57 let Some(pos) = tab.block_ids.iter().position(|b| b == &block_id) else {
58 return Vec::new();
59 };
60 tab.block_ids.remove(pos);
61 state.blocks.remove(&block_id);
62 let v = state.bump_version();
63 vec![Event::BlockDeleted {
64 tab_id,
65 block_id,
66 version: v,
67 }]
68}
69
70pub(super) fn handle_move_block(
78 state: &mut State,
79 block_id: String,
80 src_tab_id: String,
81 dst_tab_id: String,
82 dst_index: u32,
83) -> Vec<Event> {
84 let validation_error: Option<String> = {
85 if !state.tabs.contains_key(&src_tab_id) {
86 Some(format!("MoveBlock: src tab not found: {}", src_tab_id))
87 } else if !state.tabs.contains_key(&dst_tab_id) {
88 Some(format!("MoveBlock: dst tab not found: {}", dst_tab_id))
89 } else {
90 match state.blocks.get(&block_id) {
91 None => Some(format!("MoveBlock: block not found: {}", block_id)),
92 Some(block) if block.tab_id != src_tab_id => Some(format!(
93 "MoveBlock: block {} belongs to tab {}, not {}",
94 block_id, block.tab_id, src_tab_id
95 )),
96 _ => None,
97 }
98 }
99 };
100 if let Some(message) = validation_error {
101 let v = state.bump_version();
102 return vec![Event::Error {
103 code: ErrorCode::InvalidCommand,
104 message,
105 fatal: false,
106 version: v,
107 }];
108 }
109
110 let final_dst_index: u32 = if src_tab_id == dst_tab_id {
115 let tab = state.tabs.get_mut(&src_tab_id).expect("checked");
116 tab.block_ids.retain(|id| id != &block_id);
117 let clamped = (dst_index as usize).min(tab.block_ids.len());
118 tab.block_ids.insert(clamped, block_id.clone());
119 clamped as u32
120 } else {
121 state
123 .tabs
124 .get_mut(&src_tab_id)
125 .expect("checked")
126 .block_ids
127 .retain(|id| id != &block_id);
128 let dst = state.tabs.get_mut(&dst_tab_id).expect("checked");
130 let clamped = (dst_index as usize).min(dst.block_ids.len());
131 dst.block_ids.insert(clamped, block_id.clone());
132 state.blocks.get_mut(&block_id).expect("checked").tab_id = dst_tab_id.clone();
134 clamped as u32
135 };
136
137 let v = state.bump_version();
138 vec![Event::BlockMoved {
139 block_id,
140 src_tab_id,
141 dst_tab_id,
142 dst_index: final_dst_index,
143 version: v,
144 }]
145}
146
147pub(super) fn handle_update_block_meta(
149 state: &mut State,
150 block_id: String,
151 meta_patch: serde_json::Value,
152) -> Vec<Event> {
153 if !state.blocks.contains_key(&block_id) {
154 let v = state.bump_version();
155 return vec![Event::Error {
156 code: ErrorCode::InvalidCommand,
157 message: format!("UpdateBlockMeta: block not found: {}", block_id),
158 fatal: false,
159 version: v,
160 }];
161 }
162 let v = state.bump_version();
163 vec![Event::BlockMetaUpdated {
164 block_id,
165 meta_patch,
166 version: v,
167 }]
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use crate::reducer::test_support::*;
174 use crate::reducer::update;
175 use agentmux_common::ipc::Command;
176
177 #[test]
178 fn create_block_validates_tab_exists() {
179 let mut state = State::default();
180 let events = update(
181 &mut state,
182 Command::CreateBlock { tab_id: "no-such-tab".into(), meta: serde_json::Value::Null },
183 &ctx(1),
184 );
185 assert!(matches!(&events[0], Event::Error { .. }));
186 assert!(state.blocks.is_empty());
187 }
188
189 #[test]
190 fn create_block_appends_to_tab_block_ids() {
191 let mut state = State::default();
192 let ws_id = create_workspace(&mut state, "w");
193 let tab_id = create_tab(&mut state, &ws_id, "t");
194 let events = update(
195 &mut state,
196 Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null },
197 &ctx(2),
198 );
199 assert!(matches!(&events[0], Event::BlockCreated { .. }));
200 let block_id = match &events[0] {
201 Event::BlockCreated { block_id, .. } => block_id.clone(),
202 _ => panic!(),
203 };
204 assert_eq!(state.tabs[&tab_id].block_ids, vec![block_id.clone()]);
205 assert_eq!(state.blocks[&block_id].tab_id, tab_id);
206 }
207
208 #[test]
209 fn delete_block_removes_from_state_and_tab() {
210 let mut state = State::default();
211 let ws_id = create_workspace(&mut state, "w");
212 let tab_id = create_tab(&mut state, &ws_id, "t");
213 let _ = update(
214 &mut state,
215 Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null },
216 &ctx(2),
217 );
218 let block_id = state.tabs[&tab_id].block_ids[0].clone();
219 let events = update(
220 &mut state,
221 Command::DeleteBlock {
222 tab_id: tab_id.clone(),
223 block_id: block_id.clone(),
224 },
225 &ctx(3),
226 );
227 assert!(matches!(&events[0], Event::BlockDeleted { .. }));
228 assert!(!state.blocks.contains_key(&block_id));
229 assert!(state.tabs[&tab_id].block_ids.is_empty());
230 }
231
232 #[test]
233 fn delete_block_unknown_silent_no_op() {
234 let mut state = State::default();
235 let ws_id = create_workspace(&mut state, "w");
236 let tab_id = create_tab(&mut state, &ws_id, "t");
237 let events = update(
238 &mut state,
239 Command::DeleteBlock {
240 tab_id,
241 block_id: "ghost".into(),
242 },
243 &ctx(2),
244 );
245 assert!(events.is_empty());
246 }
247
248 #[test]
251 fn move_block_cross_tab_updates_lists_and_parent() {
252 let mut state = State::default();
253 let ws = create_workspace(&mut state, "w");
254 let src_tab = create_tab(&mut state, &ws, "src");
255 let dst_tab = create_tab(&mut state, &ws, "dst");
256 let block = create_block(&mut state, &src_tab);
257 let dst_existing = create_block(&mut state, &dst_tab);
258 let events = update(
259 &mut state,
260 Command::MoveBlock {
261 block_id: block.clone(),
262 src_tab_id: src_tab.clone(),
263 dst_tab_id: dst_tab.clone(),
264 dst_index: 0,
265 },
266 &ctx(2),
267 );
268 assert!(matches!(&events[0], Event::BlockMoved { .. }));
269 assert_eq!(state.tabs[&src_tab].block_ids, Vec::<String>::new());
270 assert_eq!(state.tabs[&dst_tab].block_ids, vec![block.clone(), dst_existing]);
271 assert_eq!(state.blocks[&block].tab_id, dst_tab);
272 }
273
274 #[test]
275 fn move_block_intra_tab_repositions() {
276 let mut state = State::default();
277 let ws = create_workspace(&mut state, "w");
278 let tab = create_tab(&mut state, &ws, "t");
279 let b1 = create_block(&mut state, &tab);
280 let b2 = create_block(&mut state, &tab);
281 let b3 = create_block(&mut state, &tab);
282 let events = update(
284 &mut state,
285 Command::MoveBlock {
286 block_id: b1.clone(),
287 src_tab_id: tab.clone(),
288 dst_tab_id: tab.clone(),
289 dst_index: 2,
290 },
291 &ctx(2),
292 );
293 match &events[0] {
294 Event::BlockMoved { dst_index, .. } => assert_eq!(*dst_index, 2),
295 other => panic!("expected BlockMoved, got {:?}", other),
296 }
297 assert_eq!(state.tabs[&tab].block_ids, vec![b2, b3, b1]);
298 }
299
300 #[test]
301 fn move_block_rejects_unknown_src_or_dst_or_block() {
302 let mut state = State::default();
303 let ws = create_workspace(&mut state, "w");
304 let tab = create_tab(&mut state, &ws, "t");
305 let other_tab = create_tab(&mut state, &ws, "other");
306 let block = create_block(&mut state, &tab);
307
308 let events = update(
309 &mut state,
310 Command::MoveBlock {
311 block_id: block.clone(),
312 src_tab_id: "ghost-src".into(),
313 dst_tab_id: other_tab.clone(),
314 dst_index: 0,
315 },
316 &ctx(2),
317 );
318 assert!(matches!(&events[0], Event::Error { .. }));
319
320 let events = update(
321 &mut state,
322 Command::MoveBlock {
323 block_id: block.clone(),
324 src_tab_id: tab.clone(),
325 dst_tab_id: "ghost-dst".into(),
326 dst_index: 0,
327 },
328 &ctx(3),
329 );
330 assert!(matches!(&events[0], Event::Error { .. }));
331
332 let events = update(
333 &mut state,
334 Command::MoveBlock {
335 block_id: "ghost-block".into(),
336 src_tab_id: tab,
337 dst_tab_id: other_tab,
338 dst_index: 0,
339 },
340 &ctx(4),
341 );
342 assert!(matches!(&events[0], Event::Error { .. }));
343 }
344
345 #[test]
346 fn move_block_rejects_when_block_belongs_to_different_tab() {
347 let mut state = State::default();
348 let ws = create_workspace(&mut state, "w");
349 let real_src = create_tab(&mut state, &ws, "real");
350 let other = create_tab(&mut state, &ws, "other");
351 let dst = create_tab(&mut state, &ws, "dst");
352 let block = create_block(&mut state, &real_src);
353 let events = update(
354 &mut state,
355 Command::MoveBlock {
356 block_id: block,
357 src_tab_id: other,
358 dst_tab_id: dst,
359 dst_index: 0,
360 },
361 &ctx(2),
362 );
363 match &events[0] {
364 Event::Error { message, .. } => {
365 assert!(message.contains("belongs to tab"), "got: {}", message);
366 }
367 other => panic!("expected Error, got {:?}", other),
368 }
369 }
370}