Skip to main content

kimun_notes/components/settings/
workspaces_section.rs

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    /// Sorted list of (name, path, is_current).
25    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        // Preserve selection or default to first
81        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    // ---- private helpers ----
101
102    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            // Caller (SettingsScreen) handles Enter via mode + input().
181            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                // Stays in ConfirmDelete; SettingsScreen reads mode + selected_name
194                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                    // For Creating: signal OpenFileBrowser so SettingsScreen can pick up
215                    if self.mode == Mode::Creating && !self.input.value().trim().is_empty() {
216                        tx.send(AppEvent::OpenFileBrowser).ok();
217                    }
218                    // For both modes the caller inspects mode() + input()
219                    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 fg_muted = theme.fg_muted.to_ratatui();
231        let bg = theme.bg_panel.to_ratatui();
232
233        // Reserve bottom lines: 1 for hint, 1 for error (if any), 2 for border
234        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        // Split inner into: list area and bottom bar(s)
249        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        // --- List ---
259        if self.entries.is_empty() {
260            f.render_widget(
261                Paragraph::new("  No workspaces configured.")
262                    .style(Style::default().fg(fg_muted).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.bg_selected.to_ratatui()));
287
288            f.render_stateful_widget(list, rows[0], &mut self.list_state);
289        }
290
291        // --- Hint line ---
292        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(fg_muted).bg(bg)),
324            rows[hint_idx],
325        );
326
327        // --- Error line ---
328        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        // Sorted alphabetically
388        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        // Wraps
413        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        // Select "b" (index 1)
426        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        // "a" is at index 0 and is current
438        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        // Still in ConfirmDelete so caller can act
519        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}