1use 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 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 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 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 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 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 self.last_fetch = None;
270 return;
271 }
272 }
273
274 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 s.handle_key(key(KeyCode::Char('a')));
367 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 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 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 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}