diff --git a/crates/plnk-cli/tests/cli_auth.rs b/crates/plnk-cli/tests/cli_auth.rs index 35ffe75..7b5b17f 100644 --- a/crates/plnk-cli/tests/cli_auth.rs +++ b/crates/plnk-cli/tests/cli_auth.rs @@ -571,5 +571,8 @@ fn version_output() { .arg("--version") .assert() .success() - .stdout(predicate::str::contains("plnk 0.1.0")); + .stdout(predicate::str::contains(format!( + "plnk {}", + env!("CARGO_PKG_VERSION") + ))); } diff --git a/crates/plnk-tui/src/main.rs b/crates/plnk-tui/src/main.rs index 7601e76..0ecae57 100644 --- a/crates/plnk-tui/src/main.rs +++ b/crates/plnk-tui/src/main.rs @@ -374,6 +374,12 @@ struct InlineEditorState { cursor: usize, } +#[derive(Debug, Clone)] +struct FilterEditorState { + buffer: String, + cursor: usize, +} + #[derive(Debug, Clone)] enum DraftFieldUpdate { Unchanged, @@ -858,6 +864,8 @@ struct AppState { board_errors: HashMap, selected: Option, explorer_view: ExplorerView, + filter_query: String, + filter_editor: Option, focus: PaneFocus, detail_scroll: usize, card_comments: HashMap>, @@ -916,6 +924,8 @@ impl AppState { let save_started_at = None; let pending_save_completion = None; let notice = None; + let filter_query = String::new(); + let filter_editor = None; let selected = subscribed_board_id .as_deref() @@ -960,6 +970,8 @@ impl AppState { board_errors, selected, explorer_view: ExplorerView::Hierarchy, + filter_query, + filter_editor, focus: PaneFocus::Explorer, detail_scroll: 0, card_comments, @@ -1120,12 +1132,21 @@ impl AppState { } } + fn active_filter_query(&self) -> &str { + self.filter_editor + .as_ref() + .map_or(self.filter_query.as_str(), |editor| editor.buffer.as_str()) + } + + fn has_active_filter(&self) -> bool { + !self.active_filter_query().trim().is_empty() + } + #[allow(clippy::too_many_lines)] - fn visible_rows(&self) -> Vec { + fn all_rows(&self) -> Vec { let mut rows = Vec::new(); for project in &self.projects { - let project_expanded = self.expanded_projects.contains(&project.id); rows.push(TreeRow { key: TreeKey::Project(project.id.clone()), parent: None, @@ -1138,16 +1159,11 @@ impl AppState { Some(format!("{} boards", project.boards.len())) }, has_children: !project.boards.is_empty(), - expanded: project_expanded, + expanded: self.expanded_projects.contains(&project.id), live: false, }); - if !project_expanded { - continue; - } - for board in &project.boards { - let board_expanded = self.expanded_boards.contains(&board.id); let board_loaded = board.is_loaded(); let board_loading = self.loading_boards.contains(&board.id); let board_meta = if self.is_live_target(&board.id) @@ -1177,18 +1193,17 @@ impl AppState { label: board.name.clone(), meta: board_meta, has_children: !board_loaded || !board.active_lists.is_empty(), - expanded: board_expanded, + expanded: self.expanded_boards.contains(&board.id), live: self.is_live_connected(&board.id), }); - if !(board_expanded && board_loaded) { + if !board_loaded { continue; } match self.explorer_view { ExplorerView::Hierarchy => { for list in &board.active_lists { - let list_expanded = self.expanded_lists.contains(&list.id); rows.push(TreeRow { key: TreeKey::List(list.id.clone()), parent: Some(TreeKey::Board(board.id.clone())), @@ -1200,14 +1215,10 @@ impl AppState { list.active_card_count, list.closed_card_count )), has_children: !list.cards.is_empty(), - expanded: list_expanded, + expanded: self.expanded_lists.contains(&list.id), live: false, }); - if !list_expanded { - continue; - } - for card in &list.cards { rows.push(TreeRow { key: TreeKey::Card(card.id.clone()), @@ -1229,7 +1240,6 @@ impl AppState { } ExplorerView::Labels => { for list in &board.active_lists { - let list_expanded = self.expanded_lists.contains(&list.id); let label_groups = label_groups_for_list(board, list); rows.push(TreeRow { key: TreeKey::List(list.id.clone()), @@ -1244,22 +1254,16 @@ impl AppState { list.closed_card_count )), has_children: !label_groups.is_empty(), - expanded: list_expanded, + expanded: self.expanded_lists.contains(&list.id), live: false, }); - if !list_expanded { - continue; - } - for group in label_groups { let group_key = label_group_key( &group.board_id, &group.list_id, group.label_id.as_deref(), ); - let group_expanded = - self.expanded_label_groups.contains(&group_key); rows.push(TreeRow { key: TreeKey::LabelGroup { board_id: group.board_id.clone(), @@ -1275,14 +1279,10 @@ impl AppState { group.active_card_count, group.closed_card_count )), has_children: !group.cards.is_empty(), - expanded: group_expanded, + expanded: self.expanded_label_groups.contains(&group_key), live: false, }); - if !group_expanded { - continue; - } - for card in group.cards { let card_meta = if card.is_closed { "closed".to_string() @@ -1318,6 +1318,74 @@ impl AppState { rows } + fn apply_expansion_visibility(rows: Vec) -> Vec { + let mut visible = Vec::new(); + let mut expanded_visible = HashSet::new(); + + for row in rows { + let parent_visible = row + .parent + .as_ref() + .is_none_or(|parent| expanded_visible.contains(parent)); + if !parent_visible { + continue; + } + if row.expanded { + expanded_visible.insert(row.key.clone()); + } + visible.push(row); + } + + visible + } + + fn filter_visible_rows(&self, rows: Vec) -> Vec { + let query = self.active_filter_query().trim(); + if query.is_empty() { + return Self::apply_expansion_visibility(rows); + } + + let parent_by_key = rows + .iter() + .map(|row| (row.key.clone(), row.parent.clone())) + .collect::>(); + let matched_keys = rows + .iter() + .filter(|row| filter_matches(query, &row.label)) + .map(|row| row.key.clone()) + .collect::>(); + + if matched_keys.is_empty() { + return Vec::new(); + } + + let mut included = HashSet::new(); + let mut force_expanded = HashSet::new(); + for key in matched_keys { + included.insert(key.clone()); + let mut current = parent_by_key.get(&key).cloned().flatten(); + while let Some(parent) = current { + force_expanded.insert(parent.clone()); + included.insert(parent.clone()); + current = parent_by_key.get(&parent).cloned().flatten(); + } + } + + rows.into_iter() + .filter_map(|mut row| { + if !included.contains(&row.key) { + return None; + } + row.expanded = force_expanded.contains(&row.key); + Some(row) + }) + .collect() + } + + fn visible_rows(&self) -> Vec { + self.filter_visible_rows(self.all_rows()) + } + fn selected_index(&self, rows: &[TreeRow]) -> usize { rows.iter() .position(|row| { @@ -1818,6 +1886,117 @@ impl AppState { } } + fn start_filter_editor(&mut self) { + self.focus_explorer(); + self.filter_editor = Some(FilterEditorState { + buffer: self.filter_query.clone(), + cursor: self.filter_query.chars().count(), + }); + self.set_notice("Filter explorer rows by text or glob (*, ?)."); + } + + fn sync_filter_editor(&mut self) { + let Some(editor) = self.filter_editor.as_ref() else { + return; + }; + self.filter_query.clone_from(&editor.buffer); + self.ensure_selected_visible(); + } + + fn finish_filter_editor(&mut self) { + self.sync_filter_editor(); + self.filter_editor = None; + if self.has_active_filter() { + self.set_notice(format!( + "Explorer filter active: {}", + truncate(self.active_filter_query(), 60) + )); + } else { + self.set_notice("Explorer filter cleared."); + } + } + + fn clear_or_close_filter_editor(&mut self) { + let Some(editor) = self.filter_editor.as_mut() else { + return; + }; + if editor.buffer.is_empty() { + self.filter_editor = None; + self.set_notice("Exited filter mode."); + } else { + editor.buffer.clear(); + editor.cursor = 0; + self.sync_filter_editor(); + self.set_notice("Explorer filter cleared. Esc again to close filter mode."); + } + } + + fn edit_filter_insert(&mut self, ch: char) { + if let Some(editor) = self.filter_editor.as_mut() { + let mut chars = editor.buffer.chars().collect::>(); + let cursor = editor.cursor.min(chars.len()); + chars.insert(cursor, ch); + editor.buffer = chars.into_iter().collect(); + editor.cursor = cursor.saturating_add(1); + } + self.sync_filter_editor(); + } + + fn edit_filter_backspace(&mut self) { + let Some(editor) = self.filter_editor.as_mut() else { + return; + }; + if editor.cursor == 0 { + return; + } + let mut chars = editor.buffer.chars().collect::>(); + let index = editor.cursor.saturating_sub(1); + let _ = chars.remove(index); + editor.buffer = chars.into_iter().collect(); + editor.cursor = index; + self.sync_filter_editor(); + } + + fn edit_filter_delete(&mut self) { + let Some(editor) = self.filter_editor.as_mut() else { + return; + }; + let mut chars = editor.buffer.chars().collect::>(); + if editor.cursor >= chars.len() { + return; + } + let _ = chars.remove(editor.cursor); + editor.buffer = chars.into_iter().collect(); + self.sync_filter_editor(); + } + + fn edit_filter_move_left(&mut self) { + if let Some(editor) = self.filter_editor.as_mut() { + editor.cursor = editor.cursor.saturating_sub(1); + } + } + + fn edit_filter_move_right(&mut self) { + if let Some(editor) = self.filter_editor.as_mut() { + editor.cursor = editor + .cursor + .saturating_add(1) + .min(editor.buffer.chars().count()); + } + } + + fn edit_filter_move_home(&mut self) { + if let Some(editor) = self.filter_editor.as_mut() { + editor.cursor = 0; + } + } + + fn edit_filter_move_end(&mut self) { + if let Some(editor) = self.filter_editor.as_mut() { + editor.cursor = editor.buffer.chars().count(); + } + } + fn mark_selected_card_comments_loading(&mut self) -> Option { let card_id = self.selected_card_id()?.to_string(); if self.card_comments.contains_key(&card_id) @@ -1951,6 +2130,13 @@ impl AppState { let index = self.selected_index(&rows); let row = &rows[index]; + if self.has_active_filter() { + if let Some(parent) = &row.parent { + self.set_selected(parent.clone()); + } + return; + } + match &row.key { TreeKey::Project(project_id) => { self.expanded_projects.remove(project_id); @@ -3405,6 +3591,46 @@ fn truncate(text: &str, max: usize) -> String { } } +fn filter_matches(query: &str, label: &str) -> bool { + let query = query.to_lowercase(); + let label = label.to_lowercase(); + if query.contains(['*', '?']) { + glob_matches(&query, &label) + } else { + label.contains(&query) + } +} + +fn glob_matches(pattern: &str, text: &str) -> bool { + let pattern = pattern.chars().collect::>(); + let text = text.chars().collect::>(); + let mut dp = vec![vec![false; text.len() + 1]; pattern.len() + 1]; + dp[0][0] = true; + + for i in 0..pattern.len() { + match pattern[i] { + '*' => { + dp[i + 1][0] = dp[i][0]; + for j in 0..text.len() { + dp[i + 1][j + 1] = dp[i][j + 1] || dp[i + 1][j]; + } + } + '?' => { + for j in 0..text.len() { + dp[i + 1][j + 1] = dp[i][j]; + } + } + ch => { + for j in 0..text.len() { + dp[i + 1][j + 1] = dp[i][j] && ch == text[j]; + } + } + } + } + + dp[pattern.len()][text.len()] +} + fn init_terminal() -> Result>, TuiError> { enable_raw_mode()?; let mut stdout = io::stdout(); @@ -3606,6 +3832,24 @@ fn run_app( continue; } + if app.filter_editor.is_some() { + match key.code { + KeyCode::Esc => app.clear_or_close_filter_editor(), + KeyCode::Enter => app.finish_filter_editor(), + KeyCode::Left => app.edit_filter_move_left(), + KeyCode::Right => app.edit_filter_move_right(), + KeyCode::Home => app.edit_filter_move_home(), + KeyCode::End => app.edit_filter_move_end(), + KeyCode::Backspace => app.edit_filter_backspace(), + KeyCode::Delete => app.edit_filter_delete(), + KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + app.edit_filter_insert(ch); + } + _ => {} + } + continue; + } + match key.code { KeyCode::Char('q') | KeyCode::Esc if app.has_dirty_card_draft() => { app.set_notice("Unsaved card changes. Ctrl-s save • Ctrl-x discard."); @@ -3632,6 +3876,9 @@ fn run_app( app.discard_card_draft(); app.set_notice("Discarded local card edits."); } + KeyCode::Char('/') => { + app.start_filter_editor(); + } KeyCode::Char('v') if app.has_dirty_card_draft() => { app.set_notice( "Save or discard dirty card before changing explorer view.", @@ -3853,6 +4100,13 @@ fn draw(frame: &mut ratatui::Frame<'_>, app: &AppState) { header_title.push(Span::raw(" • ")); header_title.push(chip_span("EDITING TITLE", Color::LightYellow)); } + if app.filter_editor.is_some() { + header_title.push(Span::raw(" • ")); + header_title.push(chip_span("FILTER MODE", Color::Magenta)); + } else if app.has_active_filter() { + header_title.push(Span::raw(" • ")); + header_title.push(chip_span("FILTER", Color::Magenta)); + } if app.has_dirty_card_draft() { header_title.push(Span::raw(" • ")); header_title.push(chip_span("DIRTY", Color::Yellow)); @@ -3877,10 +4131,15 @@ fn draw(frame: &mut ratatui::Frame<'_>, app: &AppState) { app.server, app.login, app.current_user.name, app.current_user.username )), Line::from(format!( - "visible projects: {} | current user id: {} | explorer view: {} | live target: {}", + "visible projects: {} | current user id: {} | explorer view: {} | filter: {} | live target: {}", app.projects.len(), app.current_user.id, app.explorer_view.label(), + if app.has_active_filter() { + truncate(app.active_filter_query(), 30) + } else { + "none".to_string() + }, app.subscribed_board_id .as_deref() .unwrap_or("none (press L on a board)") @@ -3902,7 +4161,14 @@ fn draw(frame: &mut ratatui::Frame<'_>, app: &AppState) { let selected_index = app.selected_index(&rows); let tree_items = if rows.is_empty() { - vec![ListItem::new("No projects visible for this user.")] + vec![ListItem::new(if app.has_active_filter() { + format!( + "No nodes match filter '{}'", + truncate(app.active_filter_query(), 60) + ) + } else { + "No projects visible for this user.".to_string() + })] } else { rows.iter().map(render_tree_row).collect::>() }; @@ -3914,7 +4180,15 @@ fn draw(frame: &mut ratatui::Frame<'_>, app: &AppState) { let tree = List::new(tree_items) .block(panel_block( - &format!("explorer • {}", app.explorer_view.label()), + &if app.has_active_filter() { + format!( + "explorer • {} • filter: {}", + app.explorer_view.label(), + truncate(app.active_filter_query(), 24) + ) + } else { + format!("explorer • {}", app.explorer_view.label()) + }, app.focus == PaneFocus::Explorer, )) .highlight_style( @@ -3976,8 +4250,10 @@ fn draw(frame: &mut ratatui::Frame<'_>, app: &AppState) { "SAVING: waiting for server response • controls paused • Ctrl-c force quit" } else if app.title_editor.is_some() { "TITLE MODE: type text • ←/→ move • Enter save • Esc cancel • Ctrl-c force quit" + } else if app.filter_editor.is_some() { + "FILTER MODE: type text • * and ? globs • Enter keep • Esc clear/close • Ctrl-c force quit" } else { - "↑/↓ nav • →/Enter expand • r refresh • v toggle view • L live on/off • e edit title • E edit description ($EDITOR) • D debug log • Ctrl-c quit" + "↑/↓ nav • / filter • →/Enter expand • r refresh • v toggle view • L live on/off • e edit title • E edit description ($EDITOR) • D debug log • Ctrl-c quit" }; frame.render_widget( Paragraph::new(key_help).style(Style::default().fg(Color::DarkGray)), @@ -3987,6 +4263,9 @@ fn draw(frame: &mut ratatui::Frame<'_>, app: &AppState) { if app.show_debug_log { draw_debug_overlay(frame, area, app); } + if app.filter_editor.is_some() { + draw_filter_editor_overlay(frame, area, app); + } if app.title_editor.is_some() { draw_title_editor_overlay(frame, area, app); } @@ -4818,6 +5097,58 @@ fn draw_debug_overlay(frame: &mut ratatui::Frame<'_>, area: Rect, app: &AppState ); } +fn draw_filter_editor_overlay(frame: &mut ratatui::Frame<'_>, area: Rect, app: &AppState) { + let Some(editor) = app.filter_editor.as_ref() else { + return; + }; + + let popup = centered_rect(72, 22, area); + let chars = editor.buffer.chars().collect::>(); + let cursor = editor.cursor.min(chars.len()); + let before = chars[..cursor].iter().collect::(); + let at = chars + .get(cursor) + .map_or(" ".to_string(), ToString::to_string); + let after = if cursor < chars.len() { + chars[cursor + 1..].iter().collect::() + } else { + String::new() + }; + + let lines = vec![ + Line::from(Span::styled( + "Filter explorer", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )), + Line::from(dim_span( + "Live client-side filter • substring or glob (*, ?)", + )), + Line::from(dim_span("Enter keep • Esc clear/close • Ctrl-c force quit")), + Line::from(""), + Line::from(vec![ + Span::styled(before, Style::default().fg(Color::White)), + Span::styled( + at, + Style::default() + .fg(Color::Black) + .bg(Color::Magenta) + .add_modifier(Modifier::BOLD), + ), + Span::styled(after, Style::default().fg(Color::White)), + ]), + ]; + + frame.render_widget(Clear, popup); + frame.render_widget( + Paragraph::new(lines) + .block(panel_block("filter", true)) + .wrap(Wrap { trim: false }), + popup, + ); +} + fn draw_title_editor_overlay(frame: &mut ratatui::Frame<'_>, area: Rect, app: &AppState) { let Some(editor) = app.title_editor.as_ref() else { return; diff --git a/docs/tui/keybindings.md b/docs/tui/keybindings.md index 19c9d98..f188e44 100644 --- a/docs/tui/keybindings.md +++ b/docs/tui/keybindings.md @@ -17,6 +17,7 @@ A flat reference. The footer at the bottom of the TUI shows a short subset of th | `↑` / `↓` | Move selection up/down through visible tree rows. | | `→` / `Enter` | Expand the selected node. For unloaded boards, this triggers a lazy snapshot load. | | `←` | Collapse the selected node, or move to its parent if already collapsed. | +| `/` | Enter explorer filter mode. Type a case-insensitive substring, or use `*` / `?` as glob wildcards. `Enter` keeps the filter active; `Esc` clears/closes. | | `v` | Toggle the explorer view between **hierarchy** (project → board → list → card) and **labels** (board → label groups). | | `r` / `R` | Refresh the hierarchy below the selected node. On a project, this refetches the project tree and any already-loaded boards under it. On a board/list/card, this refetches that board snapshot; card refresh also reloads comments. | | `L` | Toggle the selected board as the live target. Press once to subscribe, press again on the same board to unsubscribe and return to idle. See [live-target.md](live-target.md). | diff --git a/docs/tui/overview.md b/docs/tui/overview.md index 6fda3a1..77deb9e 100644 --- a/docs/tui/overview.md +++ b/docs/tui/overview.md @@ -56,7 +56,7 @@ Environment variables (clap honors them automatically): ## First-run experience -The TUI lands on the projects view with no live subscription active. Expand a project with `→` or `Enter`, pick a board, and either explore it read-only or press `L` to make it the live target — from that point on, edits on that board stream in. Press `L` again on the same board to unsubscribe and return to idle. Press `r` on the selected node when you want to refetch that slice of the hierarchy on demand. +The TUI lands on the projects view with no live subscription active. Expand a project with `→` or `Enter`, pick a board, and either explore it read-only or press `L` to make it the live target — from that point on, edits on that board stream in. Press `L` again on the same board to unsubscribe and return to idle. Press `r` on the selected node when you want to refetch that slice of the hierarchy on demand, or `/` to filter the current explorer view client-side by substring or glob pattern. For the detailed live-sync model, see [live-target.md](live-target.md). For the full key map, see [keybindings.md](keybindings.md). For the tree view's data-model and rendering contract, see [tree-view.md](tree-view.md). diff --git a/docs/tui/tree-view.md b/docs/tui/tree-view.md index f2c0f46..b86f580 100644 --- a/docs/tui/tree-view.md +++ b/docs/tui/tree-view.md @@ -39,14 +39,14 @@ The body splits further: │ │ │ latest event: … │ │ │ │ notice: … │ └─────────────────────────────┘ └──────────────────────────┘ -↑/↓ nav • →/Enter expand • r refresh • v toggle view • L live on/off • … +↑/↓ nav • / filter • →/Enter expand • r refresh • v toggle view • L live on/off • … ``` ### Session header - Row 1: the word `plnk-tui explorer`, a bullet, then one of a small set of status chips: `READ-ONLY`, `DIRTY`, `REMOTE CHANGED`, `SAVING`, or the websocket connection label. - Row 2: `server: | login: | current user: ()` — identifies who is connected to where. -- Row 3: visible project count, current user id, explorer view mode, and the live target board id (or `none (press L on a board)` when idle). +- Row 3: visible project count, current user id, explorer view mode, current filter text (`none` when inactive), and the live target board id (or `none (press L on a board)` when idle). ### Explorer pane @@ -55,6 +55,8 @@ Renders a collapsible tree in one of two views, toggled with `v`: - **hierarchy** — project → board → list → card. This is the default. - **labels** — project → board → list → label group → card. Groups cards by the labels applied to them on a given list. +Press `/` to enter a client-side filter mode for the current explorer view. Plain text uses case-insensitive substring matching; queries containing `*` or `?` use case-insensitive glob matching. Matching descendants keep their ancestor context visible. + See [data model](#data-model) below for the underlying types. ### Details pane @@ -78,6 +80,7 @@ A fixed 7-row block beneath details: - `websocket: ` — one of `no live target`, `loading`, `connecting raw websocket`, `live websocket connected`, or `error: ` - `live target: ` — the currently subscribed board, or `none — select a board and press L to promote it` when idle - project rows may temporarily show `refreshing hierarchy…` while a manual refresh is in flight +- when a filter is active, the explorer title and session header show the active query - `latest event: ` — short summary of the last `socket.io` event applied - `notice: ` — transient status messages (save progress, edit outcomes) @@ -85,7 +88,7 @@ A fixed 7-row block beneath details: A single dim-gray line with the most relevant keybindings for the current mode. Mode-aware: -- Default: navigation, manual refresh, view toggle, live on/off, edit, debug, quit. +- Default: navigation, filter, manual refresh, view toggle, live on/off, edit, debug, quit. - Title edit mode: the title-editing key set. - Saving mode: controls paused until the server responds.