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