use super::*;
#[derive(Debug, Clone)]
pub enum SidebarRow {
Header(String),
Gap,
Item(SidebarItem),
}
#[derive(Debug, Clone)]
pub struct SidebarItem {
pub label: String,
pub icon: &'static str,
pub color: Option<ratatui::style::Color>,
pub depth: u8,
pub action: SidebarAction,
pub expanded: Option<bool>,
pub trailing: Option<String>,
pub tone: Tone,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tone {
Subtle,
Normal,
Strong,
}
impl SidebarItem {
pub fn nav(&self) -> Option<Nav> {
match self.action {
SidebarAction::Go(nav) => Some(nav),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SidebarAction {
Go(Nav),
Fold(usize),
SwitchTeam,
}
impl App {
pub fn sidebar_layout(&self) -> Vec<SidebarRow> {
let item =
|label: &str, icon: &'static str, depth: u8, action: SidebarAction, tone: Tone| {
SidebarRow::Item(SidebarItem {
label: label.to_string(),
icon,
color: None,
depth,
action,
expanded: None,
trailing: None,
tone,
})
};
let mut rows = vec![
item(
"My Issues",
"\u{25c9}",
0,
SidebarAction::Go(Nav::MyIssues),
Tone::Normal,
),
item(
"Views",
"\u{2261}",
0,
SidebarAction::Go(Nav::Views),
Tone::Subtle,
),
];
if !self.store.favorites.is_empty() {
rows.push(SidebarRow::Gap);
rows.push(SidebarRow::Header("Favorites".into()));
for (index, fav) in self.store.favorites.iter().enumerate() {
if fav.parent.is_some() {
continue;
}
rows.push(self.favorite_row(index, 0));
if fav.is_folder() && !self.view.sidebar.collapsed_folders.contains(&fav.id) {
for (child, _) in self
.store
.favorites
.iter()
.enumerate()
.filter(|(_, f)| f.parent.as_ref().is_some_and(|p| p.id == fav.id))
{
rows.push(self.favorite_row(child, 1));
}
}
}
}
if let Some(team) = self.current_team() {
let index = self.nav.team;
rows.push(SidebarRow::Gap);
rows.push(SidebarRow::Header("Team".into()));
rows.push(SidebarRow::Item(SidebarItem {
label: team.name.clone(),
icon: "\u{25cf}",
color: team.color.as_deref().and_then(hex_color),
depth: 0,
action: SidebarAction::SwitchTeam,
expanded: None,
trailing: (self.store.teams.len() > 1).then(|| "t \u{21c5}".to_string()),
tone: Tone::Strong,
}));
rows.push(item(
"Issues",
"\u{2263}",
1,
SidebarAction::Go(Nav::Team(index, TeamSection::Issues)),
Tone::Normal,
));
if team.cycles_enabled {
rows.push(item(
"Cycles",
"\u{25d4}",
1,
SidebarAction::Go(Nav::Team(index, TeamSection::Cycles)),
Tone::Normal,
));
}
rows.push(item(
"Projects",
"\u{25a3}",
1,
SidebarAction::Go(Nav::Team(index, TeamSection::Projects)),
Tone::Normal,
));
rows.push(item(
"Views",
"\u{2261}",
1,
SidebarAction::Go(Nav::Team(index, TeamSection::Views)),
Tone::Normal,
));
}
rows
}
fn favorite_row(&self, index: usize, depth: u8) -> SidebarRow {
let fav = &self.store.favorites[index];
let color = fav
.color
.as_deref()
.or_else(|| fav.project.as_ref().and_then(|p| p.color.as_deref()))
.or_else(|| {
fav.issue
.as_ref()
.and_then(|i| i.state.as_ref()?.color.as_deref())
})
.and_then(hex_color);
let icon = match fav.kind.as_str() {
"folder" | "document" => "\u{25a4}",
"project" => "\u{25a3}",
"customView" | "predefinedView" => "\u{2261}",
"issue" => fav
.issue
.as_ref()
.and_then(|i| i.state.as_ref()?.state_type)
.unwrap_or(StateType::Unknown)
.glyph(),
"cycle" => "\u{25d4}",
"label" => "\u{25cf}",
_ => "\u{2022}",
};
let label = match (&fav.issue, fav.kind.as_str()) {
(Some(issue), "issue") => format!("{} {}", issue.identifier, issue.title),
_ => fav.label(),
};
SidebarRow::Item(SidebarItem {
label,
icon,
color,
depth,
action: self.favorite_action(index),
expanded: fav
.is_folder()
.then(|| !self.view.sidebar.collapsed_folders.contains(&fav.id)),
trailing: None,
tone: Tone::Strong,
})
}
pub(super) fn favorite_action(&self, index: usize) -> SidebarAction {
let fav = &self.store.favorites[index];
if fav.is_folder() {
return SidebarAction::Fold(index);
}
if let Some(view) = &fav.custom_view
&& let Some(i) = self.store.custom_views.iter().position(|v| v.id == view.id)
{
return SidebarAction::Go(Nav::View(i));
}
if fav.kind == "predefinedView" {
if fav.predefined_view_type.as_deref() == Some("myIssues") {
return SidebarAction::Go(Nav::MyIssues);
}
let team = fav
.predefined_view_team
.as_ref()
.and_then(|t| self.store.teams.iter().position(|x| x.id == t.id));
let section = match fav.predefined_view_type.as_deref() {
Some("issues" | "allIssues" | "activeIssues" | "backlog") => {
Some(TeamSection::Issues)
}
Some("cycles") => Some(TeamSection::Cycles),
Some("projects") => Some(TeamSection::Projects),
_ => None,
};
if let (Some(team), Some(section)) = (team, section) {
return SidebarAction::Go(Nav::Team(team, section));
}
}
SidebarAction::Go(Nav::Favorite(index))
}
pub(super) fn open_favorite(&mut self, index: usize) {
let Some(fav) = self.store.favorites.get(index).cloned() else {
return;
};
self.view.sidebar.focus = false;
if let Some(project) = fav.project {
self.nav.dest = Nav::Favorite(index);
self.open_project(project);
} else if let Some(cycle) = fav.cycle {
self.nav.dest = Nav::Favorite(index);
self.open_cycle(cycle);
} else if let Some(issue) = fav.issue {
let stub = Issue {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
state: issue.state,
..Issue::default()
};
self.open_issue_from_list(&stub);
self.nav.dest = Nav::Favorite(index);
} else if let Some(url) = fav.url {
self.request(Request::OpenUrl(url));
} else {
self.set_status("This favorite cannot be opened here");
}
}
fn sidebar_stops(&self) -> Vec<usize> {
self.frame
.sidebar_rows
.iter()
.enumerate()
.filter(|(_, row)| matches!(row, SidebarRow::Item(_)))
.map(|(i, _)| i)
.collect()
}
pub fn sidebar_move(&mut self, delta: isize) {
let stops = self.sidebar_stops();
if stops.is_empty() {
return;
}
let current = stops
.iter()
.position(|i| *i == self.view.sidebar.index)
.unwrap_or(0);
let mut next = current;
Self::nav_by(stops.len(), &mut next, delta);
self.view.sidebar.index = stops[next];
}
fn sidebar_item(&self, index: usize) -> Option<&SidebarItem> {
match self.frame.sidebar_rows.get(index) {
Some(SidebarRow::Item(item)) => Some(item),
_ => None,
}
}
pub fn open_favorite_entry(&mut self, index: usize) {
if index < self.store.favorites.len() {
let action = self.favorite_action(index);
self.run_sidebar_action(action);
}
}
pub fn sidebar_activate(&mut self) {
if let Some(action) = self.sidebar_item(self.view.sidebar.index).map(|i| i.action) {
self.run_sidebar_action(action);
}
}
pub fn run_sidebar_action(&mut self, action: SidebarAction) {
match action {
SidebarAction::Go(nav) => self.activate(nav),
SidebarAction::Fold(index) => self.toggle_folder(index),
SidebarAction::SwitchTeam => self.open_team_select(),
}
}
pub fn sidebar_toggle(&mut self) {
if let Some(SidebarAction::Fold(index)) =
self.sidebar_item(self.view.sidebar.index).map(|i| i.action)
{
self.toggle_folder(index);
}
}
pub fn toggle_folder(&mut self, index: usize) {
let Some(id) = self.store.favorites.get(index).map(|f| f.id.clone()) else {
return;
};
if !self.view.sidebar.collapsed_folders.remove(&id) {
self.view.sidebar.collapsed_folders.insert(id);
}
self.frame.sidebar_rows = self.sidebar_layout();
}
pub fn focus_sidebar(&mut self, focused: bool) {
if focused && !self.view.sidebar.visible {
return;
}
self.view.sidebar.focus = focused;
if focused {
if let Some(index) = self.frame.sidebar_rows.iter().position(
|row| matches!(row, SidebarRow::Item(item) if item.nav() == Some(self.nav.dest)),
) {
self.view.sidebar.index = index;
}
}
}
pub fn toggle_sidebar(&mut self) {
self.view.sidebar.visible = !self.view.sidebar.visible;
if !self.view.sidebar.visible {
self.view.sidebar.focus = false;
}
}
}