mod app;
mod draw;
mod keys;
use super::issues::{list_issues, ListIssuesFilter};
use super::require_interactive;
use app::{App, Mode, View};
use crossterm::event::{self, Event, KeyEventKind, MouseEventKind};
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
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::process::{Command, Stdio};
use std::time::Duration;
const COMMANDS: &[&str] = &[
"all",
"assignee",
"back",
"bottom",
"browser",
"commands",
"comments",
"copy",
"completed",
"detail",
"exit",
"first",
"help",
"hide-completed",
"include-completed",
"last",
"limit",
"list",
"me",
"next",
"nome",
"open",
"open-url",
"page-down",
"page-up",
"prev",
"priority",
"q",
"quit",
"reload",
"sort",
"state",
"status",
"team",
"top",
"undo",
"view",
"view-comments",
];
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, EnableMouseCapture)
.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(),
DisableMouseCapture,
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 {
if let Ok(size) = terminal.size() {
app.viewport_rows = size.height.saturating_sub(6).max(5) as usize;
}
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 = event::read().map_err(|e| e.to_string())?;
let action = match event {
Event::Key(key) => {
if key.kind != KeyEventKind::Press {
continue;
}
keys::map_key(app, key)
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => Action::MoveUp,
MouseEventKind::ScrollDown => Action::MoveDown,
_ => Action::None,
},
_ => continue,
};
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.comments_popup = None;
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::CommandHistoryPrev => {
if app.mode == Mode::Command {
app.previous_command();
}
}
Action::CommandHistoryNext => {
if app.mode == Mode::Command {
app.next_command();
}
}
Action::CancelInput => {
app.comments_popup = None;
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.record_command(cmd.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::RepeatLastCommand => {
if let Some(cmd) = app.last_command() {
app.toast_ok(format!("Repeating :{cmd}"));
if handle_command(api_key, app, &cmd).await? {
break;
}
} else {
app.toast_error("No command history");
}
}
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);
}
}
Action::ViewComments => {
if let Err(e) = view_comments(api_key, app).await {
app.toast_error(e);
}
}
Action::Undo => {
if let Err(e) = undo_last(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),
"help" | "h" => {
let rest: String = parts.collect::<Vec<_>>().join(" ");
if rest.trim().is_empty() {
app.mode = Mode::Help;
} else {
app.comments_popup = Some(command_help(&rest));
}
}
"commands" | "command-list" => {
app.comments_popup = Some(format!("Commands\n\n{}", COMMANDS.join("\n")));
}
"top" | "first" => {
app.selected = 0;
app.ensure_visible();
app.toast_ok("Moved to first issue");
}
"bottom" | "last" => {
app.selected = app.visible.len().saturating_sub(1);
app.ensure_visible();
app.toast_ok("Moved to last issue");
}
"next" | "down" | "j" => {
let amount = parts
.next()
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(1);
app.move_sel(amount.max(1));
}
"prev" | "previous" | "up" | "k" => {
let amount = parts
.next()
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(1);
app.move_sel(-amount.max(1));
}
"page-down" | "pagedown" => {
app.move_sel(app.page_size() as i32);
}
"page-up" | "pageup" => {
app.move_sel(-(app.page_size() as i32));
}
"open" | "view" | "detail" => {
if app.selected_issue().is_some() {
app.view = View::Detail;
app.mode = Mode::Normal;
} else {
app.toast_error("No issue selected");
}
}
"open-url" | "browser" => {
if let Err(e) = open_selected_issue_url(app) {
app.toast_error(e);
}
}
"copy" | "yank" => {
let field = parts.next().unwrap_or("url");
if let Err(e) = copy_selected_issue_field(app, field) {
app.toast_error(e);
}
}
"back" | "list" => {
app.comments_popup = None;
app.view = View::List;
app.mode = Mode::Normal;
}
"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.to_ascii_lowercase());
}
reload_issues(api_key, app).await;
}
"status" | "set-status" => {
let rest: String = parts.collect::<Vec<_>>().join(" ");
if rest.trim().is_empty() {
app.toast_error("Usage: :status <workflow state>");
} else if let Err(e) = set_status(api_key, app, &rest).await {
app.toast_error(e);
}
}
"priority" | "pri" => {
let rest: String = parts.collect::<Vec<_>>().join(" ");
if rest.trim().is_empty() {
app.toast_error("Usage: :priority <none|urgent|high|medium|low|0-4>");
} else if let Err(e) = set_priority(api_key, app, &rest).await {
app.toast_error(e);
}
}
"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;
}
"active" | "hide-completed" => {
app.filter.include_completed = false;
reload_issues(api_key, app).await;
}
"reload" | "r" => {
reload_issues(api_key, app).await;
}
"comments" | "view-comments" => {
if let Err(e) = view_comments(api_key, app).await {
app.toast_error(e);
}
}
"undo" => {
if let Err(e) = undo_last(api_key, app).await {
app.toast_error(e);
}
}
other => app.toast_error(format!("Unknown command `:{other}` — try :help")),
}
Ok(false)
}
fn command_help(query: &str) -> String {
let query = query.trim().to_ascii_lowercase();
let matches = COMMANDS
.iter()
.copied()
.filter(|command| command.contains(&query))
.collect::<Vec<_>>();
if matches.is_empty() {
return format!("No commands match `{query}`.\n\nUse :commands to list all commands.");
}
format!("Commands matching `{query}`\n\n{}", matches.join("\n"))
}
fn open_selected_issue_url(app: &mut App) -> Result<(), String> {
let issue = app
.selected_issue()
.ok_or_else(|| "No issue selected".to_string())?;
let url = issue
.url
.as_deref()
.ok_or_else(|| "Selected issue has no URL".to_string())?;
open_external_url(url)?;
app.toast_ok(format!("Opened {}", issue.identifier));
Ok(())
}
fn copy_selected_issue_field(app: &mut App, field: &str) -> Result<(), String> {
let issue = app
.selected_issue()
.ok_or_else(|| "No issue selected".to_string())?;
let value = match field.to_ascii_lowercase().as_str() {
"id" | "identifier" => issue.identifier.clone(),
"title" => issue.title.clone(),
"url" | "link" => issue
.url
.clone()
.ok_or_else(|| "Selected issue has no URL".to_string())?,
other => {
return Err(format!(
"Unknown copy field `{other}`. Use id, title, or url."
))
}
};
copy_to_clipboard(&value)?;
app.toast_ok(format!("Copied {field}"));
Ok(())
}
fn open_external_url(url: &str) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
Command::new("cmd")
.args(["/C", "start", "", url])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to open URL: {e}"))?;
return Ok(());
}
#[cfg(target_os = "macos")]
{
Command::new("open")
.arg(url)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to open URL: {e}"))?;
return Ok(());
}
#[cfg(all(unix, not(target_os = "macos")))]
{
Command::new("xdg-open")
.arg(url)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to open URL with xdg-open: {e}"))?;
return Ok(());
}
#[allow(unreachable_code)]
Err("Opening URLs is unsupported on this platform".into())
}
fn copy_to_clipboard(value: &str) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
let mut child = Command::new("powershell.exe")
.args(["-NoProfile", "-Command", "Set-Clipboard"])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start clipboard command: {e}"))?;
if let Some(stdin) = child.stdin.as_mut() {
use std::io::Write;
stdin
.write_all(value.as_bytes())
.map_err(|e| format!("Failed to write clipboard text: {e}"))?;
}
let status = child
.wait()
.map_err(|e| format!("Failed to finish clipboard command: {e}"))?;
if status.success() {
return Ok(());
}
return Err("Clipboard command failed".into());
}
#[cfg(target_os = "macos")]
{
return pipe_to_clipboard_command("pbcopy", &[], value);
}
#[cfg(all(unix, not(target_os = "macos")))]
{
for (program, args) in [
("wl-copy", Vec::<&str>::new()),
("xclip", vec!["-selection", "clipboard"]),
("xsel", vec!["--clipboard", "--input"]),
] {
if pipe_to_clipboard_command(program, &args, value).is_ok() {
return Ok(());
}
}
return Err("No clipboard command found: tried wl-copy, xclip, xsel".into());
}
#[allow(unreachable_code)]
Err("Clipboard is unsupported on this platform".into())
}
#[cfg(any(target_os = "macos", all(unix, not(target_os = "macos"))))]
fn pipe_to_clipboard_command(program: &str, args: &[&str], value: &str) -> Result<(), String> {
let mut child = Command::new(program)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start {program}: {e}"))?;
if let Some(stdin) = child.stdin.as_mut() {
use std::io::Write;
stdin
.write_all(value.as_bytes())
.map_err(|e| format!("Failed to write clipboard text: {e}"))?;
}
let status = child
.wait()
.map_err(|e| format!("Failed to finish {program}: {e}"))?;
if status.success() {
Ok(())
} else {
Err(format!("{program} failed"))
}
}
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 = (1..=states.len())
.map(|offset| &states[(idx + offset) % states.len()])
.find(|state| !state.state_type.eq_ignore_ascii_case("duplicate"))
.ok_or_else(|| "No non-duplicate workflow states".to_string())?;
let updated = set_status_by_id(api_key, app, next.id.clone(), next.name.clone()).await?;
app.replace_issue(updated);
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 = set_priority_value(api_key, app, next).await?;
app.replace_issue(updated);
Ok(())
}
async fn set_status(api_key: &str, app: &mut App, state: &str) -> 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?;
let wanted = state.trim();
let Some(next) = states.iter().find(|s| {
s.id == wanted
|| s.name.eq_ignore_ascii_case(wanted)
|| s.state_type.eq_ignore_ascii_case(wanted)
}) else {
return Err(format!("No workflow state matching `{wanted}`."));
};
let updated = set_status_by_id(api_key, app, next.id.clone(), next.name.clone()).await?;
app.replace_issue(updated);
Ok(())
}
async fn set_status_by_id(
api_key: &str,
app: &mut App,
state_id: String,
state_name: String,
) -> Result<super::issues::LinearIssue, String> {
let Some(original) = app.selected_issue().cloned() else {
return Err("No issue selected".into());
};
app.push_undo(original.clone());
app.update_selected_issue(|issue| {
issue.state_id = Some(state_id.clone());
issue.state_name = Some(state_name.clone());
});
app.toast_ok(format!("Status -> {state_name}"));
match super::issues::update_issue(
api_key,
&original.id,
super::issues::UpdateIssueInput {
state_id: Some(state_id),
..Default::default()
},
)
.await
{
Ok(updated) => Ok(updated),
Err(err) => {
app.replace_issue(original);
Err(err)
}
}
}
async fn set_priority(api_key: &str, app: &mut App, priority: &str) -> Result<(), String> {
let value = super::parse_priority_arg(priority)?;
let updated = set_priority_value(api_key, app, value).await?;
app.replace_issue(updated);
Ok(())
}
async fn set_priority_value(
api_key: &str,
app: &mut App,
priority: i32,
) -> Result<super::issues::LinearIssue, String> {
let Some(original) = app.selected_issue().cloned() else {
return Err("No issue selected".into());
};
app.push_undo(original.clone());
app.update_selected_issue(|issue| {
issue.priority = priority;
});
app.toast_ok(format!("Priority -> {}", super::priority_label(priority)));
match super::issues::update_issue(
api_key,
&original.id,
super::issues::UpdateIssueInput {
priority: Some(priority),
..Default::default()
},
)
.await
{
Ok(updated) => Ok(updated),
Err(err) => {
app.replace_issue(original);
Err(err)
}
}
}
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(())
}
async fn view_comments(api_key: &str, app: &mut App) -> Result<(), String> {
let Some(issue) = app.selected_issue().cloned() else {
return Err("No issue selected".into());
};
let comments = super::comments::list_comments(api_key, &issue.identifier).await?;
if comments.is_empty() {
app.comments_popup = Some(format!("{} has no comments.", issue.identifier));
return Ok(());
}
let mut out = format!("{} comments\n\n", issue.identifier);
for comment in comments {
let author = comment.user_name.as_deref().unwrap_or("unknown");
let created = comment.created_at.as_deref().unwrap_or("");
out.push_str(&format!("{author} {created}\n{}\n\n", comment.body.trim()));
}
app.comments_popup = Some(out);
Ok(())
}
async fn undo_last(api_key: &str, app: &mut App) -> Result<(), String> {
let Some(previous) = app.undo_stack.pop() else {
app.toast_ok("Nothing to undo");
return Ok(());
};
let Some(current) = app
.issues
.iter()
.find(|issue| issue.id == previous.id)
.cloned()
else {
app.replace_issue(previous);
app.toast_ok("Restored locally");
return Ok(());
};
let input = super::issues::UpdateIssueInput {
title: if current.title != previous.title {
Some(previous.title.clone())
} else {
None
},
description: None,
priority: if current.priority != previous.priority {
Some(previous.priority)
} else {
None
},
state_id: if current.state_id != previous.state_id {
previous.state_id.clone()
} else {
None
},
assignee_id: if current.assignee_id != previous.assignee_id {
Some(previous.assignee_id.clone())
} else {
None
},
project_id: None,
label_ids: None,
cycle_id: None,
};
app.replace_issue(previous.clone());
let updated = super::issues::update_issue(api_key, &previous.id, input).await?;
app.replace_issue(updated);
app.toast_ok("Undone");
Ok(())
}