1use ratatui::Frame;
2use ratatui::crossterm::event::{KeyCode, KeyEvent};
3use ratatui::layout::{Constraint, Direction, Layout, Rect};
4use ratatui::style::{Modifier, Style};
5use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
6use std::path::PathBuf;
7
8use crate::components::Component;
9use crate::components::event_state::EventState;
10use crate::components::events::{AppEvent, AppTx, InputEvent};
11use crate::components::single_line_input::{InputOutcome, SingleLineInput};
12use crate::settings::AppSettings;
13use crate::settings::themes::Theme;
14
15#[derive(Debug, Clone, PartialEq)]
16pub enum Mode {
17 Normal,
18 Creating,
19 Renaming,
20 ConfirmDelete,
21}
22
23pub struct WorkspacesSection {
24 entries: Vec<(String, PathBuf, bool)>,
26 list_state: ListState,
27 mode: Mode,
28 input: SingleLineInput,
29 error: Option<String>,
30}
31
32impl WorkspacesSection {
33 pub fn new(settings: &AppSettings) -> Self {
34 let mut section = Self {
35 entries: Vec::new(),
36 list_state: ListState::default(),
37 mode: Mode::Normal,
38 input: SingleLineInput::new(),
39 error: None,
40 };
41 section.refresh(settings);
42 section
43 }
44
45 pub fn mode(&self) -> &Mode {
46 &self.mode
47 }
48
49 pub fn input(&self) -> &str {
50 self.input.value()
51 }
52
53 pub fn selected_name(&self) -> Option<&str> {
54 self.list_state
55 .selected()
56 .and_then(|i| self.entries.get(i))
57 .map(|(name, _, _)| name.as_str())
58 }
59
60 pub fn current_path(&self) -> Option<PathBuf> {
61 self.entries
62 .iter()
63 .find(|(_, _, is_current)| *is_current)
64 .map(|(_, path, _)| path.clone())
65 }
66
67 pub fn refresh(&mut self, settings: &AppSettings) {
68 self.entries.clear();
69 if let Some(ref wc) = settings.workspace_config {
70 let current = &wc.global.current_workspace;
71 let mut names: Vec<&String> = wc.workspaces.keys().collect();
72 names.sort();
73 for name in names {
74 if let Some(entry) = wc.workspaces.get(name) {
75 self.entries
76 .push((name.clone(), entry.path.clone(), name == current));
77 }
78 }
79 }
80 let max = self.entries.len();
82 if max == 0 {
83 self.list_state.select(None);
84 } else {
85 let prev = self.list_state.selected().unwrap_or(0);
86 self.list_state.select(Some(prev.min(max - 1)));
87 }
88 }
89
90 pub fn reset_mode(&mut self) {
91 self.mode = Mode::Normal;
92 self.input.clear();
93 self.error = None;
94 }
95
96 pub fn set_error(&mut self, msg: String) {
97 self.error = Some(msg);
98 }
99
100 fn move_up(&mut self) {
103 if !self.entries.is_empty() {
104 let cur = self.list_state.selected().unwrap_or(0);
105 let next = if cur == 0 {
106 self.entries.len() - 1
107 } else {
108 cur - 1
109 };
110 self.list_state.select(Some(next));
111 }
112 }
113
114 fn move_down(&mut self) {
115 if !self.entries.is_empty() {
116 let cur = self.list_state.selected().unwrap_or(0);
117 let next = (cur + 1) % self.entries.len();
118 self.list_state.select(Some(next));
119 }
120 }
121
122 fn handle_normal(&mut self, code: KeyCode, tx: &AppTx) -> EventState {
123 match code {
124 KeyCode::Up => {
125 self.move_up();
126 EventState::Consumed
127 }
128 KeyCode::Down => {
129 self.move_down();
130 EventState::Consumed
131 }
132 KeyCode::Enter => {
133 if let Some((name, _, is_current)) =
134 self.list_state.selected().and_then(|i| self.entries.get(i))
135 && !is_current
136 {
137 tx.send(AppEvent::WorkspaceSwitched(name.clone())).ok();
138 }
139 EventState::Consumed
140 }
141 KeyCode::Char('n') => {
142 self.mode = Mode::Creating;
143 self.input = if self.entries.is_empty() {
144 SingleLineInput::with_value("default")
145 } else {
146 SingleLineInput::new()
147 };
148 self.error = None;
149 EventState::Consumed
150 }
151 KeyCode::Char('r') => {
152 if let Some(name) = self.selected_name().map(|s| s.to_string()) {
153 self.mode = Mode::Renaming;
154 self.input = SingleLineInput::with_value(name);
155 self.error = None;
156 }
157 EventState::Consumed
158 }
159 KeyCode::Char('d') => {
160 if self.list_state.selected().is_some() {
161 self.mode = Mode::ConfirmDelete;
162 self.error = None;
163 }
164 EventState::Consumed
165 }
166 KeyCode::Char('b') => {
167 tx.send(AppEvent::OpenFileBrowser).ok();
168 EventState::Consumed
169 }
170 _ => EventState::NotConsumed,
171 }
172 }
173
174 fn handle_text_input(&mut self, key: &KeyEvent) -> EventState {
175 match self.input.handle_key(key) {
176 InputOutcome::Cancel => {
177 self.reset_mode();
178 EventState::Consumed
179 }
180 InputOutcome::Submit | InputOutcome::Consumed => EventState::Consumed,
182 InputOutcome::Changed => {
183 self.error = None;
184 EventState::Consumed
185 }
186 InputOutcome::NotConsumed => EventState::NotConsumed,
187 }
188 }
189
190 fn handle_confirm_delete(&mut self, code: KeyCode) -> EventState {
191 match code {
192 KeyCode::Char('y') => {
193 EventState::Consumed
195 }
196 KeyCode::Char('n') | KeyCode::Esc => {
197 self.reset_mode();
198 EventState::Consumed
199 }
200 _ => EventState::NotConsumed,
201 }
202 }
203}
204
205impl Component for WorkspacesSection {
206 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
207 let InputEvent::Key(key) = event else {
208 return EventState::NotConsumed;
209 };
210 match self.mode {
211 Mode::Normal => self.handle_normal(key.code, tx),
212 Mode::Creating | Mode::Renaming => {
213 if key.code == KeyCode::Enter {
214 if self.mode == Mode::Creating && !self.input.value().trim().is_empty() {
216 tx.send(AppEvent::OpenFileBrowser).ok();
217 }
218 return EventState::Consumed;
220 }
221 self.handle_text_input(key)
222 }
223 Mode::ConfirmDelete => self.handle_confirm_delete(key.code),
224 }
225 }
226
227 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
228 let border_style = theme.border_style(focused);
229 let fg = theme.fg.to_ratatui();
230 let gray = theme.gray.to_ratatui();
231 let bg = theme.bg_panel.to_ratatui();
232
233 let title = format!("Workspaces ({})", self.entries.len());
235 let block = Block::default()
236 .title(title)
237 .borders(Borders::ALL)
238 .border_style(border_style)
239 .style(theme.base_style());
240
241 let inner = block.inner(rect);
242 f.render_widget(block, rect);
243
244 if inner.height < 2 {
245 return;
246 }
247
248 let mut constraints = vec![Constraint::Min(0), Constraint::Length(1)];
250 if self.error.is_some() {
251 constraints.push(Constraint::Length(1));
252 }
253 let rows = Layout::default()
254 .direction(Direction::Vertical)
255 .constraints(constraints)
256 .split(inner);
257
258 if self.entries.is_empty() {
260 f.render_widget(
261 Paragraph::new(" No workspaces configured.")
262 .style(Style::default().fg(gray).bg(bg)),
263 rows[0],
264 );
265 } else {
266 let items: Vec<ListItem> = self
267 .entries
268 .iter()
269 .map(|(name, path, is_current)| {
270 let marker = if *is_current { "\u{25CF} " } else { " " };
271 let line = format!("{}{} {}", marker, name, path.to_string_lossy());
272 let style = if *is_current {
273 Style::default()
274 .fg(theme.accent.to_ratatui())
275 .bg(bg)
276 .add_modifier(Modifier::BOLD)
277 } else {
278 Style::default().fg(fg).bg(bg)
279 };
280 ListItem::new(line).style(style)
281 })
282 .collect();
283
284 let list = List::new(items)
285 .style(Style::default().bg(bg))
286 .highlight_style(Style::default().bg(theme.selection_bg.to_ratatui()));
287
288 f.render_stateful_widget(list, rows[0], &mut self.list_state);
289 }
290
291 let hint_idx = 1;
293 let hint_text = match &self.mode {
294 Mode::Normal => {
295 " [Enter] Switch [n] New [r] Rename [d] Delete [b] Browse path".to_string()
296 }
297 Mode::Creating => {
298 let display = format!(" Name: {}", self.input.value());
299 let visible_cursor = self.input.cursor_display_col() as u16;
300 let cursor_x = rows[hint_idx].x + 7 + visible_cursor;
301 let cursor_y = rows[hint_idx].y;
302 if cursor_x < rows[hint_idx].x + rows[hint_idx].width {
303 f.set_cursor_position((cursor_x, cursor_y));
304 }
305 display
306 }
307 Mode::Renaming => {
308 let display = format!(" New name: {}", self.input.value());
309 let visible_cursor = self.input.cursor_display_col() as u16;
310 let cursor_x = rows[hint_idx].x + 11 + visible_cursor;
311 let cursor_y = rows[hint_idx].y;
312 if cursor_x < rows[hint_idx].x + rows[hint_idx].width {
313 f.set_cursor_position((cursor_x, cursor_y));
314 }
315 display
316 }
317 Mode::ConfirmDelete => {
318 let name = self.selected_name().unwrap_or("?");
319 format!(" Delete workspace '{}'? [y] Yes [n/Esc] No", name)
320 }
321 };
322 f.render_widget(
323 Paragraph::new(hint_text).style(Style::default().fg(gray).bg(bg)),
324 rows[hint_idx],
325 );
326
327 if let Some(ref err) = self.error {
329 let err_idx = 2;
330 if err_idx < rows.len() {
331 f.render_widget(
332 Paragraph::new(format!(" {}", err))
333 .style(Style::default().fg(theme.accent.to_ratatui()).bg(bg)),
334 rows[err_idx],
335 );
336 }
337 }
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use crate::settings::workspace_config::{GlobalConfig, WorkspaceConfig, WorkspaceEntry};
345 use ratatui::crossterm::event::{KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
346 use std::collections::BTreeMap;
347
348 fn make_settings(workspaces: Vec<(&str, &str)>, current: &str) -> AppSettings {
349 let mut ws_map = BTreeMap::new();
350 for (name, path) in &workspaces {
351 ws_map.insert(
352 name.to_string(),
353 WorkspaceEntry {
354 path: PathBuf::from(path),
355 last_paths: vec![],
356 created: chrono::Utc::now(),
357 quick_note_path: None,
358 inbox_path: None,
359 resolved_path: None,
360 file_key: None,
361 },
362 );
363 }
364 let mut settings = AppSettings::default();
365 settings.workspace_config = Some(WorkspaceConfig {
366 global: GlobalConfig {
367 current_workspace: current.to_string(),
368 update_check: true,
369 mouse: true,
370 kimun_server_url: None,
371 kimun_server_token: None,
372 },
373 workspaces: ws_map,
374 });
375 settings
376 }
377
378 fn key(code: KeyCode) -> InputEvent {
379 InputEvent::Key(KeyEvent {
380 code,
381 modifiers: KeyModifiers::NONE,
382 kind: KeyEventKind::Press,
383 state: KeyEventState::NONE,
384 })
385 }
386
387 #[test]
388 fn new_section_loads_workspaces() {
389 let settings = make_settings(vec![("work", "/work"), ("personal", "/personal")], "work");
390 let section = WorkspacesSection::new(&settings);
391 assert_eq!(section.entries.len(), 2);
392 assert_eq!(section.entries[0].0, "personal");
394 assert_eq!(section.entries[1].0, "work");
395 }
396
397 #[test]
398 fn current_path_returns_active_workspace() {
399 let settings = make_settings(vec![("notes", "/my/notes")], "notes");
400 let section = WorkspacesSection::new(&settings);
401 assert_eq!(section.current_path(), Some(PathBuf::from("/my/notes")));
402 }
403
404 #[test]
405 fn up_down_navigate() {
406 let settings = make_settings(vec![("a", "/a"), ("b", "/b"), ("c", "/c")], "a");
407 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
408 let mut section = WorkspacesSection::new(&settings);
409 section.list_state.select(Some(0));
410
411 section.handle_input(&key(KeyCode::Down), &tx);
412 assert_eq!(section.list_state.selected(), Some(1));
413
414 section.handle_input(&key(KeyCode::Down), &tx);
415 assert_eq!(section.list_state.selected(), Some(2));
416
417 section.handle_input(&key(KeyCode::Down), &tx);
419 assert_eq!(section.list_state.selected(), Some(0));
420
421 section.handle_input(&key(KeyCode::Up), &tx);
422 assert_eq!(section.list_state.selected(), Some(2));
423 }
424
425 #[test]
426 fn enter_sends_workspace_switched() {
427 let settings = make_settings(vec![("a", "/a"), ("b", "/b")], "a");
428 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
429 let mut section = WorkspacesSection::new(&settings);
430 section.list_state.select(Some(1));
432 section.handle_input(&key(KeyCode::Enter), &tx);
433 let msg = rx.try_recv().expect("should send event");
434 assert!(matches!(msg, AppEvent::WorkspaceSwitched(name) if name == "b"));
435 }
436
437 #[test]
438 fn enter_on_current_does_not_send_event() {
439 let settings = make_settings(vec![("a", "/a"), ("b", "/b")], "a");
440 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
441 let mut section = WorkspacesSection::new(&settings);
442 section.list_state.select(Some(0));
444 section.handle_input(&key(KeyCode::Enter), &tx);
445 assert!(rx.try_recv().is_err());
446 }
447
448 #[test]
449 fn n_enters_creating_mode() {
450 let settings = make_settings(vec![("a", "/a")], "a");
451 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
452 let mut section = WorkspacesSection::new(&settings);
453 section.handle_input(&key(KeyCode::Char('n')), &tx);
454 assert_eq!(*section.mode(), Mode::Creating);
455 }
456
457 #[test]
458 fn creating_mode_collects_text_and_sends_file_browser() {
459 let settings = make_settings(vec![("a", "/a")], "a");
460 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
461 let mut section = WorkspacesSection::new(&settings);
462 section.handle_input(&key(KeyCode::Char('n')), &tx);
463
464 section.handle_input(&key(KeyCode::Char('t')), &tx);
465 section.handle_input(&key(KeyCode::Char('e')), &tx);
466 section.handle_input(&key(KeyCode::Char('s')), &tx);
467 section.handle_input(&key(KeyCode::Char('t')), &tx);
468 assert_eq!(section.input(), "test");
469
470 section.handle_input(&key(KeyCode::Enter), &tx);
471 let msg = rx.try_recv().expect("should send OpenFileBrowser");
472 assert!(matches!(msg, AppEvent::OpenFileBrowser));
473 }
474
475 #[test]
476 fn creating_empty_name_does_not_send_file_browser() {
477 let settings = make_settings(vec![("a", "/a")], "a");
478 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
479 let mut section = WorkspacesSection::new(&settings);
480 section.handle_input(&key(KeyCode::Char('n')), &tx);
481 section.handle_input(&key(KeyCode::Enter), &tx);
482 assert!(rx.try_recv().is_err());
483 }
484
485 #[test]
486 fn esc_cancels_creating() {
487 let settings = make_settings(vec![("a", "/a")], "a");
488 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
489 let mut section = WorkspacesSection::new(&settings);
490 section.handle_input(&key(KeyCode::Char('n')), &tx);
491 section.handle_input(&key(KeyCode::Char('x')), &tx);
492 section.handle_input(&key(KeyCode::Esc), &tx);
493 assert_eq!(*section.mode(), Mode::Normal);
494 assert!(section.input().is_empty());
495 }
496
497 #[test]
498 fn r_enters_renaming_mode() {
499 let settings = make_settings(vec![("a", "/a")], "a");
500 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
501 let mut section = WorkspacesSection::new(&settings);
502 section.handle_input(&key(KeyCode::Char('r')), &tx);
503 assert_eq!(*section.mode(), Mode::Renaming);
504 }
505
506 #[test]
507 fn d_enters_confirm_delete() {
508 let settings = make_settings(vec![("a", "/a")], "a");
509 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
510 let mut section = WorkspacesSection::new(&settings);
511 section.handle_input(&key(KeyCode::Char('d')), &tx);
512 assert_eq!(*section.mode(), Mode::ConfirmDelete);
513 }
514
515 #[test]
516 fn confirm_delete_y_stays_in_mode_for_caller() {
517 let settings = make_settings(vec![("a", "/a")], "a");
518 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
519 let mut section = WorkspacesSection::new(&settings);
520 section.handle_input(&key(KeyCode::Char('d')), &tx);
521 let result = section.handle_input(&key(KeyCode::Char('y')), &tx);
522 assert!(result.is_consumed());
523 assert_eq!(*section.mode(), Mode::ConfirmDelete);
525 }
526
527 #[test]
528 fn confirm_delete_n_cancels() {
529 let settings = make_settings(vec![("a", "/a")], "a");
530 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
531 let mut section = WorkspacesSection::new(&settings);
532 section.handle_input(&key(KeyCode::Char('d')), &tx);
533 section.handle_input(&key(KeyCode::Char('n')), &tx);
534 assert_eq!(*section.mode(), Mode::Normal);
535 }
536
537 #[test]
538 fn b_sends_open_file_browser() {
539 let settings = make_settings(vec![("a", "/a")], "a");
540 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
541 let mut section = WorkspacesSection::new(&settings);
542 section.handle_input(&key(KeyCode::Char('b')), &tx);
543 let msg = rx.try_recv().expect("should send event");
544 assert!(matches!(msg, AppEvent::OpenFileBrowser));
545 }
546
547 #[test]
548 fn backspace_deletes_char() {
549 let settings = make_settings(vec![], "");
550 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
551 let mut section = WorkspacesSection::new(&settings);
552 section.mode = Mode::Creating;
553
554 section.handle_input(&key(KeyCode::Char('a')), &tx);
555 section.handle_input(&key(KeyCode::Char('b')), &tx);
556 assert_eq!(section.input(), "ab");
557
558 section.handle_input(&key(KeyCode::Backspace), &tx);
559 assert_eq!(section.input(), "a");
560 }
561
562 #[test]
563 fn text_input_cursor_movement() {
564 let settings = make_settings(vec![], "");
565 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
566 let mut section = WorkspacesSection::new(&settings);
567 section.mode = Mode::Creating;
568
569 section.handle_input(&key(KeyCode::Char('a')), &tx);
570 section.handle_input(&key(KeyCode::Char('b')), &tx);
571 section.handle_input(&key(KeyCode::Char('c')), &tx);
572 assert_eq!(section.input.cursor_char_offset(), 3);
573
574 section.handle_input(&key(KeyCode::Left), &tx);
575 assert_eq!(section.input.cursor_char_offset(), 2);
576
577 section.handle_input(&key(KeyCode::Left), &tx);
578 assert_eq!(section.input.cursor_char_offset(), 1);
579
580 section.handle_input(&key(KeyCode::Right), &tx);
581 assert_eq!(section.input.cursor_char_offset(), 2);
582 }
583
584 #[test]
585 fn refresh_updates_entries() {
586 let settings1 = make_settings(vec![("a", "/a")], "a");
587 let mut section = WorkspacesSection::new(&settings1);
588 assert_eq!(section.entries.len(), 1);
589
590 let settings2 = make_settings(vec![("a", "/a"), ("b", "/b")], "a");
591 section.refresh(&settings2);
592 assert_eq!(section.entries.len(), 2);
593 }
594
595 #[test]
596 fn reset_mode_clears_state() {
597 let settings = make_settings(vec![], "");
598 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
599 let mut section = WorkspacesSection::new(&settings);
600 section.mode = Mode::Creating;
601 section.handle_input(&key(KeyCode::Char('x')), &tx);
602 section.set_error("oops".to_string());
603
604 section.reset_mode();
605 assert_eq!(*section.mode(), Mode::Normal);
606 assert!(section.input().is_empty());
607 assert_eq!(section.input.cursor_char_offset(), 0);
608 assert!(section.error.is_none());
609 }
610
611 #[test]
612 fn empty_settings_shows_no_workspaces() {
613 let settings = AppSettings::default();
614 let section = WorkspacesSection::new(&settings);
615 assert!(section.entries.is_empty());
616 assert!(section.current_path().is_none());
617 }
618
619 #[test]
620 fn renders_without_panic() {
621 use ratatui::Terminal;
622 use ratatui::backend::TestBackend;
623 let settings = make_settings(vec![("notes", "/my/notes")], "notes");
624 let mut section = WorkspacesSection::new(&settings);
625 let theme = Theme::gruvbox_dark();
626 let backend = TestBackend::new(80, 10);
627 let mut terminal = Terminal::new(backend).unwrap();
628 terminal
629 .draw(|f| {
630 section.render(f, f.area(), &theme, true);
631 })
632 .unwrap();
633 let buffer = terminal.backend().buffer().clone();
634 let flat: String = buffer.content.iter().map(|c| c.symbol()).collect();
635 assert!(flat.contains("Workspaces (1)"));
636 assert!(flat.contains("notes"));
637 }
638}