Skip to content

Commit b91b7ed

Browse files
foharaclaude
andcommitted
Add tree node expand/collapse on Enter in TUI file pane
The file tree view was displaying but Enter/Right didn't expand directories. This was because the selection tracking used the flat files list index, not the visual tree node index. Added: - SelectedTreeNode struct to capture node info including tree path - selected_tree_node() to find the node at current visual index - toggle_selected_node() to expand/collapse using the path - visible_tree_node_count() for proper navigation bounds - Updated handle_select() to toggle directories, load tasks for files - Fixed move_down/go_bottom to use visible node count 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent bfbfebf commit b91b7ed

2 files changed

Lines changed: 172 additions & 11 deletions

File tree

‎crates/lash-tui/src/app.rs‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -165,14 +165,30 @@ impl TuiApp {
165165
fn handle_select(&mut self) -> TuiResult<()> {
166166
match self.state.focused_pane {
167167
crate::state::FocusedPane::Navigation => {
168-
// Load tasks for selected file
169-
if let Some(file) = self.state.selected_file() {
170-
let task_repo = TaskRepository::new(&self.conn);
171-
self.state.tasks = task_repo
172-
.get_by_file(file.id)
173-
.map_err(|e| TuiError::App(format!("Failed to load tasks: {e}")))?;
174-
self.state.selected_task_index = 0;
175-
self.state.switch_pane();
168+
// Check if tree view is active and get selected node info
169+
if let Some(selected) = self.state.selected_tree_node() {
170+
if selected.is_directory {
171+
// Toggle expand/collapse for directories
172+
self.state.toggle_selected_node();
173+
} else if let Some(file) = selected.file_record {
174+
// Load tasks for file and switch to detail pane
175+
let task_repo = TaskRepository::new(&self.conn);
176+
self.state.tasks = task_repo
177+
.get_by_file(file.id)
178+
.map_err(|e| TuiError::App(format!("Failed to load tasks: {e}")))?;
179+
self.state.selected_task_index = 0;
180+
self.state.switch_pane();
181+
}
182+
} else {
183+
// Fallback for flat view: load tasks for selected file
184+
if let Some(file) = self.state.selected_file() {
185+
let task_repo = TaskRepository::new(&self.conn);
186+
self.state.tasks = task_repo
187+
.get_by_file(file.id)
188+
.map_err(|e| TuiError::App(format!("Failed to load tasks: {e}")))?;
189+
self.state.selected_task_index = 0;
190+
self.state.switch_pane();
191+
}
176192
}
177193
}
178194
crate::state::FocusedPane::Detail => {

‎crates/lash-tui/src/state.rs‎

Lines changed: 148 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,25 @@ pub struct ThemeSelectorState {
103103
pub scheme_names: Vec<String>,
104104
}
105105

106+
/// Information about a selected tree node
107+
#[derive(Debug, Clone)]
108+
pub struct SelectedTreeNode {
109+
/// Whether this is a directory node
110+
pub is_directory: bool,
111+
112+
/// Whether the node is currently expanded
113+
pub is_expanded: bool,
114+
115+
/// Whether the node has children
116+
pub has_children: bool,
117+
118+
/// The file record if this is a file node
119+
pub file_record: Option<FileRecord>,
120+
121+
/// Path to this node in the tree (indices at each level)
122+
pub path: Vec<usize>,
123+
}
124+
106125
impl AppState {
107126
/// Create new application state with default theme
108127
///
@@ -213,7 +232,8 @@ impl AppState {
213232
pub fn move_down(&mut self) {
214233
match self.focused_pane {
215234
FocusedPane::Navigation => {
216-
if self.selected_file_index + 1 < self.files.len() {
235+
let max_index = self.visible_tree_node_count();
236+
if self.selected_file_index + 1 < max_index {
217237
self.selected_file_index += 1;
218238
}
219239
}
@@ -237,8 +257,9 @@ impl AppState {
237257
pub fn go_bottom(&mut self) {
238258
match self.focused_pane {
239259
FocusedPane::Navigation => {
240-
if !self.files.is_empty() {
241-
self.selected_file_index = self.files.len() - 1;
260+
let max_index = self.visible_tree_node_count();
261+
if max_index > 0 {
262+
self.selected_file_index = max_index - 1;
242263
}
243264
}
244265
FocusedPane::Detail => {
@@ -335,6 +356,130 @@ impl AppState {
335356
self.files.get(self.selected_file_index)
336357
}
337358

359+
/// Get the selected node from the file tree view
360+
///
361+
/// Returns the node at the current `selected_file_index` position in the
362+
/// flattened visible tree. This accounts for expand/collapse state.
363+
#[must_use]
364+
pub fn selected_tree_node(&self) -> Option<SelectedTreeNode> {
365+
let trees = self.file_tree.as_ref()?;
366+
367+
let mut current_index = 0;
368+
for (root_idx, tree) in trees.iter().enumerate() {
369+
if let Some(result) = Self::find_node_at_index(
370+
tree,
371+
self.selected_file_index,
372+
&mut current_index,
373+
&[root_idx],
374+
) {
375+
return Some(result);
376+
}
377+
}
378+
None
379+
}
380+
381+
/// Recursively find the node at a given visual index
382+
fn find_node_at_index(
383+
node: &TreeNode<DirectoryNode>,
384+
target_index: usize,
385+
current_index: &mut usize,
386+
path: &[usize],
387+
) -> Option<SelectedTreeNode> {
388+
if *current_index == target_index {
389+
return Some(SelectedTreeNode {
390+
is_directory: node.data.is_directory,
391+
is_expanded: node.expanded,
392+
has_children: node.has_children(),
393+
file_record: node.data.file_record.clone(),
394+
path: path.to_vec(),
395+
});
396+
}
397+
398+
*current_index += 1;
399+
400+
if node.expanded {
401+
for (child_idx, child) in node.children.iter().enumerate() {
402+
let mut child_path = path.to_vec();
403+
child_path.push(child_idx);
404+
if let Some(result) =
405+
Self::find_node_at_index(child, target_index, current_index, &child_path)
406+
{
407+
return Some(result);
408+
}
409+
}
410+
}
411+
412+
None
413+
}
414+
415+
/// Toggle expand/collapse on the currently selected tree node
416+
///
417+
/// Returns `true` if a node was toggled, `false` if nothing was done.
418+
pub fn toggle_selected_node(&mut self) -> bool {
419+
let Some(selected) = self.selected_tree_node() else {
420+
return false;
421+
};
422+
423+
if !selected.is_directory || !selected.has_children {
424+
return false;
425+
}
426+
427+
let Some(trees) = &mut self.file_tree else {
428+
return false;
429+
};
430+
431+
// Navigate to the node using the path
432+
if selected.path.is_empty() {
433+
return false;
434+
}
435+
436+
let root_idx = selected.path[0];
437+
if root_idx >= trees.len() {
438+
return false;
439+
}
440+
441+
let mut node = &mut trees[root_idx];
442+
443+
for &child_idx in &selected.path[1..] {
444+
if child_idx >= node.children.len() {
445+
return false;
446+
}
447+
node = &mut node.children[child_idx];
448+
}
449+
450+
// Toggle
451+
if node.expanded {
452+
node.collapse();
453+
} else {
454+
node.expand();
455+
}
456+
457+
true
458+
}
459+
460+
/// Count total visible nodes in file tree
461+
///
462+
/// Used for bounds checking in `move_up`/`move_down` when tree view is active.
463+
#[must_use]
464+
pub fn visible_tree_node_count(&self) -> usize {
465+
let Some(trees) = &self.file_tree else {
466+
return self.files.len();
467+
};
468+
469+
trees.iter().map(Self::count_visible_nodes).sum()
470+
}
471+
472+
/// Recursively count visible nodes in a tree
473+
fn count_visible_nodes(node: &TreeNode<DirectoryNode>) -> usize {
474+
let mut count = 1; // This node
475+
if node.expanded {
476+
for child in &node.children {
477+
count += Self::count_visible_nodes(child);
478+
}
479+
}
480+
count
481+
}
482+
338483
/// Get currently selected task
339484
#[must_use]
340485
pub fn selected_task(&self) -> Option<&TaskRecord> {

0 commit comments

Comments
 (0)