#[allow(clippy::wildcard_imports)]
use super::*;
pub(in crate::app) fn error_message(error: &TixError) -> String {
match error {
TixError::Parse(message) => message.clone(),
other => other.to_string(),
}
}
pub(in crate::app) fn names_backup(message: &str, backup: Option<&PathBuf>) -> String {
match backup {
Some(path) => format!("{message}; previous version saved at {}", path.display()),
None => message.to_string(),
}
}
pub fn drain_disclosures(app: &mut App) {
for disclosure in crate::storage::take_disclosures() {
app.note_disclosure(disclosure);
}
}
pub fn run(config: &Config) -> Result<()> {
run_session(config, false)
}
pub fn run_new_issue(config: &Config) -> Result<()> {
run_session(config, true)
}
#[allow(clippy::too_many_lines)]
fn run_session(config: &Config, new_issue_form: bool) -> Result<()> {
let mut app = App::new(config)?;
if new_issue_form {
app.open_new_issue_form();
}
if app.store.config.tips_on_startup {
app.open_tips();
}
let mut watcher = crate::watcher::FsWatcher::new(app.store.issues_dir.clone());
let mut coalescer = crate::watcher::DeltaCoalescer::default();
enable_raw_mode().map_err(io_err)?;
execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
mouse_capture_on(io::stdout()).map_err(io_err)?;
let guard = RestoreGuard;
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend).map_err(io_err)?;
let result = loop {
drain_disclosures(&mut app);
terminal
.draw(|f| render(&app, f, f.area()))
.map_err(io_err)?;
if event::poll(Duration::from_secs(1)).map_err(io_err)? {
let action = match event::read().map_err(io_err)? {
Event::Key(key) => app.handle_key(key)?,
Event::Mouse(mouse) => app.handle_mouse(mouse)?,
_ => Action::None,
};
match action {
Action::Quit => break Ok(()),
other => terminal = apply_session_action(&mut app, terminal, other)?,
}
}
if let Some(delta) = coalescer.observe(watcher.poll()) {
if let Err(e) = app.refresh_from_disk(&delta) {
app.status_msg = Some(format!("watch: {e}"));
coalescer.retry(delta);
}
}
};
drop(guard);
result
}
#[allow(clippy::too_many_lines)]
fn apply_session_action(
app: &mut App,
mut terminal: Terminal<CrosstermBackend<io::Stdout>>,
action: Action,
) -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
match action {
Action::Quit => {}
Action::None => {}
Action::SwitchBoard(index) => {
app.switch_board(index);
}
Action::ConfirmDeleteYes => {
let _ = app.confirm_delete_yes();
}
Action::ConfirmDeleteNo => {
let _ = app.confirm_delete_no();
}
Action::FilterModalFocus(_) | Action::FilterPickerOption(_) | Action::FilterPickerClose => {
let _ = app.apply_filter_modal_action(action);
}
Action::SettingsRow(row) => {
app.apply_config_click(Action::SettingsRow(row));
}
Action::ConfigDetailRow(row) => {
app.apply_config_click(Action::ConfigDetailRow(row));
let _ = app.settings_toggle_selected();
}
Action::ConfigSection(section) => {
app.apply_config_click(Action::ConfigSection(section));
}
Action::ConfigPlanningRow(row) => {
app.apply_config_click(Action::ConfigPlanningRow(row));
}
Action::ConfigListRow(row) => {
app.apply_config_click(Action::ConfigListRow(row));
}
Action::SelectBoard(_)
| Action::SelectCycle(_)
| Action::SelectMilestone(_)
| Action::NewFromTemplate(_) => {
let _ = app.activate_branch(&action);
}
Action::DatePickerHit(hit) => match hit {
DatePickerHit::Day(date) => {
if let Some(picker) = app.date_picker.as_mut() {
picker.select_date_str(&date);
}
}
DatePickerHit::PrevMonth => {
if let Some(picker) = app.date_picker.as_mut() {
picker.shift_month(-1);
}
}
DatePickerHit::NextMonth => {
if let Some(picker) = app.date_picker.as_mut() {
picker.shift_month(1);
}
}
DatePickerHit::Submit => {
let _ = app.handle_date_picker_key(KeyEvent::new(
KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
));
}
},
Action::HelpClose => {
app.close_help();
}
Action::SwitchMode(mode) => {
let _ = app.switch_top_mode(mode);
}
Action::TriageOpenStatus
| Action::TriageOpenAssignee
| Action::TriageCreate
| Action::TriageFocusQueue
| Action::TriagePickOption(_)
| Action::TriageDropdownClose => {
let _ = app.apply_triage_action(action);
}
Action::CycleSelect(index) => app.select_cycle_row(index),
Action::OpenIssue(filename) => {
if let Err(e) = app.open_issue_by_filename(&filename) {
app.status_msg = Some(format!("open: {e}"));
}
}
Action::TemplateOpen | Action::TemplatePickOption(_) => {
let _ = app.apply_new_issue_action(action);
}
Action::OpenEditor(filename) => {
drop(terminal);
let _ = disable_raw_mode();
let _ = mouse_capture_off(io::stdout());
let _ = execute!(io::stdout(), LeaveAlternateScreen);
if let Err(e) = app.open_editor_round_trip(&filename) {
app.status_msg = Some(format!("editor: {e}"));
}
enable_raw_mode().map_err(io_err)?;
execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
mouse_capture_on(io::stdout()).map_err(io_err)?;
let backend = CrosstermBackend::new(io::stdout());
terminal = Terminal::new(backend).map_err(io_err)?;
}
Action::CompareEdit => {
drop(terminal);
let _ = disable_raw_mode();
let _ = mouse_capture_off(io::stdout());
let _ = execute!(io::stdout(), LeaveAlternateScreen);
if let Err(e) = app.compare_conflicting_edit() {
app.status_msg = Some(format!("editor: {e}"));
}
enable_raw_mode().map_err(io_err)?;
execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
mouse_capture_on(io::stdout()).map_err(io_err)?;
let backend = CrosstermBackend::new(io::stdout());
terminal = Terminal::new(backend).map_err(io_err)?;
}
Action::ReeditRepair => {
drop(terminal);
let _ = disable_raw_mode();
let _ = mouse_capture_off(io::stdout());
let _ = execute!(io::stdout(), LeaveAlternateScreen);
if let Err(e) = app.reedit_repair() {
app.status_msg = Some(format!("editor: {e}"));
}
enable_raw_mode().map_err(io_err)?;
execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
mouse_capture_on(io::stdout()).map_err(io_err)?;
let backend = CrosstermBackend::new(io::stdout());
terminal = Terminal::new(backend).map_err(io_err)?;
}
Action::OpenAgent(filename) => {
drop(terminal);
let _ = disable_raw_mode();
let _ = mouse_capture_off(io::stdout());
let _ = execute!(io::stdout(), LeaveAlternateScreen);
let edit_result = (|| -> Result<()> {
let agent = editor::resolve_agent(app.store.config.agent.as_deref())?;
let issue = app.store.get(IssueSelector::Filename(filename.clone()))?;
let temp = std::env::temp_dir().join(format!(
"tix-agent-{}-{}.md",
std::process::id(),
issue.number
));
std::fs::write(&temp, issue.body.as_bytes()).map_err(io_err)?;
let result = std::process::Command::new(&agent)
.arg(&temp)
.status()
.map_err(|e| {
crate::error::TixError::Editor(format!("failed to launch {agent}: {e}"))
});
let _ = std::fs::remove_file(&temp);
result?;
Ok(())
})();
if let Err(e) = edit_result {
app.status_msg = Some(format!("agent: {e}"));
}
enable_raw_mode().map_err(io_err)?;
execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
mouse_capture_on(io::stdout()).map_err(io_err)?;
let backend = CrosstermBackend::new(io::stdout());
terminal = Terminal::new(backend).map_err(io_err)?;
}
Action::OpenViewer(filename) => {
drop(terminal);
let _ = disable_raw_mode();
let _ = mouse_capture_off(io::stdout());
let _ = execute!(io::stdout(), LeaveAlternateScreen);
let view_result = (|| -> Result<()> {
let viewer = editor::resolve_viewer(app.store.config.viewer.as_deref())?;
let issue = app.store.get(IssueSelector::Filename(filename.clone()))?;
let path = app.store.issues_dir.join(&issue.filename);
editor::run_viewer(&viewer, &path)?;
app.status_msg = Some(format!("Viewed with {viewer}"));
Ok(())
})();
if let Err(e) = view_result {
app.status_msg = Some(format!("viewer: {e}"));
}
enable_raw_mode().map_err(io_err)?;
execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
mouse_capture_on(io::stdout()).map_err(io_err)?;
let backend = CrosstermBackend::new(io::stdout());
terminal = Terminal::new(backend).map_err(io_err)?;
}
Action::OpenViewerAt(path) => {
drop(terminal);
let _ = disable_raw_mode();
let _ = mouse_capture_off(io::stdout());
let _ = execute!(io::stdout(), LeaveAlternateScreen);
let view_result = (|| -> Result<()> {
let viewer = editor::resolve_viewer(app.store.config.viewer.as_deref())?;
editor::run_viewer(&viewer, &path)?;
app.status_msg = Some(format!("Viewed with {viewer}"));
Ok(())
})();
if let Err(e) = view_result {
app.status_msg = Some(format!("viewer: {e}"));
}
enable_raw_mode().map_err(io_err)?;
execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
mouse_capture_on(io::stdout()).map_err(io_err)?;
let backend = CrosstermBackend::new(io::stdout());
terminal = Terminal::new(backend).map_err(io_err)?;
}
Action::OpenRepo(path) => {
drop(terminal);
let _ = disable_raw_mode();
let _ = mouse_capture_off(io::stdout());
let _ = execute!(io::stdout(), LeaveAlternateScreen);
if let Err(e) = run_child_tix(&path, &app.project_root()) {
app.status_msg = Some(format!("repo: {e}"));
}
enable_raw_mode().map_err(io_err)?;
execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
mouse_capture_on(io::stdout()).map_err(io_err)?;
let backend = CrosstermBackend::new(io::stdout());
terminal = Terminal::new(backend).map_err(io_err)?;
let _ = app.refresh_corpus();
}
}
Ok(terminal)
}
pub fn render_once(config: &Config) -> Result<String> {
let size = resolve_render_size(crossterm::terminal::size().ok());
render_once_at(config, size.0, size.1)
}
pub(in crate::app) fn resolve_render_size(measured: Option<(u16, u16)>) -> (u16, u16) {
let (width, height) = measured.unwrap_or((RENDER_FALLBACK_WIDTH, RENDER_FALLBACK_HEIGHT));
(width.max(1), height.max(1))
}
pub(in crate::app) fn render_once_at(config: &Config, width: u16, height: u16) -> Result<String> {
let render_h = height.max(1).saturating_sub(RENDER_BOTTOM_MARGIN).max(1);
let mut app = App::new(config)?;
app.mode = Mode::Board;
app.rollover_dialog = None;
app.one_shot = true;
let cells = render_to_cells(&app, width, render_h)?;
Ok(cells_to_text(&cells, width, render_h))
}
pub(in crate::app) fn render_to_cells(app: &App, width: u16, height: u16) -> Result<Vec<String>> {
let backend = ratatui::backend::TestBackend::new(width.max(1), height.max(1));
let mut terminal = ratatui::Terminal::new(backend).map_err(io_err)?;
terminal
.draw(|f| render(app, f, f.area()))
.map_err(io_err)?;
let buffer = terminal.backend().buffer();
Ok(buffer
.content
.iter()
.map(|cell| cell.symbol().to_string())
.collect())
}
pub(in crate::app) fn cells_to_text(cells: &[String], width: u16, height: u16) -> String {
let mut lines = Vec::new();
for row in 0..height as usize {
let start = row * width as usize;
let end = start + width as usize;
let line = cells[start..end].concat();
lines.push(line.trim_end().to_string());
}
while matches!(lines.last(), Some(last) if last.is_empty()) {
let _ = lines.pop();
}
if lines.is_empty() {
String::new()
} else {
format!("{}\n", lines.join("\n"))
}
}
fn mouse_capture_on(mut stream: io::Stdout) -> Result<()> {
execute!(stream, EnableMouseCapture).map_err(io_err)
}
fn mouse_capture_off(mut stream: io::Stdout) -> Result<()> {
execute!(stream, DisableMouseCapture).map_err(io_err)
}
struct RestoreGuard;
impl Drop for RestoreGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = mouse_capture_off(io::stdout());
let _ = execute!(io::stdout(), LeaveAlternateScreen);
}
}
pub(in crate::app) fn io_err<E: std::fmt::Display>(e: E) -> TixError {
TixError::Io(io::Error::other(e.to_string()))
}