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