From 0df79e78c60d25d24d360725bff0ca725e18f1b2 Mon Sep 17 00:00:00 2001 From: Timur Gordon <31495328+timurgordon@users.noreply.github.com> Date: Thu, 7 Aug 2025 15:36:55 +0200 Subject: [PATCH] update terminal ui to show nested examples --- core/actor/src/terminal_ui.rs | 409 +++++++++++++++++++++++++++++++--- 1 file changed, 375 insertions(+), 34 deletions(-) diff --git a/core/actor/src/terminal_ui.rs b/core/actor/src/terminal_ui.rs index ff98c74..81d8706 100644 --- a/core/actor/src/terminal_ui.rs +++ b/core/actor/src/terminal_ui.rs @@ -82,6 +82,70 @@ pub struct ExampleScript { pub path: PathBuf, } +#[derive(Debug, Clone)] +pub enum ExampleTreeNode { + File { + name: String, + path: PathBuf, + }, + Folder { + name: String, + path: PathBuf, + children: Vec, + expanded: bool, + }, +} + +impl ExampleTreeNode { + pub fn name(&self) -> &str { + match self { + ExampleTreeNode::File { name, .. } => name, + ExampleTreeNode::Folder { name, .. } => name, + } + } + + pub fn path(&self) -> &PathBuf { + match self { + ExampleTreeNode::File { path, .. } => path, + ExampleTreeNode::Folder { path, .. } => path, + } + } + + pub fn is_file(&self) -> bool { + matches!(self, ExampleTreeNode::File { .. }) + } + + pub fn is_folder(&self) -> bool { + matches!(self, ExampleTreeNode::Folder { .. }) + } + + pub fn is_expanded(&self) -> bool { + match self { + ExampleTreeNode::File { .. } => false, + ExampleTreeNode::Folder { expanded, .. } => *expanded, + } + } + + pub fn toggle_expanded(&mut self) { + if let ExampleTreeNode::Folder { expanded, .. } = self { + *expanded = !*expanded; + } + } + + pub fn set_expanded(&mut self, expand: bool) { + if let ExampleTreeNode::Folder { expanded, .. } = self { + *expanded = expand; + } + } +} + +#[derive(Debug, Clone)] +pub struct ExampleTreeItem { + pub node: ExampleTreeNode, + pub depth: usize, + pub index: usize, +} + #[derive(Debug, Clone)] pub struct JobInfo { pub job: Job, @@ -108,6 +172,11 @@ pub struct App { pub status_message: Option, pub last_executed_job: Option, pub last_refresh: Instant, + // New hierarchical examples system + pub example_tree: Vec, + pub example_tree_items: Vec, + pub example_tree_state: ListState, + pub selected_tree_item: Option, } pub struct ActorTui; @@ -138,44 +207,194 @@ impl App { status_message: None, last_executed_job: None, last_refresh: Instant::now(), + // Initialize new hierarchical examples system + example_tree: Vec::new(), + example_tree_items: Vec::new(), + example_tree_state: ListState::default(), + selected_tree_item: None, }; if let Some(dir) = example_dir { - if let Err(e) = app.load_examples(dir) { - error!("Failed to load examples: {}", e); + if let Err(e) = app.load_examples_tree(dir) { + error!("Failed to load examples tree: {}", e); } } Ok(app) } - pub fn load_examples(&mut self, dir: PathBuf) -> Result<()> { + pub fn load_examples_tree(&mut self, dir: PathBuf) -> Result<()> { if !dir.exists() { + log::warn!("Examples directory does not exist: {:?}", dir); return Ok(()); } + log::info!("Loading examples tree from: {:?}", dir); + + // Load hierarchical tree structure + match self.load_example_tree(&dir) { + Ok(tree) => { + self.example_tree = tree; + log::info!("Successfully loaded {} top-level tree nodes", self.example_tree.len()); + } + Err(e) => { + log::error!("Failed to load example tree: {}", e); + // Create a simple fallback structure + self.example_tree = vec![ + ExampleTreeNode::File { + name: "Error loading examples".to_string(), + path: dir.join("error.rhai"), + } + ]; + } + } + + self.rebuild_tree_items(); + log::info!("Rebuilt tree items: {} total items", self.example_tree_items.len()); + + if !self.example_tree_items.is_empty() { + self.example_tree_state.select(Some(0)); + self.selected_tree_item = Some(self.example_tree_items[0].clone()); + + // Update selected_example for backward compatibility + if let Some(first_file) = self.find_first_file_in_tree() { + self.selected_example = Some(ExampleScript { + name: first_file.name().to_string(), + path: first_file.path().clone(), + }); + } + } + + Ok(()) + } + + fn load_example_tree(&self, dir: &PathBuf) -> Result> { + let mut nodes = Vec::new(); + + log::debug!("Loading directory: {:?}", dir); + + let mut entries: Vec<_> = fs::read_dir(dir)? + .collect::, _>>()?; + + log::debug!("Found {} entries in {:?}", entries.len(), dir); + + // Sort entries: directories first, then files, both alphabetically + entries.sort_by(|a, b| { + let a_is_dir = a.path().is_dir(); + let b_is_dir = b.path().is_dir(); + + match (a_is_dir, b_is_dir) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.file_name().cmp(&b.file_name()), + } + }); + + for entry in entries { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + + if path.is_dir() { + log::debug!("Loading folder: {}", name); + let children = self.load_example_tree(&path)?; + log::debug!("Folder '{}' has {} children", name, children.len()); + nodes.push(ExampleTreeNode::Folder { + name, + path, + children, + expanded: true, // Expand folders by default to show hierarchy + }); + } else if path.extension().map_or(false, |ext| ext == "rhai") { + log::debug!("Loading file: {}", name); + nodes.push(ExampleTreeNode::File { + name: path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(&name) + .to_string(), + path, + }); + } + } + + Ok(nodes) + } + + fn load_flat_examples(&mut self, dir: &PathBuf) -> Result<()> { + let mut examples = Vec::new(); + self.collect_all_rhai_files(&mut examples, dir)?; + self.examples = examples; + + if !self.examples.is_empty() { + self.example_list_state.select(Some(0)); + if self.selected_example.is_none() { + self.selected_example = Some(self.examples[0].clone()); + } + } + + Ok(()) + } + + fn collect_all_rhai_files(&self, examples: &mut Vec, dir: &PathBuf) -> Result<()> { for entry in fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); - if path.is_file() && path.extension().map_or(false, |ext| ext == "rhai") { + if path.is_dir() { + self.collect_all_rhai_files(examples, &path)?; + } else if path.extension().map_or(false, |ext| ext == "rhai") { if let Some(name) = path.file_stem().and_then(|s| s.to_str()) { - self.examples.push(ExampleScript { + examples.push(ExampleScript { name: name.to_string(), path, }); } } } - - if !self.examples.is_empty() { - self.example_list_state.select(Some(0)); - self.selected_example = Some(self.examples[0].clone()); - } - Ok(()) } + fn rebuild_tree_items(&mut self) { + self.example_tree_items.clear(); + log::info!("Rebuilding tree items from {} root nodes", self.example_tree.len()); + let mut index = 0; + let tree_clone = self.example_tree.clone(); + for node in &tree_clone { + log::info!("Processing root node: {:?}", node.name()); + self.add_tree_items_recursive(node, 0, &mut index); + } + log::info!("Final tree items count: {}", self.example_tree_items.len()); + } + + fn add_tree_items_recursive(&mut self, node: &ExampleTreeNode, depth: usize, index: &mut usize) { + // Always add the current node to the flattened list + log::debug!("Adding tree item: {} at depth {}", node.name(), depth); + self.example_tree_items.push(ExampleTreeItem { + node: node.clone(), + depth, + index: *index, + }); + *index += 1; + + // For folders, add children only if the folder is expanded + if let ExampleTreeNode::Folder { children, expanded, .. } = node { + log::debug!("Folder '{}' has {} children, expanded: {}", node.name(), children.len(), expanded); + if *expanded { + for child in children { + self.add_tree_items_recursive(child, depth + 1, index); + } + } + } + } + + fn find_first_file_in_tree(&self) -> Option<&ExampleTreeNode> { + for item in &self.example_tree_items { + if item.node.is_file() { + return Some(&item.node); + } + } + None + } + pub async fn refresh_jobs(&mut self) -> Result<()> { let mut conn = self.redis_client.get_multiplexed_async_connection().await?; @@ -403,13 +622,13 @@ impl App { } pub fn next_example(&mut self) { - if self.examples.is_empty() { + if self.example_tree_items.is_empty() { return; } - let selected = match self.example_list_state.selected() { + let i = match self.example_tree_state.selected() { Some(i) => { - if i >= self.examples.len() - 1 { + if i >= self.example_tree_items.len() - 1 { 0 } else { i + 1 @@ -417,27 +636,109 @@ impl App { } None => 0, }; - self.example_list_state.select(Some(selected)); - self.selected_example = Some(self.examples[selected].clone()); + self.example_tree_state.select(Some(i)); + self.selected_tree_item = Some(self.example_tree_items[i].clone()); + + // Update selected_example for backward compatibility + if self.example_tree_items[i].node.is_file() { + self.selected_example = Some(ExampleScript { + name: self.example_tree_items[i].node.name().to_string(), + path: self.example_tree_items[i].node.path().clone(), + }); + } } pub fn previous_example(&mut self) { - if self.examples.is_empty() { + if self.example_tree_items.is_empty() { return; } - let selected = match self.example_list_state.selected() { + let i = match self.example_tree_state.selected() { Some(i) => { if i == 0 { - self.examples.len() - 1 + self.example_tree_items.len() - 1 } else { i - 1 } } None => 0, }; - self.example_list_state.select(Some(selected)); - self.selected_example = Some(self.examples[selected].clone()); + self.example_tree_state.select(Some(i)); + self.selected_tree_item = Some(self.example_tree_items[i].clone()); + + // Update selected_example for backward compatibility + if self.example_tree_items[i].node.is_file() { + self.selected_example = Some(ExampleScript { + name: self.example_tree_items[i].node.name().to_string(), + path: self.example_tree_items[i].node.path().clone(), + }); + } + } + + pub fn expand_selected_folder(&mut self) { + if let Some(selected_index) = self.example_tree_state.selected() { + if selected_index < self.example_tree_items.len() { + let item = &self.example_tree_items[selected_index]; + if item.node.is_folder() { + let path = item.node.path().clone(); + self.expand_folder_by_path(&path); + } + } + } + } + + pub fn collapse_selected_folder(&mut self) { + if let Some(selected_index) = self.example_tree_state.selected() { + if selected_index < self.example_tree_items.len() { + let item = &self.example_tree_items[selected_index]; + if item.node.is_folder() { + let path = item.node.path().clone(); + self.collapse_folder_by_path(&path); + } + } + } + } + + pub fn toggle_selected_folder(&mut self) { + if let Some(selected_index) = self.example_tree_state.selected() { + if selected_index < self.example_tree_items.len() { + let item = &self.example_tree_items[selected_index]; + if item.node.is_folder() { + let path = item.node.path().clone(); + let is_expanded = item.node.is_expanded(); + if is_expanded { + self.collapse_folder_by_path(&path); + } else { + self.expand_folder_by_path(&path); + } + } + } + } + } + + fn expand_folder_by_path(&mut self, target_path: &PathBuf) { + Self::set_folder_expanded_by_path_static(&mut self.example_tree, target_path, true); + self.rebuild_tree_items(); + } + + fn collapse_folder_by_path(&mut self, target_path: &PathBuf) { + Self::set_folder_expanded_by_path_static(&mut self.example_tree, target_path, false); + self.rebuild_tree_items(); + } + + fn set_folder_expanded_by_path_static(nodes: &mut Vec, target_path: &PathBuf, expanded: bool) { + for node in nodes { + match node { + ExampleTreeNode::Folder { path, children, expanded: node_expanded, .. } => { + if path == target_path { + *node_expanded = expanded; + return; + } + Self::set_folder_expanded_by_path_static(children, target_path, expanded); + } + _ => {} + } + } } @@ -868,20 +1169,33 @@ fn render_examples(f: &mut Frame, app: &mut App, area: Rect) { .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) .split(area); - // Example list - let examples: Vec = app.examples + // Hierarchical example tree list + let tree_items: Vec = app.example_tree_items .iter() - .map(|example| { - ListItem::new(Line::from(vec![Span::raw(example.name.clone())])) + .map(|item| { + let indent = " ".repeat(item.depth); + let (icon, name) = match &item.node { + ExampleTreeNode::File { name, .. } => ("📄", name.as_str()), + ExampleTreeNode::Folder { name, expanded, .. } => { + if *expanded { + ("📂", name.as_str()) + } else { + ("📁", name.as_str()) + } + } + }; + + let display_text = format!("{}{} {}", indent, icon, name); + ListItem::new(Line::from(vec![Span::raw(display_text)])) }) .collect(); - let examples_list = List::new(examples) - .block(Block::default().borders(Borders::ALL).title("Example Scripts")) + let examples_list = List::new(tree_items) + .block(Block::default().borders(Borders::ALL).title("Example Scripts (→ expand, ← collapse)")) .highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)) .highlight_symbol("> "); - f.render_stateful_widget(examples_list, chunks[0], &mut app.example_list_state); + f.render_stateful_widget(examples_list, chunks[0], &mut app.example_tree_state); // Example content if let Some(ref example) = app.selected_example { @@ -992,14 +1306,41 @@ async fn run_app(terminal: &mut Terminal, mut app: App) -> Result _ => {} } } + KeyCode::Left => { + if app.current_tab == TabState::Examples { + app.collapse_selected_folder(); + } + } + KeyCode::Right => { + if app.current_tab == TabState::Examples { + app.expand_selected_folder(); + } + } KeyCode::Enter => { if app.current_tab == TabState::Examples { - if let Some(example) = app.selected_example.clone() { - if let Err(e) = app.load_example_to_new_job(&example) { - app.status_message = Some(format!("Error loading example: {}", e)); - } else { - app.current_tab = TabState::NewJob; - app.status_message = Some(format!("Loaded example '{}' into New Job tab", example.name)); + // Handle hierarchical tree navigation + if let Some(selected_index) = app.example_tree_state.selected() { + if selected_index < app.example_tree_items.len() { + let item = app.example_tree_items[selected_index].clone(); + match &item.node { + ExampleTreeNode::Folder { .. } => { + // Toggle folder expand/collapse + app.toggle_selected_folder(); + } + ExampleTreeNode::File { path, name, .. } => { + // Load file into New Job tab + let example = ExampleScript { + name: name.clone(), + path: path.clone(), + }; + if let Err(e) = app.load_example_to_new_job(&example) { + app.status_message = Some(format!("Error loading example: {}", e)); + } else { + app.current_tab = TabState::NewJob; + app.status_message = Some(format!("Loaded example '{}' into New Job tab", name)); + } + } + } } } } else if app.current_tab == TabState::NewJob {