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::{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#[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
28pub 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 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 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
653pub struct RunOpts {
655 pub layout: Layout,
656 pub socket: PathBuf,
657 pub offline: bool,
658 pub agent: String,
659}
660
661pub 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}