summaryrefslogtreecommitdiff
path: root/makima/src/daemon/tui/app.rs
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/daemon/tui/app.rs')
-rw-r--r--makima/src/daemon/tui/app.rs462
1 files changed, 462 insertions, 0 deletions
diff --git a/makima/src/daemon/tui/app.rs b/makima/src/daemon/tui/app.rs
new file mode 100644
index 0000000..a2c82a2
--- /dev/null
+++ b/makima/src/daemon/tui/app.rs
@@ -0,0 +1,462 @@
+//! 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())
+ })
+ }
+}