1use std::path::PathBuf;
4
5use ratatui::crossterm::event::KeyCode;
6use ratatui::crossterm::event::KeyEvent;
7use vissue_core::config::Layout;
8use vissue_core::keys::{ActionId, KeyMap};
9use vissue_core::views::{IssueDetail, ListQuery};
10
11use crate::attach::{AttachHooks, AttachOutcome, ServeStatus, try_attach};
12use crate::backend::{BoardBackend, ListPage, UpdateReq};
13use crate::core_backend::CoreBackend;
14use crate::keys::{
15 Action, ConfirmKind, DetailTab, Focus, Pane, PromptKind, char_of, chord_of, help_text, is_press,
16};
17
18#[derive(Debug, Clone)]
20pub struct BoardRow {
21 pub id: String,
23 pub state: String,
25 pub priority: String,
27 pub title: String,
29 pub project: String,
31 pub extra: String,
33}
34
35#[derive(Debug)]
37pub struct App {
38 backend: Box<dyn BoardBackend>,
39 agent: String,
40 status: ServeStatus,
41 message: String,
42 pub pane: Pane,
44 pub detail_tab: DetailTab,
46 pub focus: Focus,
48 pub rows: Vec<BoardRow>,
50 pub selected: usize,
52 pub project: Option<String>,
54 pub projects: Vec<String>,
56 pub detail: Option<IssueDetail>,
58 pub detail_body: String,
60 pub prompt: Option<(PromptKind, String)>,
62 pub confirm: Option<ConfirmKind>,
64 pub help: bool,
66 pub clipboard: String,
68 search_query: String,
69 keymap: KeyMap,
71}
72
73impl App {
74 pub fn open_core(layout: Layout, agent: String) -> Result<Self, vissue_core::error::Error> {
81 let backend = CoreBackend::open(layout, agent.clone())?;
82 Self::with_backend(Box::new(backend), agent, ServeStatus::Offline)
83 }
84
85 pub fn with_backend(
91 backend: Box<dyn BoardBackend>,
92 agent: String,
93 status: ServeStatus,
94 ) -> Result<Self, vissue_core::error::Error> {
95 let projects = backend.projects().unwrap_or_default();
96 let mut app = Self {
97 backend,
98 agent,
99 status,
100 message: String::new(),
101 pane: Pane::Ready,
102 detail_tab: DetailTab::Show,
103 focus: Focus::Rows,
104 rows: Vec::new(),
105 selected: 0,
106 project: None,
107 projects,
108 detail: None,
109 detail_body: String::new(),
110 prompt: None,
111 confirm: None,
112 help: false,
113 clipboard: String::new(),
114 search_query: String::new(),
115 keymap: KeyMap::from_defaults(),
116 };
117 #[cfg(not(test))]
118 {
119 app.keymap = KeyMap::load();
120 if let Some(err) = app.keymap.overlay_error.clone() {
121 app.message = err;
122 }
123 }
124 app.reload()?;
125 Ok(app)
126 }
127
128 pub fn set_keymap(&mut self, keymap: KeyMap) {
130 self.keymap = keymap;
131 }
132
133 pub fn keymap(&self) -> &KeyMap {
135 &self.keymap
136 }
137
138 pub fn serve_status(&self) -> ServeStatus {
140 self.status
141 }
142
143 pub fn agent(&self) -> &str {
145 &self.agent
146 }
147
148 pub fn generation(&self) -> u64 {
150 self.backend.generation()
151 }
152
153 pub fn revision(&self) -> u64 {
155 self.backend.revision()
156 }
157
158 pub fn selected_id(&self) -> Option<&str> {
160 self.rows.get(self.selected).map(|r| r.id.as_str())
161 }
162
163 pub fn selected_state(&self) -> Option<&str> {
165 self.rows.get(self.selected).map(|r| r.state.as_str())
166 }
167
168 pub fn backend(&self) -> &dyn BoardBackend {
170 self.backend.as_ref()
171 }
172
173 pub fn replace_backend(&mut self, backend: Box<dyn BoardBackend>, status: ServeStatus) {
175 self.backend = backend;
176 self.status = status;
177 self.agent = self.backend.identity().to_string();
178 }
179
180 pub fn attach(
186 &mut self,
187 socket: &std::path::Path,
188 offline: bool,
189 hooks: &AttachHooks,
190 ) -> Result<(), vissue_core::error::Error> {
191 let layout = self.backend.layout().clone();
192 let agent = self.agent.clone();
193 match try_attach(&layout, socket, &agent, offline, hooks) {
194 AttachOutcome::Switch { backend, status } => {
195 self.replace_backend(backend, status);
196 self.message.clear();
197 }
198 AttachOutcome::Stay { status, message } => {
199 self.status = status;
200 self.message = message;
201 }
202 }
203 self.reload()
204 }
205
206 pub fn status_line(&self) -> String {
208 let kind = match self.status {
209 ServeStatus::Live => "live",
210 ServeStatus::Offline => "offline",
211 ServeStatus::Mismatch => "mismatch",
212 };
213 let mut line = format!(
214 "serve:{kind} gen={} rev={} agent={}",
215 self.backend.generation(),
216 self.backend.revision(),
217 self.agent
218 );
219 if let Some(project) = &self.project {
220 line.push_str(" project=");
221 line.push_str(project);
222 }
223 if !self.message.is_empty() {
224 line.push_str(" ");
225 line.push_str(&self.message);
226 }
227 line
228 }
229
230 pub fn reload(&mut self) -> Result<(), vissue_core::error::Error> {
236 let project = self.project.as_deref();
237 match self.pane {
238 Pane::Ready => self.apply_issue_page(self.backend.ready(project)?),
239 Pane::List => self.apply_issue_page(self.backend.list(ListQuery {
240 project: project.map(str::to_string),
241 ..ListQuery::default()
242 })?),
243 Pane::Claims => {
244 self.rows = self
245 .backend
246 .claims(None, project)?
247 .into_iter()
248 .map(row_from_claim)
249 .collect();
250 }
251 Pane::Agenda => {
252 self.rows = self
253 .backend
254 .agenda(14, project)?
255 .into_iter()
256 .map(row_from_agenda)
257 .collect();
258 }
259 Pane::Search => {
260 self.rows = if self.search_query.is_empty() {
261 Vec::new()
262 } else {
263 self.backend
264 .search(&self.search_query, 50)?
265 .into_iter()
266 .map(row_from_search)
267 .collect()
268 };
269 }
270 }
271 if self.selected >= self.rows.len() {
272 self.selected = self.rows.len().saturating_sub(1);
273 }
274 self.refresh_detail();
275 Ok(())
276 }
277
278 fn apply_issue_page(&mut self, page: ListPage) {
281 if page.unchanged {
282 return;
283 }
284 self.rows = page.issues.into_iter().map(row_from_issue).collect();
285 }
286
287 pub fn poll_updates(&mut self) {
289 let last = match self.backend.live() {
290 crate::backend::BackendKind::Control => self.backend.revision(),
291 crate::backend::BackendKind::Core => self.backend.generation(),
292 };
293 if let Ok(next) = self.backend.wait(last, 1)
294 && next > last
295 {
296 let _ = self.reload();
297 }
298 }
299
300 pub fn handle_key(&mut self, key: KeyEvent) -> Action {
302 if !is_press(key) {
303 return Action::Continue;
304 }
305 if self.help {
306 if matches!(
307 key.code,
308 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?')
309 ) {
310 self.help = false;
311 }
312 return Action::Continue;
313 }
314 if self.confirm.is_some() {
315 return self.handle_confirm(key);
316 }
317 if self.prompt.is_some() {
318 return self.handle_prompt(key);
319 }
320 if let Some(id) = chord_of(key).and_then(|chord| self.keymap.get(&chord)) {
323 return self.handle_action(id);
324 }
325 match key.code {
326 KeyCode::Char('q') => Action::Quit,
327 KeyCode::Esc => {
328 if self.focus == Focus::Detail {
329 self.focus = Focus::Rows;
330 Action::Continue
331 } else {
332 Action::Quit
333 }
334 }
335 KeyCode::Down => {
336 self.move_sel(1);
337 Action::Continue
338 }
339 KeyCode::Up => {
340 self.move_sel(-1);
341 Action::Continue
342 }
343 _ => Action::Continue,
344 }
345 }
346
347 fn handle_action(&mut self, id: ActionId) -> Action {
350 match id {
351 ActionId::ListDown => self.move_sel(1),
352 ActionId::ListUp => self.move_sel(-1),
353 ActionId::PaneNext => return self.goto_pane(self.pane.next()),
354 ActionId::PaneReady => return self.goto_pane(Pane::Ready),
355 ActionId::PaneList => return self.goto_pane(Pane::List),
356 ActionId::PaneClaims => return self.goto_pane(Pane::Claims),
357 ActionId::PaneAgenda => return self.goto_pane(Pane::Agenda),
358 ActionId::PaneSearch => return self.goto_pane(Pane::Search),
359 ActionId::ListSelect | ActionId::DetailCycle => {
360 if self.focus == Focus::Detail {
361 self.detail_tab = self.detail_tab.next();
362 } else {
363 self.focus = Focus::Detail;
364 }
365 self.refresh_detail();
366 }
367 ActionId::ProjectCycle => {
368 self.prompt = Some((
369 PromptKind::Project,
370 self.project.clone().unwrap_or_default(),
371 ));
372 }
373 ActionId::Search => {
374 if self.pane != Pane::Search {
375 self.backend.invalidate_since();
376 self.pane = Pane::Search;
377 }
378 self.prompt = Some((PromptKind::Search, self.search_query.clone()));
379 }
380 ActionId::Claim => self.claim_selected(),
381 ActionId::Note => {
382 if self.selected_id().is_some() {
383 self.prompt = Some((PromptKind::Note, String::new()));
384 }
385 }
386 ActionId::Deed => {
387 if self.selected_id().is_some() {
388 self.prompt = Some((PromptKind::Deed, String::new()));
389 }
390 }
391 ActionId::StateCycle => self.cycle_state(),
392 ActionId::ConfirmDone | ActionId::ListDone => {
393 if self.selected_id().is_some() {
394 self.confirm = Some(ConfirmKind::Done);
395 self.message = "confirm DONE? y/n".into();
396 }
397 }
398 ActionId::ConfirmCancel => {
399 if self.selected_id().is_some() {
400 self.confirm = Some(ConfirmKind::Cancelled);
401 self.message = "confirm CANCELLED? y/n".into();
402 }
403 }
404 ActionId::Open => self.open_selected(),
405 ActionId::CopyId => {
406 if let Some(id) = self.selected_id().map(str::to_string) {
407 self.clipboard = id.clone();
408 self.message = format!("copied {id}");
409 }
410 }
411 ActionId::Reload => {
412 let _ = self.reload();
413 self.message = "reloaded".into();
414 }
415 ActionId::Help => self.help = true,
416 ActionId::Add
417 | ActionId::Palette
418 | ActionId::PreviewToggle
419 | ActionId::PreviewDown
420 | ActionId::PreviewUp
421 | ActionId::WindowPopOut => {}
422 }
423 Action::Continue
424 }
425
426 fn handle_confirm(&mut self, key: KeyEvent) -> Action {
427 let kind = self.confirm.unwrap();
428 match key.code {
429 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
430 self.confirm = None;
431 self.apply_state(kind.state());
432 }
433 _ => {
434 self.confirm = None;
435 self.message.clear();
436 }
437 }
438 Action::Continue
439 }
440
441 fn handle_prompt(&mut self, key: KeyEvent) -> Action {
442 let Some((kind, mut text)) = self.prompt.take() else {
443 return Action::Continue;
444 };
445 match key.code {
446 KeyCode::Esc => {
447 self.message.clear();
448 }
449 KeyCode::Enter => match kind {
450 PromptKind::Search => {
451 self.search_query = text;
452 if self.pane != Pane::Search {
453 self.backend.invalidate_since();
454 }
455 self.pane = Pane::Search;
456 let _ = self.reload();
457 }
458 PromptKind::Note => {
459 if let Some(id) = self.selected_id().map(str::to_string) {
460 match self.backend.note(&id, &text) {
461 Ok(result) => {
462 self.message = result.report.trim().to_string();
463 let _ = self.reload();
464 }
465 Err(err) => self.message = err.to_string(),
466 }
467 }
468 }
469 PromptKind::Deed => {
470 if let Some(id) = self.selected_id().map(str::to_string) {
475 let accession = text.trim().to_string();
476 if accession.is_empty() {
477 self.message = "no deed cited".to_string();
478 } else {
479 match self.backend.deed(&id, &[accession]) {
480 Ok(result) => {
481 self.message = result.report.trim().to_string();
482 let _ = self.reload();
483 }
484 Err(err) => self.message = err.to_string(),
485 }
486 }
487 }
488 }
489 PromptKind::Project => {
490 let trimmed = text.trim();
491 let next = if trimmed.is_empty() {
492 None
493 } else {
494 Some(trimmed.to_string())
495 };
496 if next != self.project {
497 self.backend.invalidate_since();
498 }
499 self.project = next;
500 let _ = self.reload();
501 }
502 },
503 KeyCode::Backspace => {
504 text.pop();
505 self.prompt = Some((kind, text));
506 }
507 _ => {
508 if let Some(c) = char_of(key) {
509 text.push(c);
510 }
511 self.prompt = Some((kind, text));
512 }
513 }
514 Action::Continue
515 }
516
517 fn goto_pane(&mut self, pane: Pane) -> Action {
518 if self.pane != pane {
519 self.backend.invalidate_since();
520 self.pane = pane;
521 }
522 let _ = self.reload();
523 Action::Continue
524 }
525
526 fn move_sel(&mut self, delta: i32) {
527 if self.rows.is_empty() {
528 return;
529 }
530 let len = self.rows.len() as i32;
531 let next = (self.selected as i32 + delta).clamp(0, len - 1) as usize;
532 if next != self.selected {
533 self.selected = next;
534 self.refresh_detail();
535 }
536 }
537
538 fn claim_selected(&mut self) {
539 let Some(id) = self.selected_id().map(str::to_string) else {
540 return;
541 };
542 match self.backend.claim(&id, false) {
543 Ok(result) => {
544 self.message = result.report.trim().to_string();
545 let _ = self.reload();
546 }
547 Err(err) => self.message = err.to_string(),
548 }
549 }
550
551 fn cycle_state(&mut self) {
552 let Some(id) = self.selected_id().map(str::to_string) else {
553 return;
554 };
555 let Some(state) = self.selected_state() else {
556 return;
557 };
558 let next = match state {
559 "TODO" => "STARTED",
560 "STARTED" => "BLOCKED",
561 "BLOCKED" => "TODO",
562 _ => {
563 self.message = format!("{id} is {state}; s cycles TODO/STARTED/BLOCKED");
564 return;
565 }
566 };
567 self.apply_state(next);
568 }
569
570 fn apply_state(&mut self, state: &str) {
571 let Some(id) = self.selected_id().map(str::to_string) else {
572 return;
573 };
574 match self.backend.update(UpdateReq {
575 id: id.clone(),
576 state: Some(state.to_string()),
577 if_state: self.selected_state().map(str::to_string),
578 ..UpdateReq::default()
579 }) {
580 Ok(result) => {
581 self.message = result.report.trim().to_string();
582 let _ = self.reload();
583 }
584 Err(err) => self.message = err.to_string(),
585 }
586 }
587
588 fn open_selected(&mut self) {
589 let Some(id) = self.selected_id().map(str::to_string) else {
590 return;
591 };
592 match self.backend.open(&id) {
593 Ok(detail) => {
594 self.detail = Some(detail);
595 self.message = format!("opened {id}");
596 self.refresh_detail();
597 }
598 Err(err) => self.message = err.to_string(),
599 }
600 }
601
602 fn refresh_detail(&mut self) {
603 let Some(id) = self.selected_id().map(str::to_string) else {
604 self.detail = None;
605 self.detail_body.clear();
606 return;
607 };
608 match self.detail_tab {
609 DetailTab::Show => match self.backend.get(&id) {
610 Ok(detail) => {
611 self.detail_body = format_show(&detail);
612 self.detail = Some(detail);
613 }
614 Err(err) => self.detail_body = err.to_string(),
615 },
616 DetailTab::Excerpt => match self.backend.excerpt(&id) {
617 Ok(excerpt) => {
618 let mut text = excerpt.text;
619 if !text.ends_with('\n') {
620 text.push('\n');
621 }
622 text.push_str("body lives in file; open the range above");
623 self.detail_body = text;
624 }
625 Err(err) => self.detail_body = err.to_string(),
626 },
627 DetailTab::Tree => match self.backend.tree(&id) {
628 Ok(node) => self.detail_body = format_tree(&node, 0),
629 Err(err) => self.detail_body = err.to_string(),
630 },
631 DetailTab::Related => match self.backend.related(&id, 2, 20) {
632 Ok(hits) => self.detail_body = format_related(&hits),
633 Err(err) => self.detail_body = err.to_string(),
634 },
635 DetailTab::Recall => match self.backend.recall(&id, 1) {
636 Ok(set) => self.detail_body = format_recall(&set),
637 Err(err) => self.detail_body = err.to_string(),
638 },
639 }
640 }
641
642 pub fn prompt_line(&self) -> Option<String> {
644 self.prompt.as_ref().map(|(kind, text)| {
645 let label = match kind {
646 PromptKind::Search => "search",
647 PromptKind::Note => "note",
648 PromptKind::Deed => "deed",
649 PromptKind::Project => "project",
650 };
651 format!("{label}: {text}")
652 })
653 }
654
655 pub fn confirm_line(&self) -> Option<String> {
657 self.confirm
658 .map(|kind| format!("confirm {}? y/n", kind.state()))
659 }
660
661 pub fn help_text(&self) -> String {
663 help_text(&self.keymap)
664 }
665}
666
667fn row_from_issue(row: vissue_core::views::IssueRow) -> BoardRow {
668 let extra = row.claimed_by.unwrap_or_default();
669 BoardRow {
670 id: row.id,
671 state: row.state,
672 priority: row.priority,
673 title: row.title,
674 project: row.project,
675 extra,
676 }
677}
678
679fn row_from_claim(row: vissue_core::views::ClaimRow) -> BoardRow {
680 BoardRow {
681 id: row.id,
682 state: row.state,
683 priority: row.priority,
684 title: row.title,
685 project: row.project,
686 extra: format!("{} {}d", row.holder.unwrap_or_default(), row.age_days),
687 }
688}
689
690fn row_from_agenda(row: vissue_core::views::AgendaRow) -> BoardRow {
691 let extra = match row.kind.as_str() {
692 "deadline" if row.overdue_days > 0 => {
693 format!("deadline {} {}d overdue", row.date, row.overdue_days)
694 }
695 "deadline" => format!("deadline {}", row.date),
696 "scheduled" => format!("scheduled {}", row.date),
697 _ => format!("on {}", row.date),
698 };
699 BoardRow {
700 id: row.id,
701 state: row.state,
702 priority: row.priority,
703 title: row.title,
704 project: row.project,
705 extra,
706 }
707}
708
709fn row_from_search(row: vissue_core::views::SearchHit) -> BoardRow {
710 BoardRow {
711 id: row.id,
712 state: row.state,
713 priority: row.priority,
714 title: row.title,
715 project: row.project,
716 extra: row.snippet,
717 }
718}
719
720fn format_show(d: &IssueDetail) -> String {
721 let mut out = format!(
722 "id: {}\nproject: {}\nstate: {}\npriority: {}\ntitle: {}\nfile: {}\n",
723 d.id, d.project, d.state, d.priority, d.title, d.file
724 );
725 if let Some(parent) = &d.parent {
726 out.push_str(&format!("parent: {parent}\n"));
727 }
728 if !d.blocked_by.is_empty() {
729 out.push_str(&format!("blocked_by: {}\n", d.blocked_by.join(", ")));
730 }
731 if let Some(who) = &d.claimed_by {
732 out.push_str(&format!("claimed_by: {who}\n"));
733 }
734 if !d.tags.is_empty() {
735 out.push_str(&format!("tags: {}\n", d.tags.join(", ")));
736 }
737 out
738}
739
740fn format_tree(node: &vissue_core::views::TreeNode, depth: usize) -> String {
741 let pad = " ".repeat(depth);
742 let mut out = format!("{pad}{} [{}] {}\n", node.id, node.state, node.title);
743 for child in &node.children {
744 out.push_str(&format_tree(child, depth + 1));
745 }
746 out
747}
748
749fn format_related(hits: &[vissue_core::views::RelatedHit]) -> String {
750 if hits.is_empty() {
751 return "no related issues\n".into();
752 }
753 let mut out = String::new();
754 for hit in hits {
755 out.push_str(&format!(
756 "{} [{}] {} score={:.2} {}\n",
757 hit.id,
758 hit.state,
759 hit.title,
760 hit.score,
761 hit.evidence.join(", ")
762 ));
763 }
764 out
765}
766
767fn format_recall(set: &vissue_core::views::Recall) -> String {
773 let mut out = String::new();
774 for step in &set.plan {
775 out.push_str(&format!(
776 "plan {} [{}] {}\n",
777 step.id, step.state, step.title
778 ));
779 }
780 if set.inputs.is_empty() {
781 out.push_str("no declared inputs\n");
782 }
783 for input in &set.inputs {
784 out.push_str(&format!(
785 "{} [{}] {} ({})\n",
786 input.id, input.state, input.title, input.relation
787 ));
788 for deed in &input.deeds {
789 out.push_str(&format!(" {deed}\n"));
790 }
791 if let Some(note) = &input.last_note {
792 out.push_str(&format!(
793 " note: {}\n",
794 note.lines().next().unwrap_or_default().trim()
795 ));
796 }
797 }
798 if !set.produced.is_empty() {
799 out.push_str("produced here\n");
800 for deed in &set.produced {
801 out.push_str(&format!(" {deed}\n"));
802 }
803 }
804 out
805}
806
807#[derive(Debug)]
809pub struct RunOpts {
810 pub layout: Layout,
812 pub socket: PathBuf,
814 pub offline: bool,
816 pub agent: String,
818}
819
820pub fn run(opts: RunOpts) -> Result<(), vissue_core::error::Error> {
827 let mut app = App::open_core(opts.layout.clone(), opts.agent.clone())?;
828 let mut terminal = crate::view::install()?;
829 let result = (|| {
830 terminal.draw(|f| crate::view::draw(f, &app))?;
831 app.attach(&opts.socket, opts.offline, &AttachHooks::default())?;
832 loop {
833 terminal.draw(|f| crate::view::draw(f, &app))?;
834 if ratatui::crossterm::event::poll(std::time::Duration::from_millis(200))? {
835 if let ratatui::crossterm::event::Event::Key(key) =
836 ratatui::crossterm::event::read()?
837 && app.handle_key(key) == Action::Quit
838 {
839 break;
840 }
841 } else {
842 app.poll_updates();
843 }
844 }
845 Ok(())
846 })();
847 crate::view::restore()?;
848 result
849}