mod app;
mod draw;
mod keys;
use super::require_interactive;
use super::issues::{list_issues, ListIssuesFilter};
use app::{App, Mode, View};
use crossterm::event::{self, Event, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use keys::Action;
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use std::io::{self, stdout};
use std::time::Duration;
pub async fn run_linear_tui(api_key: &str, filter: ListIssuesFilter) -> Result<(), String> {
require_interactive("Linear TUI")?;
let mut app = App::new(filter);
app.status = "Loading issues…".into();
reload_issues(api_key, &mut app).await;
enable_raw_mode().map_err(|e| format!("Failed to enable raw mode: {e}"))?;
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen)
.map_err(|e| format!("Failed to enter alternate screen: {e}"))?;
let backend = CrosstermBackend::new(stdout);
let mut terminal =
Terminal::new(backend).map_err(|e| format!("Failed to create terminal: {e}"))?;
let result = run_loop(api_key, &mut terminal, &mut app).await;
disable_raw_mode().ok();
execute!(terminal.backend_mut(), LeaveAlternateScreen).ok();
terminal.show_cursor().ok();
result
}
async fn run_loop(
api_key: &str,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
) -> Result<(), String> {
loop {
terminal
.draw(|frame| draw::draw(frame, app))
.map_err(|e| format!("Failed to draw TUI: {e}"))?;
if !event::poll(Duration::from_millis(100)).map_err(|e| e.to_string())? {
continue;
}
let Event::Key(key) = event::read().map_err(|e| e.to_string())? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
let action = keys::map_key(app, key);
match action {
Action::None => {}
Action::Quit => break,
Action::Redraw => {}
Action::MoveUp => app.move_sel(-1),
Action::MoveDown => app.move_sel(1),
Action::PageUp => app.move_sel(-(app.page_size() as i32)),
Action::PageDown => app.move_sel(app.page_size() as i32),
Action::Top => {
app.selected = 0;
app.ensure_visible();
}
Action::Bottom => {
let len = app.visible.len();
app.selected = len.saturating_sub(1);
app.ensure_visible();
}
Action::OpenDetail => {
if app.selected_issue().is_some() {
app.view = View::Detail;
app.mode = Mode::Normal;
}
}
Action::Back => {
app.view = View::List;
app.mode = Mode::Normal;
app.input.clear();
}
Action::StartSearch => {
app.mode = Mode::Search;
app.input.clear();
}
Action::StartCommand => {
app.mode = Mode::Command;
app.input.clear();
}
Action::StartHelp => {
app.mode = Mode::Help;
}
Action::InputChar(c) => {
app.input.push(c);
if app.mode == Mode::Search {
app.apply_search_filter();
}
}
Action::InputBackspace => {
app.input.pop();
if app.mode == Mode::Search {
app.apply_search_filter();
}
}
Action::CancelInput => {
if app.mode == Mode::Search {
app.input.clear();
app.apply_search_filter();
}
app.mode = Mode::Normal;
app.input.clear();
}
Action::SubmitInput => {
match app.mode {
Mode::Search => {
let q = app.input.trim().to_string();
app.filter.query = if q.is_empty() { None } else { Some(q) };
app.mode = Mode::Normal;
app.input.clear();
app.recompute_visible();
}
Mode::Command => {
let cmd = app.input.clone();
app.mode = Mode::Normal;
app.input.clear();
if handle_command(api_key, app, &cmd).await? {
break;
}
}
Mode::EditTitle => {
if let Err(e) = save_title(api_key, app).await {
app.toast_error(e);
} else {
app.mode = Mode::Normal;
app.input.clear();
app.toast_ok("Title updated");
}
}
Mode::EditComment => {
if let Err(e) = save_comment(api_key, app).await {
app.toast_error(e);
} else {
app.mode = Mode::Normal;
app.input.clear();
app.toast_ok("Comment posted");
}
}
_ => {}
}
}
Action::Reload => {
app.status = "Reloading…".into();
reload_issues(api_key, app).await;
}
Action::CycleSort => {
app.filter.sort = app.filter.sort.cycle();
app.recompute_visible();
app.status = format!("Sort: {}", app.filter.sort.as_str());
}
Action::ToggleSortDir => {
app.filter.sort_asc = !app.filter.sort_asc;
app.recompute_visible();
app.status = format!(
"Sort: {} {}",
app.filter.sort.as_str(),
if app.filter.sort_asc { "asc" } else { "desc" }
);
}
Action::ToggleMe => {
if app.filter.assignee_id.is_some() && app.me_active {
app.filter.assignee_id = None;
app.me_active = false;
app.status = "Filter: all assignees".into();
} else {
match super::meta::resolve_assignee_id(api_key, "me").await {
Ok(Some(id)) => {
app.filter.assignee_id = Some(id);
app.me_active = true;
app.status = "Filter: assigned to me".into();
}
Ok(None) => app.toast_error("Could not resolve viewer"),
Err(e) => app.toast_error(e),
}
}
reload_issues(api_key, app).await;
}
Action::NextMatch => app.next_search_match(true),
Action::PrevMatch => app.next_search_match(false),
Action::StartEditTitle => {
if let Some(title) = app.selected_issue().map(|i| i.title.clone()) {
app.mode = Mode::EditTitle;
app.input = title;
}
}
Action::StartComment => {
if app.selected_issue().is_some() {
app.mode = Mode::EditComment;
app.input.clear();
}
}
Action::CycleStatus => {
if let Err(e) = cycle_status(api_key, app).await {
app.toast_error(e);
}
}
Action::CyclePriority => {
if let Err(e) = cycle_priority(api_key, app).await {
app.toast_error(e);
}
}
Action::AssignMe => {
if let Err(e) = assign_me(api_key, app).await {
app.toast_error(e);
}
}
}
}
Ok(())
}
async fn reload_issues(api_key: &str, app: &mut App) {
match list_issues(api_key, app.filter.clone()).await {
Ok(issues) => {
app.issues = issues;
app.recompute_visible();
app.status = format!(
"{} issue(s) · sort {}{} · / search · : help · ? keys",
app.visible.len(),
app.filter.sort.as_str(),
if app.filter.sort_asc { "↑" } else { "↓" }
);
app.error = None;
}
Err(e) => {
app.toast_error(e);
}
}
}
async fn handle_command(api_key: &str, app: &mut App, cmd: &str) -> Result<bool, String> {
let cmd = cmd.trim();
if cmd.is_empty() {
return Ok(false);
}
let mut parts = cmd.split_whitespace();
let head = parts.next().unwrap_or("").to_ascii_lowercase();
match head.as_str() {
"q" | "quit" | "exit" => return Ok(true),
"me" => {
match super::meta::resolve_assignee_id(api_key, "me").await {
Ok(Some(id)) => {
app.filter.assignee_id = Some(id);
app.me_active = true;
}
Ok(None) => app.toast_error("viewer unresolved"),
Err(e) => app.toast_error(e),
}
reload_issues(api_key, app).await;
}
"nome" | "all" => {
app.filter.assignee_id = None;
app.me_active = false;
reload_issues(api_key, app).await;
}
"sort" => {
if let Some(field) = parts.next() {
match super::issues::IssueSortField::parse(field) {
Ok(s) => {
app.filter.sort = s;
if let Some(dir) = parts.next() {
app.filter.sort_asc = matches!(
dir.to_ascii_lowercase().as_str(),
"asc" | "a" | "up"
);
}
app.recompute_visible();
app.status = format!("Sort: {}", app.filter.sort.as_str());
}
Err(e) => app.toast_error(e),
}
} else {
app.filter.sort = app.filter.sort.cycle();
app.recompute_visible();
}
}
"state" => {
let rest: String = parts.collect::<Vec<_>>().join(" ");
if rest.is_empty() || rest.eq_ignore_ascii_case("clear") {
app.filter.state = None;
} else {
app.filter.state = Some(rest);
}
reload_issues(api_key, app).await;
}
"team" => {
let rest: String = parts.collect::<Vec<_>>().join(" ");
if rest.is_empty() || rest.eq_ignore_ascii_case("clear") {
app.filter.team_id = None;
reload_issues(api_key, app).await;
} else {
match super::meta::resolve_team_id(api_key, &rest).await {
Ok(id) => {
app.filter.team_id = Some(id);
reload_issues(api_key, app).await;
}
Err(e) => app.toast_error(e),
}
}
}
"assignee" => {
let rest: String = parts.collect::<Vec<_>>().join(" ");
if rest.is_empty() || rest.eq_ignore_ascii_case("clear") || rest.eq_ignore_ascii_case("none")
{
app.filter.assignee_id = None;
app.me_active = false;
reload_issues(api_key, app).await;
} else {
match super::meta::resolve_assignee_id(api_key, &rest).await {
Ok(id) => {
app.me_active = rest.eq_ignore_ascii_case("me");
app.filter.assignee_id = id;
reload_issues(api_key, app).await;
}
Err(e) => app.toast_error(e),
}
}
}
"limit" => {
if let Some(n) = parts.next().and_then(|s| s.parse::<usize>().ok()) {
app.filter.limit = n.clamp(1, 250);
reload_issues(api_key, app).await;
} else {
app.toast_error("Usage: :limit <1-250>");
}
}
"completed" | "include-completed" => {
app.filter.include_completed = true;
reload_issues(api_key, app).await;
}
"open" | "hide-completed" => {
app.filter.include_completed = false;
reload_issues(api_key, app).await;
}
"reload" | "r" => {
reload_issues(api_key, app).await;
}
"help" | "h" => {
app.mode = Mode::Help;
}
other => app.toast_error(format!("Unknown command `:{other}` — try :help")),
}
Ok(false)
}
async fn save_title(api_key: &str, app: &mut App) -> Result<(), String> {
let Some(issue) = app.selected_issue().cloned() else {
return Err("No issue selected".into());
};
let title = app.input.trim().to_string();
if title.is_empty() {
return Err("Title cannot be empty".into());
}
let updated = super::issues::update_issue(
api_key,
&issue.id,
super::issues::UpdateIssueInput {
title: Some(title),
..Default::default()
},
)
.await?;
app.replace_issue(updated);
Ok(())
}
async fn save_comment(api_key: &str, app: &mut App) -> Result<(), String> {
let Some(issue) = app.selected_issue().cloned() else {
return Err("No issue selected".into());
};
let body = app.input.trim().to_string();
if body.is_empty() {
return Err("Comment cannot be empty".into());
}
super::comments::create_comment(api_key, &issue.id, &body).await?;
Ok(())
}
async fn cycle_status(api_key: &str, app: &mut App) -> Result<(), String> {
let Some(issue) = app.selected_issue().cloned() else {
return Err("No issue selected".into());
};
let team_id = issue
.team_id
.clone()
.ok_or_else(|| "Issue has no team".to_string())?;
let states = super::meta::list_workflow_states(api_key, &team_id).await?;
if states.is_empty() {
return Err("No workflow states".into());
}
let current = issue.state_id.as_deref();
let idx = states
.iter()
.position(|s| Some(s.id.as_str()) == current)
.unwrap_or(0);
let next = &states[(idx + 1) % states.len()];
let updated = super::issues::update_issue(
api_key,
&issue.id,
super::issues::UpdateIssueInput {
state_id: Some(next.id.clone()),
..Default::default()
},
)
.await?;
app.replace_issue(updated);
app.toast_ok(format!("Status → {}", next.name));
Ok(())
}
async fn cycle_priority(api_key: &str, app: &mut App) -> Result<(), String> {
let Some(issue) = app.selected_issue().cloned() else {
return Err("No issue selected".into());
};
let next = match issue.priority {
0 => 1,
1 => 2,
2 => 3,
3 => 4,
_ => 0,
};
let updated = super::issues::update_issue(
api_key,
&issue.id,
super::issues::UpdateIssueInput {
priority: Some(next),
..Default::default()
},
)
.await?;
app.replace_issue(updated);
app.toast_ok(format!("Priority → {}", super::priority_label(next)));
Ok(())
}
async fn assign_me(api_key: &str, app: &mut App) -> Result<(), String> {
let Some(issue) = app.selected_issue().cloned() else {
return Err("No issue selected".into());
};
let me = super::meta::resolve_assignee_id(api_key, "me")
.await?
.ok_or_else(|| "Could not resolve viewer".to_string())?;
let updated = super::issues::update_issue(
api_key,
&issue.id,
super::issues::UpdateIssueInput {
assignee_id: Some(Some(me)),
..Default::default()
},
)
.await?;
app.replace_issue(updated);
app.toast_ok("Assigned to you");
Ok(())
}