1use 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#[derive(Debug, Clone)]
19pub struct BoardRow {
20 pub id: String,
22 pub state: String,
24 pub priority: String,
26 pub title: String,
28 pub project: String,
30 pub extra: String,
32}
33
34#[derive(Debug)]
36pub struct App {
37 backend: Box<dyn BoardBackend>,
38 agent: String,
39 status: ServeStatus,
40 message: String,
41 pub pane: Pane,
43 pub detail_tab: DetailTab,
45 pub focus: Focus,
47 pub rows: Vec<BoardRow>,
49 pub selected: usize,
51 pub project: Option<String>,
53 pub projects: Vec<String>,
55 pub detail: Option<IssueDetail>,
57 pub detail_body: String,
59 pub prompt: Option<(PromptKind, String)>,
61 pub confirm: Option<ConfirmKind>,
63 pub help: bool,
65 pub clipboard: String,
67 search_query: String,
68}
69
70impl App {
71 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 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 pub fn serve_status(&self) -> ServeStatus {
119 self.status
120 }
121
122 pub fn agent(&self) -> &str {
124 &self.agent
125 }
126
127 pub fn generation(&self) -> u64 {
129 self.backend.generation()
130 }
131
132 pub fn revision(&self) -> u64 {
134 self.backend.revision()
135 }
136
137 pub fn selected_id(&self) -> Option<&str> {
139 self.rows.get(self.selected).map(|r| r.id.as_str())
140 }
141
142 pub fn selected_state(&self) -> Option<&str> {
144 self.rows.get(self.selected).map(|r| r.state.as_str())
145 }
146
147 pub fn backend(&self) -> &dyn BoardBackend {
149 self.backend.as_ref()
150 }
151
152 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 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 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 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 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 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 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('d') => {
359 if self.selected_id().is_some() {
360 self.prompt = Some((PromptKind::Deed, String::new()));
361 }
362 Action::Continue
363 }
364 KeyCode::Char('s') => {
365 self.cycle_state();
366 Action::Continue
367 }
368 KeyCode::Char('D') => {
369 if self.selected_id().is_some() {
370 self.confirm = Some(ConfirmKind::Done);
371 self.message = "confirm DONE? y/n".into();
372 }
373 Action::Continue
374 }
375 KeyCode::Char('X') => {
376 if self.selected_id().is_some() {
377 self.confirm = Some(ConfirmKind::Cancelled);
378 self.message = "confirm CANCELLED? y/n".into();
379 }
380 Action::Continue
381 }
382 KeyCode::Char('o') => {
383 self.open_selected();
384 Action::Continue
385 }
386 KeyCode::Char('y') => {
387 if let Some(id) = self.selected_id().map(str::to_string) {
388 self.clipboard = id.clone();
389 self.message = format!("copied {id}");
390 }
391 Action::Continue
392 }
393 KeyCode::Char('R') => {
394 let _ = self.reload();
395 self.message = "reloaded".into();
396 Action::Continue
397 }
398 KeyCode::Char('?') => {
399 self.help = true;
400 Action::Continue
401 }
402 _ => Action::Continue,
403 }
404 }
405
406 fn handle_confirm(&mut self, key: KeyEvent) -> Action {
407 let kind = self.confirm.unwrap();
408 match key.code {
409 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
410 self.confirm = None;
411 self.apply_state(kind.state());
412 }
413 _ => {
414 self.confirm = None;
415 self.message.clear();
416 }
417 }
418 Action::Continue
419 }
420
421 fn handle_prompt(&mut self, key: KeyEvent) -> Action {
422 let Some((kind, mut text)) = self.prompt.take() else {
423 return Action::Continue;
424 };
425 match key.code {
426 KeyCode::Esc => {
427 self.message.clear();
428 }
429 KeyCode::Enter => match kind {
430 PromptKind::Search => {
431 self.search_query = text;
432 if self.pane != Pane::Search {
433 self.backend.invalidate_since();
434 }
435 self.pane = Pane::Search;
436 let _ = self.reload();
437 }
438 PromptKind::Note => {
439 if let Some(id) = self.selected_id().map(str::to_string) {
440 match self.backend.note(&id, &text) {
441 Ok(result) => {
442 self.message = result.report.trim().to_string();
443 let _ = self.reload();
444 }
445 Err(err) => self.message = err.to_string(),
446 }
447 }
448 }
449 PromptKind::Deed => {
450 if let Some(id) = self.selected_id().map(str::to_string) {
455 let accession = text.trim().to_string();
456 if accession.is_empty() {
457 self.message = "no deed cited".to_string();
458 } else {
459 match self.backend.deed(&id, &[accession]) {
460 Ok(result) => {
461 self.message = result.report.trim().to_string();
462 let _ = self.reload();
463 }
464 Err(err) => self.message = err.to_string(),
465 }
466 }
467 }
468 }
469 PromptKind::Project => {
470 let trimmed = text.trim();
471 let next = if trimmed.is_empty() {
472 None
473 } else {
474 Some(trimmed.to_string())
475 };
476 if next != self.project {
477 self.backend.invalidate_since();
478 }
479 self.project = next;
480 let _ = self.reload();
481 }
482 },
483 KeyCode::Backspace => {
484 text.pop();
485 self.prompt = Some((kind, text));
486 }
487 _ => {
488 if let Some(c) = char_of(key) {
489 text.push(c);
490 }
491 self.prompt = Some((kind, text));
492 }
493 }
494 Action::Continue
495 }
496
497 fn goto_pane(&mut self, pane: Pane) -> Action {
498 if self.pane != pane {
499 self.backend.invalidate_since();
500 self.pane = pane;
501 }
502 let _ = self.reload();
503 Action::Continue
504 }
505
506 fn move_sel(&mut self, delta: i32) {
507 if self.rows.is_empty() {
508 return;
509 }
510 let len = self.rows.len() as i32;
511 let next = (self.selected as i32 + delta).clamp(0, len - 1) as usize;
512 if next != self.selected {
513 self.selected = next;
514 self.refresh_detail();
515 }
516 }
517
518 fn claim_selected(&mut self) {
519 let Some(id) = self.selected_id().map(str::to_string) else {
520 return;
521 };
522 match self.backend.claim(&id, false) {
523 Ok(result) => {
524 self.message = result.report.trim().to_string();
525 let _ = self.reload();
526 }
527 Err(err) => self.message = err.to_string(),
528 }
529 }
530
531 fn cycle_state(&mut self) {
532 let Some(id) = self.selected_id().map(str::to_string) else {
533 return;
534 };
535 let Some(state) = self.selected_state() else {
536 return;
537 };
538 let next = match state {
539 "TODO" => "STARTED",
540 "STARTED" => "BLOCKED",
541 "BLOCKED" => "TODO",
542 _ => {
543 self.message = format!("{id} is {state}; s cycles TODO/STARTED/BLOCKED");
544 return;
545 }
546 };
547 self.apply_state(next);
548 }
549
550 fn apply_state(&mut self, state: &str) {
551 let Some(id) = self.selected_id().map(str::to_string) else {
552 return;
553 };
554 match self.backend.update(UpdateReq {
555 id: id.clone(),
556 state: Some(state.to_string()),
557 if_state: self.selected_state().map(str::to_string),
558 ..UpdateReq::default()
559 }) {
560 Ok(result) => {
561 self.message = result.report.trim().to_string();
562 let _ = self.reload();
563 }
564 Err(err) => self.message = err.to_string(),
565 }
566 }
567
568 fn open_selected(&mut self) {
569 let Some(id) = self.selected_id().map(str::to_string) else {
570 return;
571 };
572 match self.backend.open(&id) {
573 Ok(detail) => {
574 self.detail = Some(detail);
575 self.message = format!("opened {id}");
576 self.refresh_detail();
577 }
578 Err(err) => self.message = err.to_string(),
579 }
580 }
581
582 fn refresh_detail(&mut self) {
583 let Some(id) = self.selected_id().map(str::to_string) else {
584 self.detail = None;
585 self.detail_body.clear();
586 return;
587 };
588 match self.detail_tab {
589 DetailTab::Show => match self.backend.get(&id) {
590 Ok(detail) => {
591 self.detail_body = format_show(&detail);
592 self.detail = Some(detail);
593 }
594 Err(err) => self.detail_body = err.to_string(),
595 },
596 DetailTab::Excerpt => match self.backend.excerpt(&id) {
597 Ok(excerpt) => {
598 let mut text = excerpt.text;
599 if !text.ends_with('\n') {
600 text.push('\n');
601 }
602 text.push_str("body lives in file; open the range above");
603 self.detail_body = text;
604 }
605 Err(err) => self.detail_body = err.to_string(),
606 },
607 DetailTab::Tree => match self.backend.tree(&id) {
608 Ok(node) => self.detail_body = format_tree(&node, 0),
609 Err(err) => self.detail_body = err.to_string(),
610 },
611 DetailTab::Related => match self.backend.related(&id, 2, 20) {
612 Ok(hits) => self.detail_body = format_related(&hits),
613 Err(err) => self.detail_body = err.to_string(),
614 },
615 DetailTab::Recall => match self.backend.recall(&id, 1) {
616 Ok(set) => self.detail_body = format_recall(&set),
617 Err(err) => self.detail_body = err.to_string(),
618 },
619 }
620 }
621
622 pub fn prompt_line(&self) -> Option<String> {
624 self.prompt.as_ref().map(|(kind, text)| {
625 let label = match kind {
626 PromptKind::Search => "search",
627 PromptKind::Note => "note",
628 PromptKind::Deed => "deed",
629 PromptKind::Project => "project",
630 };
631 format!("{label}: {text}")
632 })
633 }
634
635 pub fn confirm_line(&self) -> Option<String> {
637 self.confirm
638 .map(|kind| format!("confirm {}? y/n", kind.state()))
639 }
640
641 pub fn help_text(&self) -> &'static str {
643 HELP
644 }
645}
646
647fn row_from_issue(row: vissue_core::views::IssueRow) -> BoardRow {
648 let extra = row.claimed_by.unwrap_or_default();
649 BoardRow {
650 id: row.id,
651 state: row.state,
652 priority: row.priority,
653 title: row.title,
654 project: row.project,
655 extra,
656 }
657}
658
659fn row_from_claim(row: vissue_core::views::ClaimRow) -> BoardRow {
660 BoardRow {
661 id: row.id,
662 state: row.state,
663 priority: row.priority,
664 title: row.title,
665 project: row.project,
666 extra: format!("{} {}d", row.holder.unwrap_or_default(), row.age_days),
667 }
668}
669
670fn row_from_agenda(row: vissue_core::views::AgendaRow) -> BoardRow {
671 let extra = match row.kind.as_str() {
672 "deadline" if row.overdue_days > 0 => {
673 format!("deadline {} {}d overdue", row.date, row.overdue_days)
674 }
675 "deadline" => format!("deadline {}", row.date),
676 "scheduled" => format!("scheduled {}", row.date),
677 _ => format!("on {}", row.date),
678 };
679 BoardRow {
680 id: row.id,
681 state: row.state,
682 priority: row.priority,
683 title: row.title,
684 project: row.project,
685 extra,
686 }
687}
688
689fn row_from_search(row: vissue_core::views::SearchHit) -> BoardRow {
690 BoardRow {
691 id: row.id,
692 state: row.state,
693 priority: row.priority,
694 title: row.title,
695 project: row.project,
696 extra: row.snippet,
697 }
698}
699
700fn format_show(d: &IssueDetail) -> String {
701 let mut out = format!(
702 "id: {}\nproject: {}\nstate: {}\npriority: {}\ntitle: {}\nfile: {}\n",
703 d.id, d.project, d.state, d.priority, d.title, d.file
704 );
705 if let Some(parent) = &d.parent {
706 out.push_str(&format!("parent: {parent}\n"));
707 }
708 if !d.blocked_by.is_empty() {
709 out.push_str(&format!("blocked_by: {}\n", d.blocked_by.join(", ")));
710 }
711 if let Some(who) = &d.claimed_by {
712 out.push_str(&format!("claimed_by: {who}\n"));
713 }
714 if !d.tags.is_empty() {
715 out.push_str(&format!("tags: {}\n", d.tags.join(", ")));
716 }
717 out
718}
719
720fn format_tree(node: &vissue_core::views::TreeNode, depth: usize) -> String {
721 let pad = " ".repeat(depth);
722 let mut out = format!("{pad}{} [{}] {}\n", node.id, node.state, node.title);
723 for child in &node.children {
724 out.push_str(&format_tree(child, depth + 1));
725 }
726 out
727}
728
729fn format_related(hits: &[vissue_core::views::RelatedHit]) -> String {
730 if hits.is_empty() {
731 return "no related issues\n".into();
732 }
733 let mut out = String::new();
734 for hit in hits {
735 out.push_str(&format!(
736 "{} [{}] {} score={:.2} {}\n",
737 hit.id,
738 hit.state,
739 hit.title,
740 hit.score,
741 hit.evidence.join(", ")
742 ));
743 }
744 out
745}
746
747fn format_recall(set: &vissue_core::views::Recall) -> String {
753 let mut out = String::new();
754 for step in &set.plan {
755 out.push_str(&format!(
756 "plan {} [{}] {}\n",
757 step.id, step.state, step.title
758 ));
759 }
760 if set.inputs.is_empty() {
761 out.push_str("no declared inputs\n");
762 }
763 for input in &set.inputs {
764 out.push_str(&format!(
765 "{} [{}] {} ({})\n",
766 input.id, input.state, input.title, input.relation
767 ));
768 for deed in &input.deeds {
769 out.push_str(&format!(" {deed}\n"));
770 }
771 if let Some(note) = &input.last_note {
772 out.push_str(&format!(
773 " note: {}\n",
774 note.lines().next().unwrap_or_default().trim()
775 ));
776 }
777 }
778 if !set.produced.is_empty() {
779 out.push_str("produced here\n");
780 for deed in &set.produced {
781 out.push_str(&format!(" {deed}\n"));
782 }
783 }
784 out
785}
786
787#[derive(Debug)]
789pub struct RunOpts {
790 pub layout: Layout,
792 pub socket: PathBuf,
794 pub offline: bool,
796 pub agent: String,
798}
799
800pub fn run(opts: RunOpts) -> Result<(), vissue_core::error::Error> {
807 let mut app = App::open_core(opts.layout.clone(), opts.agent.clone())?;
808 let mut terminal = crate::view::install()?;
809 let result = (|| {
810 terminal.draw(|f| crate::view::draw(f, &app))?;
811 app.attach(&opts.socket, opts.offline, &AttachHooks::default())?;
812 loop {
813 terminal.draw(|f| crate::view::draw(f, &app))?;
814 if ratatui::crossterm::event::poll(std::time::Duration::from_millis(200))? {
815 if let ratatui::crossterm::event::Event::Key(key) =
816 ratatui::crossterm::event::read()?
817 && app.handle_key(key) == Action::Quit
818 {
819 break;
820 }
821 } else {
822 app.poll_updates();
823 }
824 }
825 Ok(())
826 })();
827 crate::view::restore()?;
828 result
829}