1use 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
30pub 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; 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 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 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 app.select_workspace(app.workspace_index);
75 app.screen = Screen::Dashboard;
76 app.screen_stack.clear();
77 } else {
78 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 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 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 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 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
169pub fn render(app: &App, frame: &mut Frame) {
172 let chunks = Layout::vertical([
173 Constraint::Length(6), Constraint::Length(3), Constraint::Min(5), Constraint::Length(2), ])
178 .split(frame.area());
179
180 render_banner(frame, chunks[0]);
181
182 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 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 if !app.workspaces.is_empty() {
281 items.push(ListItem::new(Line::from("")));
282 }
283
284 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 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), Constraint::Length(1), ])
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 let actions = Paragraph::new(vec![Line::from("")]).centered();
726
727 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;