finery 0.15.0

Keyboard-first Jira backlog and Composer with optional MCP access
Documentation
use std::{
    cell::RefCell,
    collections::HashMap,
    rc::Rc,
    sync::atomic::{AtomicU64, Ordering},
    time::{Duration, SystemTime, UNIX_EPOCH},
};

use ratatui::{
    Frame,
    layout::{Constraint, Rect},
    style::Style,
};
use tuicore::{
    AnimationSettings, Column, EventCtx, EventOutcome, EventRoute, FocusCtx, FocusId, FocusTarget,
    LayoutCtx, LayoutProposal, LayoutResult, LayoutSizeHint, LifecycleCtx, ListControl,
    ListControlEvent, ListControlField, RenderCtx, TickResult, TuiEvent, TuiNode, theme,
};

use crate::{
    app_settings::ComposerSequenceBinding,
    service::{AppService, composer_service::validate_web_link},
    store::composer::{ComposerAction, ComposerState, ComposerViewMode, TicketWebLink},
};

type PendingActions = Rc<RefCell<Vec<ComposerAction>>>;
type WebLinkControl = ListControl<WebLinkRow, String>;

#[derive(Clone, Copy, PartialEq, Eq)]
enum WebLinkDiff {
    None,
    Added,
    Removed,
}

#[derive(Clone, PartialEq, Eq)]
struct WebLinkRow {
    row_id: String,
    link_id: String,
    title: String,
    url: String,
    diff: WebLinkDiff,
}

pub(super) struct BoundWebLinks {
    state: Rc<RefCell<ComposerState>>,
    pending: PendingActions,
    service: AppService,
    control: WebLinkControl,
    synced_rows: Vec<WebLinkRow>,
    disabled: bool,
}

impl BoundWebLinks {
    pub(super) fn new(
        state: Rc<RefCell<ComposerState>>,
        pending: PendingActions,
        service: AppService,
        hotkey: ComposerSequenceBinding,
    ) -> Self {
        let mut control = ListControl::new_fields(
            [],
            |row: &WebLinkRow| row.row_id.clone(),
            [
                ListControlField::text("Link title"),
                ListControlField::text("https://example.com"),
            ],
            |values, _| WebLinkRow {
                row_id: local_web_link_id(),
                link_id: String::new(),
                title: values[0].trim().into(),
                url: values[1].trim().into(),
                diff: WebLinkDiff::None,
            },
        )
        .editable(
            |row| vec![row.title.clone(), row.url.clone()],
            |row, values| {
                row.title = values[0].trim().into();
                row.url = values[1].trim().into();
            },
        )
        .columns([
            Column::text("title", "", Constraint::Length(0), web_link_title),
            Column::text("url", "", Constraint::Length(0), |row: &WebLinkRow| {
                row.url.clone()
            }),
        ])
        .headers(false)
        .focus_id("web-links-data-view")
        .row_height(1)
        .max_rows(3)
        .filter_controls(false)
        .action_bar(true)
        .title("Web links")
        .hotkey(hotkey.sequence())
        .empty_message("No web links");
        control.data_view_mut().set_row_style_by(|row| {
            let theme = theme();
            match row.diff {
                WebLinkDiff::None => None,
                WebLinkDiff::Added => Some(
                    Style::default()
                        .fg(theme.diff_added_fg())
                        .bg(theme.diff_added_bg()),
                ),
                WebLinkDiff::Removed => Some(
                    Style::default()
                        .fg(theme.diff_removed_fg())
                        .bg(theme.diff_removed_bg()),
                ),
            }
        });
        let mut bound = Self {
            state,
            pending,
            service,
            control,
            synced_rows: Vec::new(),
            disabled: false,
        };
        bound.sync();
        bound
    }

    fn sync(&mut self) -> bool {
        let state = self.state.borrow();
        let rows = web_link_rows(&state);
        let disabled = !state.selected_is_editable();
        let changed = rows != self.synced_rows || disabled != self.disabled;
        if rows != self.synced_rows {
            self.control.set_rows(rows.clone());
            self.synced_rows = rows;
        }
        if disabled != self.disabled {
            self.control.set_disabled(disabled);
            self.disabled = disabled;
        }
        changed
    }

    fn drain_events(&mut self) {
        for event in self.control.take_events() {
            let action = match event {
                ListControlEvent::Added { row_id } => {
                    let row = self
                        .control
                        .items()
                        .iter()
                        .find(|row| row.row_id == row_id)
                        .cloned();
                    row.and_then(|row| {
                        self.valid_values(&row.title, &row.url).map_or_else(
                            || {
                                self.control.set_rows(self.synced_rows.clone());
                                None
                            },
                            |(title, url)| {
                                Some(ComposerAction::AddWebLink {
                                    id: row_id,
                                    title,
                                    url,
                                })
                            },
                        )
                    })
                }
                ListControlEvent::Edited { row_id } => {
                    let row = self
                        .control
                        .items()
                        .iter()
                        .find(|row| row.row_id == row_id)
                        .cloned();
                    row.and_then(|row| {
                        self.valid_values(&row.title, &row.url).map_or_else(
                            || {
                                self.control.set_rows(self.synced_rows.clone());
                                None
                            },
                            |(title, url)| {
                                Some(ComposerAction::UpdateWebLink {
                                    id: row.link_id,
                                    title,
                                    url,
                                })
                            },
                        )
                    })
                }
                ListControlEvent::Removed { row_id } => self
                    .synced_rows
                    .iter()
                    .find(|row| row.row_id == row_id)
                    .map(|row| ComposerAction::RemoveWebLink(row.link_id.clone())),
                _ => None,
            };
            if let Some(action) = action {
                self.pending.borrow_mut().push(action);
            }
        }
    }

    fn valid_values(&self, title: &str, url: &str) -> Option<(String, String)> {
        let values = validated_web_link(title, url);
        if values.is_none() {
            self.service
                .report_notification(tuicore::Notification::error(
                    "Invalid web link",
                    "Enter a title and a valid web address.",
                ));
        }
        values
    }
}

fn validated_web_link(title: &str, url: &str) -> Option<(String, String)> {
    validate_web_link(title.into(), url.into()).ok()
}

fn web_link_rows(state: &ComposerState) -> Vec<WebLinkRow> {
    if state.view_mode != ComposerViewMode::Diff {
        return state
            .selected_ticket()
            .into_iter()
            .flat_map(|ticket| &ticket.web_links)
            .map(|link| row(link, link.id.clone(), WebLinkDiff::None))
            .collect();
    }
    let source = state
        .selected_source()
        .map(|ticket| &ticket.web_links)
        .cloned()
        .unwrap_or_default();
    let changes = state
        .selected_changes()
        .map(|ticket| &ticket.web_links)
        .cloned()
        .unwrap_or_default();
    let source_by_id = source
        .iter()
        .map(|link| (link.id.as_str(), link))
        .collect::<HashMap<_, _>>();
    let changes_by_id = changes
        .iter()
        .map(|link| (link.id.as_str(), link))
        .collect::<HashMap<_, _>>();
    let mut rows = Vec::new();
    for link in &source {
        match changes_by_id.get(link.id.as_str()) {
            Some(changed) if changed.title == link.title && changed.url == link.url => {
                rows.push(row(link, link.id.clone(), WebLinkDiff::None));
            }
            Some(changed) => {
                rows.push(row(
                    link,
                    format!("diff-removed:{}", link.id),
                    WebLinkDiff::Removed,
                ));
                rows.push(row(
                    changed,
                    format!("diff-added:{}", link.id),
                    WebLinkDiff::Added,
                ));
            }
            None => rows.push(row(
                link,
                format!("diff-removed:{}", link.id),
                WebLinkDiff::Removed,
            )),
        }
    }
    rows.extend(
        changes
            .iter()
            .filter(|link| !source_by_id.contains_key(link.id.as_str()))
            .map(|link| row(link, format!("diff-added:{}", link.id), WebLinkDiff::Added)),
    );
    rows
}

fn row(link: &TicketWebLink, row_id: String, diff: WebLinkDiff) -> WebLinkRow {
    WebLinkRow {
        row_id,
        link_id: link.id.clone(),
        title: link.title.clone(),
        url: link.url.clone(),
        diff,
    }
}

fn web_link_title(row: &WebLinkRow) -> String {
    let marker = match row.diff {
        WebLinkDiff::None => "",
        WebLinkDiff::Added => "+ ",
        WebLinkDiff::Removed => "- ",
    };
    format!("{marker}{}", row.title)
}

fn local_web_link_id() -> String {
    static NEXT: AtomicU64 = AtomicU64::new(1);
    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis();
    format!("local-{timestamp}-{}", NEXT.fetch_add(1, Ordering::Relaxed))
}

impl TuiNode for BoundWebLinks {
    fn measure(&self, proposal: LayoutProposal) -> LayoutSizeHint {
        self.control.measure(proposal)
    }
    fn layout(&mut self, area: Rect, ctx: &mut LayoutCtx) -> LayoutResult {
        self.sync();
        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();
        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();
        outcome
    }
    fn tick(&mut self, dt: Duration, settings: AnimationSettings) -> TickResult {
        self.control.tick(dt, settings).merge(if self.sync() {
            TickResult::CHANGED
        } else {
            TickResult::IDLE
        })
    }
    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);
    }
}

#[cfg(test)]
mod tests {
    use super::validated_web_link;

    #[test]
    fn web_link_validation_rejects_malformed_and_unsupported_urls() {
        assert!(validated_web_link("Docs", "not a url").is_none());
        assert!(validated_web_link("Docs", "localhost").is_none());
        assert!(validated_web_link("Docs", "https://intranet").is_none());
        assert!(validated_web_link("Docs", "www..example.com").is_none());
        assert!(validated_web_link("Docs", "-bad.example.com").is_none());
        assert!(validated_web_link("Docs", "ftp://example.com/docs").is_none());
        assert!(validated_web_link("", "https://example.com/docs").is_none());
        assert_eq!(
            validated_web_link(" Docs ", " https://example.com/docs "),
            Some(("Docs".into(), "https://example.com/docs".into()))
        );
        assert_eq!(
            validated_web_link("Google", "www.google.com"),
            Some(("Google".into(), "https://www.google.com".into()))
        );
    }
}