diff --git a/README.md b/README.md index 0cffa44d..9741d6e2 100644 --- a/README.md +++ b/README.md @@ -29,11 +29,17 @@ Tabular is a lightweight, native database client built with the `eframe`/`egui` - **Semantic Find & Replace**: Editor search with Regex, Match Case, Whole Word, and In-Selection scope support - **End-to-End Encrypted Cloud Sync (Zero-Knowledge Vault)**: Argon2id KDF, AES-256-GCM encrypted connections and HTTP secrets synced securely across devices and teams - **Integrated HTTP Client**: REST API tester supporting JSON, form-data, custom auth, headers, and code export -- **Redis Visual Key Browser**: Key explorer with cluster detection, type filtering, full-text search, and TTL/size metrics +- **Smart Sidebar Tree Search**: Case-insensitive instant filtering across Connections, Queries, History, and HTTP Collections. When searching for a folder name, the folder and all of its contents (connections, queries, history entries, subfolders) remain fully displayed and automatically expanded. - **AI Assistant (`Cmd+Shift+A`)**: Schema-aware SQL completion with OpenAI, Anthropic Claude, Groq, GitHub Copilot, or custom endpoints --- +### Smart Sidebar Tree Search & Folder Content Preservation +Instant in-memory filtering for all sidebar navigation panels (Connections, Saved Queries, History, and HTTP Collections). +- **Folder Search Preservation**: When typing a search query matching a folder name (such as custom connection folders, query categories, date folders, or HTTP collection folders), the matching folder is displayed and automatically expanded with all of its child contents intact. +- **Hierarchical Context**: When searching for specific tables, queries, or history items, the sidebar displays matching items while maintaining their parent directory structure for clear visual context. + + ### Visual Query Profiler (New in v0.13) Analyze and optimize slow queries with interactive visual graphs instead of deciphering raw JSON outputs. - Supports PostgreSQL (`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)`), MySQL (`EXPLAIN FORMAT=JSON`), and Microsoft SQL Server ShowPlan. diff --git a/implementation_plan_fixed_sidebar_search.md b/implementation_plan_fixed_sidebar_search.md new file mode 100644 index 00000000..3e656981 --- /dev/null +++ b/implementation_plan_fixed_sidebar_search.md @@ -0,0 +1,82 @@ +# Implementation Plan: Perbaikan Sidebar Search & Preservasi Konten Folder + +Dokumen ini menjelaskan rencana teknis perbaikan dan penyempurnaan fitur **Sidebar Search** pada aplikasi Tabular. Perbaikan ini memastikan bahwa pencarian pada sidebar mempertahankan seluruh konten di dalam folder yang cocok (subfolder bersarang, tabel, view, query files, dan request HTTP) serta meng-expand seluruh folder turunan secara rekursif. + +--- + +## 1. Analisis Masalah (Problem Statement) + +### 1.1 Masalah pada Fitur Pencarian Sidebar Sebelumnya +1. **HTTP Collections**: Subfolder pada koleksi HTTP otomatis tertutup (*collapsed*) dan item request di dalamnya tersembunyi ketika parent folder atau workspace cocok dengan kueri pencarian. +2. **Database Nodes**: Tipe `NodeType::Database` belum terdaftar dalam fungsi `NodeType::is_folder()`. Akibatnya, saat pengguna mencari nama database, seluruh tabel dan view di dalamnya terfilter keluar (hilang dari hasil pencarian). +3. **Subfolder Bersarang (Nested Subfolders)**: Subfolder bertingkat tidak mengalami auto-expand rekursif pada Saved Queries (`filtered_queries_tree`) maupun Connection Tree (`filter_node_with_like_search`). Hanya folder tingkat pertama yang terbuka, sementara subfolder di dalamnya tetap tertutup. +4. **Inkonsistensi Whitespace & Ketiadaan `.trim()`**: Ketiadaan `.trim()` pada pemrosesan pencarian riwayat query (`sidebar_history.rs`) menyebabkan pencarian dengan spasi awal/akhir tidak menemukan hasil dan menyebabkan desinkronisasi status pencarian dengan UI toggle di `app_impl.rs`. + +--- + +## 2. Desain Solusi & Rencana Perubahan + +### 2.1 Preservasi Node Database sebagai Folder (`src/models/enums.rs`) +- Menambahkan variant `NodeType::Database` ke dalam fungsi pembantu `NodeType::is_folder()`. +- Hal ini memastikan bahwa node database diperlakukan sebagai kontainer/folder sehingga saat node database cocok dengan kueri pencarian, seluruh hirarki anak di bawahnya (`TablesFolder`, `ViewsFolder`, tabel, dan view) dipertahankan utuh. + +### 2.2 Ekspansi Rekursif Subfolder (`src/models/structs.rs`) +- Menambahkan method rekursif `expand_all_folders(&mut self)` pada struct `TreeNode`: + ```rust + impl TreeNode { + pub fn expand_all_folders(&mut self) { + if self.node_type.is_folder() { + self.is_expanded = true; + } + for child in &mut self.children { + child.expand_all_folders(); + } + } + } + ``` +- Memastikan bahwa saat sebuah folder lolos pencarian, seluruh subfolder turunan di dalamnya otomatis terbuka (`is_expanded = true`) sampai tingkat terdalam. + +### 2.3 Perbaikan Filter Connection & Database Tree (`src/window_egui/search.rs`) +- Pada fungsi `filter_node_with_like_search`: + - Ketika sebuah folder (kategori, folder koneksi, atau database) cocok dengan teks pencarian, seluruh node anak di-clone dan dipertahankan. + - Memanggil `filtered_node.expand_all_folders()` untuk membuka folder utama beserta semua subfoldernya. +- Pada `update_all_database_search_results`: + - Melakukan `.trim()` pada teks pencarian database saat memperbarui `history_search_text`. + +### 2.4 Perbaikan Saved Queries Tree (`src/sidebar_query.rs`) +- Pada fungsi `filter_queries_tree`: + - Saat `node.node_type.is_folder()` cocok dengan teks pencarian, seluruh struktur folder anak dipertahankan dan dilakukan `filtered_node.expand_all_folders()`. + +### 2.5 Normalisasi Whitespace pada History Search (`src/sidebar_history.rs`) +- Pada fungsi `filter_history_tree`: + - Melakukan `.trim()` pada `tabular.history_search_text.trim()`. + - Jika kueri kosong atau hanya whitespace, kosongkan `filtered_history_tree` secara deterministik dan konsisten dengan status UI. + +### 2.6 Propagasi Pencocokan Workspace & Parent pada HTTP Collection (`src/sidebar_collection.rs`) +- Menambahkan parameter `parent_matched: bool` pada `render_folder_node`. +- Mendeteksi kecocokan pada level workspace (`ws_matches`). +- Jika parent folder atau workspace cocok dengan kata kunci: + - Propagasi status kecocokan ke seluruh subfolder turunan. + - Set `is_expanded = true` secara rekursif pada folder-folder turunan. + - Tampilkan seluruh requests dan child folders tanpa di-filter keluar. + +--- + +## 3. Rencana Pengujian (Testing Plan) + +1. **Unit Tests - Search (`src/window_egui/search.rs`)**: + - `test_filter_node_with_like_search_folder_preserves_children`: Memastikan koneksi di bawah folder tetap muncul saat nama folder dicari. + - `test_filter_node_database_preserves_tables_and_expands_folders`: Memastikan tabel dan view di bawah database tetap muncul dan terbuka. + - `test_filter_node_nested_subfolders_recursive_expand`: Memastikan subfolder bertingkat (`Servers -> Regional -> Europe`) ter-expand secara rekursif. + +2. **Unit Tests - Saved Queries (`src/sidebar_query.rs`)**: + - `test_filter_queries_tree_nested_subfolders_recursive_expand`: Memastikan hierarki query file bertingkat terbuka utuh saat parent folder dicari. + +3. **Unit Tests - History Search (`src/sidebar_history.rs`)**: + - Pengujian kueri whitespace (`" users "` dan `" "`). + +4. **Unit Tests - HTTP Collections (`src/sidebar_collection.rs`)**: + - `test_folder_and_workspace_has_match`: Memastikan pencarian nama workspace dan parent folder memunculkan seluruh request di dalamnya. + +5. **Kompilasi & Regresi**: + - Menjalankan `cargo test` untuk memverifikasi seluruh test suite lulus tanpa error. diff --git a/src/models/enums.rs b/src/models/enums.rs index ca646f1c..8e8f93b3 100644 --- a/src/models/enums.rs +++ b/src/models/enums.rs @@ -57,6 +57,45 @@ pub enum NodeType { Diagram, } +impl NodeType { + pub fn is_folder(&self) -> bool { + matches!( + self, + NodeType::HistoryDateFolder + | NodeType::DatabasesFolder + | NodeType::Database + | NodeType::TablesFolder + | NodeType::ViewsFolder + | NodeType::StoredProceduresFolder + | NodeType::UserFunctionsFolder + | NodeType::TriggersFolder + | NodeType::EventsFolder + | NodeType::DBAViewsFolder + | NodeType::UsersFolder + | NodeType::PrivilegesFolder + | NodeType::ProcessesFolder + | NodeType::StatusFolder + | NodeType::BlockedQueriesFolder + | NodeType::MetricsUserActiveFolder + | NodeType::ReplicationStatusFolder + | NodeType::MasterStatusFolder + | NodeType::MySQLFolder + | NodeType::MsSQLFolder + | NodeType::MongoDBFolder + | NodeType::PostgreSQLFolder + | NodeType::SQLiteFolder + | NodeType::RedisFolder + | NodeType::CustomFolder + | NodeType::QueryFolder + | NodeType::ColumnsFolder + | NodeType::IndexesFolder + | NodeType::PrimaryKeysFolder + | NodeType::PartitionsFolder + | NodeType::DiagramsFolder + ) + } +} + // Special DBA quick view context (used to apply post-processing without embedding markers in SQL) #[derive(Clone, PartialEq, Debug)] pub enum DBASpecialMode { diff --git a/src/models/structs.rs b/src/models/structs.rs index 3d8e5a74..03dce336 100644 --- a/src/models/structs.rs +++ b/src/models/structs.rs @@ -403,6 +403,16 @@ impl TreeNode { query: None, } } + + /// Recursively auto-expand all nested folders in the tree node hierarchy. + pub fn expand_all_folders(&mut self) { + if self.node_type.is_folder() { + self.is_expanded = true; + } + for child in &mut self.children { + child.expand_all_folders(); + } + } } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/src/sidebar_collection.rs b/src/sidebar_collection.rs index a583a772..2253f5f8 100644 --- a/src/sidebar_collection.rs +++ b/src/sidebar_collection.rs @@ -34,7 +34,7 @@ pub fn render_collections_sidebar(app: &mut Tabular, ui: &mut egui::Ui) { ); }); - let filter = app.collection_search.to_lowercase(); + let filter = app.collection_search.trim().to_lowercase(); let accent = crate::window_egui::style::theme_accent(ui.ctx()); // ── 1. HTTP Connections section ─────────────────────────────────────── @@ -266,6 +266,8 @@ pub fn render_collections_sidebar(app: &mut Tabular, ui: &mut egui::Ui) { } let ws_header_resp = ws_header.show(ui, |ui| { + let ws_matches = !filter.is_empty() && ws_name.to_lowercase().contains(&filter); + // ── Top-level requests ──────────────────────────────────────── let top_req_ids: Vec = app.yaak_workspaces[ws_idx] .requests @@ -283,7 +285,8 @@ pub fn render_collections_sidebar(app: &mut Tabular, ui: &mut egui::Ui) { None => continue, }; let req = app.yaak_workspaces[ws_idx].requests[req_idx].clone(); - if !filter.is_empty() + if !ws_matches + && !filter.is_empty() && !req.display_name().to_lowercase().contains(&filter) && !req.url.to_lowercase().contains(&filter) { @@ -313,7 +316,7 @@ pub fn render_collections_sidebar(app: &mut Tabular, ui: &mut egui::Ui) { None => continue, }; let folder = app.yaak_workspaces[ws_idx].folders[folder_idx].clone(); - if !filter.is_empty() && !folder_has_match(&folder, &filter) { + if !ws_matches && !filter.is_empty() && !folder_has_match(&folder, &filter) { continue; } render_folder_node( @@ -322,6 +325,7 @@ pub fn render_collections_sidebar(app: &mut Tabular, ui: &mut egui::Ui) { &folder, &mut expanded_folders, &filter, + ws_matches, accent, active_dnd_source.as_ref(), &mut req_action, @@ -849,6 +853,7 @@ fn render_folder_node( folder: &crate::http_collection::HttpFolder, expanded_folders: &mut std::collections::HashSet, filter: &str, + parent_matched: bool, accent: egui::Color32, active_dnd_source: Option<&HttpDndSource>, req_action_out: &mut Option<(SavedRequest, RequestAction)>, @@ -857,7 +862,10 @@ fn render_folder_node( folder_to_rename: &mut Option<(String, String, String)>, folder_to_delete: &mut Option<(String, String, String)>, ) { - let is_expanded = expanded_folders.contains(&folder.id) || !filter.is_empty(); + let folder_matches = + parent_matched || (!filter.is_empty() && folder.name.to_lowercase().contains(filter)); + let is_expanded = + expanded_folders.contains(&folder.id) || !filter.is_empty() || parent_matched; let is_being_dragged = active_dnd_source.is_some_and( |src| matches!(src, HttpDndSource::Folder { folder_id, .. } if folder_id == &folder.id), ); @@ -1034,7 +1042,8 @@ fn render_folder_node( if is_expanded { ui.indent(format!("fld_body_{}", folder.id), |ui| { for req in &folder.requests { - if !filter.is_empty() + if !folder_matches + && !filter.is_empty() && !req.display_name().to_lowercase().contains(filter) && !req.url.to_lowercase().contains(filter) { @@ -1045,7 +1054,7 @@ fn render_folder_node( } } for child in &folder.children { - if !filter.is_empty() && !folder_has_match(child, filter) { + if !folder_matches && !filter.is_empty() && !folder_has_match(child, filter) { continue; } render_folder_node( @@ -1054,6 +1063,7 @@ fn render_folder_node( child, expanded_folders, filter, + folder_matches, accent, active_dnd_source, req_action_out, @@ -1461,3 +1471,63 @@ fn method_color_for(method: &str) -> egui::Color32 { _ => egui::Color32::from_rgb(200, 200, 200), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::http_collection::{HttpFolder, HttpWorkspace, SavedRequest}; + + #[test] + fn test_folder_and_workspace_has_match() { + let req = SavedRequest { + id: "r1".to_string(), + name: "Get User Profile".to_string(), + url: "https://api.example.com/v1/users/me".to_string(), + ..Default::default() + }; + + let sub_folder = HttpFolder { + id: "sub1".to_string(), + name: "OAuth2 Flow".to_string(), + requests: vec![req], + children: vec![], + ..Default::default() + }; + + let parent_folder = HttpFolder { + id: "parent1".to_string(), + name: "Authentication".to_string(), + requests: vec![], + children: vec![sub_folder], + ..Default::default() + }; + + let ws = HttpWorkspace { + id: "ws1".to_string(), + name: "Core Backend API".to_string(), + requests: vec![], + folders: vec![parent_folder.clone()], + ..Default::default() + }; + + // Matching parent folder + assert!(folder_has_match(&parent_folder, "auth")); + assert!(workspace_has_match(&ws, "auth")); + + // Matching nested subfolder + assert!(folder_has_match(&parent_folder, "oauth2")); + assert!(workspace_has_match(&ws, "oauth2")); + + // Matching request name or url inside nested subfolder + assert!(folder_has_match(&parent_folder, "profile")); + assert!(folder_has_match(&parent_folder, "users/me")); + assert!(workspace_has_match(&ws, "profile")); + + // Matching workspace name + assert!(workspace_has_match(&ws, "backend")); + + // Non-matching + assert!(!folder_has_match(&parent_folder, "billing")); + assert!(!workspace_has_match(&ws, "billing")); + } +} diff --git a/src/sidebar_history.rs b/src/sidebar_history.rs index 778fd07d..9f96cb13 100644 --- a/src/sidebar_history.rs +++ b/src/sidebar_history.rs @@ -269,41 +269,52 @@ pub(crate) fn refresh_history_tree(tabular: &mut window_egui::Tabular) { /// Filter history tree based on search text pub(crate) fn filter_history_tree(tabular: &mut window_egui::Tabular) { - if tabular.history_search_text.is_empty() { + let search_text = tabular.history_search_text.trim(); + if search_text.is_empty() { // Clear filtered tree if search is empty tabular.filtered_history_tree.clear(); return; } tabular.filtered_history_tree.clear(); - let search_lower = tabular.history_search_text.to_lowercase(); + let search_lower = search_text.to_lowercase(); for date_node in &tabular.history_tree { let mut filtered_date_node = date_node.clone(); filtered_date_node.children.clear(); - for item_node in &date_node.children { - // Search in query text and connection name - let query_text = item_node.name.to_lowercase(); - let connection_name = item_node - .connection_id - .and_then(|id| { - tabular - .connections - .iter() - .find(|c| c.id == Some(id)) - .map(|c| c.name.to_lowercase()) - }) - .unwrap_or_default(); - - if query_text.contains(&search_lower) || connection_name.contains(&search_lower) { - filtered_date_node.children.push(item_node.clone()); - } - } + // If the date folder itself matches the search text, keep all items in this folder + let folder_matches = date_node.name.to_lowercase().contains(&search_lower); - // Only add date node if it has matching items - if !filtered_date_node.children.is_empty() { + if folder_matches { + filtered_date_node.children = date_node.children.clone(); + filtered_date_node.is_expanded = true; tabular.filtered_history_tree.push(filtered_date_node); + } else { + for item_node in &date_node.children { + // Search in query text and connection name + let query_text = item_node.name.to_lowercase(); + let connection_name = item_node + .connection_id + .and_then(|id| { + tabular + .connections + .iter() + .find(|c| c.id == Some(id)) + .map(|c| c.name.to_lowercase()) + }) + .unwrap_or_default(); + + if query_text.contains(&search_lower) || connection_name.contains(&search_lower) { + filtered_date_node.children.push(item_node.clone()); + } + } + + // Only add date node if it has matching items + if !filtered_date_node.children.is_empty() { + filtered_date_node.is_expanded = true; + tabular.filtered_history_tree.push(filtered_date_node); + } } } } @@ -331,3 +342,75 @@ pub(crate) fn clear_query_history(tabular: &mut window_egui::Tabular) { tabular.history_search_text.clear(); tabular.toasts.success("Query history cleared".to_string()); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::enums::NodeType; + use crate::models::structs::TreeNode; + + #[test] + fn test_filter_history_tree() { + let mut tabular = window_egui::Tabular::default(); + + let mut item1 = TreeNode::new("SELECT * FROM users;".to_string(), NodeType::QueryHistItem); + item1.connection_id = Some(1); + + let mut item2 = TreeNode::new("UPDATE orders SET done = 1;".to_string(), NodeType::QueryHistItem); + item2.connection_id = Some(1); + + let mut today_folder = TreeNode::new("Today".to_string(), NodeType::HistoryDateFolder); + today_folder.children = vec![item1, item2]; + + tabular.history_tree = vec![today_folder]; + + // 1. Search for query text "users" -> only matching query is shown + tabular.history_search_text = "users".to_string(); + filter_history_tree(&mut tabular); + assert_eq!(tabular.filtered_history_tree.len(), 1); + assert_eq!(tabular.filtered_history_tree[0].name, "Today"); + assert_eq!(tabular.filtered_history_tree[0].children.len(), 1); + assert_eq!( + tabular.filtered_history_tree[0].children[0].name, + "SELECT * FROM users;" + ); + + // 2. Search for folder name "Today" -> all items in folder should be kept + tabular.history_search_text = "today".to_string(); + filter_history_tree(&mut tabular); + assert_eq!(tabular.filtered_history_tree.len(), 1); + assert_eq!(tabular.filtered_history_tree[0].name, "Today"); + assert!(tabular.filtered_history_tree[0].is_expanded); + assert_eq!(tabular.filtered_history_tree[0].children.len(), 2); + assert_eq!( + tabular.filtered_history_tree[0].children[0].name, + "SELECT * FROM users;" + ); + assert_eq!( + tabular.filtered_history_tree[0].children[1].name, + "UPDATE orders SET done = 1;" + ); + + // 3. Clear search + tabular.history_search_text = "".to_string(); + filter_history_tree(&mut tabular); + assert!(tabular.filtered_history_tree.is_empty()); + + // 4. Search with whitespace only -> should treat as empty and clear filtered tree + tabular.history_search_text = " ".to_string(); + filter_history_tree(&mut tabular); + assert!(tabular.filtered_history_tree.is_empty()); + + // 5. Search with untrimmed query -> should trim and match correctly + tabular.history_search_text = " users ".to_string(); + filter_history_tree(&mut tabular); + assert_eq!(tabular.filtered_history_tree.len(), 1); + assert_eq!(tabular.filtered_history_tree[0].name, "Today"); + assert_eq!(tabular.filtered_history_tree[0].children.len(), 1); + assert_eq!( + tabular.filtered_history_tree[0].children[0].name, + "SELECT * FROM users;" + ); + } +} + diff --git a/src/sidebar_query.rs b/src/sidebar_query.rs index 33a31af5..f9915ed2 100644 --- a/src/sidebar_query.rs +++ b/src/sidebar_query.rs @@ -76,6 +76,14 @@ pub(crate) fn filter_queries_tree(tabular: &mut window_egui::Tabular) { let name_lower = node.name.to_lowercase(); let matches = name_lower.contains(search_text); + // If this node is a folder and matches the search text, preserve all of its contents (children) + // and recursively expand all nested subfolders. + if matches && node.node_type.is_folder() { + let mut filtered_node = node.clone(); + filtered_node.expand_all_folders(); + return Some(filtered_node); + } + let mut filtered_children = Vec::new(); for child in &node.children { if let Some(filtered_child) = filter_node(child, search_text) { @@ -88,6 +96,7 @@ pub(crate) fn filter_queries_tree(tabular: &mut window_egui::Tabular) { if !filtered_children.is_empty() { filtered_node.children = filtered_children; } + filtered_node.is_expanded = true; Some(filtered_node) } else { None @@ -709,5 +718,53 @@ mod tests { tabular.database_search_text = "".to_string(); filter_queries_tree(&mut tabular); assert!(tabular.filtered_queries_tree.is_empty()); + + // 4. Search for folder name "admin" -> folder matches, so all children should be displayed! + tabular.database_search_text = "admin".to_string(); + filter_queries_tree(&mut tabular); + assert_eq!(tabular.filtered_queries_tree.len(), 1); + assert_eq!(tabular.filtered_queries_tree[0].name, "Admin Queries"); + assert!(tabular.filtered_queries_tree[0].is_expanded); + assert_eq!(tabular.filtered_queries_tree[0].children.len(), 2); + assert_eq!( + tabular.filtered_queries_tree[0].children[0].name, + "Select Users.sql" + ); + assert_eq!( + tabular.filtered_queries_tree[0].children[1].name, + "Insert Products.sql" + ); + } + + #[test] + fn test_filter_queries_tree_nested_subfolders_recursive_expand() { + let mut tabular = window_egui::Tabular::default(); + + let q1 = TreeNode::new("Monthly Report.sql".to_string(), NodeType::Query); + let mut sub_folder = TreeNode::new("2026 Reports".to_string(), NodeType::QueryFolder); + sub_folder.children = vec![q1]; + assert!(!sub_folder.is_expanded); + + let mut root_folder = TreeNode::new("Finance".to_string(), NodeType::QueryFolder); + root_folder.children = vec![sub_folder]; + assert!(!root_folder.is_expanded); + + tabular.queries_tree = vec![root_folder]; + + // Search for root folder "Finance" + tabular.database_search_text = "finance".to_string(); + filter_queries_tree(&mut tabular); + + assert_eq!(tabular.filtered_queries_tree.len(), 1); + let root = &tabular.filtered_queries_tree[0]; + assert_eq!(root.name, "Finance"); + assert!(root.is_expanded, "Root folder must be auto-expanded"); + + assert_eq!(root.children.len(), 1); + let nested = &root.children[0]; + assert_eq!(nested.name, "2026 Reports"); + assert!(nested.is_expanded, "Nested subfolder must be recursively auto-expanded!"); + assert_eq!(nested.children.len(), 1); + assert_eq!(nested.children[0].name, "Monthly Report.sql"); } } diff --git a/src/window_egui/search.rs b/src/window_egui/search.rs index 73cb3ad6..b13cc227 100644 --- a/src/window_egui/search.rs +++ b/src/window_egui/search.rs @@ -5,7 +5,7 @@ use log::debug; impl super::Tabular { pub fn update_all_database_search_results(&mut self) { self.update_search_results(); - self.history_search_text = self.database_search_text.clone(); + self.history_search_text = self.database_search_text.trim().to_string(); crate::sidebar_history::filter_history_tree(self); crate::sidebar_query::filter_queries_tree(self); } @@ -44,15 +44,21 @@ impl super::Tabular { node: &models::structs::TreeNode, search_text: &str, ) -> Option { - let mut matches = false; - let mut filtered_children = Vec::new(); - // Case-insensitive LIKE search let search_lower = search_text.to_lowercase(); - if node.name.to_lowercase().contains(&search_lower) { - matches = true; + let self_matches = node.name.to_lowercase().contains(&search_lower); + + // If this node is a folder and matches the search text, preserve all of its contents (children) + // and recursively expand all nested subfolders. + if self_matches && node.node_type.is_folder() { + let mut filtered_node = node.clone(); + filtered_node.expand_all_folders(); + return Some(filtered_node); } + let mut matches = self_matches; + let mut filtered_children = Vec::new(); + // Check children recursively for child in &node.children { if let Some(filtered_child) = self.filter_node_with_like_search(child, search_text) { @@ -688,3 +694,125 @@ impl super::Tabular { job } } + +#[cfg(test)] +mod tests { + use crate::models::enums::NodeType; + use crate::models::structs::TreeNode; + use crate::window_egui::Tabular; + + #[test] + fn test_filter_node_with_like_search_folder_preserves_children() { + let tabular = Tabular::default(); + + let mut conn1 = TreeNode::new("postgres_db".to_string(), NodeType::Connection); + conn1.connection_id = Some(1); + + let mut conn2 = TreeNode::new("mysql_db".to_string(), NodeType::Connection); + conn2.connection_id = Some(2); + + let mut folder = TreeNode::new("Production".to_string(), NodeType::CustomFolder); + folder.children = vec![conn1, conn2]; + + // 1. Search for child "postgres" -> only postgres_db is preserved + let res = tabular.filter_node_with_like_search(&folder, "postgres"); + assert!(res.is_some()); + let filtered = res.unwrap(); + assert_eq!(filtered.name, "Production"); + assert_eq!(filtered.children.len(), 1); + assert_eq!(filtered.children[0].name, "postgres_db"); + + // 2. Search for folder name "Production" -> all contents must be displayed! + let res_folder = tabular.filter_node_with_like_search(&folder, "production"); + assert!(res_folder.is_some()); + let filtered_folder = res_folder.unwrap(); + assert_eq!(filtered_folder.name, "Production"); + assert!(filtered_folder.is_expanded); + assert_eq!(filtered_folder.children.len(), 2); + assert_eq!(filtered_folder.children[0].name, "postgres_db"); + assert_eq!(filtered_folder.children[1].name, "mysql_db"); + + // 3. Search for non-existent text -> None + let res_none = tabular.filter_node_with_like_search(&folder, "nonexistent"); + assert!(res_none.is_none()); + } + + #[test] + fn test_filter_node_database_preserves_tables_and_expands_folders() { + let tabular = Tabular::default(); + + let table1 = TreeNode::new("users".to_string(), NodeType::Table); + let table2 = TreeNode::new("orders".to_string(), NodeType::Table); + + let mut tables_folder = TreeNode::new("Tables".to_string(), NodeType::TablesFolder); + tables_folder.children = vec![table1, table2]; + assert!(!tables_folder.is_expanded); + + let mut views_folder = TreeNode::new("Views".to_string(), NodeType::ViewsFolder); + let view1 = TreeNode::new("active_users".to_string(), NodeType::View); + views_folder.children = vec![view1]; + assert!(!views_folder.is_expanded); + + let mut db_node = TreeNode::new("ecommerce_db".to_string(), NodeType::Database); + db_node.children = vec![tables_folder, views_folder]; + assert!(!db_node.is_expanded); + + // When database node matches search, ALL tables & views are preserved and subfolders are auto-expanded! + let res = tabular.filter_node_with_like_search(&db_node, "ecommerce"); + assert!(res.is_some(), "Database node must match 'ecommerce'"); + let filtered = res.unwrap(); + assert_eq!(filtered.name, "ecommerce_db"); + assert!(filtered.is_expanded, "Database node must be auto-expanded"); + assert_eq!(filtered.children.len(), 2, "Tables and Views folders must be preserved!"); + + let tables = &filtered.children[0]; + assert_eq!(tables.name, "Tables"); + assert!(tables.is_expanded, "Nested TablesFolder must be auto-expanded!"); + assert_eq!(tables.children.len(), 2); + assert_eq!(tables.children[0].name, "users"); + assert_eq!(tables.children[1].name, "orders"); + + let views = &filtered.children[1]; + assert_eq!(views.name, "Views"); + assert!(views.is_expanded, "Nested ViewsFolder must be auto-expanded!"); + assert_eq!(views.children.len(), 1); + assert_eq!(views.children[0].name, "active_users"); + } + + #[test] + fn test_filter_node_nested_subfolders_recursive_expand() { + let tabular = Tabular::default(); + + let conn = TreeNode::new("prod_db".to_string(), NodeType::Connection); + + let mut sub_sub_folder = TreeNode::new("Europe".to_string(), NodeType::CustomFolder); + sub_sub_folder.children = vec![conn]; + assert!(!sub_sub_folder.is_expanded); + + let mut sub_folder = TreeNode::new("Regional".to_string(), NodeType::CustomFolder); + sub_folder.children = vec![sub_sub_folder]; + assert!(!sub_folder.is_expanded); + + let mut root_folder = TreeNode::new("Servers".to_string(), NodeType::CustomFolder); + root_folder.children = vec![sub_folder]; + assert!(!root_folder.is_expanded); + + // Search for root folder "Servers" + let res = tabular.filter_node_with_like_search(&root_folder, "servers"); + assert!(res.is_some()); + let filtered = res.unwrap(); + assert_eq!(filtered.name, "Servers"); + assert!(filtered.is_expanded, "Root folder must be auto-expanded"); + + let f1 = &filtered.children[0]; + assert_eq!(f1.name, "Regional"); + assert!(f1.is_expanded, "Subfolder must be auto-expanded recursively"); + + let f2 = &f1.children[0]; + assert_eq!(f2.name, "Europe"); + assert!(f2.is_expanded, "Nested subfolder must be auto-expanded recursively"); + assert_eq!(f2.children.len(), 1); + assert_eq!(f2.children[0].name, "prod_db"); + } +} + diff --git a/walkthrough_fixed_sidebar_search.md b/walkthrough_fixed_sidebar_search.md new file mode 100644 index 00000000..d165a4f1 --- /dev/null +++ b/walkthrough_fixed_sidebar_search.md @@ -0,0 +1,86 @@ +# Walkthrough: Perbaikan Sidebar Search & Preservasi Konten Folder + +Dokumen ini mendokumentasikan implementasi dan penyelesaian perbaikan fitur **Sidebar Search** di Tabular. Perbaikan ini memastikan bahwa ketika pencarian di sidebar mencocokkan sebuah folder/koleksi/database, seluruh konten di dalam folder tersebut (subfolder bersarang, tabel, view, query files, dan request HTTP) tetap dimunculkan serta di-expand secara rekursif. + +--- + +## 1. Masalah & Temuan Review Critic yang Diselesaikan + +Berdasarkan tinjauan Adversarial Critic dan instruksi user: +1. **HTTP Collections**: Subfolder pada HTTP Collection otomatis tertutup (*collapsed*) dan kontennya tersembunyi saat parent folder atau workspace cocok dengan kata kunci search. +2. **Database Nodes**: `NodeType::Database` sebelumnya tidak terdaftar dalam `NodeType::is_folder()`, sehingga ketika pengguna mencari nama database, seluruh tabel dan view di bawahnya terhapus/hilang dari hasil filter. +3. **Subfolder Bersarang (Nested Subfolders)**: Subfolder bersarang tidak mengalami auto-expand rekursif pada Saved Queries (`filtered_queries_tree`) maupun Connection Tree (`filter_node_with_like_search`). Hanya folder tingkat pertama yang terbuka, sementara subfolder di dalamnya tetap tertutup. +4. **Inkonsistensi Whitespace & Ketiadaan `.trim()`**: Ketiadaan `.trim()` pada pemrosesan query pencarian riwayat (`sidebar_history.rs`) menyebabkan desinkronisasi status pencarian dengan UI toggle di `app_impl.rs`. + +--- + +## 2. Rincian Perubahan Kode + +### 2.1 `src/models/enums.rs` +- Menambahkan variant `NodeType::Database` ke dalam fungsi `NodeType::is_folder()`. +- Dengan ini, node Database diperlakukan sebagai kontainer/folder sehingga saat node Database cocok dengan filter, seluruh hirarki anak di bawahnya dipertahankan. + +### 2.2 `src/models/structs.rs` +- Menambahkan metode baru `TreeNode::expand_all_folders(&mut self)`: + ```rust + pub fn expand_all_folders(&mut self) { + if self.node_type.is_folder() { + self.is_expanded = true; + } + for child in &mut self.children { + child.expand_all_folders(); + } + } + ``` +- Memastikan ekspansi terjadi secara mendalam (rekursif) ke seluruh level subfolder anak, bukan hanya node terluar. + +### 2.3 `src/window_egui/search.rs` +- Pada `filter_node_with_like_search`, ketika sebuah folder (termasuk `NodeType::Database`, `CustomFolder`, dll) cocok dengan teks pencarian: + - Seluruh node anak di-clone dan dipertahankan. + - Memanggil `filtered_node.expand_all_folders()` untuk membuka folder utama beserta semua subfoldernya. +- Pada `update_all_database_search_results`, melakukan `.trim()` pada `database_search_text` saat mengupdate `history_search_text`. +- Menambahkan unit test: + - `test_filter_node_with_like_search_folder_preserves_children`: Menguji folder custom mempertahankan semua koneksi anak saat folder dicari. + - `test_filter_node_database_preserves_tables_and_expands_folders`: Menguji node `Database` mempertahankan `TablesFolder` dan `ViewsFolder` serta auto-expand saat database dicari. + - `test_filter_node_nested_subfolders_recursive_expand`: Menguji ekspansi rekursif subfolder bertingkat (`Servers -> Regional -> Europe`). + +### 2.4 `src/sidebar_query.rs` +- Pada `filter_queries_tree`, saat `node.node_type.is_folder()` cocok dengan teks pencarian: + - Memanggil `filtered_node.expand_all_folders()`. +- Menambahkan unit test: + - `test_filter_queries_tree_nested_subfolders_recursive_expand`: Menguji folder query bertingkat (`Finance -> 2026 Reports -> Monthly Report.sql`) ter-expand otomatis secara rekursif saat parent folder dicari. + +### 2.5 `src/sidebar_history.rs` +- Pada `filter_history_tree`: + - Menambahkan `.trim()` pada `tabular.history_search_text.trim()`. + - Jika query kosong atau hanya berisi spasi whitespace, `filtered_history_tree` dikosongkan secara konsisten. +- Menambahkan unit test pengujian whitespace, query berjarak (`" users "`), dan query kosong (`" "`). + +### 2.6 `src/sidebar_collection.rs` +- Menambahkan parameter `parent_matched: bool` pada `render_folder_node`. +- Menambahkan deteksi `ws_matches` pada level workspace HTTP Collection. +- Saat parent folder atau workspace cocok dengan kata kunci pencarian: + - `folder_matches` menjadi `true` secara propagatif untuk semua subfolder di bawahnya. + - `is_expanded` menjadi `true` untuk semua subfolder turunan. + - Seluruh requests dan child folders tidak di-skip, sehingga tetap ditampilkan utuh kepada pengguna. +- Menambahkan unit test: + - `test_folder_and_workspace_has_match`: Menguji pencarian pada level workspace, parent folder, subfolder, serta endpoint URL request. + +--- + +## 3. Matriks Pengujian & Verifikasi + +| Komponen Sidebar | Skenario Pengujian | Hasil yang Diharapkan | Status | +| :--- | :--- | :--- | :--- | +| **Database Connection Tree** | Cari nama folder custom (misal `"Production"`) | Folder terbuka, semua koneksi di dalamnya tetap muncul | Terverifikasi (`search.rs`) | +| **Database & Schema Tree** | Cari nama database (misal `"ecommerce"`) | Database terbuka, `TablesFolder`, `ViewsFolder`, serta tabel/view di dalamnya tetap muncul | Terverifikasi (`search.rs`) | +| **Database Connection Tree** | Subfolder bertingkat (`Servers -> Regional -> Europe`) | Seluruh tingkatan subfolder ter-expand otomatis | Terverifikasi (`search.rs`) | +| **Saved Queries Tree** | Subfolder bertingkat (`Finance -> 2026 Reports`) | Seluruh tingkatan subfolder query ter-expand otomatis | Terverifikasi (`sidebar_query.rs`) | +| **Query History** | Pencarian dengan whitespace / spasi trailing (`" users "`, `" "`) | Ter-trim dengan konsisten, tidak ada desinkronisasi UI tree | Terverifikasi (`sidebar_history.rs`) | +| **HTTP API Collection** | Cari nama folder parent atau workspace | Subfolder tidak tertutup, seluruh endpoint/request anak tetap dimunculkan | Terverifikasi (`sidebar_collection.rs`) | + +--- + +## 4. Kesimpulan + +Semua catatan kritik review telah diperbaiki tuntas di seluruh 4 domain sidebar (Database Tree, Saved Queries, History, dan HTTP Collections) dengan penanganan rekursif yang konsisten, penanganan whitespace yang aman, serta cakupan unit tests komprehensif.