Skip to main content

git_same/tui/screens/
workspaces.rs

1//! Workspace screen — two-pane layout with workspace list (left) and detail (right).
2//!
3//! Left sidebar lists all workspaces plus a "Create New Workspace" entry.
4//! Right panel shows detail for the selected workspace or a create prompt.
5
6use chrono::{DateTime, Utc};
7use ratatui::{
8    layout::{Constraint, Layout, Rect},
9    style::{Color, Modifier, Style},
10    text::{Line, Span},
11    widgets::{Block, Borders, List, ListItem, Paragraph},
12    Frame,
13};
14
15use crossterm::event::{KeyCode, KeyEvent};
16use tokio::sync::mpsc::UnboundedSender;
17
18#[cfg(test)]
19use std::sync::atomic::{AtomicUsize, Ordering};
20
21use crate::banner::render_banner;
22use crate::config::{Config, SyncMode, WorkspaceConfig, WorkspaceManager};
23use crate::setup::state::SetupState;
24use crate::tui::app::{App, Screen, WorkspacePane};
25use crate::tui::event::{AppEvent, BackendMessage};
26
27#[cfg(test)]
28static OPEN_WORKSPACE_FOLDER_CALLS: AtomicUsize = AtomicUsize::new(0);
29
30// ── Key handler ─────────────────────────────────────────────────────────────
31
32pub async fn handle_key(app: &mut App, key: KeyEvent, backend_tx: &UnboundedSender<AppEvent>) {
33    let num_ws = app.workspaces.len();
34    let total_entries = num_ws + 1; // workspaces + "Create New Workspace"
35
36    match key.code {
37        KeyCode::Left => {
38            app.workspace_pane = WorkspacePane::Left;
39        }
40        KeyCode::Right => {
41            app.workspace_pane = WorkspacePane::Right;
42        }
43        KeyCode::Tab => {
44            app.workspace_pane = match app.workspace_pane {
45                WorkspacePane::Left => WorkspacePane::Right,
46                WorkspacePane::Right => WorkspacePane::Left,
47            };
48        }
49        // Right pane: scroll detail when config is expanded
50        KeyCode::Down
51            if app.workspace_pane == WorkspacePane::Right && app.settings_config_expanded =>
52        {
53            app.workspace_detail_scroll = app.workspace_detail_scroll.saturating_add(1);
54        }
55        KeyCode::Up
56            if app.workspace_pane == WorkspacePane::Right && app.settings_config_expanded =>
57        {
58            app.workspace_detail_scroll = app.workspace_detail_scroll.saturating_sub(1);
59        }
60        // Left pane: navigate workspace list
61        KeyCode::Down if app.workspace_pane == WorkspacePane::Left && total_entries > 0 => {
62            app.workspace_index = (app.workspace_index + 1) % total_entries;
63            app.settings_config_expanded = false;
64            app.workspace_detail_scroll = 0;
65        }
66        KeyCode::Up if app.workspace_pane == WorkspacePane::Left && total_entries > 0 => {
67            app.workspace_index = (app.workspace_index + total_entries - 1) % total_entries;
68            app.settings_config_expanded = false;
69            app.workspace_detail_scroll = 0;
70        }
71        KeyCode::Enter => {
72            if app.workspace_index < num_ws {
73                // Select workspace and go to dashboard
74                app.select_workspace(app.workspace_index);
75                app.screen = Screen::Dashboard;
76                app.screen_stack.clear();
77            } else {
78                // "Create New Workspace" entry
79                let default_path = std::env::current_dir()
80                    .map(|p| crate::setup::state::tilde_collapse(&p.to_string_lossy()))
81                    .unwrap_or_else(|_| "~/Git-Same/GitHub".to_string());
82                app.setup_state = Some(SetupState::new(&default_path));
83                app.navigate_to(Screen::WorkspaceSetup);
84            }
85        }
86        KeyCode::Char('c') if app.workspace_index < num_ws => {
87            app.workspace_pane = WorkspacePane::Right;
88            app.settings_config_expanded = !app.settings_config_expanded;
89            app.workspace_detail_scroll = 0;
90        }
91        KeyCode::Char('n') => {
92            // Shortcut to create workspace
93            let default_path = std::env::current_dir()
94                .map(|p| crate::setup::state::tilde_collapse(&p.to_string_lossy()))
95                .unwrap_or_else(|_| "~/Git-Same/GitHub".to_string());
96            app.setup_state = Some(SetupState::new(&default_path));
97            app.navigate_to(Screen::WorkspaceSetup);
98        }
99        KeyCode::Char('d') if app.workspace_index < num_ws => {
100            // Set default workspace
101            if let Some(ws) = app.workspaces.get(app.workspace_index) {
102                let ws_path = crate::config::workspace::tilde_collapse_path(&ws.root_path);
103                let current_default = app.config.default_workspace.as_deref();
104                if current_default == Some(ws_path.as_str()) {
105                    // Already default, do nothing
106                    return;
107                }
108                let new_default = Some(ws_path);
109                let tx = backend_tx.clone();
110                let default_clone = new_default.clone();
111                tokio::spawn(async move {
112                    match Config::save_default_workspace(default_clone.as_deref()) {
113                        Ok(()) => {
114                            let _ = tx.send(AppEvent::Backend(
115                                BackendMessage::DefaultWorkspaceUpdated(default_clone),
116                            ));
117                        }
118                        Err(e) => {
119                            let _ = tx.send(AppEvent::Backend(
120                                BackendMessage::DefaultWorkspaceError(format!("{}", e)),
121                            ));
122                        }
123                    }
124                });
125            }
126        }
127        KeyCode::Char('f') if app.workspace_index < num_ws => {
128            // Open workspace folder
129            if let Some(ws) = app.workspaces.get(app.workspace_index) {
130                let path = ws.expanded_base_path();
131                open_workspace_folder(&path);
132            }
133        }
134        _ => {}
135    }
136}
137
138#[cfg(all(not(test), target_os = "macos"))]
139fn open_workspace_folder(path: &std::path::Path) {
140    let _ = std::process::Command::new("open").arg(path).spawn();
141}
142
143#[cfg(all(not(test), target_os = "linux"))]
144fn open_workspace_folder(path: &std::path::Path) {
145    let _ = std::process::Command::new("xdg-open").arg(path).spawn();
146}
147
148#[cfg(all(not(test), target_os = "windows"))]
149fn open_workspace_folder(path: &std::path::Path) {
150    let _ = std::process::Command::new("explorer").arg(path).spawn();
151}
152
153#[cfg(all(
154    not(test),
155    not(any(target_os = "macos", target_os = "linux", target_os = "windows"))
156))]
157fn open_workspace_folder(_path: &std::path::Path) {}
158
159#[cfg(test)]
160fn open_workspace_folder(_path: &std::path::Path) {
161    OPEN_WORKSPACE_FOLDER_CALLS.fetch_add(1, Ordering::SeqCst);
162}
163
164#[cfg(test)]
165fn take_open_workspace_folder_call_count() -> usize {
166    OPEN_WORKSPACE_FOLDER_CALLS.swap(0, Ordering::SeqCst)
167}
168
169// ── Render ──────────────────────────────────────────────────────────────────
170
171pub fn render(app: &App, frame: &mut Frame) {
172    let chunks = Layout::vertical([
173        Constraint::Length(6), // Banner
174        Constraint::Length(3), // Title
175        Constraint::Min(5),    // Content (two panes)
176        Constraint::Length(2), // Bottom actions (2 lines)
177    ])
178    .split(frame.area());
179
180    render_banner(frame, chunks[0]);
181
182    // Title
183    let title = Paragraph::new(Line::from(vec![Span::styled(
184        " Workspaces ",
185        Style::default()
186            .fg(Color::Cyan)
187            .add_modifier(Modifier::BOLD),
188    )]))
189    .block(
190        Block::default()
191            .borders(Borders::ALL)
192            .border_style(Style::default().fg(Color::DarkGray)),
193    )
194    .centered();
195    frame.render_widget(title, chunks[1]);
196
197    // Two-pane split
198    let panes = Layout::horizontal([Constraint::Percentage(25), Constraint::Percentage(75)])
199        .split(chunks[2]);
200
201    render_workspace_nav(app, frame, panes[0]);
202
203    if app.workspace_index < app.workspaces.len() {
204        if let Some(ws) = app.workspaces.get(app.workspace_index) {
205            render_workspace_detail(app, ws, frame, panes[1]);
206        }
207    } else {
208        render_create_workspace_detail(frame, panes[1]);
209    }
210
211    render_bottom_actions(app, frame, chunks[3]);
212}
213
214fn render_workspace_nav(app: &App, frame: &mut Frame, area: Rect) {
215    let dim = Style::default().fg(Color::DarkGray);
216    let mut items: Vec<ListItem> = Vec::new();
217
218    if app.workspaces.is_empty() {
219        items.push(ListItem::new(Line::from(Span::styled(
220            "    (no workspaces)",
221            dim,
222        ))));
223    }
224
225    if !app.workspaces.is_empty() {
226        items.push(ListItem::new(Line::from("")));
227    }
228
229    for (i, ws) in app.workspaces.iter().enumerate() {
230        let selected = app.workspace_index == i;
231        let is_active = app
232            .active_workspace
233            .as_ref()
234            .map(|aw| aw.root_path == ws.root_path)
235            .unwrap_or(false);
236        let ws_path = crate::config::workspace::tilde_collapse_path(&ws.root_path);
237        let is_default = app.config.default_workspace.as_deref() == Some(ws_path.as_str());
238
239        let folder_name = ws
240            .root_path
241            .file_name()
242            .and_then(|f| f.to_str())
243            .unwrap_or(ws_path.as_str());
244
245        let (marker, style) = if selected {
246            (
247                ">",
248                Style::default()
249                    .fg(Color::Cyan)
250                    .add_modifier(Modifier::BOLD),
251            )
252        } else {
253            (" ", Style::default())
254        };
255
256        let mut spans = vec![
257            Span::styled(format!("  {} ", marker), style),
258            Span::styled(folder_name.to_string(), style),
259        ];
260
261        if is_active {
262            spans.push(Span::styled(
263                " \u{25CF}",
264                Style::default()
265                    .fg(Color::Rgb(21, 128, 61))
266                    .add_modifier(Modifier::BOLD),
267            ));
268        }
269        if is_default {
270            spans.push(Span::styled(
271                " (default)",
272                Style::default().fg(Color::Rgb(21, 128, 61)),
273            ));
274        }
275
276        items.push(ListItem::new(Line::from(spans)));
277    }
278
279    // Spacer before Create entry
280    if !app.workspaces.is_empty() {
281        items.push(ListItem::new(Line::from("")));
282    }
283
284    // "Create New Workspace" entry
285    let create_selected = app.workspace_index == app.workspaces.len();
286    let (create_marker, create_style) = if create_selected {
287        (
288            ">",
289            Style::default()
290                .fg(Color::Cyan)
291                .add_modifier(Modifier::BOLD),
292        )
293    } else {
294        (" ", Style::default().fg(Color::Rgb(21, 128, 61)))
295    };
296    items.push(ListItem::new(Line::from(vec![
297        Span::styled(format!("  {} ", create_marker), create_style),
298        Span::styled("Create New Workspace [n]", create_style),
299    ])));
300
301    let list = List::new(items).block(
302        Block::default()
303            .borders(Borders::ALL)
304            .border_style(Style::default().fg(Color::DarkGray)),
305    );
306    frame.render_widget(list, area);
307}
308
309fn render_workspace_detail(app: &App, ws: &WorkspaceConfig, frame: &mut Frame, area: Rect) {
310    let dim = Style::default().fg(Color::DarkGray);
311    let section_style = Style::default()
312        .fg(Color::White)
313        .add_modifier(Modifier::BOLD);
314    let val_style = Style::default().fg(Color::White);
315    let key_style = Style::default()
316        .fg(Color::Rgb(37, 99, 235))
317        .add_modifier(Modifier::BOLD);
318
319    let ws_tilde_path = crate::config::workspace::tilde_collapse_path(&ws.root_path);
320
321    let is_default = app
322        .config
323        .default_workspace
324        .as_deref()
325        .map(|d| d == ws_tilde_path)
326        .unwrap_or(false);
327
328    let is_active = app
329        .active_workspace
330        .as_ref()
331        .map(|aw| aw.root_path == ws.root_path)
332        .unwrap_or(false);
333
334    let full_path = ws.root_path.display().to_string();
335
336    let config_file = WorkspaceManager::dot_dir(&ws.root_path)
337        .join("config.toml")
338        .display()
339        .to_string();
340
341    let cache_file = WorkspaceManager::cache_path(&ws.root_path)
342        .display()
343        .to_string();
344
345    let username = if ws.username.is_empty() {
346        "\u{2014}".to_string()
347    } else {
348        ws.username.clone()
349    };
350
351    let sync_mode = ws
352        .sync_mode
353        .map(sync_mode_name)
354        .map(ToString::to_string)
355        .unwrap_or_else(|| format!("{} (global default)", sync_mode_name(app.config.sync_mode)));
356
357    let concurrency = ws
358        .concurrency
359        .map(|c| c.to_string())
360        .unwrap_or_else(|| format!("{} (global default)", app.config.concurrency));
361
362    let (last_synced_relative, last_synced_absolute) =
363        format_last_synced(ws.last_synced.as_deref());
364
365    let default_label = if is_default { "Yes" } else { "No" };
366    let active_label = if is_active { "Yes" } else { "No" };
367
368    let folder_name_owned = ws
369        .root_path
370        .file_name()
371        .and_then(|f| f.to_str())
372        .unwrap_or(ws_tilde_path.as_str())
373        .to_string();
374
375    let mut lines = vec![
376        Line::from(""),
377        Line::from(Span::styled(
378            format!("  Workspace: {}", folder_name_owned),
379            section_style,
380        )),
381        Line::from(""),
382    ];
383
384    lines.push(detail_row_with_hint(
385        area,
386        "Active",
387        active_label,
388        Some(("[Enter]", "Select Workspace")),
389        dim,
390        val_style,
391        key_style,
392    ));
393    lines.push(detail_row_with_hint(
394        area,
395        "Default",
396        default_label,
397        if is_default {
398            None
399        } else {
400            Some(("[d]", "Set default"))
401        },
402        dim,
403        val_style,
404        key_style,
405    ));
406    lines.push(Line::from(vec![
407        Span::styled(format!("    {:<14}", "Provider"), dim),
408        Span::styled(ws.provider.kind.display_name().to_string(), val_style),
409    ]));
410
411    lines.push(Line::from(""));
412    lines.push(Line::from(Span::styled("  Paths", section_style)));
413    lines.push(Line::from(""));
414    lines.push(detail_row_with_hint(
415        area,
416        "Path",
417        &ws_tilde_path,
418        None,
419        dim,
420        val_style,
421        key_style,
422    ));
423    lines.push(detail_row_with_hint(
424        area,
425        "Full path",
426        &full_path,
427        Some(("[f]", "Open Finder Folder")),
428        dim,
429        val_style,
430        key_style,
431    ));
432    lines.push(detail_row_with_hint(
433        area,
434        "Config",
435        &config_file,
436        None,
437        dim,
438        val_style,
439        key_style,
440    ));
441    lines.push(Line::from(vec![
442        Span::styled(format!("    {:<14}", "Cache file"), dim),
443        Span::styled(cache_file, val_style),
444    ]));
445
446    lines.push(Line::from(""));
447    lines.push(Line::from(Span::styled("  Sync", section_style)));
448    lines.push(Line::from(""));
449    lines.push(Line::from(vec![
450        Span::styled(format!("    {:<14}", "Sync mode"), dim),
451        Span::styled(sync_mode, val_style),
452    ]));
453    lines.push(Line::from(vec![
454        Span::styled(format!("    {:<14}", "Concurrency"), dim),
455        Span::styled(concurrency, val_style),
456    ]));
457    lines.push(Line::from(vec![
458        Span::styled(format!("    {:<14}", "Last synced"), dim),
459        Span::styled(last_synced_relative, val_style),
460    ]));
461    if let Some(absolute) = last_synced_absolute {
462        lines.push(Line::from(vec![
463            Span::styled(format!("    {:<14}", ""), dim),
464            Span::styled(absolute, val_style),
465        ]));
466    }
467
468    lines.push(Line::from(""));
469    lines.push(Line::from(Span::styled("  Account", section_style)));
470    lines.push(Line::from(""));
471    lines.push(Line::from(vec![
472        Span::styled(format!("    {:<14}", "Username"), dim),
473        Span::styled(username, val_style),
474    ]));
475    let org_lines = wrap_comma_separated_values(&ws.orgs, field_value_width(area, 14));
476    if let Some((first, rest)) = org_lines.split_first() {
477        lines.push(Line::from(vec![
478            Span::styled(format!("    {:<14}", "Organizations"), dim),
479            Span::styled(first.as_str(), val_style),
480        ]));
481        for line in rest {
482            lines.push(Line::from(vec![
483                Span::styled(format!("    {:<14}", ""), dim),
484                Span::styled(line.as_str(), val_style),
485            ]));
486        }
487    }
488
489    // Config content section (collapsible)
490    lines.push(Line::from(""));
491    if app.settings_config_expanded {
492        lines.push(section_line_with_hint(
493            area,
494            "\u{25BC} Config",
495            "[c]",
496            "Collapse config file",
497            section_style,
498            dim,
499            key_style,
500        ));
501        lines.push(Line::from(""));
502        match ws.to_toml() {
503            Ok(toml) => {
504                for toml_line in toml.lines() {
505                    lines.push(Line::from(Span::styled(format!("    {}", toml_line), dim)));
506                }
507            }
508            Err(_) => {
509                lines.push(Line::from(Span::styled(
510                    "    (failed to serialize config)",
511                    dim,
512                )));
513            }
514        }
515    } else {
516        lines.push(section_line_with_hint(
517            area,
518            "\u{25B6} Config",
519            "[c]",
520            "Expand config file",
521            section_style,
522            dim,
523            key_style,
524        ));
525    }
526
527    let content = Paragraph::new(lines)
528        .block(
529            Block::default()
530                .borders(Borders::ALL)
531                .border_style(Style::default().fg(Color::DarkGray)),
532        )
533        .scroll((app.workspace_detail_scroll, 0));
534    frame.render_widget(content, area);
535}
536
537fn sync_mode_name(mode: SyncMode) -> &'static str {
538    match mode {
539        SyncMode::Fetch => "fetch",
540        SyncMode::Pull => "pull",
541    }
542}
543
544fn detail_row_with_hint(
545    area: Rect,
546    label: &str,
547    value: &str,
548    hint: Option<(&str, &str)>,
549    dim: Style,
550    val_style: Style,
551    key_style: Style,
552) -> Line<'static> {
553    let right_padding = 2usize;
554    let label_text = format!("    {:<14}", label);
555    let mut spans = vec![
556        Span::styled(label_text.clone(), dim),
557        Span::styled(value.to_string(), val_style),
558    ];
559
560    if let Some((hint_key, hint_label)) = hint {
561        let content_width = area.width.saturating_sub(2) as usize;
562        let left_width = label_text.chars().count() + value.chars().count();
563        let hint_width = hint_key.chars().count() + 1 + hint_label.chars().count() + right_padding;
564        let gap = content_width.saturating_sub(left_width + hint_width).max(1);
565
566        spans.push(Span::raw(" ".repeat(gap)));
567        spans.push(Span::styled(hint_key.to_string(), key_style));
568        spans.push(Span::styled(format!(" {}", hint_label), dim));
569        spans.push(Span::raw(" ".repeat(right_padding)));
570    }
571
572    Line::from(spans)
573}
574
575fn section_line_with_hint(
576    area: Rect,
577    section: &str,
578    hint_key: &str,
579    hint_label: &str,
580    section_style: Style,
581    dim: Style,
582    key_style: Style,
583) -> Line<'static> {
584    let right_padding = 2usize;
585    let section_text = format!("  {}", section);
586    let content_width = area.width.saturating_sub(2) as usize;
587    let left_width = section_text.chars().count();
588    let hint_width = hint_key.chars().count() + 1 + hint_label.chars().count() + right_padding;
589    let gap = content_width.saturating_sub(left_width + hint_width).max(1);
590
591    Line::from(vec![
592        Span::styled(section_text, section_style),
593        Span::raw(" ".repeat(gap)),
594        Span::styled(hint_key.to_string(), key_style),
595        Span::styled(format!(" {}", hint_label), dim),
596        Span::raw(" ".repeat(right_padding)),
597    ])
598}
599
600fn format_last_synced(raw: Option<&str>) -> (String, Option<String>) {
601    let Some(raw) = raw else {
602        return ("never".to_string(), None);
603    };
604
605    match DateTime::parse_from_rfc3339(raw) {
606        Ok(dt) => {
607            let absolute = dt.format("%Y-%m-%d %H:%M:%S").to_string();
608            let duration = Utc::now().signed_duration_since(dt);
609            let relative = if duration.num_days() > 30 {
610                format!("about {}mo ago", duration.num_days() / 30)
611            } else if duration.num_days() > 0 {
612                format!("about {}d ago", duration.num_days())
613            } else if duration.num_hours() > 0 {
614                format!("about {}h ago", duration.num_hours())
615            } else if duration.num_minutes() > 0 {
616                format!("about {} min ago", duration.num_minutes())
617            } else {
618                "just now".to_string()
619            };
620            (relative, Some(absolute))
621        }
622        Err(_) => (raw.to_string(), None),
623    }
624}
625
626fn field_value_width(area: Rect, label_width: usize) -> usize {
627    let content_width = area.width.saturating_sub(2) as usize;
628    let prefix_width = 4 + label_width;
629    content_width.saturating_sub(prefix_width).max(16)
630}
631
632fn wrap_comma_separated_values(values: &[String], max_width: usize) -> Vec<String> {
633    if values.is_empty() {
634        return vec!["all".to_string()];
635    }
636
637    let mut lines = Vec::new();
638    let mut current = String::new();
639
640    for value in values {
641        if current.is_empty() {
642            current.push_str(value);
643            continue;
644        }
645
646        if current.len() + 2 + value.len() <= max_width {
647            current.push_str(", ");
648            current.push_str(value);
649        } else {
650            lines.push(current);
651            current = value.clone();
652        }
653    }
654
655    if !current.is_empty() {
656        lines.push(current);
657    }
658
659    lines
660}
661
662fn render_create_workspace_detail(frame: &mut Frame, area: Rect) {
663    let dim = Style::default().fg(Color::DarkGray);
664    let section_style = Style::default()
665        .fg(Color::White)
666        .add_modifier(Modifier::BOLD);
667    let key_style = Style::default()
668        .fg(Color::Rgb(37, 99, 235))
669        .add_modifier(Modifier::BOLD);
670
671    let lines = vec![
672        Line::from(""),
673        section_line_with_hint(
674            area,
675            "New Workspace",
676            "[n]",
677            "Create New Workspace",
678            section_style,
679            dim,
680            key_style,
681        ),
682        Line::from(""),
683        Line::from(Span::styled(
684            "    Press Enter to launch the Setup Wizard",
685            dim,
686        )),
687        Line::from(Span::styled("    and configure a new workspace.", dim)),
688        Line::from(""),
689        Line::from(Span::styled("    The wizard will guide you through:", dim)),
690        Line::from(Span::styled(
691            "      \u{2022} Choosing a base directory",
692            dim,
693        )),
694        Line::from(Span::styled(
695            "      \u{2022} Connecting to a provider (GitHub)",
696            dim,
697        )),
698        Line::from(Span::styled(
699            "      \u{2022} Selecting organizations to sync",
700            dim,
701        )),
702    ];
703
704    let content = Paragraph::new(lines).block(
705        Block::default()
706            .borders(Borders::ALL)
707            .border_style(Style::default().fg(Color::DarkGray)),
708    );
709    frame.render_widget(content, area);
710}
711
712fn render_bottom_actions(app: &App, frame: &mut Frame, area: Rect) {
713    let rows = Layout::vertical([
714        Constraint::Length(1), // Actions
715        Constraint::Length(1), // Navigation
716    ])
717    .split(area);
718
719    let dim = Style::default().fg(Color::DarkGray);
720    let key_style = Style::default()
721        .fg(Color::Rgb(37, 99, 235))
722        .add_modifier(Modifier::BOLD);
723
724    // Line 1: intentionally blank (action hints are shown inline in the right panel)
725    let actions = Paragraph::new(vec![Line::from("")]).centered();
726
727    // Line 2: Navigation — left (quit, back) and right (arrows)
728    let nav_cols =
729        Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(rows[1]);
730
731    let left_spans = vec![
732        Span::raw(" "),
733        Span::styled("[q]", key_style),
734        Span::styled(" Quit", dim),
735        Span::raw("   "),
736        Span::styled("[Esc]", key_style),
737        Span::styled(" Back", dim),
738    ];
739
740    let right_spans = if app.workspace_pane == WorkspacePane::Right
741        && app.workspace_index < app.workspaces.len()
742        && app.settings_config_expanded
743    {
744        vec![
745            Span::styled("[\u{2190}]", key_style),
746            Span::raw(" "),
747            Span::styled("[\u{2192}]", key_style),
748            Span::styled(" Panel", dim),
749            Span::raw("   "),
750            Span::styled("[\u{2191}]", key_style),
751            Span::raw(" "),
752            Span::styled("[\u{2193}]", key_style),
753            Span::styled(" Scroll", dim),
754            Span::raw("   "),
755            Span::styled("[c]", key_style),
756            Span::styled(" Collapse", dim),
757            Span::raw("  "),
758        ]
759    } else if app.workspace_pane == WorkspacePane::Left {
760        vec![
761            Span::styled("[\u{2190}]", key_style),
762            Span::raw(" "),
763            Span::styled("[\u{2192}]", key_style),
764            Span::styled(" Panel", dim),
765            Span::raw("   "),
766            Span::styled("[\u{2191}]", key_style),
767            Span::raw(" "),
768            Span::styled("[\u{2193}]", key_style),
769            Span::styled(" Move", dim),
770            Span::raw("   "),
771            Span::styled("[Enter]", key_style),
772            Span::styled(" Select", dim),
773            Span::raw("  "),
774        ]
775    } else {
776        vec![
777            Span::styled("[\u{2190}]", key_style),
778            Span::raw(" "),
779            Span::styled("[\u{2192}]", key_style),
780            Span::styled(" Panel", dim),
781            Span::raw("   "),
782            Span::styled("[c]", key_style),
783            Span::styled(" Expand", dim),
784            Span::raw("   "),
785            Span::styled("[Enter]", key_style),
786            Span::styled(" Select", dim),
787            Span::raw("  "),
788        ]
789    };
790
791    frame.render_widget(actions, rows[0]);
792    frame.render_widget(Paragraph::new(vec![Line::from(left_spans)]), nav_cols[0]);
793    frame.render_widget(
794        Paragraph::new(vec![Line::from(right_spans)]).right_aligned(),
795        nav_cols[1],
796    );
797}
798
799#[cfg(test)]
800#[path = "workspaces_tests.rs"]
801mod tests;