use crate::core::app::{AppContext, PageState};
use crate::core::config::{Config, LoadedPageConfig, ProjectsBoard};
use crate::core::keymap::{Keymap, ViewAction};
use crate::core::layout::{split_page_frame, PageLayoutConfig};
use crate::core::page::PageAction;
use crate::core::pane::{self, Pane, PaneEvent, PaneSet, PaneShared};
use crate::core::search::SearchState;
use crate::core::ui::status_bar;
use crate::projects::domain::types::{Board, Project, ProjectListCache, RepoInfo};
use crate::projects::domain::{client, disk_cache};
use crate::projects::panes::board::{BoardAction, BoardPane};
use crate::projects::panes::detail::{DetailAction, DetailPane, ItemDetail};
use crate::projects::panes::projects::{ProjectsAction, ProjectsPane};
use anyhow::Result;
use crossterm::event::KeyEvent;
use ratatui::{
layout::Rect,
style::{Color, Style},
text::{Line, Span},
widgets::Paragraph,
Frame,
};
use std::collections::{HashMap, HashSet};
use std::sync::mpsc;
use std::time::{Duration, Instant};
pub const LIST_REFRESH_AFTER: Duration = Duration::from_secs(300);
pub const SCOPE_NOTICE: &str = "gh needs the project scope: run `gh auth refresh -s project`";
pub const NO_LINKED_NOTICE: &str = "No projects are linked to this repository \
(link one from the repository's Projects tab or `gh project link`)";
pub const PINNED_MESSAGE: &str = "board pinned by config (projects-board)";
#[derive(Debug, Clone, Copy)]
pub struct ProjectsPaneIds {
pub projects: usize,
pub board: usize,
pub detail: usize,
}
impl ProjectsPaneIds {
fn from_config(cfg: &LoadedPageConfig) -> Self {
Self {
projects: cfg.resolve_id_expect("projects"),
board: cfg.resolve_id_expect("board"),
detail: cfg.resolve_id_expect("detail"),
}
}
}
pub enum ProjectsBgMessage {
Repo(Result<RepoInfo, String>),
Board {
number: u64,
result: Result<Board, String>,
},
ItemDetail {
key: String,
result: Result<ItemDetail, String>,
},
}
pub struct ProjectsPanes {
pub projects: ProjectsPane,
pub board: BoardPane,
pub detail: DetailPane,
pub ids: ProjectsPaneIds,
}
impl PaneSet for ProjectsPanes {
fn get_mut(&mut self, idx: usize) -> Option<&mut dyn Pane<PaneEvent>> {
if idx == self.ids.projects {
Some(&mut self.projects)
} else if idx == self.ids.board {
Some(&mut self.board)
} else if idx == self.ids.detail {
Some(&mut self.detail)
} else {
None
}
}
}
pub struct ProjectsState {
pub pane: PaneShared,
pub panes: ProjectsPanes,
pub gh_available: Option<bool>,
pub gh_error: Option<String>,
pub scope_missing: bool,
repo: Option<String>,
list_placed: bool,
links_known: bool,
pinned: Option<ProjectsBoard>,
bg_rx: Option<mpsc::Receiver<ProjectsBgMessage>>,
bg_tx: Option<mpsc::Sender<ProjectsBgMessage>>,
initialized: bool,
last_list_refresh: Option<Instant>,
board_cache: HashMap<u64, Board>,
board_inflight: HashSet<u64>,
use_disk_cache: bool,
layout_config: PageLayoutConfig,
view_keymap: Keymap<ViewAction>,
}
impl pane::PageLayout for ProjectsState {
type Panes = ProjectsPanes;
fn page_parts_mut(
&mut self,
) -> (
&mut PaneShared,
&mut Self::Panes,
&Keymap<ViewAction>,
&PageLayoutConfig,
) {
(
&mut self.pane,
&mut self.panes,
&self.view_keymap,
&self.layout_config,
)
}
}
impl ProjectsState {
pub fn new(cfg: &Config) -> Result<Self> {
let page_cfg = cfg.projects_page()?;
let ids = ProjectsPaneIds::from_config(&page_cfg);
let list_placed = page_cfg.is_placed("projects");
let pinned = cfg.projects_board()?;
let _ = page_cfg.resolve_select_bindings();
let projects_km = page_cfg.keymap::<ProjectsAction>("projects")?;
let board_km = page_cfg.keymap::<BoardAction>("board")?;
let detail_km = page_cfg.keymap::<DetailAction>("detail")?;
let view_km = page_cfg.keymap::<ViewAction>("view")?;
let mut projects = ProjectsPane::new(ids.projects, ids.board);
projects.set_keymap(projects_km);
let mut board = BoardPane::new(ids.board, ids.detail, list_placed.then_some(ids.projects));
board.set_keymap(board_km);
let mut detail = DetailPane::new(ids.detail);
detail.set_keymap(detail_km);
let first = if list_placed { ids.projects } else { ids.board };
Ok(Self {
pane: PaneShared {
focused_pane: first,
previous_pane: first,
search: SearchState::new(),
},
panes: ProjectsPanes {
projects,
board,
detail,
ids,
},
gh_available: None,
gh_error: None,
scope_missing: false,
repo: None,
list_placed,
links_known: false,
pinned,
bg_rx: None,
bg_tx: None,
initialized: false,
last_list_refresh: None,
board_cache: HashMap::new(),
board_inflight: HashSet::new(),
use_disk_cache: true,
layout_config: page_cfg.layout,
view_keymap: view_km,
})
}
fn initialize(&mut self) {
if self.initialized {
return;
}
self.initialized = true;
let (tx, rx) = mpsc::channel();
self.bg_tx = Some(tx);
self.bg_rx = Some(rx);
let cached = if self.use_disk_cache {
disk_cache::load_project_list()
} else {
None
};
if let Some(cache) = cached {
self.links_known = true;
self.set_repo(Some(cache.repo));
let projects = self.apply_pin(cache.projects);
self.panes.projects.set_projects(projects);
self.sync_board();
}
self.spawn_list();
}
fn set_repo(&mut self, repo: Option<String>) {
self.repo = repo.filter(|r| !r.is_empty());
self.panes.board.set_repo(self.repo.clone());
}
fn spawn_list(&mut self) {
let Some(tx) = self.bg_tx.clone() else {
return;
};
self.last_list_refresh = Some(Instant::now());
self.panes.projects.set_loading(true);
self.update_notice();
std::thread::spawn(move || {
let _ = tx.send(ProjectsBgMessage::Repo(client::repo_info()));
});
}
fn spawn_board(&mut self, number: u64) {
let Some(tx) = self.bg_tx.clone() else {
return;
};
let Some(owner) = self
.panes
.projects
.items
.iter()
.find(|p| p.number == number)
.map(|p| p.owner.login.clone())
else {
return;
};
if !self.board_inflight.insert(number) {
return;
}
self.panes.board.set_loading(true);
std::thread::spawn(move || {
let result = client::fetch_board(&owner, number);
let _ = tx.send(ProjectsBgMessage::Board { number, result });
});
}
pub fn is_loading(&self) -> bool {
self.panes.projects.is_loading()
|| self.panes.board.is_loading()
|| self.panes.detail.is_loading()
}
fn refresh(&mut self) {
self.gh_error = None;
self.scope_missing = false;
if self.gh_available == Some(false) {
self.gh_available = None;
}
self.board_cache.clear();
self.panes.detail.clear_cache();
self.spawn_list();
if let Some(number) = self.panes.projects.selected_number() {
self.spawn_board(number);
}
if let Some(tx) = self.bg_tx.clone() {
self.panes.detail.reload_current(&tx);
}
}
fn cycle_project(&mut self, forward: bool) {
let n = self.panes.projects.items.len();
if n < 2 {
return;
}
let idx = self.panes.projects.selected_idx;
self.panes.projects.selected_idx = if forward {
(idx + 1) % n
} else {
(idx + n - 1) % n
};
self.sync_board();
}
fn apply_pin(&self, projects: Vec<Project>) -> Vec<Project> {
match &self.pinned {
None => projects,
Some(pin) => projects
.into_iter()
.filter(|p| pin.matches(p.number, &p.title))
.collect(),
}
}
fn update_notice(&mut self) {
let notice = if !self.panes.projects.items.is_empty() {
None
} else if self.panes.projects.is_loading() {
Some("Loading...".to_string())
} else if !self.links_known {
None
} else if let Some(pin) = &self.pinned {
Some(format!(
"projects-board {pin} does not match any project linked to this repository"
))
} else {
Some(NO_LINKED_NOTICE.to_string())
};
self.panes.board.set_notice(notice);
}
fn sync_board(&mut self) {
self.update_notice();
let Some(project) = self.panes.projects.selected().cloned() else {
self.panes.board.clear();
self.panes.detail.show_none();
return;
};
let number = project.number;
self.panes.board.set_project_url(Some(project.url.clone()));
let shown = self.panes.board.board.as_ref().map(|b| b.number);
if shown != Some(number) {
let cached = self.board_cache.get(&number).cloned().or_else(|| {
self.use_disk_cache
.then(|| disk_cache::load_board(number))
.flatten()
});
match cached {
Some(board) => self.panes.board.set_board(board),
None => self.panes.board.clear(),
}
}
if !self.board_cache.contains_key(&number) {
self.spawn_board(number);
}
self.sync_detail();
}
fn sync_detail(&mut self) {
let Some(tx) = self.bg_tx.clone() else {
return;
};
let fields = self
.panes
.board
.board
.as_ref()
.map(|b| b.fields.clone())
.unwrap_or_default();
match self.panes.board.selected_item().cloned() {
Some(item) => self.panes.detail.load(&item, &fields, &tx),
None => self.panes.detail.show_none(),
}
}
fn follow(&mut self, pane_id: usize) {
let ids = self.panes.ids;
if pane_id == ids.projects {
self.sync_board();
} else if pane_id == ids.board {
self.sync_detail();
}
}
fn note_error(&mut self, e: String) {
if client::is_scope_error(&e) {
self.scope_missing = true;
} else if self.gh_error.is_none() {
self.gh_error = Some(e);
}
}
fn drain_bg_messages(&mut self) {
let messages: Vec<_> = match &self.bg_rx {
Some(rx) => rx.try_iter().collect(),
None => return,
};
for msg in messages {
match msg {
ProjectsBgMessage::Repo(result) => {
self.panes.projects.set_loading(false);
match result {
Ok(info) => {
self.gh_available = Some(true);
self.links_known = true;
self.set_repo(Some(info.name_with_owner.clone()));
let mut projects = info.linked_projects();
for p in &mut projects {
if let Some(known) = self
.panes
.projects
.items
.iter()
.find(|k| k.number == p.number)
{
p.items = known.items.clone();
}
}
if self.use_disk_cache {
disk_cache::save_project_list(&ProjectListCache {
repo: self.repo.clone().unwrap_or_default(),
projects: projects.clone(),
});
}
let projects = self.apply_pin(projects);
self.panes.projects.set_projects(projects);
self.sync_board();
}
Err(e) => {
if client::is_gh_missing(&e) {
self.gh_available = Some(false);
self.gh_error = Some(e);
} else {
self.gh_available = Some(true);
self.note_error(e);
}
self.update_notice();
}
}
}
ProjectsBgMessage::Board { number, result } => {
self.board_inflight.remove(&number);
self.panes.board.set_loading(false);
let current = self.panes.projects.selected_number() == Some(number);
match result {
Ok(board) => {
if self.use_disk_cache {
disk_cache::save_board(&board);
}
self.panes
.projects
.set_item_count(number, board.total_count);
self.board_cache.insert(number, board.clone());
if current {
self.panes.board.set_board(board);
self.sync_detail();
}
}
Err(e) => {
if client::is_scope_error(&e) {
self.scope_missing = true;
} else if current && self.panes.board.board.is_none() {
self.panes.board.set_error(Some(e));
} else {
self.note_error(e);
}
}
}
}
ProjectsBgMessage::ItemDetail { key, result } => {
self.panes.detail.apply(&key, result);
}
}
}
}
fn process_events(
&mut self,
ctx: &mut AppContext,
events: Vec<PaneEvent>,
) -> Result<PageAction> {
let ids = self.panes.ids;
for event in events {
if matches!(event, PaneEvent::SetFocus(id) if id == ids.projects && !self.list_placed) {
continue;
}
if pane::process_common_event(&mut self.pane, ctx, &event) {
continue;
}
match event {
PaneEvent::SetFocus(id) if id == ids.board || id == ids.detail => {
self.follow(self.pane.previous_pane);
}
PaneEvent::SelectionChanged => {
self.follow(self.pane.focused_pane);
}
PaneEvent::JumpToMatch(forward) => {
if let Some(origin) =
self.pane
.jump_to_search_match(&mut self.panes, ctx, forward)
{
self.follow(origin);
}
}
_ => {}
}
}
Ok(PageAction::None)
}
fn handle_view_key(&mut self, ctx: &mut AppContext, key: KeyEvent) -> Result<PageAction> {
if let Some(action) = self.view_keymap.lookup(key) {
if let Some(page_action) = pane::execute_common_view_action(ctx, *action) {
return Ok(page_action);
}
match action {
ViewAction::Refresh => {
self.refresh();
return Ok(PageAction::None);
}
ViewAction::NextProject | ViewAction::PrevProject => {
if self.pinned.is_some() {
ctx.status_message = Some(PINNED_MESSAGE.to_string());
} else {
self.cycle_project(*action == ViewAction::NextProject);
}
return Ok(PageAction::None);
}
_ => {}
}
}
let events = pane::dispatch_page_key(self, key);
self.process_events(ctx, events)
}
pub fn counts(&self) -> (usize, usize, usize, bool) {
(
self.panes.projects.items.len(),
self.panes.board.item_count(),
self.panes.board.column_count(),
self.panes.board.truncated(),
)
}
pub fn board_label(&self) -> Option<(&str, usize, usize)> {
let project = self.panes.projects.selected()?;
Some((
project.title.as_str(),
self.panes.projects.selected_idx + 1,
self.panes.projects.items.len(),
))
}
fn render_notice(&self, f: &mut Frame, area: Rect, lines: Vec<String>) {
let lines: Vec<Line> = std::iter::once(Line::default())
.chain(lines.into_iter().map(|l| {
Line::from(Span::styled(
format!(" {l}"),
Style::default().fg(Color::DarkGray),
))
}))
.collect();
f.render_widget(Paragraph::new(lines), area);
}
}
impl PageState for ProjectsState {
fn id(&self) -> &'static str {
"projects"
}
fn label(&self) -> &'static str {
"Projects"
}
fn help_bindings(&self) -> Vec<(String, String)> {
use crate::core::keymap::help_section;
let mut entries = self.view_keymap.help_entries();
if self.list_placed {
entries.extend(help_section("Projects"));
entries.extend(self.panes.projects.keymap().help_entries());
}
entries.extend(help_section("Board"));
entries.extend(self.panes.board.keymap().help_entries());
entries.extend(help_section("Detail"));
entries.extend(self.panes.detail.keymap().help_entries());
entries
}
fn handle_key(&mut self, ctx: &mut AppContext, key: KeyEvent) -> Result<PageAction> {
if self.pane.handle_search_input(&mut self.panes, ctx, key) {
let origin = self.pane.search.origin;
if !self.pane.search.active {
self.follow(origin);
}
return Ok(PageAction::None);
}
self.handle_view_key(ctx, key)
}
fn render(&mut self, f: &mut Frame, ctx: &AppContext, area: Rect) {
let frame = split_page_frame(area);
status_bar::render_projects_header(f, ctx, self, frame.header);
if self.gh_available == Some(false) {
let err = self.gh_error.as_deref().unwrap_or("gh not found");
self.render_notice(
f,
frame.content,
vec![
format!("gh not available: {err}"),
"Install the GitHub CLI and run `gh auth login`, then press r.".to_string(),
],
);
} else if self.scope_missing {
self.render_notice(
f,
frame.content,
vec![SCOPE_NOTICE.to_string(), "Then press r.".to_string()],
);
} else {
pane::render_page_content(self, f, ctx, frame.content);
}
status_bar::render_projects_status_bar(f, ctx, self, frame.status_bar);
}
fn intercepts_all_keys(&self) -> bool {
self.pane.search.active
}
fn on_activate(&mut self, _ctx: &mut AppContext) {
if !self.initialized {
self.initialize();
return;
}
let stale = self
.last_list_refresh
.is_none_or(|t| t.elapsed() >= LIST_REFRESH_AFTER);
if stale && self.gh_available != Some(false) && !self.scope_missing {
self.spawn_list();
}
}
fn drain_background(&mut self) {
self.drain_bg_messages();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::keymap::KeyInput;
use crate::projects::domain::types::tests::{board, repo_info};
fn key(s: &str) -> KeyEvent {
let ki: KeyInput = s.parse().unwrap();
KeyEvent::new(ki.code, ki.modifiers)
}
fn ctx() -> AppContext {
AppContext {
should_quit: false,
active_page: 0,
page_labels: vec![],
page_keys: vec![],
show_help: false,
status_message: None,
error_dialog: None,
workdir: std::path::PathBuf::new(),
needs_full_redraw: false,
}
}
fn state_with(cfg: &Config) -> ProjectsState {
let mut st = ProjectsState::new(cfg).expect("projects page");
let (tx, rx) = mpsc::channel();
st.bg_tx = Some(tx);
st.bg_rx = Some(rx);
st.initialized = true;
st.use_disk_cache = false;
st
}
fn state() -> ProjectsState {
state_with(&Config::builtin())
}
fn list_config() -> Config {
let doc: kdl::KdlDocument = r#"page "projects" {
layout {
split direction="horizontal" {
place "projects" size="22%"
split direction="vertical" size="min:30" {
place "board" size="60%"
place "detail" size="min:5"
}
}
}
tabs "projects" "board" "detail"
}"#
.parse()
.unwrap();
Config::with_user(&doc, std::path::PathBuf::from("/u/config.kdl")).unwrap()
}
fn press(st: &mut ProjectsState, c: &mut AppContext, k: &str) {
st.handle_key(c, key(k)).unwrap();
}
#[test]
fn projects_page_builds_from_builtin_config() {
let cfg = Config::builtin();
let state = ProjectsState::new(&cfg).expect("projects page");
assert_eq!(state.id(), "projects");
assert_eq!(state.label(), "Projects");
assert!(!state.list_placed);
assert_eq!(state.pane.focused_pane, state.panes.ids.board);
assert_eq!(
state.layout_config.tab_panes,
vec![state.panes.ids.board, state.panes.ids.detail]
);
let help = state.help_bindings();
assert_eq!(help[0], ("q".to_string(), "Quit".to_string()));
assert!(help.iter().all(|(_, d)| d != "Switch view"));
assert!(help
.iter()
.any(|(k, d)| k == "p" && d == "Next linked project"));
assert!(help
.iter()
.any(|(k, d)| k == "P" && d == "Prev linked project"));
assert!(help
.iter()
.any(|(k, d)| k == "t" && d == "Toggle table mode"));
assert!(help.iter().any(|(_, d)| d.contains("Board")));
assert!(
!help.iter().any(|(_, d)| d.contains("── Projects ──")),
"no help section for the unplaced list pane"
);
}
#[test]
fn linked_projects_drive_the_board_and_p_cycles_them() {
let mut st = state();
let tx = st.bg_tx.clone().unwrap();
assert!(st.board_label().is_none());
tx.send(ProjectsBgMessage::Repo(Ok(repo_info()))).unwrap();
st.drain_bg_messages();
assert_eq!(st.gh_available, Some(true));
assert_eq!(st.repo.as_deref(), Some("td72/vig"));
assert_eq!(st.panes.projects.selected_number(), Some(2));
assert_eq!(st.board_label(), Some(("vig demo board", 1, 2)));
assert!(st.board_inflight.contains(&2));
assert!(st.panes.board.is_loading());
assert!(st.panes.board.notice().is_none());
let mut other = board();
other.number = 7;
tx.send(ProjectsBgMessage::Board {
number: 7,
result: Ok(other),
})
.unwrap();
st.drain_bg_messages();
assert!(st.panes.board.board.is_none());
tx.send(ProjectsBgMessage::Board {
number: 2,
result: Ok(board()),
})
.unwrap();
st.drain_bg_messages();
assert_eq!(st.panes.board.item_count(), 5);
assert_eq!(st.counts(), (2, 5, 5, true));
assert_eq!(st.panes.projects.items[0].items.total_count, 7);
assert_eq!(st.panes.detail.item().map(|i| i.id.as_str()), Some("I4"));
assert!(st.panes.detail.is_loading());
let mut c = ctx();
press(&mut st, &mut c, "p");
assert_eq!(st.panes.projects.selected_number(), Some(7));
assert_eq!(st.board_label(), Some(("Roadmap", 2, 2)));
assert_eq!(st.panes.board.board.as_ref().map(|b| b.number), Some(7));
assert!(!st.board_inflight.contains(&7));
press(&mut st, &mut c, "p");
assert_eq!(st.board_label(), Some(("vig demo board", 1, 2)));
press(&mut st, &mut c, "P");
assert_eq!(st.board_label(), Some(("Roadmap", 2, 2)));
tx.send(ProjectsBgMessage::Repo(Ok(repo_info()))).unwrap();
st.drain_bg_messages();
assert_eq!(st.panes.projects.selected_number(), Some(7));
assert_eq!(st.panes.projects.items[0].items.total_count, 7);
}
#[test]
fn a_repository_without_linked_projects_shows_the_notice() {
let mut st = state();
let tx = st.bg_tx.clone().unwrap();
st.spawn_list();
assert_eq!(
st.panes.board.notice().map(String::as_str),
Some("Loading...")
);
let info: RepoInfo = serde_json::from_str(
r#"{"nameWithOwner":"td72/empty","owner":{"login":"td72"},"projectsV2":{"Nodes":[]}}"#,
)
.unwrap();
tx.send(ProjectsBgMessage::Repo(Ok(info))).unwrap();
st.drain_bg_messages();
assert_eq!(st.gh_available, Some(true));
assert_eq!(st.counts(), (0, 0, 0, false));
assert!(st.board_label().is_none());
assert_eq!(
st.panes.board.notice().map(String::as_str),
Some(NO_LINKED_NOTICE)
);
assert!(st.gh_error.is_none());
let mut c = ctx();
press(&mut st, &mut c, "p");
assert!(st.board_label().is_none());
tx.send(ProjectsBgMessage::Repo(Ok(repo_info()))).unwrap();
st.drain_bg_messages();
assert!(st.panes.board.notice().is_none());
}
#[test]
fn scope_errors_replace_the_panes_with_the_notice() {
let mut st = state();
let tx = st.bg_tx.clone().unwrap();
tx.send(ProjectsBgMessage::Repo(Ok(repo_info()))).unwrap();
tx.send(ProjectsBgMessage::Board {
number: 2,
result: Err(
"error: your authentication token is missing required scopes [project]".into(),
),
})
.unwrap();
st.drain_bg_messages();
assert!(st.scope_missing);
assert!(st.gh_error.is_none());
assert!(!st.panes.projects.is_loading());
let mut st = state();
let tx = st.bg_tx.clone().unwrap();
tx.send(ProjectsBgMessage::Repo(Err(
"gh not found: No such file or directory (os error 2)".into(),
)))
.unwrap();
st.drain_bg_messages();
assert_eq!(st.gh_available, Some(false));
assert!(st.gh_error.is_some());
let mut st = state();
let tx = st.bg_tx.clone().unwrap();
tx.send(ProjectsBgMessage::Repo(Err("HTTP 502".into())))
.unwrap();
st.drain_bg_messages();
assert_eq!(st.gh_available, Some(true));
assert_eq!(st.gh_error.as_deref(), Some("HTTP 502"));
assert!(st.panes.board.notice().is_none(), "not the no-links notice");
}
#[test]
fn enter_and_esc_walk_board_and_detail_without_the_list() {
let mut st = state();
let ids = st.panes.ids;
st.panes
.projects
.set_projects(repo_info().linked_projects());
st.board_cache.insert(2, board());
st.sync_board();
let mut c = ctx();
assert_eq!(st.pane.focused_pane, ids.board);
let events = pane::dispatch_page_key(&mut st, key("l"));
st.process_events(&mut c, events).unwrap();
assert_eq!(st.panes.detail.item().map(|i| i.id.as_str()), Some("I2"));
let events = pane::dispatch_page_key(&mut st, key("Enter"));
assert!(matches!(events.as_slice(), [PaneEvent::SetFocus(id)] if *id == ids.detail));
st.process_events(&mut c, events).unwrap();
assert_eq!(st.pane.focused_pane, ids.detail);
let events = pane::dispatch_page_key(&mut st, key("Esc"));
st.process_events(&mut c, events).unwrap();
assert_eq!(st.pane.focused_pane, ids.board);
let events = pane::dispatch_page_key(&mut st, key("Esc"));
assert!(events.is_empty());
assert_eq!(st.pane.focused_pane, ids.board);
let events = pane::dispatch_page_key(&mut st, key("Tab"));
assert!(matches!(events.as_slice(), [PaneEvent::SetFocus(id)] if *id == ids.detail));
st.process_events(&mut c, events).unwrap();
let events = pane::dispatch_page_key(&mut st, key("Tab"));
assert!(matches!(events.as_slice(), [PaneEvent::SetFocus(id)] if *id == ids.board));
st.process_events(&mut c, events).unwrap();
assert_eq!(st.pane.focused_pane, ids.board);
st.process_events(&mut c, vec![PaneEvent::SetFocus(ids.projects)])
.unwrap();
assert_eq!(st.pane.focused_pane, ids.board);
}
#[test]
fn a_placed_list_pane_takes_focus_and_drives_the_board() {
let cfg = list_config();
let mut st = state_with(&cfg);
let ids = st.panes.ids;
assert!(st.list_placed);
assert_eq!(st.pane.focused_pane, ids.projects);
assert_eq!(
st.layout_config.tab_panes,
vec![ids.projects, ids.board, ids.detail]
);
assert!(st
.help_bindings()
.iter()
.any(|(_, d)| d.contains("── Projects ──")));
st.panes
.projects
.set_projects(repo_info().linked_projects());
st.board_cache.insert(2, board());
st.board_cache.insert(7, {
let mut b = board();
b.number = 7;
b
});
st.sync_board();
let mut c = ctx();
let events = pane::dispatch_page_key(&mut st, key("j"));
st.process_events(&mut c, events).unwrap();
assert_eq!(st.panes.board.board.as_ref().map(|b| b.number), Some(7));
assert_eq!(st.board_label(), Some(("Roadmap", 2, 2)));
let events = pane::dispatch_page_key(&mut st, key("Enter"));
st.process_events(&mut c, events).unwrap();
assert_eq!(st.pane.focused_pane, ids.board);
let events = pane::dispatch_page_key(&mut st, key("Esc"));
st.process_events(&mut c, events).unwrap();
assert_eq!(st.pane.focused_pane, ids.projects);
press(&mut st, &mut c, "p");
assert_eq!(st.panes.projects.selected_number(), Some(2));
assert_eq!(st.panes.board.board.as_ref().map(|b| b.number), Some(2));
}
fn pinned_config(node: &str) -> Config {
let doc: kdl::KdlDocument = node.parse().unwrap();
Config::with_user(&doc, std::path::PathBuf::from("/u/config.kdl")).unwrap()
}
#[test]
fn projects_board_pins_the_board_by_title_case_insensitively() {
let cfg = pinned_config(r#"projects-board "VIG Demo Board""#);
let mut st = state_with(&cfg);
let tx = st.bg_tx.clone().unwrap();
tx.send(ProjectsBgMessage::Repo(Ok(repo_info()))).unwrap();
st.drain_bg_messages();
assert_eq!(st.panes.projects.selected_number(), Some(2));
assert_eq!(st.board_label(), Some(("vig demo board", 1, 1)));
assert!(st.panes.board.notice().is_none());
assert!(st.board_inflight.contains(&2));
let mut c = ctx();
press(&mut st, &mut c, "p");
assert_eq!(st.panes.projects.selected_number(), Some(2));
assert_eq!(c.status_message.as_deref(), Some(PINNED_MESSAGE));
c.status_message = None;
press(&mut st, &mut c, "P");
assert_eq!(st.panes.projects.selected_number(), Some(2));
assert_eq!(c.status_message.as_deref(), Some(PINNED_MESSAGE));
}
#[test]
fn projects_board_pins_the_board_by_number() {
let cfg = pinned_config("projects-board 7");
let mut st = state_with(&cfg);
let tx = st.bg_tx.clone().unwrap();
tx.send(ProjectsBgMessage::Repo(Ok(repo_info()))).unwrap();
st.drain_bg_messages();
assert_eq!(st.panes.projects.selected_number(), Some(7));
assert_eq!(st.board_label(), Some(("Roadmap", 1, 1)));
assert!(st.panes.board.notice().is_none());
}
#[test]
fn an_unmatched_projects_board_pin_names_itself_in_the_notice() {
let cfg = pinned_config(r#"projects-board "foo""#);
let mut st = state_with(&cfg);
let tx = st.bg_tx.clone().unwrap();
tx.send(ProjectsBgMessage::Repo(Ok(repo_info()))).unwrap();
st.drain_bg_messages();
assert!(st.board_label().is_none());
assert_eq!(
st.panes.board.notice().map(String::as_str),
Some(r#"projects-board "foo" does not match any project linked to this repository"#)
);
assert!(st.gh_error.is_none());
}
}