qqq-cli 0.7.0

Local-first task queue for coding agent sessions
use super::render::{PopupKind, PopupRow};
use crate::db::{ContentConflict, CurrentContent};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use unicode_segmentation::UnicodeSegmentation;

#[derive(Clone, Copy)]
enum Resolution {
    Reload,
    Overwrite,
}

pub enum Action {
    Keep,
    Reload,
    Overwrite(i64),
}

pub struct View {
    id: i64,
    current: Option<CurrentContent>,
    local: String,
    local_page: bool,
    top: usize,
    confirmation: Option<Resolution>,
}

impl View {
    pub fn new(conflict: &ContentConflict, local: String) -> Self {
        Self {
            id: conflict.task_id,
            current: conflict.current.as_ref().map(|current| CurrentContent {
                description: current.description.clone(),
                revision: current.revision,
            }),
            local,
            local_page: false,
            top: 0,
            confirmation: None,
        }
    }

    pub fn mark_removed(&mut self) {
        self.current = None;
        self.local_page = false;
        self.top = 0;
        self.confirmation = None;
    }

    fn text(&self) -> &str {
        if self.local_page {
            &self.local
        } else {
            self.current
                .as_ref()
                .map_or("Task removed. Local draft kept.", |current| {
                    current.description.as_str()
                })
        }
    }

    fn wrapped(&self, width: usize) -> Vec<String> {
        let fragments: Vec<_> = self.text().graphemes(true).map(str::to_owned).collect();
        super::render::Layout::new(&fragments, &[], width.max(1)).rows
    }

    pub fn rows(&self, width: usize, height: usize) -> Vec<PopupRow> {
        if let Some(resolution) = self.confirmation {
            return vec![
                PopupRow::new(
                    match resolution {
                        Resolution::Reload => "Reload current DB text?",
                        Resolution::Overwrite => "Overwrite DB text?",
                    },
                    PopupKind::Heading,
                ),
                PopupRow::new(
                    match resolution {
                        Resolution::Reload => "Discard local text and pending images",
                        Resolution::Overwrite => "Replace DB text with local draft",
                    },
                    PopupKind::Warning,
                ),
                PopupRow::new("y confirm  n/Esc cancel", PopupKind::Hint),
            ];
        }
        let wrapped = self.wrapped(width);
        let available = height.saturating_sub(3).max(1);
        let start = self.top.min(wrapped.len().saturating_sub(available));
        let title = if self.local_page {
            "Local draft".to_owned()
        } else {
            self.current
                .as_ref()
                .map_or("Current DB: task removed".to_owned(), |current| {
                    format!("Current DB revision {}", current.revision)
                })
        };
        let mut rows = vec![
            PopupRow::new(format!("Content conflict #{}", self.id), PopupKind::Error),
            PopupRow::new(title, PopupKind::Heading),
        ];
        rows.extend(
            wrapped
                .into_iter()
                .skip(start)
                .take(available)
                .map(|text| PopupRow::new(text, PopupKind::Body)),
        );
        rows.push(PopupRow::new(
            if self.current.is_some() {
                "Tab text  r reload  o overwrite  Esc keep"
            } else {
                "Tab text  Esc keep"
            },
            PopupKind::Hint,
        ));
        rows
    }

    pub fn key(&mut self, key: KeyEvent, width: usize, height: usize) -> Option<Action> {
        if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
            self.confirmation = None;
            return Some(Action::Keep);
        }
        if key
            .modifiers
            .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
        {
            return None;
        }
        if let Some(resolution) = self.confirmation {
            match key.code {
                KeyCode::Char('y' | 'Y') => {
                    self.confirmation = None;
                    return match resolution {
                        Resolution::Reload => Some(Action::Reload),
                        Resolution::Overwrite => self
                            .current
                            .as_ref()
                            .map(|current| Action::Overwrite(current.revision)),
                    };
                }
                KeyCode::Char('n' | 'N') | KeyCode::Enter | KeyCode::Esc => {
                    self.confirmation = None
                }
                _ => (),
            }
            return None;
        }
        let page = height.saturating_sub(3).max(1);
        let max_top = self.wrapped(width).len().saturating_sub(page);
        match key.code {
            KeyCode::Esc => return Some(Action::Keep),
            KeyCode::Tab | KeyCode::BackTab => {
                self.local_page = !self.local_page;
                self.top = 0;
            }
            KeyCode::Up => self.top = self.top.saturating_sub(1),
            KeyCode::Down => self.top = (self.top + 1).min(max_top),
            KeyCode::PageUp => self.top = self.top.saturating_sub(page),
            KeyCode::PageDown => self.top = self.top.saturating_add(page).min(max_top),
            KeyCode::Home => self.top = 0,
            KeyCode::End => self.top = max_top,
            KeyCode::Char('r' | 'R') if self.current.is_some() => {
                self.confirmation = Some(Resolution::Reload)
            }
            KeyCode::Char('o' | 'O') if self.current.is_some() => {
                self.confirmation = Some(Resolution::Overwrite)
            }
            _ => (),
        }
        None
    }
}

#[cfg(test)]
mod tests {
    use super::View;
    use crate::db::{ContentConflict, CurrentContent};
    use crate::tui::dashboard::{popup, popup_layout};
    use ratatui::{Terminal, backend::TestBackend, layout::Rect};

    #[test]
    fn current_and_local_wrap_edges_remain_visible_in_popup() {
        for width in [12, 20, 59, 72] {
            let area = Rect::new(0, 0, width, 24);
            let columns = usize::from(popup_layout(area, usize::MAX).content.width);
            let edge = format!("   {}XY", ".".repeat(columns - 5));
            let text = format!("{edge}Z\nFull tail");
            let mut view = View::new(
                &ContentConflict {
                    task_id: 2,
                    expected_revision: 1,
                    current: Some(CurrentContent {
                        description: text.clone(),
                        revision: 2,
                    }),
                },
                text,
            );
            for local in [false, true] {
                view.local_page = local;
                let lines = view.rows(columns, 18);
                let content = popup_layout(area, lines.len()).content;
                for color in [false, true] {
                    let mut terminal = Terminal::new(TestBackend::new(width, 24)).unwrap();
                    terminal
                        .draw(|frame| popup(frame, &lines, color, true))
                        .unwrap();
                    let buffer = terminal.backend().buffer();
                    let row: String = (content.x..content.x + content.width)
                        .map(|x| buffer[(x, content.y + 2)].symbol())
                        .collect();
                    assert_eq!(row, edge, "width={width}, local={local}, color={color}");
                    assert_eq!(buffer[(content.x, content.y + 3)].symbol(), "Z");
                }
            }
        }
    }
}