diff --git a/crates/ov_cli/src/tui/app.rs b/crates/ov_cli/src/tui/app.rs index 8e7517d4..ab90efb8 100644 --- a/crates/ov_cli/src/tui/app.rs +++ b/crates/ov_cli/src/tui/app.rs @@ -2,6 +2,11 @@ use crate::client::HttpClient; use super::tree::TreeState; +use std::{pin::Pin, time::Instant}; + +// Type alias for confirmation callback +type ConfirmationCallback = Box FnOnce(&'a mut App) -> Pin + 'a>>>; + #[derive(Debug, Clone, Copy, PartialEq)] pub enum Panel { Tree, @@ -53,6 +58,11 @@ pub struct App { pub content_line_count: u16, pub should_quit: bool, pub status_message: String, + pub status_message_time: Option, + pub status_message_locked: bool, + pub error_message: String, + pub error_message_time: Option, + pub confirmation: Option<(String, ConfirmationCallback)>, pub vector_state: VectorRecordsState, pub showing_vector_records: bool, pub current_uri: String, @@ -70,6 +80,11 @@ impl App { content_line_count: 0, should_quit: false, status_message: String::new(), + status_message_time: None, + status_message_locked: false, + error_message: String::new(), + error_message_time: None, + confirmation: None, vector_state: VectorRecordsState::new(), showing_vector_records: false, current_uri: "/".to_string(), @@ -205,6 +220,51 @@ impl App { }; } + pub fn set_error_message(&mut self, message: String) { + self.error_message = message; + self.error_message_time = Some(Instant::now()); + } + + pub fn set_status_message(&mut self, message: String) { + if self.status_message_locked { + let message = format!("Error: Cannot set status message while locked"); + self.set_error_message(message); + return; + } + self.status_message = message; + self.status_message_time = Some(Instant::now()); + } + + pub fn update_messages(&mut self) { + // Don't clear status message if locked + if !self.status_message_locked { + if let Some(time) = self.status_message_time { + if time.elapsed().as_secs() >= 3 { + self.status_message.clear(); + self.status_message_time = None; + } + } + } + + if let Some(time) = self.error_message_time { + if time.elapsed().as_secs() >= 3 { + self.error_message.clear(); + self.error_message_time = None; + } + } + } + + pub fn create_confirmation(&mut self, message: String, on_confirmed: F) + where + F: for<'a> FnOnce(&'a mut App) -> Pin + 'a>> + 'static, + { + self.status_message_locked = true; + self.confirmation = Some(( + message, + Box::new(on_confirmed), + )); + } + pub async fn load_vector_records(&mut self, uri_prefix: Option) { self.status_message = "Loading vector records...".to_string(); match self @@ -301,4 +361,145 @@ impl App { self.vector_state.cursor = self.vector_state.records.len() - 1; } } + + /// Collect all currently expanded nodes + fn collect_expanded_nodes(&self) -> Vec { + self.tree.visible + .iter() + .filter(|r| r.expanded) + .map(|r| r.uri.clone()) + .collect() + } + + /// Ensure parent directories of a URI are expanded + async fn ensure_parent_directories_expanded(&mut self, client: &HttpClient, uri: &str) { + let mut current_path = uri.to_string(); + while current_path != "viking://" && current_path != "/" { + if let Some(last_slash) = current_path.rfind('/') { + current_path = current_path[..last_slash].to_string(); + self.tree.expand_node_by_uri(client, ¤t_path).await; + } else { + break; + } + } + } + + /// Find cursor position for a given URI, fallback to parent if not found + fn find_cursor_for_uri(&self, uri: &str) -> usize { + self.tree.visible.iter() + .position(|r| r.uri == uri) + .unwrap_or_else(|| { + let mut parent_path = uri.to_string(); + while parent_path != "viking://" && parent_path != "/" { + if let Some(last_slash) = parent_path.rfind('/') { + parent_path = parent_path[..last_slash].to_string(); + if let Some(pos) = self.tree.visible.iter().position(|r| r.uri == parent_path) { + return pos; + } + } else { + break; + } + } + 0 + }) + } + + /// Reload the entire tree and restore state + async fn reload_tree_and_restore_state(&mut self, client: &HttpClient, expanded_nodes: &[String], target_uri: &str) { + self.tree.load_root(client, "viking://").await; + + // Restore expanded state for previously expanded nodes + for uri in expanded_nodes { + self.tree.expand_node_by_uri(client, uri).await; + } + + // Ensure parent directories of target URI are expanded + self.ensure_parent_directories_expanded(client, target_uri).await; + + // Find and set cursor to target URI + let cursor = self.find_cursor_for_uri(target_uri); + self.tree.cursor = cursor; + + // Load content for selected node + self.load_content_for_selected().await; + } + + pub async fn reload_entire_tree(&mut self) { + let client = self.client.clone(); + let selected_node = self.tree.selected_uri() + .map(|uri| uri.to_string()) + .unwrap_or_else(|| "viking://".to_string()); + + // Collect expanded nodes before refresh + let expanded_nodes = self.collect_expanded_nodes(); + + // Reload tree and restore state + self.reload_tree_and_restore_state(&client, &expanded_nodes, &selected_node).await; + + self.set_status_message("Tree refreshed".to_string()); + } + + pub async fn delete_selected_uri(&mut self) -> bool { + if let Some(selected_uri) = self.tree.selected_uri() { + let is_dir = self.tree.selected_is_dir().unwrap_or(false); + let deleted = self.delete_uri(selected_uri.to_string(), is_dir).await; + deleted + } else { + self.set_status_message("Nothing selected to delete".to_string()); + true + } + } + + pub async fn delete_uri(&mut self, selected_uri: String, is_dir: bool) -> bool { + + if !self.tree.allow_deletion(&selected_uri) { + return false; + } + + let client = self.client.clone(); + + // Collect expanded nodes before deletion + let expanded_nodes = self.collect_expanded_nodes(); + + // Determine target URI: next node if exists, otherwise previous node + let current_cursor = self.tree.cursor; + let target_uri = if current_cursor + 1 < self.tree.visible.len() { + // Use next node if it exists + self.tree.visible.get(current_cursor + 1).map(|r| r.uri.clone()) + } else if current_cursor > 0 { + // Use previous node if next doesn't exist + self.tree.visible.get(current_cursor - 1).map(|r| r.uri.clone()) + } else { + // Fallback to parent if no siblings + if let Some(last_slash) = selected_uri.rfind('/') { + if last_slash == 0 { + Some("/".to_string()) + } else { + Some(selected_uri[..last_slash].to_string()) + } + } else { + Some("/".to_string()) + } + }; + + match client.rm(&selected_uri, is_dir).await { + Ok(_) => { + self.set_status_message(format!("Deleted: {}", selected_uri)); + + // Remove deleted node from expanded nodes + let mut expanded_nodes = expanded_nodes; + expanded_nodes.retain(|uri| uri != &selected_uri); + + // Reload tree and restore state + if let Some(uri) = &target_uri { + self.reload_tree_and_restore_state(&client, &expanded_nodes, uri).await; + } + } + Err(e) => { + self.set_status_message(format!("Delete failed: {}", e)); + } + } + + true + } } diff --git a/crates/ov_cli/src/tui/event.rs b/crates/ov_cli/src/tui/event.rs index 10a914ef..158f95cc 100644 --- a/crates/ov_cli/src/tui/event.rs +++ b/crates/ov_cli/src/tui/event.rs @@ -3,6 +3,34 @@ use crossterm::event::{KeyCode, KeyEvent}; use super::app::{App, Panel}; pub async fn handle_key(app: &mut App, key: KeyEvent) { + // Check for error message first - don't accept any input when error is shown + if !app.error_message.is_empty() { + return; + } + + // Check for confirmation first + if app.confirmation.is_some() { + match key.code { + KeyCode::Char('y') => { + // Confirm action + if let Some((_, callback)) = app.confirmation.take() { + app.status_message_locked = false; + callback(app).await; + } + } + KeyCode::Char('n') => { + // Cancel action + app.confirmation.take(); + app.status_message_locked = false; + app.set_status_message("Action cancelled".to_string()); + } + _ => { + // Ignore other keys during confirmation + } + } + return; + } + match key.code { KeyCode::Char('q') => { app.should_quit = true; @@ -41,6 +69,35 @@ async fn handle_tree_key(app: &mut App, key: KeyEvent) { app.tree.toggle_expand(&client).await; app.load_content_for_selected().await; } + KeyCode::Char('d') => { + // Delete currently selected URI + if let Some(selected_uri) = app.tree.selected_uri() { + let selected_uri = selected_uri.to_string(); + let is_dir = app.tree.selected_is_dir().unwrap_or(false); + + // Create confirmation + let message = format!("Delete {}? (y/n): {}", + if is_dir { "directory" } else { "file" }, + selected_uri + ); + + app.create_confirmation(message, move |app| { + Box::pin(async move { + let deleted = app.delete_selected_uri().await; + if deleted { + app.set_status_message(format!("Deleted {}", selected_uri)); + } else { + app.set_error_message("Cannot delete root and scope directories".to_string()); + } + }) + }); + } else { + app.set_status_message("Nothing selected to delete".to_string()); + } + } + KeyCode::Char('r') => { + app.reload_entire_tree().await; + } _ => {} } } diff --git a/crates/ov_cli/src/tui/mod.rs b/crates/ov_cli/src/tui/mod.rs index 6c9e9d28..3ec52ed1 100644 --- a/crates/ov_cli/src/tui/mod.rs +++ b/crates/ov_cli/src/tui/mod.rs @@ -60,6 +60,9 @@ async fn run_loop(client: HttpClient, uri: &str) -> Result<()> { app.vector_state.adjust_scroll(tree_height); } + // Update status message (clear after 3 seconds) + app.update_messages(); + terminal.draw(|frame| ui::render(frame, &app))?; if ct_event::poll(std::time::Duration::from_millis(100))? { diff --git a/crates/ov_cli/src/tui/tree.rs b/crates/ov_cli/src/tui/tree.rs index bc90a0a5..675c331d 100644 --- a/crates/ov_cli/src/tui/tree.rs +++ b/crates/ov_cli/src/tui/tree.rs @@ -291,4 +291,34 @@ impl TreeState { self.scroll_offset = self.cursor - viewport_height + 1; } } + + /// Expand a node by its URI + pub async fn expand_node_by_uri(&mut self, client: &HttpClient, uri: &str) { + // Find the node in visible rows + if let Some(row) = self.visible.iter().find(|r| r.uri == uri) { + // Get the node index path + let index_path = row.node_index.clone(); + // Get the node and expand it + if let Some(node) = Self::get_node_mut(&mut self.nodes, &index_path) { + node.expanded = true; + // Ensure children are loaded if it's a directory + if node.entry.is_dir && !node.children_loaded { + // Load children if not already loaded + if let Ok(mut children) = Self::fetch_children(client, &node.entry.uri).await { + let child_depth = node.depth + 1; + for child in &mut children { + child.depth = child_depth; + } + node.children = children; + node.children_loaded = true; + } + } + self.rebuild_visible(); + } + } + } + + pub fn allow_deletion(&self, selected_uri: &str) -> bool { + selected_uri != "/" && !Self::ROOT_SCOPES.iter().any(|s| selected_uri == format!("viking://{}", s)) + } } diff --git a/crates/ov_cli/src/tui/ui.rs b/crates/ov_cli/src/tui/ui.rs index d3d0630f..c049cb98 100644 --- a/crates/ov_cli/src/tui/ui.rs +++ b/crates/ov_cli/src/tui/ui.rs @@ -252,6 +252,24 @@ fn render_vector_records(frame: &mut Frame, app: &App, area: ratatui::layout::Re } fn render_status_bar(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { + + // Show error message only if present + if !app.error_message.is_empty() { + let bar = Paragraph::new(app.error_message.clone()) + .style(Style::default().bg(Color::Red).fg(Color::White)); + frame.render_widget(bar, area); + return; + } + + // Show confirmation message if present + if let Some((message, _)) = &app.confirmation { + let bar = Paragraph::new(message.clone()) + .style(Style::default().bg(Color::Green).fg(Color::White)); + frame.render_widget(bar, area); + return; + } + + // Regular status bar with hints let mut hints = vec![ Span::styled( " q", @@ -336,10 +354,27 @@ fn render_status_bar(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ), - Span::raw(":top/bottom"), + Span::raw(":top/bottom "), + ]); } + hints.extend_from_slice(&[ + Span::styled( + "d", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD)), + Span::raw(":delete "), + + Span::styled( + "r", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD)), + Span::raw(":refresh"), + ]); + if !app.status_message.is_empty() { hints.push(Span::raw(" | ")); hints.push(Span::styled(