Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 201 additions & 0 deletions crates/ov_cli/src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn for<'a> FnOnce(&'a mut App) -> Pin<Box<dyn Future<Output = ()> + 'a>>>;

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Panel {
Tree,
Expand Down Expand Up @@ -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<Instant>,
pub status_message_locked: bool,
pub error_message: String,
pub error_message_time: Option<Instant>,
pub confirmation: Option<(String, ConfirmationCallback)>,
pub vector_state: VectorRecordsState,
pub showing_vector_records: bool,
pub current_uri: String,
Expand All @@ -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(),
Expand Down Expand Up @@ -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<F>(&mut self, message: String, on_confirmed: F)
where
F: for<'a> FnOnce(&'a mut App) -> Pin<Box<dyn Future<Output = ()> + '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<String>) {
self.status_message = "Loading vector records...".to_string();
match self
Expand Down Expand Up @@ -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<String> {
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, &current_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
}
}
57 changes: 57 additions & 0 deletions crates/ov_cli/src/tui/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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') => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] (non-blocking) There's no guard against deleting the root node (/) or scope-level URIs (viking://resources, viking://agent, etc.). If the server's rm endpoint doesn't reject these, it could lead to unintended data loss.

Consider adding a check before entering confirmation:

if selected_uri == "/" || Self::ROOT_SCOPES.iter().any(|s| selected_uri == format!("viking://{}", s)) {
    app.set_status_message("Cannot delete root or scope directories".to_string());
    return;
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a tree.allow_deletion method to ensure the those nodes cannot be delete.

// 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;
}
_ => {}
}
}
Expand Down
3 changes: 3 additions & 0 deletions crates/ov_cli/src/tui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))? {
Expand Down
30 changes: 30 additions & 0 deletions crates/ov_cli/src/tui/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Loading