Skip to main content

mockforge_tui/screens/
workspaces.rs

1//! Workspace list table screen with activation support.
2
3use std::time::Instant;
4
5use crossterm::event::{KeyCode, KeyEvent};
6use ratatui::{
7    layout::{Constraint, Layout, Rect},
8    text::{Line, Span},
9    widgets::{Block, Borders, Cell, Paragraph, Row, Table},
10    Frame,
11};
12use tokio::sync::mpsc;
13
14use crate::api::client::MockForgeClient;
15use crate::api::models::WorkspaceInfo;
16use crate::event::Event;
17use crate::screens::Screen;
18use crate::theme::Theme;
19use crate::widgets::confirm::ConfirmDialog;
20use crate::widgets::table::TableState;
21
22const FETCH_INTERVAL: u64 = 30;
23
24pub struct WorkspacesScreen {
25    data: Option<serde_json::Value>,
26    workspaces: Vec<WorkspaceInfo>,
27    table: TableState,
28    error: Option<String>,
29    last_fetch: Option<Instant>,
30    confirm: ConfirmDialog,
31    pending_activation: Option<String>,
32    status_message: Option<(bool, String)>,
33}
34
35impl WorkspacesScreen {
36    pub fn new() -> Self {
37        Self {
38            data: None,
39            workspaces: Vec::new(),
40            table: TableState::new(),
41            error: None,
42            last_fetch: None,
43            confirm: ConfirmDialog::new(),
44            pending_activation: None,
45            status_message: None,
46        }
47    }
48
49    fn selected_workspace(&self) -> Option<&WorkspaceInfo> {
50        self.workspaces.get(self.table.selected)
51    }
52}
53
54impl Screen for WorkspacesScreen {
55    fn title(&self) -> &str {
56        "Workspaces"
57    }
58
59    fn handle_key(&mut self, key: KeyEvent) -> bool {
60        // Confirm dialog takes priority.
61        if self.confirm.visible {
62            if let Some(confirmed) = self.confirm.handle_key(key) {
63                if confirmed {
64                    if let Some(ws) = self.selected_workspace() {
65                        self.pending_activation = Some(ws.id.clone());
66                    }
67                }
68                return true;
69            }
70            return true;
71        }
72
73        match key.code {
74            KeyCode::Char('a') | KeyCode::Enter => {
75                if let Some(ws) = self.selected_workspace() {
76                    if ws.active {
77                        self.status_message =
78                            Some((true, format!("\"{}\" is already active", ws.name)));
79                    } else {
80                        let name = ws.name.clone();
81                        self.confirm
82                            .show("Activate Workspace", format!("Activate workspace \"{name}\"?"));
83                    }
84                }
85                true
86            }
87            KeyCode::Char('c') => {
88                self.status_message = None;
89                true
90            }
91            _ => self.table.handle_key(key),
92        }
93    }
94
95    fn render(&self, frame: &mut Frame, area: Rect) {
96        if self.data.is_none() {
97            let loading = Paragraph::new("Loading workspaces...").style(Theme::dim()).block(
98                Block::default()
99                    .title(" Workspaces ")
100                    .borders(Borders::ALL)
101                    .border_style(Theme::dim()),
102            );
103            frame.render_widget(loading, area);
104            self.confirm.render(frame);
105            return;
106        }
107
108        let chunks = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(area);
109
110        let header = Row::new(vec![
111            Cell::from("ID").style(Theme::dim()),
112            Cell::from("Name").style(Theme::dim()),
113            Cell::from("Description").style(Theme::dim()),
114            Cell::from("Active").style(Theme::dim()),
115            Cell::from("Environments").style(Theme::dim()),
116        ])
117        .height(1);
118
119        let rows: Vec<Row> = self
120            .workspaces
121            .iter()
122            .skip(self.table.offset)
123            .take(self.table.visible_height)
124            .map(|ws| {
125                let active_style = if ws.active {
126                    Theme::success()
127                } else {
128                    Theme::dim()
129                };
130                Row::new(vec![
131                    Cell::from(ws.id.clone()),
132                    Cell::from(ws.name.clone()),
133                    Cell::from(ws.description.clone()),
134                    Cell::from(if ws.active { "yes" } else { "no" }).style(active_style),
135                    Cell::from(ws.environments.join(", ")),
136                ])
137            })
138            .collect();
139
140        let widths = [
141            Constraint::Length(12),
142            Constraint::Length(20),
143            Constraint::Min(20),
144            Constraint::Length(8),
145            Constraint::Length(20),
146        ];
147
148        let table = Table::new(rows, widths)
149            .header(header)
150            .row_highlight_style(Theme::highlight())
151            .block(
152                Block::default()
153                    .title(format!(" Workspaces ({}) ", self.workspaces.len()))
154                    .title_style(Theme::title())
155                    .borders(Borders::ALL)
156                    .border_style(Theme::dim())
157                    .style(Theme::surface()),
158            );
159
160        let mut table_state = self.table.to_ratatui_state();
161        frame.render_stateful_widget(table, chunks[0], &mut table_state);
162
163        // Status message bar.
164        let msg_line = if let Some((success, ref msg)) = self.status_message {
165            let style = if success {
166                Theme::success()
167            } else {
168                Theme::error()
169            };
170            Line::from(vec![
171                Span::styled(if success { "  OK: " } else { "  ERR: " }, style),
172                Span::styled(msg.as_str(), Theme::base()),
173            ])
174        } else {
175            Line::from(Span::styled("  Ready", Theme::dim()))
176        };
177        let msg_block = Block::default()
178            .borders(Borders::ALL)
179            .border_style(Theme::dim())
180            .style(Theme::surface());
181        frame.render_widget(Paragraph::new(msg_line).block(msg_block), chunks[1]);
182
183        self.confirm.render(frame);
184    }
185
186    fn tick(&mut self, client: &MockForgeClient, tx: &mpsc::UnboundedSender<Event>) {
187        // Handle pending activation.
188        if let Some(workspace_id) = self.pending_activation.take() {
189            let ws_name = self
190                .workspaces
191                .iter()
192                .find(|w| w.id == workspace_id)
193                .map(|w| w.name.clone())
194                .unwrap_or_else(|| workspace_id.clone());
195            let client = client.clone();
196            let tx = tx.clone();
197            tokio::spawn(async move {
198                let result = match client.activate_workspace(&workspace_id).await {
199                    Ok(msg) => serde_json::json!({
200                        "type": "activation_result",
201                        "success": true,
202                        "message": if msg.is_empty() {
203                            format!("Workspace \"{ws_name}\" activated")
204                        } else {
205                            msg
206                        },
207                    }),
208                    Err(e) => serde_json::json!({
209                        "type": "activation_result",
210                        "success": false,
211                        "message": e.to_string(),
212                    }),
213                };
214                let _ = tx.send(Event::Data {
215                    screen: "workspaces",
216                    payload: serde_json::to_string(&result).unwrap_or_default(),
217                });
218            });
219        }
220
221        // Periodic data fetch.
222        let should_fetch =
223            self.last_fetch.map_or(true, |t| t.elapsed().as_secs() >= FETCH_INTERVAL);
224        if !should_fetch {
225            return;
226        }
227        self.last_fetch = Some(Instant::now());
228
229        let client = client.clone();
230        let tx = tx.clone();
231        tokio::spawn(async move {
232            match client.get_workspaces().await {
233                Ok(data) => {
234                    let json = serde_json::json!(data
235                        .iter()
236                        .map(|ws| serde_json::json!({
237                            "id": ws.id,
238                            "name": ws.name,
239                            "description": ws.description,
240                            "active": ws.active,
241                            "environments": ws.environments,
242                        }))
243                        .collect::<Vec<_>>());
244                    let payload = serde_json::to_string(&json).unwrap_or_default();
245                    let _ = tx.send(Event::Data {
246                        screen: "workspaces",
247                        payload,
248                    });
249                }
250                Err(e) => {
251                    let _ = tx.send(Event::ApiError {
252                        screen: "workspaces",
253                        message: e.to_string(),
254                    });
255                }
256            }
257        });
258    }
259
260    fn on_data(&mut self, payload: &str) {
261        // Check for activation result.
262        if let Ok(val) = serde_json::from_str::<serde_json::Value>(payload) {
263            if val.get("type").and_then(|v| v.as_str()) == Some("activation_result") {
264                let success = val.get("success").and_then(|v| v.as_bool()).unwrap_or(false);
265                let message =
266                    val.get("message").and_then(|v| v.as_str()).unwrap_or("done").to_string();
267                self.status_message = Some((success, message));
268                // Force refresh to see updated active state.
269                self.last_fetch = None;
270                return;
271            }
272        }
273
274        // Normal workspace list data.
275        match serde_json::from_str::<Vec<WorkspaceInfo>>(payload) {
276            Ok(workspaces) => {
277                self.table.set_total(workspaces.len());
278                self.workspaces = workspaces;
279                self.data = serde_json::from_str(payload).ok();
280                self.error = None;
281            }
282            Err(e) => {
283                self.error = Some(format!("Parse error: {e}"));
284            }
285        }
286    }
287
288    fn on_error(&mut self, message: &str) {
289        self.error = Some(message.to_string());
290    }
291
292    fn error(&self) -> Option<&str> {
293        self.error.as_deref()
294    }
295
296    fn force_refresh(&mut self) {
297        self.last_fetch = None;
298    }
299
300    fn status_hint(&self) -> &str {
301        "j/k:navigate  a/Enter:activate  c:clear-message  g/G:top/bottom"
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crossterm::event::{KeyEventKind, KeyEventState, KeyModifiers};
309
310    fn key(code: KeyCode) -> KeyEvent {
311        KeyEvent {
312            code,
313            modifiers: KeyModifiers::NONE,
314            kind: KeyEventKind::Press,
315            state: KeyEventState::NONE,
316        }
317    }
318
319    fn sample_workspaces() -> Vec<WorkspaceInfo> {
320        vec![
321            WorkspaceInfo {
322                id: "ws-1".into(),
323                name: "Development".into(),
324                description: "Dev workspace".into(),
325                active: true,
326                created_at: None,
327                environments: vec!["dev".into(), "staging".into()],
328            },
329            WorkspaceInfo {
330                id: "ws-2".into(),
331                name: "Production".into(),
332                description: "Prod workspace".into(),
333                active: false,
334                created_at: None,
335                environments: vec!["prod".into()],
336            },
337        ]
338    }
339
340    #[test]
341    fn new_creates_empty_screen() {
342        let s = WorkspacesScreen::new();
343        assert!(s.workspaces.is_empty());
344        assert!(s.pending_activation.is_none());
345        assert!(s.status_message.is_none());
346    }
347
348    #[test]
349    fn on_data_parses_workspace_list() {
350        let mut s = WorkspacesScreen::new();
351        let workspaces = sample_workspaces();
352        let payload = serde_json::to_string(&workspaces).unwrap();
353        s.on_data(&payload);
354        assert_eq!(s.workspaces.len(), 2);
355        assert!(s.error.is_none());
356    }
357
358    #[test]
359    fn activate_already_active_shows_message() {
360        let mut s = WorkspacesScreen::new();
361        let workspaces = sample_workspaces();
362        let payload = serde_json::to_string(&workspaces).unwrap();
363        s.on_data(&payload);
364
365        // First workspace (index 0) is active. selected starts at 0.
366        s.handle_key(key(KeyCode::Char('a')));
367        // Should NOT show confirm, should show status message.
368        assert!(!s.confirm.visible);
369        assert!(s.status_message.is_some());
370        let (success, msg) = s.status_message.as_ref().unwrap();
371        assert!(success);
372        assert!(msg.contains("already active"));
373    }
374
375    #[test]
376    fn activate_inactive_shows_confirm() {
377        let mut s = WorkspacesScreen::new();
378        let workspaces = sample_workspaces();
379        let payload = serde_json::to_string(&workspaces).unwrap();
380        s.on_data(&payload);
381
382        // Navigate to second workspace (inactive) at index 1.
383        s.handle_key(key(KeyCode::Char('j')));
384        s.handle_key(key(KeyCode::Char('a')));
385        assert!(s.confirm.visible);
386    }
387
388    #[test]
389    fn confirm_yes_sets_pending_activation() {
390        let mut s = WorkspacesScreen::new();
391        let workspaces = sample_workspaces();
392        let payload = serde_json::to_string(&workspaces).unwrap();
393        s.on_data(&payload);
394
395        // Navigate to inactive workspace (index 1).
396        s.handle_key(key(KeyCode::Char('j')));
397        s.handle_key(key(KeyCode::Char('a')));
398        s.handle_key(key(KeyCode::Char('y')));
399        assert!(!s.confirm.visible);
400        assert_eq!(s.pending_activation, Some("ws-2".into()));
401    }
402
403    #[test]
404    fn confirm_no_does_not_activate() {
405        let mut s = WorkspacesScreen::new();
406        let workspaces = sample_workspaces();
407        let payload = serde_json::to_string(&workspaces).unwrap();
408        s.on_data(&payload);
409
410        // Navigate to inactive workspace (index 1).
411        s.handle_key(key(KeyCode::Char('j')));
412        s.handle_key(key(KeyCode::Char('a')));
413        s.handle_key(key(KeyCode::Char('n')));
414        assert!(s.pending_activation.is_none());
415    }
416
417    #[test]
418    fn activation_result_sets_status_message() {
419        let mut s = WorkspacesScreen::new();
420        let result = serde_json::json!({
421            "type": "activation_result",
422            "success": true,
423            "message": "Workspace activated",
424        });
425        s.on_data(&serde_json::to_string(&result).unwrap());
426        assert!(s.status_message.is_some());
427        let (success, msg) = s.status_message.as_ref().unwrap();
428        assert!(success);
429        assert_eq!(msg, "Workspace activated");
430    }
431
432    #[test]
433    fn c_key_clears_status_message() {
434        let mut s = WorkspacesScreen::new();
435        s.status_message = Some((true, "Test".into()));
436        s.handle_key(key(KeyCode::Char('c')));
437        assert!(s.status_message.is_none());
438    }
439
440    #[test]
441    fn status_hint_shows_activate() {
442        let s = WorkspacesScreen::new();
443        assert!(s.status_hint().contains("activate"));
444    }
445
446    #[test]
447    fn force_refresh_clears_last_fetch() {
448        let mut s = WorkspacesScreen::new();
449        s.last_fetch = Some(Instant::now());
450        s.force_refresh();
451        assert!(s.last_fetch.is_none());
452    }
453}