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