From 64589ed4f9e7ee77ff1dcae287d2eda6a56da8fb Mon Sep 17 00:00:00 2001 From: xiaobin Date: Tue, 17 Mar 2026 00:58:55 +0800 Subject: [PATCH 1/4] feat(rust tui): add delete functionality with confirmation and refresh behavior This commit adds two key TUI features: 1. Delete functionality (d key): - Press 'd' to delete the selected file or directory - A confirmation prompt appears: "Delete [file/directory]? (y/n): [URI]" - Press 'y' to confirm deletion or 'n' to cancel - After deletion, the tree refreshes and cursor moves to the next or previous sibling 2. Refresh behavior (r key): - Press 'r' to refresh the entire tree - Loads the root node "viking://" - Restores the cursor to the originally selected node - Maintains user context after refresh --- crates/ov_cli/src/tui/app.rs | 169 +++++++++++++++++++++++++++++++++ crates/ov_cli/src/tui/event.rs | 40 ++++++++ crates/ov_cli/src/tui/mod.rs | 3 + crates/ov_cli/src/tui/tree.rs | 26 +++++ crates/ov_cli/src/tui/ui.rs | 21 +++- 5 files changed, 257 insertions(+), 2 deletions(-) diff --git a/crates/ov_cli/src/tui/app.rs b/crates/ov_cli/src/tui/app.rs index 8e7517d4..f88622e9 100644 --- a/crates/ov_cli/src/tui/app.rs +++ b/crates/ov_cli/src/tui/app.rs @@ -2,6 +2,8 @@ use crate::client::HttpClient; use super::tree::TreeState; +use std::time::Instant; + #[derive(Debug, Clone, Copy, PartialEq)] pub enum Panel { Tree, @@ -53,6 +55,8 @@ pub struct App { pub content_line_count: u16, pub should_quit: bool, pub status_message: String, + pub status_message_time: Option, + pub delete_confirmation: Option<(String, bool)>, // (uri, is_dir) pub vector_state: VectorRecordsState, pub showing_vector_records: bool, pub current_uri: String, @@ -70,6 +74,8 @@ impl App { content_line_count: 0, should_quit: false, status_message: String::new(), + status_message_time: None, + delete_confirmation: None, vector_state: VectorRecordsState::new(), showing_vector_records: false, current_uri: "/".to_string(), @@ -205,6 +211,20 @@ impl App { }; } + pub fn set_status_message(&mut self, message: String) { + self.status_message = message; + self.status_message_time = Some(Instant::now()); + } + + pub fn update_status_message(&mut self) { + if let Some(time) = self.status_message_time { + if time.elapsed().as_secs() >= 3 { + self.status_message.clear(); + self.status_message_time = None; + } + } + } + pub async fn load_vector_records(&mut self, uri_prefix: Option) { self.status_message = "Loading vector records...".to_string(); match self @@ -301,4 +321,153 @@ impl App { self.vector_state.cursor = self.vector_state.records.len() - 1; } } + + 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: Vec = self.tree.visible + .iter() + .filter(|r| r.expanded) + .map(|r| r.uri.clone()) + .collect(); + + 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 selected node are expanded + // so the selected node becomes visible + let mut current_path = selected_node.clone(); + while current_path != "viking://" && current_path != "/" { + // Remove the last segment to get parent path + if let Some(last_slash) = current_path.rfind('/') { + current_path = current_path[..last_slash].to_string(); + // Expand parent directory + self.tree.expand_node_by_uri(&client, ¤t_path).await; + } else { + break; + } + } + + // Find the node with the original selected URI and set cursor + let cursor = self.tree.visible.iter() + .position(|r| r.uri == selected_node) + .unwrap_or_else(|| { + // If selected node not found, try to find its parent directory + let mut parent_path = selected_node.clone(); + 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 + }); + self.tree.cursor = cursor; + self.load_content_for_selected().await; + self.set_status_message("Tree refreshed".to_string()); + } + + pub async fn delete_uri(&mut self, selected_uri: String, is_dir: bool) { + let client = self.client.clone(); + + // Collect expanded nodes before deletion + let expanded_nodes: Vec = self.tree.visible + .iter() + .filter(|r| r.expanded) + .map(|r| r.uri.clone()) + .collect(); + + // 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 the root + self.tree.load_root(&client, "viking://").await; + + // Restore expanded state for remaining expanded nodes + for uri in &expanded_nodes { + self.tree.expand_node_by_uri(&client, uri).await; + } + + // Ensure parent directories of target URI are expanded + if let Some(uri) = &target_uri { + let mut current_path = uri.clone(); + 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; + } + } + } + + // Set cursor to target URI + let cursor = if let Some(uri) = target_uri { + self.tree.visible.iter() + .position(|r| r.uri == uri) + .unwrap_or_else(|| { + // If target node not found, try to find its parent directory + let mut current_path = uri.clone(); + while current_path != "viking://" && current_path != "/" { + if let Some(last_slash) = current_path.rfind('/') { + current_path = current_path[..last_slash].to_string(); + if let Some(pos) = self.tree.visible.iter().position(|r| r.uri == current_path) { + return pos; + } + } else { + break; + } + } + 0 + }) + } else { + 0 + }; + self.tree.cursor = cursor; + + self.load_content_for_selected().await; + } + Err(e) => { + self.set_status_message(format!("Delete failed: {}", e)); + } + } + } } diff --git a/crates/ov_cli/src/tui/event.rs b/crates/ov_cli/src/tui/event.rs index 10a914ef..b18d54e2 100644 --- a/crates/ov_cli/src/tui/event.rs +++ b/crates/ov_cli/src/tui/event.rs @@ -3,6 +3,27 @@ use crossterm::event::{KeyCode, KeyEvent}; use super::app::{App, Panel}; pub async fn handle_key(app: &mut App, key: KeyEvent) { + // Check for delete confirmation first + if app.delete_confirmation.is_some() { + match key.code { + KeyCode::Char('y') => { + // Confirm deletion + if let Some((selected_uri, is_dir)) = app.delete_confirmation.take() { + app.delete_uri(selected_uri, is_dir).await; + } + } + KeyCode::Char('n') => { + // Cancel deletion + app.delete_confirmation.take(); + app.set_status_message("Deletion cancelled".to_string()); + } + _ => { + // Ignore other keys during confirmation + } + } + return; + } + match key.code { KeyCode::Char('q') => { app.should_quit = true; @@ -41,6 +62,25 @@ 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); + + // Set delete confirmation state + app.delete_confirmation = Some((selected_uri.clone(), is_dir)); + app.status_message = format!("Delete {}? (y/n): {}", + if is_dir { "directory" } else { "file" }, + selected_uri + ); + } 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..26e22b54 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_status_message(); + 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..db9910fc 100644 --- a/crates/ov_cli/src/tui/tree.rs +++ b/crates/ov_cli/src/tui/tree.rs @@ -291,4 +291,30 @@ 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(); + } + } + } } diff --git a/crates/ov_cli/src/tui/ui.rs b/crates/ov_cli/src/tui/ui.rs index d3d0630f..15ce72c8 100644 --- a/crates/ov_cli/src/tui/ui.rs +++ b/crates/ov_cli/src/tui/ui.rs @@ -336,10 +336,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( @@ -351,4 +368,4 @@ fn render_status_bar(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) let bar = Paragraph::new(Line::from(hints)) .style(Style::default().bg(Color::DarkGray).fg(Color::White)); frame.render_widget(bar, area); -} +} \ No newline at end of file From 35aacc657840a84d4754348ea14e524f7e96bb31 Mon Sep 17 00:00:00 2001 From: xiaobin Date: Wed, 18 Mar 2026 00:13:37 +0800 Subject: [PATCH 2/4] Enhance TUI confirmation system and error handling Changes: - Replace delete_confirmation with generic confirmation system using callbacks - Add error message handling with dedicated display - Add status message locking during confirmations - Add delete_selected_uri method for cleaner deletion flow - Add deletion protection for root and scope directories - Update UI to show error messages and confirmation prompts - Rename update_status_message to update_messages - Add allow_deletion method to TreeState - Update key handling to prioritize error messages --- crates/ov_cli/src/tui/app.rs | 74 ++++++++++++++++++++++++++++++---- crates/ov_cli/src/tui/event.rs | 41 +++++++++++++------ crates/ov_cli/src/tui/mod.rs | 2 +- crates/ov_cli/src/tui/tree.rs | 4 ++ crates/ov_cli/src/tui/ui.rs | 18 +++++++++ 5 files changed, 118 insertions(+), 21 deletions(-) diff --git a/crates/ov_cli/src/tui/app.rs b/crates/ov_cli/src/tui/app.rs index f88622e9..9b426341 100644 --- a/crates/ov_cli/src/tui/app.rs +++ b/crates/ov_cli/src/tui/app.rs @@ -2,7 +2,10 @@ use crate::client::HttpClient; use super::tree::TreeState; -use std::time::Instant; +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 { @@ -56,7 +59,10 @@ pub struct App { pub should_quit: bool, pub status_message: String, pub status_message_time: Option, - pub delete_confirmation: Option<(String, bool)>, // (uri, is_dir) + 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, @@ -75,7 +81,10 @@ impl App { should_quit: false, status_message: String::new(), status_message_time: None, - delete_confirmation: 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(), @@ -211,20 +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_status_message(&mut self) { - if let Some(time) = self.status_message_time { + 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.status_message.clear(); - self.status_message_time = None; + 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 @@ -379,7 +419,23 @@ impl App { self.set_status_message("Tree refreshed".to_string()); } - pub async fn delete_uri(&mut self, selected_uri: String, is_dir: bool) { + 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 @@ -469,5 +525,7 @@ impl App { 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 b18d54e2..158f95cc 100644 --- a/crates/ov_cli/src/tui/event.rs +++ b/crates/ov_cli/src/tui/event.rs @@ -3,19 +3,26 @@ use crossterm::event::{KeyCode, KeyEvent}; use super::app::{App, Panel}; pub async fn handle_key(app: &mut App, key: KeyEvent) { - // Check for delete confirmation first - if app.delete_confirmation.is_some() { + // 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 deletion - if let Some((selected_uri, is_dir)) = app.delete_confirmation.take() { - app.delete_uri(selected_uri, is_dir).await; + // Confirm action + if let Some((_, callback)) = app.confirmation.take() { + app.status_message_locked = false; + callback(app).await; } } KeyCode::Char('n') => { - // Cancel deletion - app.delete_confirmation.take(); - app.set_status_message("Deletion cancelled".to_string()); + // Cancel action + app.confirmation.take(); + app.status_message_locked = false; + app.set_status_message("Action cancelled".to_string()); } _ => { // Ignore other keys during confirmation @@ -67,13 +74,23 @@ async fn handle_tree_key(app: &mut App, key: KeyEvent) { 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); - - // Set delete confirmation state - app.delete_confirmation = Some((selected_uri.clone(), is_dir)); - app.status_message = format!("Delete {}? (y/n): {}", + + // 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()); } diff --git a/crates/ov_cli/src/tui/mod.rs b/crates/ov_cli/src/tui/mod.rs index 26e22b54..3ec52ed1 100644 --- a/crates/ov_cli/src/tui/mod.rs +++ b/crates/ov_cli/src/tui/mod.rs @@ -61,7 +61,7 @@ async fn run_loop(client: HttpClient, uri: &str) -> Result<()> { } // Update status message (clear after 3 seconds) - app.update_status_message(); + app.update_messages(); terminal.draw(|frame| ui::render(frame, &app))?; diff --git a/crates/ov_cli/src/tui/tree.rs b/crates/ov_cli/src/tui/tree.rs index db9910fc..675c331d 100644 --- a/crates/ov_cli/src/tui/tree.rs +++ b/crates/ov_cli/src/tui/tree.rs @@ -317,4 +317,8 @@ impl TreeState { } } } + + 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 15ce72c8..fa6bdda1 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", From d80ac983b8402e3fe714895070da61f2fcf7b478 Mon Sep 17 00:00:00 2001 From: xiaobin Date: Wed, 18 Mar 2026 00:19:04 +0800 Subject: [PATCH 3/4] fix: new line end in ui.rs --- crates/ov_cli/src/tui/ui.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ov_cli/src/tui/ui.rs b/crates/ov_cli/src/tui/ui.rs index fa6bdda1..c049cb98 100644 --- a/crates/ov_cli/src/tui/ui.rs +++ b/crates/ov_cli/src/tui/ui.rs @@ -386,4 +386,4 @@ fn render_status_bar(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) let bar = Paragraph::new(Line::from(hints)) .style(Style::default().bg(Color::DarkGray).fg(Color::White)); frame.render_widget(bar, area); -} \ No newline at end of file +} From 54a621207ba9aad2003faac585c2f6183a43cc59 Mon Sep 17 00:00:00 2001 From: xiaobin Date: Wed, 18 Mar 2026 00:35:06 +0800 Subject: [PATCH 4/4] Refactor reload_entire_tree and delete_uri functions to reduce code duplication --- crates/ov_cli/src/tui/app.rs | 132 ++++++++++++++--------------------- 1 file changed, 53 insertions(+), 79 deletions(-) diff --git a/crates/ov_cli/src/tui/app.rs b/crates/ov_cli/src/tui/app.rs index 9b426341..ab90efb8 100644 --- a/crates/ov_cli/src/tui/app.rs +++ b/crates/ov_cli/src/tui/app.rs @@ -362,46 +362,34 @@ impl App { } } - 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: Vec = self.tree.visible + /// 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(); - - 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 selected node are expanded - // so the selected node becomes visible - let mut current_path = selected_node.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 != "/" { - // Remove the last segment to get parent path if let Some(last_slash) = current_path.rfind('/') { current_path = current_path[..last_slash].to_string(); - // Expand parent directory - self.tree.expand_node_by_uri(&client, ¤t_path).await; + self.tree.expand_node_by_uri(client, ¤t_path).await; } else { break; } } - - // Find the node with the original selected URI and set cursor - let cursor = self.tree.visible.iter() - .position(|r| r.uri == selected_node) + } + + /// 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(|| { - // If selected node not found, try to find its parent directory - let mut parent_path = selected_node.clone(); + 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(); @@ -413,9 +401,41 @@ impl App { } } 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()); } @@ -439,11 +459,7 @@ impl App { let client = self.client.clone(); // Collect expanded nodes before deletion - let expanded_nodes: Vec = self.tree.visible - .iter() - .filter(|r| r.expanded) - .map(|r| r.uri.clone()) - .collect(); + let expanded_nodes = self.collect_expanded_nodes(); // Determine target URI: next node if exists, otherwise previous node let current_cursor = self.tree.cursor; @@ -474,52 +490,10 @@ impl App { let mut expanded_nodes = expanded_nodes; expanded_nodes.retain(|uri| uri != &selected_uri); - // Reload the root - self.tree.load_root(&client, "viking://").await; - - // Restore expanded state for remaining expanded nodes - for uri in &expanded_nodes { - self.tree.expand_node_by_uri(&client, uri).await; - } - - // Ensure parent directories of target URI are expanded + // Reload tree and restore state if let Some(uri) = &target_uri { - let mut current_path = uri.clone(); - 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; - } - } + self.reload_tree_and_restore_state(&client, &expanded_nodes, uri).await; } - - // Set cursor to target URI - let cursor = if let Some(uri) = target_uri { - self.tree.visible.iter() - .position(|r| r.uri == uri) - .unwrap_or_else(|| { - // If target node not found, try to find its parent directory - let mut current_path = uri.clone(); - while current_path != "viking://" && current_path != "/" { - if let Some(last_slash) = current_path.rfind('/') { - current_path = current_path[..last_slash].to_string(); - if let Some(pos) = self.tree.visible.iter().position(|r| r.uri == current_path) { - return pos; - } - } else { - break; - } - } - 0 - }) - } else { - 0 - }; - self.tree.cursor = cursor; - - self.load_content_for_selected().await; } Err(e) => { self.set_status_message(format!("Delete failed: {}", e));