Skip to main content

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