use anyhow::Result; use crossterm::event::{Event, KeyCode, KeyModifiers, MouseEventKind}; use super::actions::{copy_to_clipboard, get_remote_url, scan_todos_from_repos, save_bookmarks, save_pins, to_web_url}; use super::modals::{handle_modal_key, process_modal_confirm}; use super::render::{render_hub, HELP_LINES}; use super::state::{ ConfirmTag, DisplayRow, HubState, Modal, ModalAction, PendingAction, PhaseResult, SelectTag, Tab, TextTag, }; use crate::todos; pub fn tui_phase(state: &mut HubState) -> Result { let mut terminal = crate::tui::setup()?; let result = (|| -> Result { loop { state.poll_incoming(); state.poll_diff(); terminal.draw(|f| render_hub(f, state))?; state.update_focused_extra(); let timeout_ms = if state.scan_rx.is_some() { 50 } else { 250 }; let event = crate::tui::next_event(timeout_ms)?; if let Some(Event::Resize(_, _)) = &event { let _ = terminal.clear(); continue; } if let Some(Event::Mouse(m)) = &event { match m.kind { MouseEventKind::ScrollDown => { state.scroll_down(); } MouseEventKind::ScrollUp => { state.scroll_up(); } _ => {} } continue; } let key = match event { Some(Event::Key(k)) => k, _ => continue }; { if state.modal.is_some() { match handle_modal_key(&mut state.modal, key.code) { ModalAction::Confirm => process_modal_confirm(state), ModalAction::Cancel => state.modal = Modal::None, ModalAction::Pending => {} } if let Some(url) = state.pending_clone.take() { return Ok(PhaseResult::ExecClone(url)); } continue; } if state.confirming.is_some() { match key.code { KeyCode::Char('y') => { state.exec_action = true; } KeyCode::Esc => { state.confirming = None; } _ => {} } if state.exec_action { let r = match state.confirming.as_ref().unwrap() { PendingAction::Sync => PhaseResult::ExecSync, PendingAction::Release => PhaseResult::ExecRelease, }; return Ok(r); } continue; } if state.filtering { match key.code { KeyCode::Esc => { state.filtering = false; state.filter_text.clear(); state.rebuild_display(); } KeyCode::Enter => { state.filtering = false; } KeyCode::Backspace => { state.filter_text.pop(); state.rebuild_display(); } KeyCode::Char(c) => { state.filter_text.push(c); state.rebuild_display(); } _ => {} } continue; } state.status_msg = None; match key.code { KeyCode::Char('q') | KeyCode::Esc => { if !state.filter_text.is_empty() { state.filter_text.clear(); state.rebuild_display(); } else { state.quit = true; return Ok(PhaseResult::None); } } KeyCode::Up | KeyCode::Char('k') => state.scroll_up(), KeyCode::Down | KeyCode::Char('j') => state.scroll_down(), KeyCode::Char('A') if key.modifiers.contains(KeyModifiers::CONTROL) => { let visible: Vec = state.display.iter().filter_map(|row| { if let DisplayRow::Repo { repo_idx } = row { Some(*repo_idx) } else { None } }).collect(); let all_sel = visible.iter().all(|ri| state.selected.contains(ri)); if all_sel { state.selected.clear(); state.status_msg = Some("All deselected".into()); } else { for ri in visible { state.selected.insert(ri); } state.status_msg = Some(format!("Selected {} repos", state.selected.len())); } } KeyCode::Char('*') => { state.modal = Modal::Select { title: "Select by condition".into(), options: vec![ "All visible".into(), "Dirty (uncommitted changes)".into(), "Behind remote".into(), "Ahead of remote".into(), "No license".into(), "Language: Rust".into(), "Language: TypeScript".into(), "Language: Python".into(), "Language: Go".into(), ], cursor: 0, tag: SelectTag::SelectByCondition, }; } KeyCode::Char(' ') => { match state.tab { Tab::Todos => { if let Some(ti) = state.cur_todo_idx() { state.todos[ti].status = state.todos[ti].status.cycle(); state.todos[ti].touch(); let _ = todos::save(&state.todos); } } _ => state.toggle_select(), } } KeyCode::Tab | KeyCode::Right => { state.tab = match state.tab { Tab::Repos => Tab::Projects, Tab::Projects => Tab::Todos, Tab::Todos => Tab::PRs, Tab::PRs => Tab::Issues, Tab::Issues => Tab::Repos, }; state.cursor = 0; state.offset = 0; state.selected.clear(); state.rebuild_display(); } KeyCode::Left => { state.tab = match state.tab { Tab::Repos => Tab::Issues, Tab::Projects => Tab::Repos, Tab::Todos => Tab::Projects, Tab::PRs => Tab::Todos, Tab::Issues => Tab::PRs, }; state.cursor = 0; state.offset = 0; state.selected.clear(); state.rebuild_display(); } KeyCode::Enter => { match state.tab { Tab::Repos | Tab::Projects => { if state.cur_repo_idx().is_some() || state.cur_proj_idx().is_some() { state.open_detail = true; return Ok(PhaseResult::OpenDetail); } } Tab::PRs => { let di = state.cur_di(); if let Some(di) = di { if let DisplayRow::Pr { idx } = &state.display[di] { let url = state.prs[*idx].url.clone(); if !url.is_empty() { let _ = std::process::Command::new("xdg-open").arg(&url).spawn(); state.status_msg = Some("Opening PR in browser...".into()); } } } } Tab::Issues => { let di = state.cur_di(); if let Some(di) = di { if let DisplayRow::Issue { idx } = &state.display[di] { let url = state.issues[*idx].url.clone(); if !url.is_empty() { let _ = std::process::Command::new("xdg-open").arg(&url).spawn(); state.status_msg = Some("Opening issue in browser...".into()); } } } } Tab::Todos => {} } } KeyCode::Char('s') => { if !state.action_targets().is_empty() { state.confirming = Some(PendingAction::Sync); } } KeyCode::Char('r') => { if !state.action_targets().is_empty() { state.confirming = Some(PendingAction::Release); } } KeyCode::Char('n') => { if let Some(ri) = state.cur_repo_idx() { let cur = state.repos[ri].name.clone(); state.modal = Modal::Text { title: "Set label".into(), prompt: "Display name (blank = reset):".into(), value: cur, tag: TextTag::Label(ri) }; } } KeyCode::Char('p') => { let mut opts = vec!["Create project".to_string()]; if !state.projects.is_empty() { opts.push("Assign repos".into()); opts.push("Rename / recolor".into()); opts.push("Delete project".into()); } state.modal = Modal::Select { title: "Projects".into(), options: opts, cursor: 0, tag: SelectTag::ProjMenu }; } KeyCode::Char('+') => { state.modal = Modal::Text { title: "New todo — Title".into(), prompt: "Title:".into(), value: String::new(), tag: TextTag::TodoTitle }; } KeyCode::Char('e') => { if let Some(ti) = state.cur_todo_idx() { let cur = state.todos[ti].title.clone(); state.modal = Modal::Text { title: "Edit todo — Title".into(), prompt: "Title:".into(), value: cur, tag: TextTag::TodoEditTitle(ti) }; } } KeyCode::Delete | KeyCode::Char('D') if key.modifiers.contains(KeyModifiers::SHIFT) => { if state.tab == Tab::Todos { if let Some(ti) = state.cur_todo_idx() { let title = state.todos[ti].title.clone(); state.modal = Modal::Confirm { message: format!("Delete '{}'?", title), tag: ConfirmTag::DeleteTodo(ti) }; } } else if state.tab == Tab::Projects { if let Some(pi) = state.cur_proj_idx() { let name = state.projects[pi].name.clone(); state.modal = Modal::Confirm { message: format!("Delete project '{}'?", name), tag: ConfirmTag::DeleteProject(pi) }; } } } KeyCode::Delete => { if state.tab == Tab::Todos { if let Some(ti) = state.cur_todo_idx() { let title = state.todos[ti].title.clone(); state.modal = Modal::Confirm { message: format!("Delete '{}'?", title), tag: ConfirmTag::DeleteTodo(ti) }; } } else if state.tab == Tab::Projects { if let Some(pi) = state.cur_proj_idx() { let name = state.projects[pi].name.clone(); state.modal = Modal::Confirm { message: format!("Delete project '{}'?", name), tag: ConfirmTag::DeleteProject(pi) }; } } } KeyCode::Char('d') => { state.filter_dirty = !state.filter_dirty; state.rebuild_display(); } KeyCode::Char('b') => { state.filter_behind = !state.filter_behind; state.rebuild_display(); } KeyCode::Char('f') => { state.filter_ahead = !state.filter_ahead; state.rebuild_display(); } KeyCode::Char('/') => { state.filtering = true; state.filter_text.clear(); state.rebuild_display(); } KeyCode::Char('?') => { state.modal = Modal::Info { title: "Keybindings".into(), lines: HELP_LINES.iter().map(|s| s.to_string()).collect(), scroll: 0, }; } KeyCode::Char('S') => { state.sort_mode = state.sort_mode.next(); state.rebuild_display(); } KeyCode::Char('o') => { if let Some(ri) = state.cur_repo_idx() { let path = state.repos[ri].path.clone(); let editor = state.editor.clone(); let _ = std::process::Command::new(&editor).arg(&path).spawn(); state.status_msg = Some(format!("Opened in {}", editor)); } } KeyCode::Char('y') => { if let Some(ri) = state.cur_repo_idx() { let path = state.repos[ri].path.to_string_lossy().to_string(); let cmd = state.clipboard_cmd.clone(); if copy_to_clipboard(&path, &cmd) { state.status_msg = Some("Path copied!".into()); } else { state.status_msg = Some("No clipboard cmd".into()); } } } KeyCode::Char('u') => { if let Some(ri) = state.cur_repo_idx() { let path = state.repos[ri].path.clone(); let cmd = state.clipboard_cmd.clone(); if let Some(url) = get_remote_url(&path) { if copy_to_clipboard(&url, &cmd) { state.status_msg = Some("URL copied!".into()); } else { state.status_msg = Some(format!(" {}", url)); } } else { state.status_msg = Some("No remote origin".into()); } } } KeyCode::Char('B') => { if let Some(ri) = state.cur_repo_idx() { let path = state.repos[ri].path.clone(); if state.bookmarks.contains(&path) { state.bookmarks.remove(&path); state.repos[ri].bookmarked = false; state.status_msg = Some("Bookmark removed".into()); } else { state.bookmarks.insert(path.clone()); state.repos[ri].bookmarked = true; state.status_msg = Some("Bookmarked!".into()); } save_bookmarks(&state.bookmarks); state.rebuild_display(); } } KeyCode::Char('T') => { let count = scan_todos_from_repos(state); state.status_msg = Some(format!("Scanned: {} new code todos", count)); state.tab = Tab::Todos; state.show_code_todos = true; state.rebuild_display(); } KeyCode::Char('X') => { if state.tab == Tab::Todos { state.show_code_todos = !state.show_code_todos; state.rebuild_display(); state.status_msg = Some(if state.show_code_todos { "Showing code scan todos".into() } else { "Showing manual todos only".into() }); } } KeyCode::Char('g') => { if let Some(ri) = state.cur_repo_idx() { let gitignore = state.repos[ri].path.join(".gitignore"); let editor = state.editor.clone(); let _ = std::process::Command::new(&editor).arg(&gitignore).spawn(); state.status_msg = Some("Opening .gitignore...".into()); } } KeyCode::Char('O') => { if let Some(ri) = state.cur_repo_idx() { let path = state.repos[ri].path.clone(); let _ = std::process::Command::new("xdg-open").arg(&path).spawn(); state.status_msg = Some("Opening in file manager...".into()); } } KeyCode::Char('W') => { if let Some(ri) = state.cur_repo_idx() { let path = state.repos[ri].path.clone(); if let Some(url) = get_remote_url(&path) { let web = to_web_url(&url); let _ = std::process::Command::new("xdg-open").arg(&web).spawn(); state.status_msg = Some("Opening in browser...".into()); } else { state.status_msg = Some("No remote origin".into()); } } } KeyCode::Char('P') => { if let Some(ri) = state.cur_repo_idx() { let path = state.repos[ri].path.clone(); if state.pins.contains(&path) { state.pins.remove(&path); state.status_msg = Some("Unpinned".into()); } else { state.pins.insert(path.clone()); state.status_msg = Some("Pinned to top! ◆".into()); } save_pins(&state.pins); state.rebuild_display(); } } KeyCode::Char('a') => { state.filter_archived = !state.filter_archived; state.rebuild_display(); } KeyCode::Char('C') => { let prompt = format!("Git URL (clones into {}):", state.workspace_root.display()); state.modal = Modal::Text { title: "Clone repository".into(), prompt, value: String::new(), tag: TextTag::CloneUrl, }; } KeyCode::Char('I') => { let prompt = format!("Folder name (created in {}):", state.workspace_root.display()); state.modal = Modal::Text { title: "Init new repository".into(), prompt, value: String::new(), tag: TextTag::InitName, }; } KeyCode::Char('L') => { state.modal = Modal::Text { title: "Link existing folder".into(), prompt: "Absolute path to folder:".into(), value: String::new(), tag: TextTag::LinkPath, }; } KeyCode::Char('R') => { state.status_msg = Some("Fetching from GitHub API...".into()); return Ok(PhaseResult::ExecRefresh); } _ => {} } } } })(); crate::tui::teardown(terminal)?; result }