Skip to main content

vissue_tui/
app.rs

1//! Board state and key handling. Drawing lives in [`crate::view`].
2
3use std::path::PathBuf;
4
5use ratatui::crossterm::event::KeyCode;
6use ratatui::crossterm::event::KeyEvent;
7use vissue_core::config::Layout;
8use vissue_core::views::{IssueDetail, ListQuery};
9
10use crate::attach::{AttachHooks, AttachOutcome, ServeStatus, try_attach};
11use crate::backend::{BoardBackend, ListPage, UpdateReq};
12use crate::core_backend::CoreBackend;
13use crate::keys::{
14    Action, ConfirmKind, DetailTab, Focus, HELP, Pane, PromptKind, char_of, is_press,
15};
16
17/// One displayed row. Every pane maps onto this shape so keys share a path.
18#[derive(Debug, Clone)]
19pub struct BoardRow {
20    /// Issue id shown in the first column.
21    pub id: String,
22    /// Org TODO state (`TODO`, `STARTED`, ...).
23    pub state: String,
24    /// Priority letter (`A`, `B`, or `C`).
25    pub priority: String,
26    /// Heading title.
27    pub title: String,
28    /// Project name.
29    pub project: String,
30    /// Pane-specific suffix: holder, agenda date, or search snippet.
31    pub extra: String,
32}
33
34/// Interactive board. Talks only to [`BoardBackend`].
35#[derive(Debug)]
36pub struct App {
37    backend: Box<dyn BoardBackend>,
38    agent: String,
39    status: ServeStatus,
40    message: String,
41    /// Active list pane.
42    pub pane: Pane,
43    /// Detail pane tab (show / excerpt / tree / related).
44    pub detail_tab: DetailTab,
45    /// Whether keys target the row list or the detail pane.
46    pub focus: Focus,
47    /// Rows for the current pane.
48    pub rows: Vec<BoardRow>,
49    /// Index into [`Self::rows`].
50    pub selected: usize,
51    /// Project filter, if any.
52    pub project: Option<String>,
53    /// Known project names for the `p` prompt.
54    pub projects: Vec<String>,
55    /// Last loaded issue detail, if any.
56    pub detail: Option<IssueDetail>,
57    /// Text drawn in the detail pane.
58    pub detail_body: String,
59    /// Open line prompt and its buffer.
60    pub prompt: Option<(PromptKind, String)>,
61    /// Pending DONE/CANCELLED confirmation.
62    pub confirm: Option<ConfirmKind>,
63    /// Help overlay is visible.
64    pub help: bool,
65    /// Last id copied with `y`.
66    pub clipboard: String,
67    search_query: String,
68}
69
70impl App {
71    /// Open a file-backed board and load the Ready pane.
72    ///
73    /// # Errors
74    ///
75    /// Returns an error if the vault cannot be parsed or the first pane cannot
76    /// be loaded.
77    pub fn open_core(layout: Layout, agent: String) -> Result<Self, vissue_core::error::Error> {
78        let backend = CoreBackend::open(layout, agent.clone())?;
79        Self::with_backend(Box::new(backend), agent, ServeStatus::Offline)
80    }
81
82    /// Build a board around an existing backend and load the Ready pane.
83    ///
84    /// # Errors
85    ///
86    /// Returns an error if the first pane cannot be loaded.
87    pub fn with_backend(
88        backend: Box<dyn BoardBackend>,
89        agent: String,
90        status: ServeStatus,
91    ) -> Result<Self, vissue_core::error::Error> {
92        let projects = backend.projects().unwrap_or_default();
93        let mut app = Self {
94            backend,
95            agent,
96            status,
97            message: String::new(),
98            pane: Pane::Ready,
99            detail_tab: DetailTab::Show,
100            focus: Focus::Rows,
101            rows: Vec::new(),
102            selected: 0,
103            project: None,
104            projects,
105            detail: None,
106            detail_body: String::new(),
107            prompt: None,
108            confirm: None,
109            help: false,
110            clipboard: String::new(),
111            search_query: String::new(),
112        };
113        app.reload()?;
114        Ok(app)
115    }
116
117    /// How the status line labels the current store.
118    pub fn serve_status(&self) -> ServeStatus {
119        self.status
120    }
121
122    /// Identity used for claims and updates.
123    pub fn agent(&self) -> &str {
124        &self.agent
125    }
126
127    /// Catalog generation from the current backend.
128    pub fn generation(&self) -> u64 {
129        self.backend.generation()
130    }
131
132    /// Serve revision from the current backend. Core is always 0.
133    pub fn revision(&self) -> u64 {
134        self.backend.revision()
135    }
136
137    /// Id of the selected row, if the pane is not empty.
138    pub fn selected_id(&self) -> Option<&str> {
139        self.rows.get(self.selected).map(|r| r.id.as_str())
140    }
141
142    /// Org state of the selected row, if the pane is not empty.
143    pub fn selected_state(&self) -> Option<&str> {
144        self.rows.get(self.selected).map(|r| r.state.as_str())
145    }
146
147    /// The store this board is talking to.
148    pub fn backend(&self) -> &dyn BoardBackend {
149        self.backend.as_ref()
150    }
151
152    /// Swap the store and adopt its identity. Does not reload rows.
153    pub fn replace_backend(&mut self, backend: Box<dyn BoardBackend>, status: ServeStatus) {
154        self.backend = backend;
155        self.status = status;
156        self.agent = self.backend.identity().to_string();
157    }
158
159    /// Post-paint attach. `--offline` never probes the socket.
160    ///
161    /// # Errors
162    ///
163    /// Returns an error if the pane cannot be reloaded after the attach attempt.
164    pub fn attach(
165        &mut self,
166        socket: &std::path::Path,
167        offline: bool,
168        hooks: &AttachHooks,
169    ) -> Result<(), vissue_core::error::Error> {
170        let layout = self.backend.layout().clone();
171        let agent = self.agent.clone();
172        match try_attach(&layout, socket, &agent, offline, hooks) {
173            AttachOutcome::Switch { backend, status } => {
174                self.replace_backend(backend, status);
175                self.message.clear();
176            }
177            AttachOutcome::Stay { status, message } => {
178                self.status = status;
179                self.message = message;
180            }
181        }
182        self.reload()
183    }
184
185    /// One-line `serve:` / gen / rev / agent / project / message summary.
186    pub fn status_line(&self) -> String {
187        let kind = match self.status {
188            ServeStatus::Live => "live",
189            ServeStatus::Offline => "offline",
190            ServeStatus::Mismatch => "mismatch",
191        };
192        let mut line = format!(
193            "serve:{kind} gen={} rev={} agent={}",
194            self.backend.generation(),
195            self.backend.revision(),
196            self.agent
197        );
198        if let Some(project) = &self.project {
199            line.push_str(" project=");
200            line.push_str(project);
201        }
202        if !self.message.is_empty() {
203            line.push_str("  ");
204            line.push_str(&self.message);
205        }
206        line
207    }
208
209    /// Fetch the current pane from the backend and refresh detail.
210    ///
211    /// # Errors
212    ///
213    /// Returns an error if the backend cannot load the pane.
214    pub fn reload(&mut self) -> Result<(), vissue_core::error::Error> {
215        let project = self.project.as_deref();
216        match self.pane {
217            Pane::Ready => self.apply_issue_page(self.backend.ready(project)?),
218            Pane::List => self.apply_issue_page(self.backend.list(ListQuery {
219                project: project.map(str::to_string),
220                ..ListQuery::default()
221            })?),
222            Pane::Claims => {
223                self.rows = self
224                    .backend
225                    .claims(None, project)?
226                    .into_iter()
227                    .map(row_from_claim)
228                    .collect();
229            }
230            Pane::Agenda => {
231                self.rows = self
232                    .backend
233                    .agenda(14, project)?
234                    .into_iter()
235                    .map(row_from_agenda)
236                    .collect();
237            }
238            Pane::Search => {
239                self.rows = if self.search_query.is_empty() {
240                    Vec::new()
241                } else {
242                    self.backend
243                        .search(&self.search_query, 50)?
244                        .into_iter()
245                        .map(row_from_search)
246                        .collect()
247                };
248            }
249        }
250        if self.selected >= self.rows.len() {
251            self.selected = self.rows.len().saturating_sub(1);
252        }
253        self.refresh_detail();
254        Ok(())
255    }
256
257    /// Serve answers `{unchanged: true, issues: []}` when `since_revision`
258    /// matches the catalog. Keep the rows from the last full page.
259    fn apply_issue_page(&mut self, page: ListPage) {
260        if page.unchanged {
261            return;
262        }
263        self.rows = page.issues.into_iter().map(row_from_issue).collect();
264    }
265
266    /// Wait briefly for a catalog change and reload when the watermark moves.
267    pub fn poll_updates(&mut self) {
268        let last = match self.backend.live() {
269            crate::backend::BackendKind::Control => self.backend.revision(),
270            crate::backend::BackendKind::Core => self.backend.generation(),
271        };
272        if let Ok(next) = self.backend.wait(last, 1)
273            && next > last
274        {
275            let _ = self.reload();
276        }
277    }
278
279    /// Dispatch one key. Repeat and press count; release is ignored.
280    pub fn handle_key(&mut self, key: KeyEvent) -> Action {
281        if !is_press(key) {
282            return Action::Continue;
283        }
284        if self.help {
285            if matches!(
286                key.code,
287                KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?')
288            ) {
289                self.help = false;
290            }
291            return Action::Continue;
292        }
293        if self.confirm.is_some() {
294            return self.handle_confirm(key);
295        }
296        if self.prompt.is_some() {
297            return self.handle_prompt(key);
298        }
299        match key.code {
300            KeyCode::Char('q') => Action::Quit,
301            KeyCode::Esc => {
302                if self.focus == Focus::Detail {
303                    self.focus = Focus::Rows;
304                    Action::Continue
305                } else {
306                    Action::Quit
307                }
308            }
309            KeyCode::Char('j') | KeyCode::Down => {
310                self.move_sel(1);
311                Action::Continue
312            }
313            KeyCode::Char('k') | KeyCode::Up => {
314                self.move_sel(-1);
315                Action::Continue
316            }
317            KeyCode::Tab => self.goto_pane(self.pane.next()),
318            KeyCode::Char('1') => self.goto_pane(Pane::Ready),
319            KeyCode::Char('2') => self.goto_pane(Pane::List),
320            KeyCode::Char('3') => self.goto_pane(Pane::Claims),
321            KeyCode::Char('4') => self.goto_pane(Pane::Agenda),
322            KeyCode::Char('5') => self.goto_pane(Pane::Search),
323            KeyCode::Enter => {
324                if self.focus == Focus::Detail {
325                    self.detail_tab = self.detail_tab.next();
326                    self.refresh_detail();
327                } else {
328                    self.focus = Focus::Detail;
329                    self.refresh_detail();
330                }
331                Action::Continue
332            }
333            KeyCode::Char('p') => {
334                self.prompt = Some((
335                    PromptKind::Project,
336                    self.project.clone().unwrap_or_default(),
337                ));
338                Action::Continue
339            }
340            KeyCode::Char('/') => {
341                if self.pane != Pane::Search {
342                    self.backend.invalidate_since();
343                    self.pane = Pane::Search;
344                }
345                self.prompt = Some((PromptKind::Search, self.search_query.clone()));
346                Action::Continue
347            }
348            KeyCode::Char('c') => {
349                self.claim_selected();
350                Action::Continue
351            }
352            KeyCode::Char('n') => {
353                if self.selected_id().is_some() {
354                    self.prompt = Some((PromptKind::Note, String::new()));
355                }
356                Action::Continue
357            }
358            KeyCode::Char('s') => {
359                self.cycle_state();
360                Action::Continue
361            }
362            KeyCode::Char('D') => {
363                if self.selected_id().is_some() {
364                    self.confirm = Some(ConfirmKind::Done);
365                    self.message = "confirm DONE? y/n".into();
366                }
367                Action::Continue
368            }
369            KeyCode::Char('X') => {
370                if self.selected_id().is_some() {
371                    self.confirm = Some(ConfirmKind::Cancelled);
372                    self.message = "confirm CANCELLED? y/n".into();
373                }
374                Action::Continue
375            }
376            KeyCode::Char('o') => {
377                self.open_selected();
378                Action::Continue
379            }
380            KeyCode::Char('y') => {
381                if let Some(id) = self.selected_id().map(str::to_string) {
382                    self.clipboard = id.clone();
383                    self.message = format!("copied {id}");
384                }
385                Action::Continue
386            }
387            KeyCode::Char('R') => {
388                let _ = self.reload();
389                self.message = "reloaded".into();
390                Action::Continue
391            }
392            KeyCode::Char('?') => {
393                self.help = true;
394                Action::Continue
395            }
396            _ => Action::Continue,
397        }
398    }
399
400    fn handle_confirm(&mut self, key: KeyEvent) -> Action {
401        let kind = self.confirm.unwrap();
402        match key.code {
403            KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
404                self.confirm = None;
405                self.apply_state(kind.state());
406            }
407            _ => {
408                self.confirm = None;
409                self.message.clear();
410            }
411        }
412        Action::Continue
413    }
414
415    fn handle_prompt(&mut self, key: KeyEvent) -> Action {
416        let Some((kind, mut text)) = self.prompt.take() else {
417            return Action::Continue;
418        };
419        match key.code {
420            KeyCode::Esc => {
421                self.message.clear();
422            }
423            KeyCode::Enter => match kind {
424                PromptKind::Search => {
425                    self.search_query = text;
426                    if self.pane != Pane::Search {
427                        self.backend.invalidate_since();
428                    }
429                    self.pane = Pane::Search;
430                    let _ = self.reload();
431                }
432                PromptKind::Note => {
433                    if let Some(id) = self.selected_id().map(str::to_string) {
434                        match self.backend.note(&id, &text) {
435                            Ok(result) => {
436                                self.message = result.report.trim().to_string();
437                                let _ = self.reload();
438                            }
439                            Err(err) => self.message = err.to_string(),
440                        }
441                    }
442                }
443                PromptKind::Project => {
444                    let trimmed = text.trim();
445                    let next = if trimmed.is_empty() {
446                        None
447                    } else {
448                        Some(trimmed.to_string())
449                    };
450                    if next != self.project {
451                        self.backend.invalidate_since();
452                    }
453                    self.project = next;
454                    let _ = self.reload();
455                }
456            },
457            KeyCode::Backspace => {
458                text.pop();
459                self.prompt = Some((kind, text));
460            }
461            _ => {
462                if let Some(c) = char_of(key) {
463                    text.push(c);
464                }
465                self.prompt = Some((kind, text));
466            }
467        }
468        Action::Continue
469    }
470
471    fn goto_pane(&mut self, pane: Pane) -> Action {
472        if self.pane != pane {
473            self.backend.invalidate_since();
474            self.pane = pane;
475        }
476        let _ = self.reload();
477        Action::Continue
478    }
479
480    fn move_sel(&mut self, delta: i32) {
481        if self.rows.is_empty() {
482            return;
483        }
484        let len = self.rows.len() as i32;
485        let next = (self.selected as i32 + delta).clamp(0, len - 1) as usize;
486        if next != self.selected {
487            self.selected = next;
488            self.refresh_detail();
489        }
490    }
491
492    fn claim_selected(&mut self) {
493        let Some(id) = self.selected_id().map(str::to_string) else {
494            return;
495        };
496        match self.backend.claim(&id, false) {
497            Ok(result) => {
498                self.message = result.report.trim().to_string();
499                let _ = self.reload();
500            }
501            Err(err) => self.message = err.to_string(),
502        }
503    }
504
505    fn cycle_state(&mut self) {
506        let Some(id) = self.selected_id().map(str::to_string) else {
507            return;
508        };
509        let Some(state) = self.selected_state() else {
510            return;
511        };
512        let next = match state {
513            "TODO" => "STARTED",
514            "STARTED" => "BLOCKED",
515            "BLOCKED" => "TODO",
516            _ => {
517                self.message = format!("{id} is {state}; s cycles TODO/STARTED/BLOCKED");
518                return;
519            }
520        };
521        self.apply_state(next);
522    }
523
524    fn apply_state(&mut self, state: &str) {
525        let Some(id) = self.selected_id().map(str::to_string) else {
526            return;
527        };
528        match self.backend.update(UpdateReq {
529            id: id.clone(),
530            state: Some(state.to_string()),
531            ..UpdateReq::default()
532        }) {
533            Ok(result) => {
534                self.message = result.report.trim().to_string();
535                let _ = self.reload();
536            }
537            Err(err) => self.message = err.to_string(),
538        }
539    }
540
541    fn open_selected(&mut self) {
542        let Some(id) = self.selected_id().map(str::to_string) else {
543            return;
544        };
545        match self.backend.open(&id) {
546            Ok(detail) => {
547                self.detail = Some(detail);
548                self.message = format!("opened {id}");
549                self.refresh_detail();
550            }
551            Err(err) => self.message = err.to_string(),
552        }
553    }
554
555    fn refresh_detail(&mut self) {
556        let Some(id) = self.selected_id().map(str::to_string) else {
557            self.detail = None;
558            self.detail_body.clear();
559            return;
560        };
561        match self.detail_tab {
562            DetailTab::Show => match self.backend.get(&id) {
563                Ok(detail) => {
564                    self.detail_body = format_show(&detail);
565                    self.detail = Some(detail);
566                }
567                Err(err) => self.detail_body = err.to_string(),
568            },
569            DetailTab::Excerpt => match self.backend.excerpt(&id) {
570                Ok(excerpt) => {
571                    let mut text = excerpt.text;
572                    if !text.ends_with('\n') {
573                        text.push('\n');
574                    }
575                    text.push_str("body lives in file; open the range above");
576                    self.detail_body = text;
577                }
578                Err(err) => self.detail_body = err.to_string(),
579            },
580            DetailTab::Tree => match self.backend.tree(&id) {
581                Ok(node) => self.detail_body = format_tree(&node, 0),
582                Err(err) => self.detail_body = err.to_string(),
583            },
584            DetailTab::Related => match self.backend.related(&id, 2, 20) {
585                Ok(hits) => self.detail_body = format_related(&hits),
586                Err(err) => self.detail_body = err.to_string(),
587            },
588        }
589    }
590
591    /// Label and buffer for the open prompt, if any.
592    pub fn prompt_line(&self) -> Option<String> {
593        self.prompt.as_ref().map(|(kind, text)| {
594            let label = match kind {
595                PromptKind::Search => "search",
596                PromptKind::Note => "note",
597                PromptKind::Project => "project",
598            };
599            format!("{label}: {text}")
600        })
601    }
602
603    /// Confirmation line for DONE/CANCELLED, if any.
604    pub fn confirm_line(&self) -> Option<String> {
605        self.confirm
606            .map(|kind| format!("confirm {}? y/n", kind.state()))
607    }
608
609    /// Text drawn on `?`.
610    pub fn help_text(&self) -> &'static str {
611        HELP
612    }
613}
614
615fn row_from_issue(row: vissue_core::views::IssueRow) -> BoardRow {
616    let extra = row.claimed_by.unwrap_or_default();
617    BoardRow {
618        id: row.id,
619        state: row.state,
620        priority: row.priority,
621        title: row.title,
622        project: row.project,
623        extra,
624    }
625}
626
627fn row_from_claim(row: vissue_core::views::ClaimRow) -> BoardRow {
628    BoardRow {
629        id: row.id,
630        state: row.state,
631        priority: row.priority,
632        title: row.title,
633        project: row.project,
634        extra: format!("{} {}d", row.holder.unwrap_or_default(), row.age_days),
635    }
636}
637
638fn row_from_agenda(row: vissue_core::views::AgendaRow) -> BoardRow {
639    BoardRow {
640        id: row.id,
641        state: row.state,
642        priority: row.priority,
643        title: row.title,
644        project: row.project,
645        extra: format!("{} {}", row.kind, row.date),
646    }
647}
648
649fn row_from_search(row: vissue_core::views::SearchHit) -> BoardRow {
650    BoardRow {
651        id: row.id,
652        state: row.state,
653        priority: row.priority,
654        title: row.title,
655        project: row.project,
656        extra: row.snippet,
657    }
658}
659
660fn format_show(d: &IssueDetail) -> String {
661    let mut out = format!(
662        "id: {}\nproject: {}\nstate: {}\npriority: {}\ntitle: {}\nfile: {}\n",
663        d.id, d.project, d.state, d.priority, d.title, d.file
664    );
665    if let Some(parent) = &d.parent {
666        out.push_str(&format!("parent: {parent}\n"));
667    }
668    if !d.blocked_by.is_empty() {
669        out.push_str(&format!("blocked_by: {}\n", d.blocked_by.join(", ")));
670    }
671    if let Some(who) = &d.claimed_by {
672        out.push_str(&format!("claimed_by: {who}\n"));
673    }
674    if !d.tags.is_empty() {
675        out.push_str(&format!("tags: {}\n", d.tags.join(", ")));
676    }
677    out
678}
679
680fn format_tree(node: &vissue_core::views::TreeNode, depth: usize) -> String {
681    let pad = "  ".repeat(depth);
682    let mut out = format!("{pad}{} [{}] {}\n", node.id, node.state, node.title);
683    for child in &node.children {
684        out.push_str(&format_tree(child, depth + 1));
685    }
686    out
687}
688
689fn format_related(hits: &[vissue_core::views::RelatedHit]) -> String {
690    if hits.is_empty() {
691        return "no related issues\n".into();
692    }
693    let mut out = String::new();
694    for hit in hits {
695        out.push_str(&format!(
696            "{} [{}] {}  score={:.2}  {}\n",
697            hit.id,
698            hit.state,
699            hit.title,
700            hit.score,
701            hit.evidence.join(", ")
702        ));
703    }
704    out
705}
706
707/// Options for the interactive `vissue tui` entry point.
708#[derive(Debug)]
709pub struct RunOpts {
710    /// Vault root and project prefix.
711    pub layout: Layout,
712    /// Control socket to attach after first paint.
713    pub socket: PathBuf,
714    /// Skip the socket and stay on [`CoreBackend`].
715    pub offline: bool,
716    /// Identity stamped on claims and updates.
717    pub agent: String,
718}
719
720/// First paint via core, then attach unless `--offline`, then the crossterm loop.
721///
722/// # Errors
723///
724/// Returns an error if the vault cannot be opened, the terminal cannot be
725/// installed or drawn, attach reload fails, or a terminal event cannot be read.
726pub fn run(opts: RunOpts) -> Result<(), vissue_core::error::Error> {
727    let mut app = App::open_core(opts.layout.clone(), opts.agent.clone())?;
728    let mut terminal = crate::view::install()?;
729    let result = (|| {
730        terminal.draw(|f| crate::view::draw(f, &app))?;
731        app.attach(&opts.socket, opts.offline, &AttachHooks::default())?;
732        loop {
733            terminal.draw(|f| crate::view::draw(f, &app))?;
734            if ratatui::crossterm::event::poll(std::time::Duration::from_millis(200))? {
735                if let ratatui::crossterm::event::Event::Key(key) =
736                    ratatui::crossterm::event::read()?
737                    && app.handle_key(key) == Action::Quit
738                {
739                    break;
740                }
741            } else {
742                app.poll_updates();
743            }
744        }
745        Ok(())
746    })();
747    crate::view::restore()?;
748    result
749}