use ratatui::{
buffer::Buffer,
layout::{Constraint, Direction, Layout, Rect},
widgets::{Clear, StatefulWidget, Widget},
};
use throbber_widgets_tui::{Throbber, WhichUse};
use super::items::{IssueItem, SessionItem, WorktreeItem};
use super::state::{PopupSection, WorkspacePopupState};
use super::{PopupSizing, SizeHint};
use crate::tui::components::{SelectableList, TextInput};
pub struct WorkspacePopup<'a> {
sessions: &'a [SessionItem],
worktrees: &'a [WorktreeItem],
issues: &'a [IssueItem],
input: &'a str,
cursor: usize,
section: PopupSection,
}
impl<'a> WorkspacePopup<'a> {
#[must_use]
pub fn new(
sessions: &'a [SessionItem],
worktrees: &'a [WorktreeItem],
issues: &'a [IssueItem],
input: &'a str,
cursor: usize,
section: PopupSection,
) -> Self {
Self {
sessions,
worktrees,
issues,
input,
cursor,
section,
}
}
}
impl PopupSizing for WorkspacePopup<'_> {
#[allow(clippy::cast_possible_truncation)] fn size_hint(&self) -> SizeHint {
let sessions_height = self.sessions.len().clamp(1, 5);
let worktrees_height = self.worktrees.len().clamp(1, 5);
let issues_height = self.issues.len().clamp(1, 5);
let min_height = (3 + sessions_height + worktrees_height + issues_height + 6) as u16;
SizeHint::percent(80, 80)
.with_min_width(50)
.with_min_height(min_height.min(25))
}
}
impl StatefulWidget for WorkspacePopup<'_> {
type State = WorkspacePopupState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
Clear.render(area, buf);
let has_spinner = state.is_loading();
let constraints = if has_spinner {
vec![
Constraint::Length(3), Constraint::Percentage(30), Constraint::Percentage(30), Constraint::Percentage(30), Constraint::Length(1), ]
} else {
vec![
Constraint::Length(3), Constraint::Percentage(30), Constraint::Percentage(30), Constraint::Percentage(30), ]
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
let input_widget = TextInput::new(self.input, self.cursor, "New Branch (Enter: create)")
.focused(self.section == PopupSection::BranchInput);
input_widget.render(chunks[0], buf);
let sessions_list =
SelectableList::new(self.sessions, "Sessions (Enter: switch, d: close)")
.focused(self.section == PopupSection::Sessions);
sessions_list.render(chunks[1], buf, &mut state.session_list);
let worktrees_list = SelectableList::new(
self.worktrees,
"Worktrees (Enter: adopt, d: delete, p: pull)",
)
.focused(self.section == PopupSection::Worktrees);
worktrees_list.render(chunks[2], buf, &mut state.worktree_list);
let issues_list = SelectableList::new(self.issues, "Issues (Enter: start work)")
.focused(self.section == PopupSection::Issues);
issues_list.render(chunks[3], buf, &mut state.issue_list);
if has_spinner {
let throbber = Throbber::default()
.label(state.loading_message())
.style(ratatui::style::Style::default().fg(ratatui::style::Color::Cyan))
.throbber_style(ratatui::style::Style::default().fg(ratatui::style::Color::Yellow))
.use_type(WhichUse::Spin);
StatefulWidget::render(throbber, chunks[4], buf, &mut state.throbber_state);
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::tui::tabs::StatusIndicator;
use crate::tui::test_utils::buffer_to_text;
use crate::worktree::GitWorktreeStatus;
use std::path::PathBuf;
#[test]
fn workspace_popup_renders_empty() {
let sessions: Vec<SessionItem> = vec![];
let worktrees: Vec<WorktreeItem> = vec![];
let popup =
WorkspacePopup::new(&sessions, &worktrees, &[], "", 0, PopupSection::BranchInput);
let area = Rect::new(0, 0, 60, 20);
let mut buf = Buffer::empty(area);
let mut state = WorkspacePopupState::new();
popup.render(area, &mut buf, &mut state);
let output = buffer_to_text(&buf);
assert!(output.contains("New Branch"));
assert!(output.contains("Sessions"));
assert!(output.contains("Worktrees"));
}
#[test]
fn workspace_popup_renders_with_sessions() {
let sessions = vec![
SessionItem::new("session-1", StatusIndicator::Running),
SessionItem::new("session-2", StatusIndicator::Terminated),
];
let worktrees: Vec<WorktreeItem> = vec![];
let popup = WorkspacePopup::new(&sessions, &worktrees, &[], "", 0, PopupSection::Sessions);
let area = Rect::new(0, 0, 60, 20);
let mut buf = Buffer::empty(area);
let mut state = WorkspacePopupState::new();
popup.render(area, &mut buf, &mut state);
let output = buffer_to_text(&buf);
assert!(output.contains("session-1"));
assert!(output.contains("session-2"));
}
#[test]
fn workspace_popup_renders_sessions_with_branches() {
let sessions = vec![
SessionItem::new("session-1", StatusIndicator::Running)
.branch(Some("feature/test".to_string())),
SessionItem::new("session-2", StatusIndicator::Terminated)
.branch(Some("bugfix/issue".to_string())),
];
let worktrees: Vec<WorktreeItem> = vec![];
let popup = WorkspacePopup::new(&sessions, &worktrees, &[], "", 0, PopupSection::Sessions);
let area = Rect::new(0, 0, 60, 20);
let mut buf = Buffer::empty(area);
let mut state = WorkspacePopupState::new();
popup.render(area, &mut buf, &mut state);
let output = buffer_to_text(&buf);
assert!(output.contains("session-1"));
assert!(output.contains("feature/test"));
assert!(output.contains("session-2"));
assert!(output.contains("bugfix/issue"));
}
#[test]
fn workspace_popup_renders_pending_indicator() {
let sessions = vec![
SessionItem::new("session-1", StatusIndicator::Running).pending(true),
SessionItem::new("session-2", StatusIndicator::Running).pending(false),
];
let worktrees: Vec<WorktreeItem> = vec![];
let popup = WorkspacePopup::new(&sessions, &worktrees, &[], "", 0, PopupSection::Sessions);
let area = Rect::new(0, 0, 60, 20);
let mut buf = Buffer::empty(area);
let mut state = WorkspacePopupState::new();
popup.render(area, &mut buf, &mut state);
let output = buffer_to_text(&buf);
assert!(
output.contains("[!]"),
"Expected [!] indicator for pending session"
);
assert_eq!(output.matches("[!]").count(), 1);
}
#[test]
fn workspace_popup_renders_with_worktrees() {
let sessions: Vec<SessionItem> = vec![];
let worktrees = vec![
WorktreeItem::new(
"tazuna/abc123",
PathBuf::from("/worktrees/abc12345"),
GitWorktreeStatus {
dirty: true,
..Default::default()
},
),
WorktreeItem::new(
"feature/test",
PathBuf::from("/worktrees/def67890"),
GitWorktreeStatus {
ahead: 2,
behind: 1,
..Default::default()
},
),
];
let popup = WorkspacePopup::new(&sessions, &worktrees, &[], "", 0, PopupSection::Worktrees);
let area = Rect::new(0, 0, 60, 20);
let mut buf = Buffer::empty(area);
let mut state = WorkspacePopupState::new();
popup.render(area, &mut buf, &mut state);
let output = buffer_to_text(&buf);
assert!(output.contains("abc12345"));
assert!(output.contains("def67890"));
assert!(output.contains("tazuna/abc123"));
assert!(output.contains("feature/test"));
assert!(output.contains("[*]")); assert!(output.contains("[↑2↓1]")); }
#[test]
fn workspace_popup_renders_input_with_cursor() {
let sessions: Vec<SessionItem> = vec![];
let worktrees: Vec<WorktreeItem> = vec![];
let popup = WorkspacePopup::new(
&sessions,
&worktrees,
&[],
"feature/",
8,
PopupSection::BranchInput,
);
let area = Rect::new(0, 0, 60, 20);
let mut buf = Buffer::empty(area);
let mut state = WorkspacePopupState::new();
popup.render(area, &mut buf, &mut state);
let output = buffer_to_text(&buf);
assert!(output.contains("feature/"));
}
#[test]
fn workspace_popup_section_focus_changes_border() {
let sessions = vec![SessionItem::new("test", StatusIndicator::Running)];
let worktrees: Vec<WorktreeItem> = vec![];
let area = Rect::new(0, 0, 60, 20);
let popup =
WorkspacePopup::new(&sessions, &worktrees, &[], "", 0, PopupSection::BranchInput);
let mut buf = Buffer::empty(area);
let mut state = WorkspacePopupState::new();
popup.render(area, &mut buf, &mut state);
let popup = WorkspacePopup::new(&sessions, &worktrees, &[], "", 0, PopupSection::Sessions);
let mut buf = Buffer::empty(area);
let mut state = WorkspacePopupState::new();
popup.render(area, &mut buf, &mut state);
let popup = WorkspacePopup::new(&sessions, &worktrees, &[], "", 0, PopupSection::Worktrees);
let mut buf = Buffer::empty(area);
let mut state = WorkspacePopupState::new();
popup.render(area, &mut buf, &mut state);
}
#[test]
fn workspace_popup_renders_with_spinner_when_loading() {
use super::super::state::LoadingOperation;
let sessions: Vec<SessionItem> = vec![];
let worktrees: Vec<WorktreeItem> = vec![];
let popup =
WorkspacePopup::new(&sessions, &worktrees, &[], "", 0, PopupSection::BranchInput);
let area = Rect::new(0, 0, 60, 25);
let mut buf = Buffer::empty(area);
let mut state = WorkspacePopupState::new();
state.loading = LoadingOperation::Fetching;
popup.render(area, &mut buf, &mut state);
let output = buffer_to_text(&buf);
assert!(output.contains("Fetching"));
}
#[test]
fn workspace_popup_renders_pulling_spinner() {
use super::super::state::LoadingOperation;
let sessions: Vec<SessionItem> = vec![];
let worktrees: Vec<WorktreeItem> = vec![];
let popup =
WorkspacePopup::new(&sessions, &worktrees, &[], "", 0, PopupSection::BranchInput);
let area = Rect::new(0, 0, 60, 25);
let mut buf = Buffer::empty(area);
let mut state = WorkspacePopupState::new();
state.loading = LoadingOperation::Pulling {
path: PathBuf::from("/test"),
};
popup.render(area, &mut buf, &mut state);
let output = buffer_to_text(&buf);
assert!(output.contains("Pulling"));
}
#[test]
fn workspace_popup_renders_deleting_spinner() {
use super::super::state::LoadingOperation;
let sessions: Vec<SessionItem> = vec![];
let worktrees: Vec<WorktreeItem> = vec![];
let popup =
WorkspacePopup::new(&sessions, &worktrees, &[], "", 0, PopupSection::BranchInput);
let area = Rect::new(0, 0, 60, 25);
let mut buf = Buffer::empty(area);
let mut state = WorkspacePopupState::new();
state.loading = LoadingOperation::Deleting {
path: PathBuf::from("/test"),
};
popup.render(area, &mut buf, &mut state);
let output = buffer_to_text(&buf);
assert!(output.contains("Deleting"));
}
#[test]
fn workspace_popup_renders_with_issues() {
let sessions: Vec<SessionItem> = vec![];
let worktrees: Vec<WorktreeItem> = vec![];
let issues = vec![
IssueItem::new(1, "First issue", "[bug]"),
IssueItem::new(2, "Second issue", "[enhancement]"),
];
let popup =
WorkspacePopup::new(&sessions, &worktrees, &issues, "", 0, PopupSection::Issues);
let area = Rect::new(0, 0, 80, 25);
let mut buf = Buffer::empty(area);
let mut state = WorkspacePopupState::new();
popup.render(area, &mut buf, &mut state);
let output = buffer_to_text(&buf);
assert!(output.contains("#1"));
assert!(output.contains("First issue"));
assert!(output.contains("[bug]"));
}
#[test]
fn workspace_popup_renders_issues_without_labels() {
let sessions: Vec<SessionItem> = vec![];
let worktrees: Vec<WorktreeItem> = vec![];
let issues = vec![IssueItem::new(42, "No labels issue", "")];
let popup =
WorkspacePopup::new(&sessions, &worktrees, &issues, "", 0, PopupSection::Issues);
let area = Rect::new(0, 0, 80, 25);
let mut buf = Buffer::empty(area);
let mut state = WorkspacePopupState::new();
popup.render(area, &mut buf, &mut state);
let output = buffer_to_text(&buf);
assert!(output.contains("#42"));
assert!(output.contains("No labels issue"));
}
#[test]
fn sessions_focused_shows_highlight_symbol() {
let sessions = vec![SessionItem::new("test-session", StatusIndicator::Running)];
let worktrees: Vec<WorktreeItem> = vec![];
let popup = WorkspacePopup::new(
&sessions,
&worktrees,
&[],
"",
0,
PopupSection::Sessions, );
let area = Rect::new(0, 0, 60, 20);
let mut buf = Buffer::empty(area);
let mut state = WorkspacePopupState::new();
state.session_list.select(Some(0));
popup.render(area, &mut buf, &mut state);
let output = buffer_to_text(&buf);
assert!(
output.contains("> "),
"Expected highlight symbol when sessions focused"
);
}
#[test]
fn sessions_unfocused_hides_highlight_symbol() {
let sessions = vec![SessionItem::new("test-session", StatusIndicator::Running)];
let worktrees: Vec<WorktreeItem> = vec![];
let popup = WorkspacePopup::new(
&sessions,
&worktrees,
&[],
"",
0,
PopupSection::Worktrees, );
let area = Rect::new(0, 0, 60, 20);
let mut buf = Buffer::empty(area);
let mut state = WorkspacePopupState::new();
state.session_list.select(Some(0));
popup.render(area, &mut buf, &mut state);
let output = buffer_to_text(&buf);
assert!(
!output.contains("> ●") && !output.contains("> ✗"),
"Unfocused sessions should not show highlight symbol"
);
}
#[test]
fn worktrees_focused_shows_highlight_symbol() {
let sessions: Vec<SessionItem> = vec![];
let worktrees = vec![WorktreeItem::new(
"feature/test",
PathBuf::from("/worktrees/abc12345"),
GitWorktreeStatus::default(),
)];
let popup = WorkspacePopup::new(
&sessions,
&worktrees,
&[],
"",
0,
PopupSection::Worktrees, );
let area = Rect::new(0, 0, 60, 20);
let mut buf = Buffer::empty(area);
let mut state = WorkspacePopupState::new();
state.worktree_list.select(Some(0));
popup.render(area, &mut buf, &mut state);
let output = buffer_to_text(&buf);
assert!(
output.contains("> "),
"Expected highlight symbol when worktrees focused"
);
}
#[test]
fn worktrees_unfocused_hides_highlight_symbol() {
let sessions: Vec<SessionItem> = vec![];
let worktrees = vec![WorktreeItem::new(
"feature/test",
PathBuf::from("/worktrees/abc12345"),
GitWorktreeStatus::default(),
)];
let popup = WorkspacePopup::new(
&sessions,
&worktrees,
&[],
"",
0,
PopupSection::Sessions, );
let area = Rect::new(0, 0, 60, 20);
let mut buf = Buffer::empty(area);
let mut state = WorkspacePopupState::new();
state.worktree_list.select(Some(0));
popup.render(area, &mut buf, &mut state);
let output = buffer_to_text(&buf);
assert!(
!output.contains("> ◆"),
"Unfocused worktrees should not show highlight symbol"
);
}
#[test]
fn issues_focused_shows_highlight_symbol() {
let sessions: Vec<SessionItem> = vec![];
let worktrees: Vec<WorktreeItem> = vec![];
let issues = vec![IssueItem::new(1, "Test issue", "bug")];
let popup = WorkspacePopup::new(
&sessions,
&worktrees,
&issues,
"",
0,
PopupSection::Issues, );
let area = Rect::new(0, 0, 80, 25);
let mut buf = Buffer::empty(area);
let mut state = WorkspacePopupState::new();
state.issue_list.select(Some(0));
popup.render(area, &mut buf, &mut state);
let output = buffer_to_text(&buf);
assert!(
output.contains("> "),
"Expected highlight symbol when issues focused"
);
}
#[test]
fn issues_unfocused_hides_highlight_symbol() {
let sessions: Vec<SessionItem> = vec![];
let worktrees: Vec<WorktreeItem> = vec![];
let issues = vec![IssueItem::new(1, "Test issue", "bug")];
let popup = WorkspacePopup::new(
&sessions,
&worktrees,
&issues,
"",
0,
PopupSection::BranchInput, );
let area = Rect::new(0, 0, 80, 25);
let mut buf = Buffer::empty(area);
let mut state = WorkspacePopupState::new();
state.issue_list.select(Some(0));
popup.render(area, &mut buf, &mut state);
let output = buffer_to_text(&buf);
assert!(
!output.contains("> #"),
"Unfocused issues should not show highlight symbol"
);
}
}