Skip to content
Merged
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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
82 changes: 82 additions & 0 deletions implementation_plan_fixed_sidebar_search.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 39 additions & 0 deletions src/models/enums.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions src/models/structs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
82 changes: 76 additions & 6 deletions src/sidebar_collection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────
Expand Down Expand Up @@ -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<String> = app.yaak_workspaces[ws_idx]
.requests
Expand All @@ -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)
{
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -849,6 +853,7 @@ fn render_folder_node(
folder: &crate::http_collection::HttpFolder,
expanded_folders: &mut std::collections::HashSet<String>,
filter: &str,
parent_matched: bool,
accent: egui::Color32,
active_dnd_source: Option<&HttpDndSource>,
req_action_out: &mut Option<(SavedRequest, RequestAction)>,
Expand All @@ -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),
);
Expand Down Expand Up @@ -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)
{
Expand All @@ -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(
Expand All @@ -1054,6 +1063,7 @@ fn render_folder_node(
child,
expanded_folders,
filter,
folder_matches,
accent,
active_dnd_source,
req_action_out,
Expand Down Expand Up @@ -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"));
}
}
Loading
Loading