finery 0.2.0

Keyboard-first Jira backlog and Composer with optional MCP access
Documentation
use std::{cell::RefCell, rc::Rc, time::Duration};

use ratatui::{
    Frame,
    layout::{Constraint, Rect},
    style::{Modifier, Style},
    text::{Line, Text},
};
use tuicore::{
    ActivationMode, AnimationSettings, CellContext, Column, EventCtx, EventOutcome, EventRoute,
    FocusCtx, FocusId, FocusTarget, LayoutCtx, LayoutProposal, LayoutResult, LayoutSizeHint,
    LifecycleCtx, ListControl, ListControlEvent, Panel, RenderCtx, TickResult, TuiEvent, TuiNode,
};

use crate::{
    service::AppService,
    store::composer::{ComposerAction, ComposerState},
};

#[derive(Clone)]
struct ChangeSetRow {
    id: String,
    name: String,
    subtitle: String,
}

pub(super) struct ChangeSetListView {
    state: Rc<RefCell<ComposerState>>,
    service: AppService,
    control: ListControl<ChangeSetRow, String>,
}

impl ChangeSetListView {
    pub(super) fn new(state: Rc<RefCell<ComposerState>>, service: AppService) -> Self {
        let rows = rows(&state.borrow());
        let control = ListControl::new(
            rows,
            |row: &ChangeSetRow| row.id.clone(),
            |name, rows| {
                let next = rows
                    .iter()
                    .filter_map(|row| row.id.strip_prefix("CS-")?.parse::<usize>().ok())
                    .max()
                    .unwrap_or(0)
                    + 1;
                ChangeSetRow {
                    id: format!("CS-{next}"),
                    name,
                    subtitle: "0 tickets · ready to compose".into(),
                }
            },
        )
        .column(change_set_column())
        .row_height(2)
        .title("Change sets")
        .panel(Panel::new().top_left("Change sets").one_row(true))
        .activation_mode(ActivationMode::OnActivateKey)
        .confirm_remove("Delete change set?", |row| {
            format!(
                "Delete {} · {}? This removes its local ticket snapshots.",
                row.id, row.name
            )
        });
        Self {
            state,
            service,
            control,
        }
    }

    pub(super) fn sync(&mut self) {
        self.control
            .data_view_mut()
            .set_rows(rows(&self.state.borrow()));
    }

    fn drain_events(&mut self, ctx: &mut EventCtx<()>) {
        for event in self.control.take_events() {
            match event {
                ListControlEvent::Added { row_id } => {
                    if let Some(row) = self.control.items().iter().find(|row| row.id == row_id) {
                        let _ = self
                            .state
                            .borrow_mut()
                            .dispatch(ComposerAction::CreateChangeSet {
                                id: row.id.clone(),
                                name: row.name.clone(),
                            });
                        if let Some(set) = self
                            .state
                            .borrow()
                            .change_sets
                            .iter()
                            .find(|set| set.id == row.id)
                            .cloned()
                        {
                            self.service.save_change_set(set);
                        }
                        let _ = self
                            .state
                            .borrow_mut()
                            .dispatch(ComposerAction::OpenChangeSet(row_id.clone()));
                        ctx.request_layout();
                        ctx.request_redraw();
                    }
                }
                ListControlEvent::Removed { row_id } => {
                    if self.state.borrow().change_set_is_submitting(&row_id) {
                        self.service
                            .report_notification(tuicore::Notification::error(
                                "Delete blocked",
                                "Cannot delete a change set with an unresolved Jira submission attempt",
                            ));
                        self.sync();
                        ctx.request_layout();
                        ctx.request_redraw();
                        continue;
                    }
                    let _ = self
                        .state
                        .borrow_mut()
                        .dispatch(ComposerAction::DeleteChangeSet(row_id.clone()));
                    self.service.delete_change_set(row_id);
                }
                _ => {}
            }
        }
        for event in self.control.data_view_mut().drain_events() {
            if let tuicore::DataViewTypedEvent::Activated { row_id } = event {
                let _ = self
                    .state
                    .borrow_mut()
                    .dispatch(ComposerAction::OpenChangeSet(row_id));
                ctx.request_layout();
                ctx.request_redraw();
            }
        }
    }
}

fn rows(state: &ComposerState) -> Vec<ChangeSetRow> {
    let mut rows: Vec<_> = state
        .change_sets
        .iter()
        .map(|set| {
            let submitted = set.submitted_count();
            let state = if set.closed { "closed" } else { "open" };
            ChangeSetRow {
                id: set.id.clone(),
                name: set.name.clone(),
                subtitle: format!("{submitted}/{} submitted · {state}", set.tickets.len()),
            }
        })
        .collect();
    rows.reverse();
    rows
}

fn change_set_column() -> Column<ChangeSetRow, String> {
    Column::multiline(
        "change_set",
        "",
        Constraint::Percentage(100),
        |row: &ChangeSetRow, _: &CellContext<String>| {
            let theme = tuicore::theme();
            Text::from(vec![
                Line::styled(
                    format!("{} · {}", row.id, row.name),
                    Style::default()
                        .fg(theme.text_fg())
                        .add_modifier(Modifier::BOLD),
                ),
                Line::styled(row.subtitle.clone(), Style::default().fg(theme.subtle_fg())),
            ])
        },
    )
}

impl TuiNode for ChangeSetListView {
    fn measure(&self, proposal: LayoutProposal) -> LayoutSizeHint {
        self.control.measure(proposal)
    }
    fn layout(&mut self, area: Rect, ctx: &mut LayoutCtx) -> LayoutResult {
        self.control.layout(area, ctx)
    }
    fn render<'a>(&'a self, frame: &mut Frame, area: Rect, ctx: &mut RenderCtx<'a>) {
        self.control.render(frame, area, ctx);
    }
    fn event(&mut self, event: &TuiEvent, ctx: &mut EventCtx<()>) -> EventOutcome {
        let outcome = self.control.event(event, ctx);
        self.drain_events(ctx);
        outcome
    }
    fn dispatch_event(
        &mut self,
        route: &EventRoute,
        event: &TuiEvent,
        ctx: &mut EventCtx<()>,
    ) -> EventOutcome {
        let outcome = self.control.dispatch_event(route, event, ctx);
        self.drain_events(ctx);
        outcome
    }
    fn tick(&mut self, dt: Duration, settings: AnimationSettings) -> TickResult {
        self.control.tick(dt, settings)
    }
    fn focus(&mut self, target: Option<&FocusId>, focused: bool, ctx: &mut FocusCtx<()>) {
        self.control.focus(target, focused, ctx);
    }
    fn dispatch_focus(&mut self, target: &FocusTarget, focused: bool, ctx: &mut FocusCtx<()>) {
        self.control.dispatch_focus(target, focused, ctx);
    }
    fn init(&mut self, ctx: &mut LifecycleCtx<()>) {
        self.control.init(ctx);
    }
    fn mount(&mut self, ctx: &mut LifecycleCtx<()>) {
        self.control.mount(ctx);
    }
    fn unmount(&mut self, ctx: &mut LifecycleCtx<()>) {
        self.control.unmount(ctx);
    }
    fn destroy(&mut self, ctx: &mut LifecycleCtx<()>) {
        self.control.destroy(ctx);
    }
}