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 },
361 );
362 }
363 let mut settings = AppSettings::default();
364 settings.workspace_config = Some(WorkspaceConfig {
365 global: GlobalConfig {
366 current_workspace: current.to_string(),
367 },
368 workspaces: ws_map,
369 });
370 settings
371 }
372
373 fn key(code: KeyCode) -> InputEvent {
374 InputEvent::Key(KeyEvent {
375 code,
376 modifiers: KeyModifiers::NONE,
377 kind: KeyEventKind::Press,
378 state: KeyEventState::NONE,
379 })
380 }
381
382 #[test]
383 fn new_section_loads_workspaces() {
384 let settings = make_settings(vec![("work", "/work"), ("personal", "/personal")], "work");
385 let section = WorkspacesSection::new(&settings);
386 assert_eq!(section.entries.len(), 2);
387 assert_eq!(section.entries[0].0, "personal");
389 assert_eq!(section.entries[1].0, "work");
390 }
391
392 #[test]
393 fn current_path_returns_active_workspace() {
394 let settings = make_settings(vec![("notes", "/my/notes")], "notes");
395 let section = WorkspacesSection::new(&settings);
396 assert_eq!(section.current_path(), Some(PathBuf::from("/my/notes")));
397 }
398
399 #[test]
400 fn up_down_navigate() {
401 let settings = make_settings(vec![("a", "/a"), ("b", "/b"), ("c", "/c")], "a");
402 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
403 let mut section = WorkspacesSection::new(&settings);
404 section.list_state.select(Some(0));
405
406 section.handle_input(&key(KeyCode::Down), &tx);
407 assert_eq!(section.list_state.selected(), Some(1));
408
409 section.handle_input(&key(KeyCode::Down), &tx);
410 assert_eq!(section.list_state.selected(), Some(2));
411
412 section.handle_input(&key(KeyCode::Down), &tx);
414 assert_eq!(section.list_state.selected(), Some(0));
415
416 section.handle_input(&key(KeyCode::Up), &tx);
417 assert_eq!(section.list_state.selected(), Some(2));
418 }
419
420 #[test]
421 fn enter_sends_workspace_switched() {
422 let settings = make_settings(vec![("a", "/a"), ("b", "/b")], "a");
423 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
424 let mut section = WorkspacesSection::new(&settings);
425 section.list_state.select(Some(1));
427 section.handle_input(&key(KeyCode::Enter), &tx);
428 let msg = rx.try_recv().expect("should send event");
429 assert!(matches!(msg, AppEvent::WorkspaceSwitched(name) if name == "b"));
430 }
431
432 #[test]
433 fn enter_on_current_does_not_send_event() {
434 let settings = make_settings(vec![("a", "/a"), ("b", "/b")], "a");
435 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
436 let mut section = WorkspacesSection::new(&settings);
437 section.list_state.select(Some(0));
439 section.handle_input(&key(KeyCode::Enter), &tx);
440 assert!(rx.try_recv().is_err());
441 }
442
443 #[test]
444 fn n_enters_creating_mode() {
445 let settings = make_settings(vec![("a", "/a")], "a");
446 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
447 let mut section = WorkspacesSection::new(&settings);
448 section.handle_input(&key(KeyCode::Char('n')), &tx);
449 assert_eq!(*section.mode(), Mode::Creating);
450 }
451
452 #[test]
453 fn creating_mode_collects_text_and_sends_file_browser() {
454 let settings = make_settings(vec![("a", "/a")], "a");
455 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
456 let mut section = WorkspacesSection::new(&settings);
457 section.handle_input(&key(KeyCode::Char('n')), &tx);
458
459 section.handle_input(&key(KeyCode::Char('t')), &tx);
460 section.handle_input(&key(KeyCode::Char('e')), &tx);
461 section.handle_input(&key(KeyCode::Char('s')), &tx);
462 section.handle_input(&key(KeyCode::Char('t')), &tx);
463 assert_eq!(section.input(), "test");
464
465 section.handle_input(&key(KeyCode::Enter), &tx);
466 let msg = rx.try_recv().expect("should send OpenFileBrowser");
467 assert!(matches!(msg, AppEvent::OpenFileBrowser));
468 }
469
470 #[test]
471 fn creating_empty_name_does_not_send_file_browser() {
472 let settings = make_settings(vec![("a", "/a")], "a");
473 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
474 let mut section = WorkspacesSection::new(&settings);
475 section.handle_input(&key(KeyCode::Char('n')), &tx);
476 section.handle_input(&key(KeyCode::Enter), &tx);
477 assert!(rx.try_recv().is_err());
478 }
479
480 #[test]
481 fn esc_cancels_creating() {
482 let settings = make_settings(vec![("a", "/a")], "a");
483 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
484 let mut section = WorkspacesSection::new(&settings);
485 section.handle_input(&key(KeyCode::Char('n')), &tx);
486 section.handle_input(&key(KeyCode::Char('x')), &tx);
487 section.handle_input(&key(KeyCode::Esc), &tx);
488 assert_eq!(*section.mode(), Mode::Normal);
489 assert!(section.input().is_empty());
490 }
491
492 #[test]
493 fn r_enters_renaming_mode() {
494 let settings = make_settings(vec![("a", "/a")], "a");
495 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
496 let mut section = WorkspacesSection::new(&settings);
497 section.handle_input(&key(KeyCode::Char('r')), &tx);
498 assert_eq!(*section.mode(), Mode::Renaming);
499 }
500
501 #[test]
502 fn d_enters_confirm_delete() {
503 let settings = make_settings(vec![("a", "/a")], "a");
504 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
505 let mut section = WorkspacesSection::new(&settings);
506 section.handle_input(&key(KeyCode::Char('d')), &tx);
507 assert_eq!(*section.mode(), Mode::ConfirmDelete);
508 }
509
510 #[test]
511 fn confirm_delete_y_stays_in_mode_for_caller() {
512 let settings = make_settings(vec![("a", "/a")], "a");
513 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
514 let mut section = WorkspacesSection::new(&settings);
515 section.handle_input(&key(KeyCode::Char('d')), &tx);
516 let result = section.handle_input(&key(KeyCode::Char('y')), &tx);
517 assert!(result.is_consumed());
518 assert_eq!(*section.mode(), Mode::ConfirmDelete);
520 }
521
522 #[test]
523 fn confirm_delete_n_cancels() {
524 let settings = make_settings(vec![("a", "/a")], "a");
525 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
526 let mut section = WorkspacesSection::new(&settings);
527 section.handle_input(&key(KeyCode::Char('d')), &tx);
528 section.handle_input(&key(KeyCode::Char('n')), &tx);
529 assert_eq!(*section.mode(), Mode::Normal);
530 }
531
532 #[test]
533 fn b_sends_open_file_browser() {
534 let settings = make_settings(vec![("a", "/a")], "a");
535 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
536 let mut section = WorkspacesSection::new(&settings);
537 section.handle_input(&key(KeyCode::Char('b')), &tx);
538 let msg = rx.try_recv().expect("should send event");
539 assert!(matches!(msg, AppEvent::OpenFileBrowser));
540 }
541
542 #[test]
543 fn backspace_deletes_char() {
544 let settings = make_settings(vec![], "");
545 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
546 let mut section = WorkspacesSection::new(&settings);
547 section.mode = Mode::Creating;
548
549 section.handle_input(&key(KeyCode::Char('a')), &tx);
550 section.handle_input(&key(KeyCode::Char('b')), &tx);
551 assert_eq!(section.input(), "ab");
552
553 section.handle_input(&key(KeyCode::Backspace), &tx);
554 assert_eq!(section.input(), "a");
555 }
556
557 #[test]
558 fn text_input_cursor_movement() {
559 let settings = make_settings(vec![], "");
560 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
561 let mut section = WorkspacesSection::new(&settings);
562 section.mode = Mode::Creating;
563
564 section.handle_input(&key(KeyCode::Char('a')), &tx);
565 section.handle_input(&key(KeyCode::Char('b')), &tx);
566 section.handle_input(&key(KeyCode::Char('c')), &tx);
567 assert_eq!(section.input.cursor_char_offset(), 3);
568
569 section.handle_input(&key(KeyCode::Left), &tx);
570 assert_eq!(section.input.cursor_char_offset(), 2);
571
572 section.handle_input(&key(KeyCode::Left), &tx);
573 assert_eq!(section.input.cursor_char_offset(), 1);
574
575 section.handle_input(&key(KeyCode::Right), &tx);
576 assert_eq!(section.input.cursor_char_offset(), 2);
577 }
578
579 #[test]
580 fn refresh_updates_entries() {
581 let settings1 = make_settings(vec![("a", "/a")], "a");
582 let mut section = WorkspacesSection::new(&settings1);
583 assert_eq!(section.entries.len(), 1);
584
585 let settings2 = make_settings(vec![("a", "/a"), ("b", "/b")], "a");
586 section.refresh(&settings2);
587 assert_eq!(section.entries.len(), 2);
588 }
589
590 #[test]
591 fn reset_mode_clears_state() {
592 let settings = make_settings(vec![], "");
593 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
594 let mut section = WorkspacesSection::new(&settings);
595 section.mode = Mode::Creating;
596 section.handle_input(&key(KeyCode::Char('x')), &tx);
597 section.set_error("oops".to_string());
598
599 section.reset_mode();
600 assert_eq!(*section.mode(), Mode::Normal);
601 assert!(section.input().is_empty());
602 assert_eq!(section.input.cursor_char_offset(), 0);
603 assert!(section.error.is_none());
604 }
605
606 #[test]
607 fn empty_settings_shows_no_workspaces() {
608 let settings = AppSettings::default();
609 let section = WorkspacesSection::new(&settings);
610 assert!(section.entries.is_empty());
611 assert!(section.current_path().is_none());
612 }
613
614 #[test]
615 fn renders_without_panic() {
616 use ratatui::Terminal;
617 use ratatui::backend::TestBackend;
618 let settings = make_settings(vec![("notes", "/my/notes")], "notes");
619 let mut section = WorkspacesSection::new(&settings);
620 let theme = Theme::gruvbox_dark();
621 let backend = TestBackend::new(80, 10);
622 let mut terminal = Terminal::new(backend).unwrap();
623 terminal
624 .draw(|f| {
625 section.render(f, f.area(), &theme, true);
626 })
627 .unwrap();
628 let buffer = terminal.backend().buffer().clone();
629 let flat: String = buffer.content.iter().map(|c| c.symbol()).collect();
630 assert!(flat.contains("Workspaces (1)"));
631 assert!(flat.contains("notes"));
632 }
633}