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('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 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 pub fn confirm_line(&self) -> Option<String> {
605 self.confirm
606 .map(|kind| format!("confirm {}? y/n", kind.state()))
607 }
608
609 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#[derive(Debug)]
709pub struct RunOpts {
710 pub layout: Layout,
712 pub socket: PathBuf,
714 pub offline: bool,
716 pub agent: String,
718}
719
720pub 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}