mod app;
pub(crate) mod config;
mod keymap;
mod terminal;
mod worker;
use std::sync::Arc;
use std::time::Duration;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crate::commands::issue::query::IssueFilter;
use crate::commands::pr::query::PrFilter;
use crate::core::{Browser, RepoId, Transport};
use app::{App, InputContext, Msg, PendingAction, Tab};
use config::{expand_template, CustomKey};
use worker::{Request, RequestKind, Worker};
const TICK: Duration = Duration::from_millis(120);
pub fn run(
authed: bool,
repo: Option<RepoId>,
transport: Arc<dyn Transport>,
header: Option<String>,
browser: Arc<dyn Browser>,
dash_config: config::DashConfig,
config_warnings: Vec<String>,
) -> anyhow::Result<()> {
let mut guard = terminal::TerminalGuard::new()?;
let mut app = App::new(authed);
app.theme = dash_config.theme;
app.active_tab = dash_config.default_tab;
if !config_warnings.is_empty() {
app.status = Some(format!("config: {}", config_warnings.join("; ")));
}
let pr_filter = PrFilter {
state: "OPEN".to_owned(),
base: None,
limit: 30,
};
let issue_filter = IssueFilter {
state: None,
limit: 30,
};
let pipeline_limit = 30usize;
let mut ticks_since_poll = 0u32;
let poll_every_ticks = u32::try_from(dash_config.refresh_secs * 1000 / 120)
.unwrap_or(40)
.max(1);
let repo_for_vars = repo.clone();
let custom_keys = dash_config.custom_keys.clone();
let worker = match (authed, repo) {
(true, Some(repo)) => {
let worker = Worker::spawn(transport, header, repo);
app.begin(RequestKind::Prs);
worker.send(Request::Prs(pr_filter.clone()));
Some(worker)
}
(true, None) => {
app.status = Some("no Bitbucket repository here — pass -R WORKSPACE/SLUG".to_owned());
None
}
(false, _) => None,
};
while !app.should_quit {
guard.terminal.draw(|frame| app.view(frame))?;
if event::poll(TICK)? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
let custom = match (app.input_context(), key.code) {
(InputContext::Normal, KeyCode::Char(c)) => custom_keys
.iter()
.find(|k| k.key == c && k.applies(app.active_tab)),
_ => None,
};
if let Some(ck) = custom {
run_custom_key(&mut guard, &mut app, ck, repo_for_vars.as_ref())?;
} else if let Some(msg) = keymap::map_key(key, app.input_context()) {
dispatch(
&mut app,
worker.as_ref(),
&pr_filter,
&issue_filter,
pipeline_limit,
&browser,
msg,
);
}
}
}
} else {
app.update(Msg::Tick);
ticks_since_poll += 1;
if ticks_since_poll >= poll_every_ticks {
ticks_since_poll = 0;
if app.pipelines_active() {
if let Some(worker) = &worker {
app.begin(RequestKind::Pipelines);
worker.send(Request::Pipelines(pipeline_limit));
}
}
}
}
if app.needs_issue_load() {
if let Some(worker) = &worker {
app.begin(RequestKind::Issues);
worker.send(Request::Issues(issue_filter.clone()));
}
}
if app.needs_pipeline_load() {
if let Some(worker) = &worker {
app.begin(RequestKind::Pipelines);
worker.send(Request::Pipelines(pipeline_limit));
}
}
if let Some(worker) = &worker {
while let Ok(response) = worker.rx.try_recv() {
let refresh = matches!(response, worker::Response::ActionDone(_));
let detail_id = if refresh { app.detail_pr_id() } else { None };
app.apply_response(response);
if refresh {
app.begin(RequestKind::Prs);
worker.send(Request::Prs(pr_filter.clone()));
if let Some(id) = detail_id {
app.begin(RequestKind::PrDetail);
worker.send(Request::PrDetail(id));
}
}
}
}
}
Ok(())
}
fn dispatch(
app: &mut App,
worker: Option<&Worker>,
pr_filter: &PrFilter,
issue_filter: &IssueFilter,
pipeline_limit: usize,
browser: &Arc<dyn Browser>,
msg: Msg,
) {
app.status = None;
match msg {
Msg::Refresh => {
if let Some(worker) = worker {
match app.active_tab {
Tab::Issues => {
app.begin(RequestKind::Issues);
worker.send(Request::Issues(issue_filter.clone()));
}
Tab::Pipelines => {
app.begin(RequestKind::Pipelines);
worker.send(Request::Pipelines(pipeline_limit));
}
Tab::PullRequests => {
app.begin(RequestKind::Prs);
worker.send(Request::Prs(pr_filter.clone()));
}
}
}
}
Msg::Open => match app.active_tab {
Tab::Issues => {
if let Some(id) = app.selected_issue_id() {
app.update(Msg::Open);
if let Some(worker) = worker {
worker.send(Request::IssueDetail(id));
}
}
}
Tab::Pipelines => {
if let Some(build) = app.selected_pipeline_build() {
app.update(Msg::Open);
if let Some(worker) = worker {
worker.send(Request::PipelineDetail(build));
}
}
}
Tab::PullRequests => {
if let Some(id) = app.selected_pr_id() {
app.update(Msg::Open);
if let Some(worker) = worker {
worker.send(Request::PrDetail(id));
}
}
}
},
Msg::OpenBrowser => {
if let Some(url) = app.current_url() {
let _ = browser.browse(url);
}
}
Msg::Approve => {
if let Some(id) = app.action_target_id() {
let now_approved = app.toggle_self_approved(id);
if let Some(worker) = worker {
app.begin(RequestKind::Action);
worker.send(if now_approved {
Request::Approve(id)
} else {
Request::Unapprove(id)
});
}
}
}
Msg::ConfirmYes => {
if let Some(action) = app.take_pending_confirm() {
if let Some(worker) = worker {
app.begin(RequestKind::Action);
worker.send(match action {
PendingAction::Merge(id) => Request::Merge(id),
PendingAction::Decline(id) => Request::Decline(id),
});
}
}
}
Msg::Submit => {
if let Some((id, body)) = app.take_comment() {
if !body.trim().is_empty() {
if let Some(worker) = worker {
app.begin(RequestKind::Action);
worker.send(Request::Comment(id, body));
}
}
}
}
other => app.update(other),
}
}
fn run_custom_key(
guard: &mut terminal::TerminalGuard,
app: &mut App,
ck: &CustomKey,
repo: Option<&RepoId>,
) -> anyhow::Result<()> {
app.status = None;
let Some(vars) = row_vars(app, repo) else {
app.status = Some(format!("{}: no selection", ck.name));
return Ok(());
};
let command = expand_template(&ck.command, &vars);
guard.suspend()?;
let result = run_shell(&command);
guard.resume()?;
app.status = Some(match result {
Ok(status) if status.success() => format!("✓ {}", ck.name),
Ok(status) => format!("{} exited with {}", ck.name, status.code().unwrap_or(-1)),
Err(e) => format!("{}: {e}", ck.name),
});
Ok(())
}
fn row_vars(app: &App, repo: Option<&RepoId>) -> Option<Vec<(&'static str, String)>> {
let mut vars = Vec::new();
if let Some(r) = repo {
vars.push(("repo", r.full_name()));
vars.push(("workspace", r.workspace().to_owned()));
vars.push(("slug", r.slug().to_owned()));
}
match app.active_tab {
Tab::PullRequests => {
let pr = app.active_pr()?;
vars.push(("id", pr.id.to_string()));
vars.push(("url", pr.html_url().unwrap_or_default().to_owned()));
vars.push(("branch", pr.source.branch_name().to_owned()));
}
Tab::Issues => {
let issue = app.active_issue()?;
vars.push(("id", issue.id.to_string()));
vars.push(("url", issue.html_url().unwrap_or_default().to_owned()));
}
Tab::Pipelines => {
let p = app.active_pipeline()?;
if let Some(n) = p.build_number {
vars.push(("id", n.to_string()));
}
}
}
Some(vars)
}
fn run_shell(command: &str) -> std::io::Result<std::process::ExitStatus> {
#[cfg(windows)]
let mut cmd = {
let mut c = std::process::Command::new("cmd");
c.arg("/C");
c
};
#[cfg(not(windows))]
let mut cmd = {
let mut c = std::process::Command::new("sh");
c.arg("-c");
c
};
cmd.arg(command).status()
}