//! TUI application state and logic.
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use serde_json::Value;
use uuid::Uuid;
/// Available views/resource types
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewType {
Tasks,
Contracts,
Files,
}
impl ViewType {
pub fn as_str(&self) -> &'static str {
match self {
ViewType::Tasks => "tasks",
ViewType::Contracts => "contracts",
ViewType::Files => "files",
}
}
}
/// Input mode for the TUI
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputMode {
/// Normal navigation mode
Normal,
/// Fuzzy search mode
Search,
/// Confirmation dialog (e.g., for delete)
Confirm,
}
/// Actions that can be performed
#[derive(Debug, Clone, PartialEq)]
pub enum Action {
/// Do nothing
None,
/// Move selection up
Up,
/// Move selection down
Down,
/// Select current item (show details)
Select,
/// Edit the selected item (open in editor)
Edit,
/// Delete the selected item
Delete,
/// Navigate to worktree (output path and exit)
Navigate,
/// Confirm pending action
ConfirmYes,
/// Cancel pending action
ConfirmNo,
/// Enter search mode
EnterSearch,
/// Exit search mode
ExitSearch,
/// Add character to search
SearchChar(char),
/// Backspace in search
SearchBackspace,
/// Clear search
ClearSearch,
/// Quit the application
Quit,
/// Output a path to stdout and exit (for cd integration)
OutputPath(String),
/// Launch editor with path
LaunchEditor(String),
/// Refresh data
Refresh,
}
/// A displayable item in the TUI
#[derive(Debug, Clone)]
pub struct ListItem {
pub id: Uuid,
pub name: String,
pub status: Option<String>,
pub description: Option<String>,
/// Extra data for actions (e.g., worktree path)
pub extra: Value,
}
impl ListItem {
pub fn from_task(value: &Value) -> Option<Self> {
let id = value.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())?;
let name = value.get("name")
.and_then(|v| v.as_str())
.unwrap_or("Unnamed")
.to_string();
let status = value.get("status")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let description = value.get("plan")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Some(Self {
id,
name,
status,
description,
extra: value.clone(),
})
}
pub fn from_contract(value: &Value) -> Option<Self> {
let id = value.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())?;
let name = value.get("name")
.and_then(|v| v.as_str())
.unwrap_or("Unnamed")
.to_string();
let status = value.get("phase")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let description = value.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Some(Self {
id,
name,
status,
description,
extra: value.clone(),
})
}
pub fn from_file(value: &Value) -> Option<Self> {
let id = value.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())?;
let name = value.get("name")
.and_then(|v| v.as_str())
.unwrap_or("Unnamed")
.to_string();
let description = value.get("template_name")
.and_then(|v| v.as_str())
.map(|s| format!("Template: {}", s));
Some(Self {
id,
name,
status: None,
description,
extra: value.clone(),
})
}
/// Get the worktree path from task extra data
pub fn get_worktree_path(&self) -> Option<String> {
// Try various field names that might contain the worktree path
self.extra.get("worktreePath")
.or_else(|| self.extra.get("worktree_path"))
.or_else(|| self.extra.get("workdir"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
/// Build a detailed view string for display
pub fn build_detail_view(&self, view_type: ViewType) -> String {
let mut lines = Vec::new();
match view_type {
ViewType::Tasks => {
lines.push(format!("╭─ Task Details ─────────────────────────"));
lines.push(format!("│ Name: {}", self.name));
lines.push(format!("│ ID: {}", self.id));
if let Some(ref status) = self.status {
lines.push(format!("│ Status: {}", status));
}
if let Some(ref desc) = self.description {
lines.push(format!("│ Plan: {}", desc));
}
if let Some(path) = self.get_worktree_path() {
lines.push(format!("│ Worktree: {}", path));
}
// Add progress if available
if let Some(progress) = self.extra.get("progress").and_then(|v| v.as_str()) {
lines.push(format!("│ Progress: {}", progress));
}
if let Some(error) = self.extra.get("error").and_then(|v| v.as_str()) {
lines.push(format!("│ Error: {}", error));
}
lines.push(format!("╰────────────────────────────────────────"));
}
ViewType::Contracts => {
lines.push(format!("╭─ Contract Details ─────────────────────"));
lines.push(format!("│ Name: {}", self.name));
lines.push(format!("│ ID: {}", self.id));
if let Some(ref status) = self.status {
lines.push(format!("│ Phase: {}", status));
}
if let Some(ref desc) = self.description {
lines.push(format!("│ Description: {}", desc));
}
lines.push(format!("╰────────────────────────────────────────"));
}
ViewType::Files => {
lines.push(format!("╭─ File Details ─────────────────────────"));
lines.push(format!("│ Name: {}", self.name));
lines.push(format!("│ ID: {}", self.id));
if let Some(ref desc) = self.description {
lines.push(format!("│ {}", desc));
}
// Add content preview if available
if let Some(content) = self.extra.get("content").and_then(|v| v.as_str()) {
lines.push(format!("│"));
lines.push(format!("│ Content:"));
for line in content.lines().take(10) {
lines.push(format!("│ {}", line));
}
if content.lines().count() > 10 {
lines.push(format!("│ ... ({} more lines)", content.lines().count() - 10));
}
}
lines.push(format!("╰────────────────────────────────────────"));
}
}
lines.join("\n")
}
}
/// TUI Application state
pub struct App {
/// Current view type
pub view_type: ViewType,
/// All items (unfiltered)
pub items: Vec<ListItem>,
/// Filtered items (based on search)
pub filtered_items: Vec<ListItem>,
/// Currently selected index in filtered list
pub selected_index: usize,
/// Current input mode
pub input_mode: InputMode,
/// Search query
pub search_query: String,
/// Fuzzy matcher
matcher: SkimMatcherV2,
/// Preview content (for selected item)
pub preview_content: String,
/// Whether preview is visible
pub preview_visible: bool,
/// Pending delete item (for confirmation)
pub pending_delete: Option<Uuid>,
/// Status message
pub status_message: Option<String>,
/// Whether the app should quit
pub should_quit: bool,
/// Action to return when exiting (for OutputPath, LaunchEditor)
pub exit_action: Option<Action>,
/// Contract ID (for API calls)
pub contract_id: Option<Uuid>,
}
impl App {
pub fn new(view_type: ViewType) -> Self {
Self {
view_type,
items: Vec::new(),
filtered_items: Vec::new(),
selected_index: 0,
input_mode: InputMode::Normal,
search_query: String::new(),
matcher: SkimMatcherV2::default(),
preview_content: String::new(),
preview_visible: false,
pending_delete: None,
status_message: None,
should_quit: false,
exit_action: None,
contract_id: None,
}
}
/// Set items and update filtered list
pub fn set_items(&mut self, items: Vec<ListItem>) {
self.items = items;
self.update_filtered_items();
}
/// Update filtered items based on search query
pub fn update_filtered_items(&mut self) {
if self.search_query.is_empty() {
self.filtered_items = self.items.clone();
} else {
let mut scored: Vec<_> = self.items
.iter()
.filter_map(|item| {
let score = self.matcher.fuzzy_match(&item.name, &self.search_query)?;
Some((score, item.clone()))
})
.collect();
// Sort by score (highest first)
scored.sort_by(|a, b| b.0.cmp(&a.0));
self.filtered_items = scored.into_iter().map(|(_, item)| item).collect();
}
// Reset selection if out of bounds
if self.selected_index >= self.filtered_items.len() {
self.selected_index = self.filtered_items.len().saturating_sub(1);
}
}
/// Get currently selected item
pub fn selected_item(&self) -> Option<&ListItem> {
self.filtered_items.get(self.selected_index)
}
/// Handle an action and return the resulting action
pub fn handle_action(&mut self, action: Action) -> Action {
match action {
Action::Up => {
if self.selected_index > 0 {
self.selected_index -= 1;
}
Action::None
}
Action::Down => {
if self.selected_index < self.filtered_items.len().saturating_sub(1) {
self.selected_index += 1;
}
Action::None
}
Action::Select => {
// Build detailed view for selected item
if let Some(item) = self.selected_item() {
self.preview_content = item.build_detail_view(self.view_type);
self.preview_visible = true;
}
Action::None
}
Action::Edit => {
// Get worktree path and signal to launch editor
if let Some(item) = self.selected_item() {
if let Some(path) = item.get_worktree_path() {
self.should_quit = true;
self.exit_action = Some(Action::LaunchEditor(path.clone()));
return Action::LaunchEditor(path);
} else {
self.status_message = Some("No worktree path for this item".to_string());
}
}
Action::None
}
Action::Delete => {
// First press: enter confirm mode
// Clone the values we need to avoid borrow issues
if let Some(item) = self.selected_item() {
let id = item.id;
let name = item.name.clone();
self.pending_delete = Some(id);
self.input_mode = InputMode::Confirm;
self.status_message = Some(format!("Delete '{}'? (y/n)", name));
}
Action::None
}
Action::Navigate => {
// Get worktree path and output it
if let Some(item) = self.selected_item() {
if let Some(path) = item.get_worktree_path() {
self.should_quit = true;
self.exit_action = Some(Action::OutputPath(path.clone()));
return Action::OutputPath(path);
} else {
self.status_message = Some("No worktree path for this item".to_string());
}
}
Action::None
}
Action::ConfirmYes => {
if self.input_mode == InputMode::Confirm {
if let Some(_delete_id) = self.pending_delete.take() {
// TODO: Make API call to delete the item
// For now, just show status
self.status_message = Some("Delete confirmed (API call not implemented)".to_string());
}
self.input_mode = InputMode::Normal;
}
Action::None
}
Action::ConfirmNo => {
if self.input_mode == InputMode::Confirm {
self.pending_delete = None;
self.input_mode = InputMode::Normal;
self.status_message = Some("Delete cancelled".to_string());
}
Action::None
}
Action::EnterSearch => {
self.input_mode = InputMode::Search;
Action::None
}
Action::ExitSearch => {
self.input_mode = InputMode::Normal;
Action::None
}
Action::SearchChar(c) => {
self.search_query.push(c);
self.update_filtered_items();
Action::None
}
Action::SearchBackspace => {
self.search_query.pop();
self.update_filtered_items();
Action::None
}
Action::ClearSearch => {
self.search_query.clear();
self.update_filtered_items();
Action::None
}
Action::Quit => {
self.should_quit = true;
Action::Quit
}
Action::Refresh => {
// Signal to caller to refresh data
Action::Refresh
}
Action::OutputPath(path) => {
self.should_quit = true;
self.exit_action = Some(Action::OutputPath(path.clone()));
Action::OutputPath(path)
}
Action::LaunchEditor(path) => {
self.should_quit = true;
self.exit_action = Some(Action::LaunchEditor(path.clone()));
Action::LaunchEditor(path)
}
Action::None => Action::None,
}
}
/// Get the name of the item being deleted (for confirmation dialog)
pub fn get_pending_delete_name(&self) -> Option<String> {
self.pending_delete.and_then(|id| {
self.filtered_items.iter()
.find(|item| item.id == id)
.map(|item| item.name.clone())
})
}
}