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                    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        // Sorted alphabetically
393        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        // Wraps
418        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        // Select "b" (index 1)
431        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        // "a" is at index 0 and is current
443        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        // Still in ConfirmDelete so caller can act
524        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}