From 555996ba1ec9363372aa43a5099c68f1c6962bb3 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Thu, 10 Sep 2026 09:29:57 +0000 Subject: [PATCH 01/18] antigravity: interim changes before upstream sync for task move_tab_editor --- README.md | 16 + ... => implementation_plan_move_tab_editor.md | 0 src/editor.rs | 294 +++++++++++++++ src/models/structs.rs | 48 +++ src/sidebar_query.rs | 1 + src/window_egui/app_impl.rs | 347 ++++++++++++++++-- src/window_egui/init.rs | 1 + src/window_egui/mod.rs | 1 + ...rough.md => walkthrough_move_tab_editor.md | 0 9 files changed, 677 insertions(+), 31 deletions(-) rename implementation_plan.md => implementation_plan_move_tab_editor.md (100%) rename walkthrough.md => walkthrough_move_tab_editor.md (100%) diff --git a/README.md b/README.md index e020abc2..e3e5b408 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,25 @@ Tabular is a lightweight, native database client built with the `eframe`/`egui` - **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 - **AI Assistant (`Cmd+Shift+A`)**: Schema-aware SQL completion with OpenAI, Anthropic Claude, Groq, GitHub Copilot, or custom endpoints +- **Editor Tab Drag & Drop Reordering & Pin Tab**: Group and reorder tabs via intuitive horizontal drag-and-drop, pin important queries/tables with 📌, prevent accidental closures, and manage tabs with full context menus --- +### Editor Tab Drag & Drop & Tab Pinning (New in v0.15) +Organize and group editor tabs seamlessly to enhance workflow when dealing with many queries, tables, and API requests. +- **Interactive Drag-and-Drop Reordering**: Drag tabs horizontally to reorder them and group related tabs together. Visual feedback includes grabbing cursor, floating badge tooltip with tab title, and a real-time vertical insertion indicator line between candidate slots. +- **Pin Tab (📌)**: Pin essential tabs to keep them docked on the left side of the tab bar. Pinned tabs feature a distinctive 📌 indicator, warm styling tint, and an optional separator dividing pinned from unpinned tabs. +- **Accidental Close Prevention**: Pinned tabs replace the "×" close button with the pin icon so they cannot be accidentally closed during rapid navigation. Clicking the pin icon directly unpins the tab. +- **Quick Hover Pin Button**: Hovering over any unpinned tab reveals a convenient quick-pin button beside the close icon. +- **Smart Boundary Synchronization**: Dragging an unpinned tab into the pinned region automatically pins it, and dragging a pinned tab past the pinned zone automatically unpins it. +- **Full Tab Context Menu**: Right-click on any tab to access: + - 📌 **Pin Tab** / 📌 **Unpin Tab** + - ⬅ **Move Tab Left** / ➡ **Move Tab Right** + - ✕ **Close Tab** + - **Close Other Tabs** (safely preserves pinned tabs) + - **Close Tabs to the Right** (safely preserves pinned tabs) +- **Middle-Click to Close**: Middle-clicking any unpinned tab quickly closes it. + ### 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.md b/implementation_plan_move_tab_editor.md similarity index 100% rename from implementation_plan.md rename to implementation_plan_move_tab_editor.md diff --git a/src/editor.rs b/src/editor.rs index 059d63e6..c43a0a86 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -60,6 +60,7 @@ pub(crate) fn create_new_tab( tx_active: false, session: None, pinned_columns: std::collections::HashSet::new(), + is_pinned: false, }; tabular.query_tabs.push(new_tab); @@ -227,6 +228,7 @@ pub(crate) fn close_tab(tabular: &mut window_egui::Tabular, tab_index: usize) { tab.file_path = None; tab.is_saved = false; tab.is_modified = false; + tab.is_pinned = false; tab.connection_id = None; // Clear connection as well tab.database_name = None; // Clear database as well // Clear per-tab result state as well @@ -287,6 +289,174 @@ pub(crate) fn close_tab(tabular: &mut window_egui::Tabular, tab_index: usize) { } } +/// Move a tab from `from` index to `to` index directly. +/// Automatically updates active_tab_index and syncs pinned status if crossing the pinned boundary. +pub(crate) fn move_tab(tabular: &mut window_egui::Tabular, from: usize, to: usize) { + let tab_count = tabular.query_tabs.len(); + if from == to || from >= tab_count || to >= tab_count { + return; + } + + let pinned_count_before = tabular.query_tabs.iter().filter(|t| t.is_pinned).count(); + let was_pinned = tabular.query_tabs[from].is_pinned; + + let mut tab = tabular.query_tabs.remove(from); + + // If moved into pinned region (< pinned_count_before), pin it. + // If moved out of pinned region (>= pinned_count_before), unpin it. + if !was_pinned && to < pinned_count_before { + tab.is_pinned = true; + } else if was_pinned && to >= pinned_count_before { + tab.is_pinned = false; + } + + tabular.query_tabs.insert(to, tab); + + // Adjust active_tab_index + if tabular.active_tab_index == from { + tabular.active_tab_index = to; + } else if from < to { + if tabular.active_tab_index > from && tabular.active_tab_index <= to { + tabular.active_tab_index -= 1; + } + } else { + if tabular.active_tab_index >= to && tabular.active_tab_index < from { + tabular.active_tab_index += 1; + } + } +} + +/// Reorder a tab dragged from `from` index and dropped at insertion slot `insert_at` (0..=tab_count). +/// Automatically updates active_tab_index and syncs pinned status if crossing the pinned boundary. +pub(crate) fn reorder_tab(tabular: &mut window_egui::Tabular, from: usize, insert_at: usize) { + let tab_count = tabular.query_tabs.len(); + if from >= tab_count { + return; + } + let to = if insert_at > from { + (insert_at - 1).min(tab_count - 1) + } else { + insert_at.min(tab_count - 1) + }; + move_tab(tabular, from, to); +} + +/// Pin a tab by index and move it to the end of the pinned section. +pub(crate) fn pin_tab(tabular: &mut window_egui::Tabular, tab_index: usize) { + if tab_index >= tabular.query_tabs.len() { + return; + } + tabular.query_tabs[tab_index].is_pinned = true; + let first_unpinned = tabular + .query_tabs + .iter() + .position(|t| !t.is_pinned) + .unwrap_or(tabular.query_tabs.len()); + if tab_index > first_unpinned { + let tab = tabular.query_tabs.remove(tab_index); + tabular.query_tabs.insert(first_unpinned, tab); + + if tabular.active_tab_index == tab_index { + tabular.active_tab_index = first_unpinned; + } else if tab_index < first_unpinned { + if tabular.active_tab_index > tab_index && tabular.active_tab_index <= first_unpinned { + tabular.active_tab_index -= 1; + } + } else { + if tabular.active_tab_index >= first_unpinned && tabular.active_tab_index < tab_index { + tabular.active_tab_index += 1; + } + } + } +} + +/// Unpin a tab by index and move it after all remaining pinned tabs if needed. +pub(crate) fn unpin_tab(tabular: &mut window_egui::Tabular, tab_index: usize) { + if tab_index >= tabular.query_tabs.len() { + return; + } + tabular.query_tabs[tab_index].is_pinned = false; + let last_pinned_idx = tabular.query_tabs.iter().rposition(|t| t.is_pinned); + if let Some(last_p) = last_pinned_idx { + if tab_index < last_p { + let tab = tabular.query_tabs.remove(tab_index); + tabular.query_tabs.insert(last_p, tab); + + if tabular.active_tab_index == tab_index { + tabular.active_tab_index = last_p; + } else if tab_index < last_p { + if tabular.active_tab_index > tab_index && tabular.active_tab_index <= last_p { + tabular.active_tab_index -= 1; + } + } else { + if tabular.active_tab_index >= last_p && tabular.active_tab_index < tab_index { + tabular.active_tab_index += 1; + } + } + } + } +} + +/// Toggle pinned status for a tab. +pub(crate) fn toggle_pin_tab(tabular: &mut window_egui::Tabular, tab_index: usize) { + if tab_index >= tabular.query_tabs.len() { + return; + } + if tabular.query_tabs[tab_index].is_pinned { + unpin_tab(tabular, tab_index); + } else { + pin_tab(tabular, tab_index); + } +} + +/// Close all tabs except `keep_index` and any pinned tabs. +pub(crate) fn close_other_tabs(tabular: &mut window_egui::Tabular, keep_index: usize) { + if keep_index >= tabular.query_tabs.len() { + return; + } + if tabular.active_tab_index != keep_index { + switch_to_tab(tabular, keep_index); + } + let mut i = 0; + while i < tabular.query_tabs.len() { + if i != tabular.active_tab_index && !tabular.query_tabs[i].is_pinned { + if let Some(session) = tabular.query_tabs[i].session.take() { + session.close(); + } + tabular.query_tabs.remove(i); + if tabular.active_tab_index > i { + tabular.active_tab_index -= 1; + } + } else { + i += 1; + } + } +} + +/// Close all unpinned tabs to the right of `tab_index`. +pub(crate) fn close_tabs_to_the_right(tabular: &mut window_egui::Tabular, tab_index: usize) { + if tab_index >= tabular.query_tabs.len() { + return; + } + if tabular.active_tab_index > tab_index && !tabular.query_tabs[tabular.active_tab_index].is_pinned { + switch_to_tab(tabular, tab_index); + } + let mut i = tab_index + 1; + while i < tabular.query_tabs.len() { + if !tabular.query_tabs[i].is_pinned { + if let Some(session) = tabular.query_tabs[i].session.take() { + session.close(); + } + tabular.query_tabs.remove(i); + if tabular.active_tab_index >= i && tabular.active_tab_index > 0 { + tabular.active_tab_index -= 1; + } + } else { + i += 1; + } + } +} + /// Find an already-open tab representing the same title/connection/database, /// so callers can activate it instead of opening a duplicate tab. pub(crate) fn find_tab_for_target( @@ -7903,5 +8073,129 @@ mod tests { assert_eq!(is_unsafe_dml_query("DELETE FROM users WHERE id = 1;"), None); assert_eq!(is_unsafe_dml_query("UPDATE users SET status = 'a' WHERE id = 1;"), None); } + + #[test] + fn test_move_tab_and_active_index() { + let mut tabular = crate::window_egui::Tabular::new(); + tabular.query_tabs.clear(); + create_new_tab(&mut tabular, "Tab 0".to_string(), "0".to_string()); + create_new_tab(&mut tabular, "Tab 1".to_string(), "1".to_string()); + create_new_tab(&mut tabular, "Tab 2".to_string(), "2".to_string()); + create_new_tab(&mut tabular, "Tab 3".to_string(), "3".to_string()); + + tabular.active_tab_index = 1; // Tab 1 is active + + // Move Tab 0 to index 2: [Tab 1, Tab 2, Tab 0, Tab 3] + move_tab(&mut tabular, 0, 2); + assert_eq!(tabular.query_tabs[0].title, "Tab 1"); + assert_eq!(tabular.query_tabs[1].title, "Tab 2"); + assert_eq!(tabular.query_tabs[2].title, "Tab 0"); + assert_eq!(tabular.query_tabs[3].title, "Tab 3"); + // Active tab was Tab 1 (index 1), shifted to index 0 + assert_eq!(tabular.active_tab_index, 0); + assert_eq!(tabular.query_tabs[tabular.active_tab_index].title, "Tab 1"); + + // Move active tab (Tab 1 at index 0) to index 3: [Tab 2, Tab 0, Tab 3, Tab 1] + move_tab(&mut tabular, 0, 3); + assert_eq!(tabular.query_tabs[3].title, "Tab 1"); + assert_eq!(tabular.active_tab_index, 3); + assert_eq!(tabular.query_tabs[tabular.active_tab_index].title, "Tab 1"); + } + + #[test] + fn test_reorder_tab_with_insert_slots() { + let mut tabular = crate::window_egui::Tabular::new(); + tabular.query_tabs.clear(); + create_new_tab(&mut tabular, "A".to_string(), "A".to_string()); + create_new_tab(&mut tabular, "B".to_string(), "B".to_string()); + create_new_tab(&mut tabular, "C".to_string(), "C".to_string()); + + // Reorder B (from = 1) to insert_at = 3 (end): [A, C, B] + reorder_tab(&mut tabular, 1, 3); + assert_eq!(tabular.query_tabs[0].title, "A"); + assert_eq!(tabular.query_tabs[1].title, "C"); + assert_eq!(tabular.query_tabs[2].title, "B"); + + // Reorder B (from = 2) to insert_at = 0 (beginning): [B, A, C] + reorder_tab(&mut tabular, 2, 0); + assert_eq!(tabular.query_tabs[0].title, "B"); + assert_eq!(tabular.query_tabs[1].title, "A"); + assert_eq!(tabular.query_tabs[2].title, "C"); + } + + #[test] + fn test_pin_and_unpin_tab() { + let mut tabular = crate::window_egui::Tabular::new(); + tabular.query_tabs.clear(); + create_new_tab(&mut tabular, "T0".to_string(), "0".to_string()); + create_new_tab(&mut tabular, "T1".to_string(), "1".to_string()); + create_new_tab(&mut tabular, "T2".to_string(), "2".to_string()); + + assert!(!tabular.query_tabs[0].is_pinned); + assert!(!tabular.query_tabs[1].is_pinned); + assert!(!tabular.query_tabs[2].is_pinned); + + // Pin T2: should become pinned and move to front (index 0) + pin_tab(&mut tabular, 2); + assert!(tabular.query_tabs[0].is_pinned); + assert_eq!(tabular.query_tabs[0].title, "T2"); + assert_eq!(tabular.query_tabs[1].title, "T0"); + assert_eq!(tabular.query_tabs[2].title, "T1"); + + // Pin T1 (currently at index 2): should become pinned and move to index 1 + pin_tab(&mut tabular, 2); + assert!(tabular.query_tabs[0].is_pinned); + assert_eq!(tabular.query_tabs[0].title, "T2"); + assert!(tabular.query_tabs[1].is_pinned); + assert_eq!(tabular.query_tabs[1].title, "T1"); + assert!(!tabular.query_tabs[2].is_pinned); + assert_eq!(tabular.query_tabs[2].title, "T0"); + + // Unpin T2 (at index 0): should unpin and move after pinned T1 (index 1) + unpin_tab(&mut tabular, 0); + assert!(tabular.query_tabs[0].is_pinned); + assert_eq!(tabular.query_tabs[0].title, "T1"); + assert!(!tabular.query_tabs[1].is_pinned); + assert_eq!(tabular.query_tabs[1].title, "T2"); + + // Toggle pin on T2: should pin it again + toggle_pin_tab(&mut tabular, 1); + assert!(tabular.query_tabs[1].is_pinned); + assert_eq!(tabular.query_tabs[1].title, "T2"); + } + + #[test] + fn test_close_other_tabs_protects_pinned() { + let mut tabular = crate::window_egui::Tabular::new(); + tabular.query_tabs.clear(); + create_new_tab(&mut tabular, "P1".to_string(), "".to_string()); + create_new_tab(&mut tabular, "P2".to_string(), "".to_string()); + create_new_tab(&mut tabular, "U1".to_string(), "".to_string()); + create_new_tab(&mut tabular, "U2".to_string(), "".to_string()); + create_new_tab(&mut tabular, "U3".to_string(), "".to_string()); + + pin_tab(&mut tabular, 0); + pin_tab(&mut tabular, 1); + + // Close others keeping U2 (index 3) + close_other_tabs(&mut tabular, 3); + + // P1 and P2 should be preserved because they are pinned, U2 kept + assert_eq!(tabular.query_tabs.len(), 3); + assert_eq!(tabular.query_tabs[0].title, "P1"); + assert!(tabular.query_tabs[0].is_pinned); + assert_eq!(tabular.query_tabs[1].title, "P2"); + assert!(tabular.query_tabs[1].is_pinned); + assert_eq!(tabular.query_tabs[2].title, "U2"); + assert!(!tabular.query_tabs[2].is_pinned); + assert_eq!(tabular.active_tab_index, 2); + + // Close tabs to right of P1 (index 0) + // P2 is pinned so it is not closed; U2 is unpinned so it closes + close_tabs_to_the_right(&mut tabular, 0); + assert_eq!(tabular.query_tabs.len(), 2); + assert_eq!(tabular.query_tabs[0].title, "P1"); + assert_eq!(tabular.query_tabs[1].title, "P2"); + } } diff --git a/src/models/structs.rs b/src/models/structs.rs index 3d8e5a74..5561abc2 100644 --- a/src/models/structs.rs +++ b/src/models/structs.rs @@ -745,6 +745,7 @@ pub struct QueryTab { pub tx_active: bool, pub session: Option, pub pinned_columns: HashSet, + pub is_pinned: bool, } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] @@ -1767,4 +1768,51 @@ mod tests { assert!(state.pinned_columns.contains("id")); assert!(state.pinned_columns.contains("name")); } + + #[test] + fn test_query_tab_pinning() { + let mut tab = QueryTab { + title: "Test Tab".to_string(), + content: "SELECT 1;".to_string(), + file_path: None, + is_saved: false, + is_modified: false, + connection_id: None, + database_name: None, + schema_name: None, + has_executed_query: false, + result_headers: Vec::new(), + result_rows: Vec::new(), + result_all_rows: Vec::new(), + result_table_name: String::new(), + result_column_metadata: None, + results: Vec::new(), + active_result_index: 0, + is_table_browse_mode: false, + current_page: 0, + page_size: 100, + total_rows: 0, + base_query: String::new(), + dba_special_mode: None, + object_ddl: None, + explain_plan_json: None, + query_message: String::new(), + query_message_is_error: false, + diagram_state: None, + should_run_on_open: false, + http_client_state: None, + redis_browser_state: None, + dba_monitor_state: None, + user_manager_state: None, + tx_mode: false, + tx_active: false, + session: None, + pinned_columns: HashSet::new(), + is_pinned: false, + }; + + assert!(!tab.is_pinned); + tab.is_pinned = true; + assert!(tab.is_pinned); + } } diff --git a/src/sidebar_query.rs b/src/sidebar_query.rs index 33a31af5..b2099dbc 100644 --- a/src/sidebar_query.rs +++ b/src/sidebar_query.rs @@ -554,6 +554,7 @@ pub(crate) fn open_query_file( tx_active: false, session: None, pinned_columns: std::collections::HashSet::new(), + is_pinned: false, }; tabular.query_tabs.push(new_tab); diff --git a/src/window_egui/app_impl.rs b/src/window_egui/app_impl.rs index 702a9b6f..63804dcb 100644 --- a/src/window_egui/app_impl.rs +++ b/src/window_egui/app_impl.rs @@ -2050,12 +2050,21 @@ impl Tabular { ui.add_space(8.0); let mut to_close = None; let mut to_switch = None; + let mut to_toggle_pin = None; + let mut to_move = None; + let mut to_close_others = None; + let mut to_close_right = None; if self.last_active_tab_index != Some(self.active_tab_index) { self.scroll_to_active_tab = true; self.last_active_tab_index = Some(self.active_tab_index); } + // Cancel drag if Escape key pressed + if ui.ctx().input(|inp| inp.key_pressed(egui::Key::Escape)) { + self.dragged_tab_index = None; + } + let tab_count = self.query_tabs.len(); let max_single_tab_w = 240.0; let min_single_tab_w = 110.0; @@ -2066,14 +2075,27 @@ impl Tabular { max_single_tab_w }; + let pointer_pos = ui.ctx().input(|inp| inp.pointer.hover_pos().or(inp.pointer.interact_pos())); + let mouse_released = ui.ctx().input(|inp| inp.pointer.any_released()); + + let mut tab_rects = Vec::with_capacity(tab_count); + for (i, tab) in self.query_tabs.iter().enumerate() { let active = i == self.active_tab_index; + let is_being_dragged = self.dragged_tab_index == Some(i); + let inactive_bg = if ui.visuals().dark_mode { egui::Color32::from_rgb(35, 35, 35) } else { egui::Color32::from_rgb(240, 240, 240) }; - let tab_bg = if active { + let tab_bg = if is_being_dragged { + if ui.visuals().dark_mode { + egui::Color32::from_rgb(30, 34, 44) + } else { + egui::Color32::from_rgb(220, 224, 235) + } + } else if active { if ui.visuals().dark_mode { egui::Color32::from_rgb(45, 48, 56) } else { @@ -2082,12 +2104,20 @@ impl Tabular { } else { inactive_bg }; - let border_color = if active { + let border_color = if is_being_dragged { + super::style::theme_accent(ui.ctx()).linear_multiply(0.8) + } else if active { if ui.visuals().dark_mode { egui::Color32::from_rgb(55, 60, 76) } else { egui::Color32::from_rgb(215, 222, 232) } + } else if tab.is_pinned { + if ui.visuals().dark_mode { + egui::Color32::from_rgb(65, 60, 48) + } else { + egui::Color32::from_rgb(215, 210, 195) + } } else { ui.visuals().widgets.inactive.bg_stroke.color }; @@ -2107,13 +2137,21 @@ impl Tabular { title = format!("{} [{}]", title, n); } let close_size = 16.0; + let pin_size = 16.0; let tab_width = (title.len() as f32 * 8.0 + 64.0) .clamp(min_single_tab_w, tab_width_cap); let menu_tab_height = 34.0; let (tab_rect, tab_resp) = ui.allocate_exact_size( egui::vec2(tab_width, menu_tab_height), - egui::Sense::click(), + egui::Sense::click_and_drag(), ); + tab_rects.push(tab_rect); + + if tab_resp.drag_started() { + self.dragged_tab_index = Some(i); + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + ui.ctx().request_repaint(); + } if active && self.scroll_to_active_tab { tab_resp.scroll_to_me(Some(egui::Align::Center)); @@ -2144,14 +2182,120 @@ impl Tabular { super::style::theme_accent(ui.ctx()), ); } - let close_rect = egui::Rect::from_min_size( + + let right_slot_rect = egui::Rect::from_min_size( egui::pos2( tab_rect.right() - close_size - 6.0, tab_rect.center().y - close_size / 2.0, ), egui::vec2(close_size, close_size), ); - let label_max_width = tab_rect.width() - close_size - 18.0; + let hover_pin_rect = egui::Rect::from_min_size( + egui::pos2( + right_slot_rect.left() - pin_size - 4.0, + tab_rect.center().y - pin_size / 2.0, + ), + egui::vec2(pin_size, pin_size), + ); + + let mut pointer_over_button = false; + + if tab.is_pinned { + // Pinned tab: show pin icon at right_slot_rect (replaces close button) + let pin_resp = ui.interact( + right_slot_rect, + ui.id().with(("tab_unpin", i)), + egui::Sense::click(), + ) + .on_hover_text("Pinned tab. Click to unpin, or right-click for options."); + if pin_resp.hovered() { + let hover_color = if active { + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30) + } else { + egui::Color32::from_rgba_unmultiplied(0, 0, 0, 20) + }; + ui.painter().rect_filled(right_slot_rect, 4.0, hover_color); + pointer_over_button = true; + } + ui.painter().text( + right_slot_rect.center(), + egui::Align2::CENTER_CENTER, + "📌", + egui::FontId::proportional(12.0), + text_color, + ); + if pin_resp.clicked() { + to_toggle_pin = Some(i); + } + } else { + // Unpinned tab: show close button + let show_close = self.query_tabs.len() > 1 || !active; + if show_close { + let close_resp = ui.interact( + right_slot_rect, + ui.id().with(("tab_close", i)), + egui::Sense::click(), + ) + .on_hover_text("Close tab"); + if close_resp.hovered() { + let hover_color = if active { + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30) + } else { + egui::Color32::from_rgba_unmultiplied(0, 0, 0, 20) + }; + ui.painter().rect_filled(right_slot_rect, 4.0, hover_color); + pointer_over_button = true; + } + ui.painter().text( + right_slot_rect.center(), + egui::Align2::CENTER_CENTER, + "×", + egui::FontId::proportional(13.0), + text_color, + ); + if close_resp.clicked() { + to_close = Some(i); + } + } + + // Quick pin button on hover + if tab_resp.hovered() && !is_being_dragged { + let quick_pin_resp = ui.interact( + hover_pin_rect, + ui.id().with(("tab_quick_pin", i)), + egui::Sense::click(), + ) + .on_hover_text("Pin tab (keep on left)"); + if quick_pin_resp.hovered() { + let hover_color = if active { + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30) + } else { + egui::Color32::from_rgba_unmultiplied(0, 0, 0, 20) + }; + ui.painter().rect_filled(hover_pin_rect, 4.0, hover_color); + pointer_over_button = true; + } + ui.painter().text( + hover_pin_rect.center(), + egui::Align2::CENTER_CENTER, + "📌", + egui::FontId::proportional(11.0), + text_color.linear_multiply(0.7), + ); + if quick_pin_resp.clicked() { + to_toggle_pin = Some(i); + } + } + } + + let right_margin = if tab.is_pinned { + close_size + 12.0 + } else if tab_resp.hovered() && !is_being_dragged { + close_size + pin_size + 16.0 + } else { + close_size + 12.0 + }; + let label_max_width = (tab_rect.width() - right_margin - 10.0).max(20.0); let label_area = egui::Rect::from_min_size( egui::pos2(tab_rect.left() + 10.0, tab_rect.top()), egui::vec2(label_max_width, tab_rect.height()), @@ -2166,37 +2310,54 @@ impl Tabular { text_color, ); - let show_close = self.query_tabs.len() > 1 || !active; - if show_close { - let close_resp = ui.interact( - close_rect, - ui.id().with(("tab_close", i)), - egui::Sense::click(), - ); - if close_resp.hovered() { - let hover_color = if active { - egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30) - } else { - egui::Color32::from_rgba_unmultiplied(0, 0, 0, 20) - }; - ui.painter().rect_filled(close_rect, 4.0, hover_color); + // Context menu on tab + let tab_is_pinned = tab.is_pinned; + let cur_tabs_len = self.query_tabs.len(); + tab_resp.context_menu(|ui| { + if tab_is_pinned { + if ui.button("📌 Unpin Tab").clicked() { + to_toggle_pin = Some(i); + ui.close(); + } + } else { + if ui.button("📌 Pin Tab").clicked() { + to_toggle_pin = Some(i); + ui.close(); + } } - ui.painter().text( - close_rect.center(), - egui::Align2::CENTER_CENTER, - "×", - egui::FontId::proportional(13.0), - text_color, - ); - if close_resp.clicked() { + ui.separator(); + if i > 0 && ui.button("⬅ Move Tab Left").clicked() { + to_move = Some((i, i - 1)); + ui.close(); + } + if i + 1 < cur_tabs_len && ui.button("➡ Move Tab Right").clicked() { + to_move = Some((i, i + 1)); + ui.close(); + } + ui.separator(); + let show_close_menu = cur_tabs_len > 1 || !active; + if ui.add_enabled(show_close_menu, egui::Button::new("✕ Close Tab")).clicked() { to_close = Some(i); + ui.close(); } - } + if cur_tabs_len > 1 && ui.button("Close Other Tabs").clicked() { + to_close_others = Some(i); + ui.close(); + } + if i + 1 < cur_tabs_len && ui.button("Close Tabs to the Right").clicked() { + to_close_right = Some(i); + ui.close(); + } + }); + + let click_pos = tab_resp.interact_pointer_pos().unwrap_or(egui::Pos2::ZERO); + let on_button = right_slot_rect.contains(click_pos) + || (!tab.is_pinned && hover_pin_rect.contains(click_pos)); if tab_resp.clicked() - && !close_rect.contains( - tab_resp.interact_pointer_pos().unwrap_or(egui::Pos2::ZERO), - ) + && !on_button + && !pointer_over_button + && self.dragged_tab_index.is_none() { if !active { to_switch = Some(i); @@ -2204,7 +2365,119 @@ impl Tabular { self.scroll_to_active_tab = true; } } + + // Middle-click to close unpinned tabs + if tab_resp.middle_clicked() + && !tab.is_pinned + && (self.query_tabs.len() > 1 || !active) + { + to_close = Some(i); + } + + // Divider between last pinned tab and first unpinned tab + let is_last_pinned = tab.is_pinned + && self.query_tabs.get(i + 1).map(|t| !t.is_pinned).unwrap_or(false); + if is_last_pinned { + let div_x = tab_rect.right() + 3.0; + ui.painter().vline( + div_x, + (tab_rect.top() + 6.0)..=(tab_rect.bottom() - 6.0), + egui::Stroke::new(1.0, if ui.visuals().dark_mode { + egui::Color32::from_rgb(70, 75, 85) + } else { + egui::Color32::from_rgb(190, 195, 205) + }), + ); + ui.add_space(6.0); + } } + + // Handle active Drag-and-Drop state and drop insertion rendering + if let Some(drag_from) = self.dragged_tab_index { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + let mut candidate_insert_at = None; + + if let Some(pos) = pointer_pos { + // Floating ghost badge following cursor + if let Some(drag_tab) = self.query_tabs.get(drag_from) { + let painter = ui.ctx().layer_painter(egui::LayerId::new( + egui::Order::Tooltip, + egui::Id::new("tab_drag_badge"), + )); + let ghost_title = format!("{} {}", if drag_tab.is_pinned { "📌" } else { "📑" }, drag_tab.title); + let font_id = egui::FontId::proportional(12.0); + let text_w = painter.layout_no_wrap(ghost_title.clone(), font_id.clone(), egui::Color32::WHITE).size().x; + let badge_w = (text_w + 24.0).clamp(80.0, 220.0); + let badge_rect = egui::Rect::from_min_size( + pos + egui::vec2(14.0, 10.0), + egui::vec2(badge_w, 24.0), + ); + painter.rect_filled(badge_rect, 4.0, egui::Color32::from_rgba_unmultiplied(28, 30, 38, 235)); + painter.rect_stroke(badge_rect, 4.0, egui::Stroke::new(1.5, super::style::theme_accent(ui.ctx())), egui::StrokeKind::Outside); + painter.text( + badge_rect.center(), + egui::Align2::CENTER_CENTER, + ghost_title, + font_id, + egui::Color32::WHITE, + ); + } + + // Determine candidate drop slot + for (idx, r) in tab_rects.iter().enumerate() { + if pos.x < r.center().x { + candidate_insert_at = Some(idx); + break; + } + } + if candidate_insert_at.is_none() && !tab_rects.is_empty() { + candidate_insert_at = Some(tab_rects.len()); + } + + // Render insertion indicator line + if let Some(target_idx) = candidate_insert_at { + if target_idx != drag_from && target_idx != drag_from + 1 { + let indicator_x = if target_idx < tab_rects.len() { + tab_rects[target_idx].left() - 1.0 + } else { + tab_rects.last().map(|r| r.right() + 1.0).unwrap_or(0.0) + }; + let indicator_top = tab_rects.first().map(|r| r.top()).unwrap_or(0.0); + let indicator_bottom = tab_rects.first().map(|r| r.bottom()).unwrap_or(34.0); + let accent_col = super::style::theme_accent(ui.ctx()); + + let ind_line_rect = egui::Rect::from_min_size( + egui::pos2(indicator_x - 1.5, indicator_top), + egui::vec2(3.0, indicator_bottom - indicator_top), + ); + ui.painter().rect_filled(ind_line_rect, 1.5, accent_col); + + let cap_top = egui::Rect::from_min_size( + egui::pos2(indicator_x - 3.5, indicator_top), + egui::vec2(7.0, 4.0), + ); + ui.painter().rect_filled(cap_top, 2.0, accent_col); + let cap_bot = egui::Rect::from_min_size( + egui::pos2(indicator_x - 3.5, indicator_bottom - 4.0), + egui::vec2(7.0, 4.0), + ); + ui.painter().rect_filled(cap_bot, 2.0, accent_col); + } + } + } + + // Process drop on mouse release + if mouse_released { + let from = self.dragged_tab_index.take(); + if let (Some(from_idx), Some(target_idx)) = (from, candidate_insert_at) { + if target_idx != from_idx && target_idx != from_idx + 1 { + editor::reorder_tab(self, from_idx, target_idx); + ui.ctx().request_repaint(); + } + } + } + } + self.scroll_to_active_tab = false; let is_http_active = self.selected_menu == "APIs" @@ -2253,9 +2526,21 @@ impl Tabular { } } + if let Some(i) = to_toggle_pin { + editor::toggle_pin_tab(self, i); + } + if let Some((from, to)) = to_move { + editor::move_tab(self, from, to); + } if let Some(i) = to_close { editor::close_tab(self, i); } + if let Some(i) = to_close_others { + editor::close_other_tabs(self, i); + } + if let Some(i) = to_close_right { + editor::close_tabs_to_the_right(self, i); + } if let Some(i) = to_switch { editor::switch_to_tab(self, i); } diff --git a/src/window_egui/init.rs b/src/window_egui/init.rs index 8167bb4b..af78524f 100644 --- a/src/window_egui/init.rs +++ b/src/window_egui/init.rs @@ -284,6 +284,7 @@ impl super::Tabular { next_tab_id: 1, scroll_to_active_tab: true, last_active_tab_index: None, + dragged_tab_index: None, show_save_dialog: false, save_filename: String::new(), save_directory: String::new(), diff --git a/src/window_egui/mod.rs b/src/window_egui/mod.rs index 90bb237e..34a2a488 100644 --- a/src/window_egui/mod.rs +++ b/src/window_egui/mod.rs @@ -172,6 +172,7 @@ pub struct Tabular { pub next_tab_id: usize, pub scroll_to_active_tab: bool, pub last_active_tab_index: Option, + pub dragged_tab_index: Option, // Save dialog pub show_save_dialog: bool, pub save_filename: String, diff --git a/walkthrough.md b/walkthrough_move_tab_editor.md similarity index 100% rename from walkthrough.md rename to walkthrough_move_tab_editor.md From a12ca654b7316151ff5f2038fc2e1cc8f8706f0f Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Thu, 10 Sep 2026 09:37:21 +0000 Subject: [PATCH 02/18] fix(tabs): eliminate dead code branches, fix premature drop, and add vertical boundary check for tab drag --- implementation_plan_move_tab_editor.md | 253 ++++++++----------------- src/editor.rs | 99 ++++++++-- src/window_egui/app_impl.rs | 25 ++- walkthrough_move_tab_editor.md | 144 ++++---------- 4 files changed, 213 insertions(+), 308 deletions(-) diff --git a/implementation_plan_move_tab_editor.md b/implementation_plan_move_tab_editor.md index 199cc61a..da023207 100644 --- a/implementation_plan_move_tab_editor.md +++ b/implementation_plan_move_tab_editor.md @@ -1,190 +1,87 @@ -# Rencana Teknis Implementasi: Export & Import Seluruh Data (ZIP) +# Rencana Teknis Implementasi: Drag & Drop Tab Editor & Fitur Pin Tab -Dokumen ini menjelaskan rencana teknis menyeluruh untuk menambahkan fitur **Export dan Import seluruh data Tabular** (Database Connections, Saved Queries, HTTP API Collections, dan Query History) dalam format file **ZIP**, serta mekanisme **Restore** data ke dalam sistem. +Dokumen ini menjelaskan rancangan teknis dan arsitektur fitur **Drag & Drop Tab Editor** dan **Pin Tab** pada Tabular, termasuk perbaikan hasil adversarial code review. --- ## 1. Analisis Kebutuhan (Requirements Analysis) -### 1.1 Latar Belakang & Tujuan -Saat ini pengguna Tabular dapat melakukan backup pada level database engine tertentu (seperti `pg_dump` atau `mysqldump`), serta import koleksi HTTP individual (seperti Postman atau Yaak). Namun, belum ada mekanisme terpadu untuk: -1. Mengekspor **seluruh konfigurasi dan workspace pengguna** sekaligus ke dalam 1 file arsip portable (ZIP). -2. Memindahkan atau memulihkan seluruh data aplikasi (migrasi perangkat atau pemulihan bencana). -3. Mengimpor kembali file ZIP tersebut sehingga seluruh data (**Connection DB**, **Query**, **HTTP API**, dan **History**) ter-restore dengan aman, konsisten, dan langsung aktif di antarmuka aplikasi. - -### 1.2 Cakupan Data (4 Domain Utama) -1. **Connection DB (Koneksi Database)**: - - Menyimpan seluruh profil koneksi (`ConnectionConfig`): nama koneksi, host, port, username, password, database default, tipe database (`MySQL`, `PostgreSQL`, `SQLite`, `Redis`, `MsSQL`, `MongoDB`, dll.), SSL settings, SSH tunneling & credentials, custom views, dan replication settings. - - Menyimpan struktur pengelompokan folder koneksi (`connection_folders`). - - Penanganan kredensial: saat diekspor, kredensial diambil dari state memori/secret store; saat diimpor, kredensial disimpan ulang ke SQLite dan di-externalize ke secrets store (`externalize_connection_secrets`). - -2. **Saved Queries (Koleksi Query SQL)**: - - Seluruh file query SQL (`.sql`) beserta subfoldernya yang tersimpan di direktori aplikasi `{app_data}/query/`. - - Mempertahankan header metadata Tabular seperti `-- tabular:connection_id=...`, `-- tabular:database=...`, dan hierarki foldernya. - -3. **HTTP API (Koleksi & Workspace HTTP)**: - - Seluruh workspace HTTP (`HttpWorkspace`), subfolder (`HttpFolder`), saved requests (`SavedRequest`), dan variabel environment (`YaakEnvironment`) dari direktori `{app_data}/http_collections/`. - -4. **History (Riwayat Eksekusi Query)**: - - Seluruh log riwayat query dari tabel `query_history` di SQLite (`id`, `query_text`, `connection_id`, `connection_name`, `executed_at`). - - Penanganan relasi Foreign Key: penyesuaian `connection_id` dengan ID baru jika koneksi diimpor ke basis data target yang memiliki ID berbeda (berdasarkan pencocokan `connection_name`). - -### 1.3 Format Struktur Arsip ZIP -Format arsip ZIP didesain modular, aman, dan mudah dibaca secara terstruktur: - -``` -tabular_backup_YYYYMMDD_HHMMSS.zip -├── manifest.json -├── connections/ -│ └── connections.json -├── queries/ -│ ├── analytics/ -│ │ └── monthly_report.sql -│ └── schema_init.sql -├── http_collections/ -│ ├── ws_1710000000_1.json -│ └── ws_1710000000_2.json -└── history/ - └── history.json -``` - -- **`manifest.json`**: - ```json - { - "version": "1.0", - "app": "Tabular", - "exported_at": "2026-09-09T14:30:00Z", - "counts": { - "connections": 5, - "connection_folders": 2, - "queries": 14, - "http_workspaces": 3, - "history_items": 100 - }, - "includes": { - "connections": true, - "queries": true, - "http_api": true, - "history": true - } - } - ``` +### 1.1 Latar Belakang & Masalah +Saat bekerja dengan banyak tab query SQL, tabel basis data, dan endpoint HTTP, pengguna memerlukan mekanisme untuk: +1. Mengelompokkan tab-tab yang saling terkait secara bebas dengan menggeser/menyeret tab (drag-and-drop). +2. Menyematkan (pin) tab penting agar selalu berada di posisi kiri tab bar dan terlindungi dari penutupan massal atau tidak sengaja. +3. Menyediakan kontrol navigasi tab lengkap melalui menu konteks (klik kanan) dan shortcut mouse (middle-click). + +### 1.2 Ruang Lingkup Fitur +1. **Interactive Drag-and-Drop Tab Reordering**: + - Mendukung penyeretan tab horizontal secara visual. + - Ghost badge melayang mengikuti posisi kursor dengan ikon status (📌 / 📑) dan judul tab. + - Indikator garis vertikal interaktif dengan aksen warna tema menandai slot penyisipan tab target. + - Pembatalan drag secara mulus melalui penekanan tombol `Escape` atau menyeret kursor keluar dari batas vertikal tab bar. + - Deteksi tombol mouse primer (`PointerButton::Primary`) guna mencegah pemicuan drop prematur dari tombol mouse lain. + +2. **Fitur Pin Tab (📌)**: + - Status `is_pinned: bool` pada setiap objek `QueryTab`. + - Tab yang disematkan terkumpul rapi di sebelah kiri tab bar sebelum tab-tab biasa (unpinned). + - Tombol close ("×") digantikan ikon pin ("📌") pada tab yang dipin untuk mencegah penutupan tak sengaja. + - Tombol quick-pin muncul saat hover pada tab unpinned. + - Garis pemisah vertikal membedakan grup tab pinned dengan unpinned. + - Sinkronisasi otomatis saat drag-and-drop: tab biasa yang diseret masuk ke area pinned otomatis menjadi pinned, dan sebaliknya. + +3. **Menu Konteks & Aksi Tab Lengkap**: + - 📌 **Pin Tab** / 📌 **Unpin Tab** + - ⬅ **Move Tab Left** / ➡ **Move Tab Right** + - ✕ **Close Tab** + - **Close Other Tabs** (melindungi tab yang sedang dipin) + - **Close Tabs to the Right** (melindungi tab yang sedang dipin) + - Middle-click untuk menutup tab unpinned. --- -## 2. Desain Arsitektur & Rencana Perubahan Berkas - -### 2.1 Modul Baru - -#### A. `src/export_import_all.rs` (Core Logic Module) -Modul independen untuk operasi kompresi, dekompresi, serialisasi, validasi, dan persistensi database: -- **Tipe Data & Model**: - - `ExportAllManifest`: Metadata arsip. - - `ExportAllOptions`: Opsi export (pilihan kategori yang disertakan). - - `ImportAllOptions`: Opsi import (pilihan kategori yang ingin di-restore, serta strategi konflik: `MergeKeepExisting`, `MergeOverwrite`, atau `CleanRestore`). - - `ExportSummary` & `ImportSummary`: Laporan jumlah data yang berhasil diproses. - - `ExportImportError`: Error handling komprehensif menggunakan `thiserror`. -- **Fungsi Inti**: - - `pub fn export_all_data(tabular: &Tabular, target_path: &Path, options: &ExportAllOptions) -> Result` - - Mengumpulkan data dari in-memory state dan file system. - - Menulis ke ZIP menggunakan `zip::ZipWriter` dengan kompresi Deflate. - - `pub fn inspect_archive(archive_path: &Path) -> Result` - - Membaca `manifest.json` dan menghitung preview entri sebelum proses restore dijalankan. - - `pub fn import_all_data(tabular: &mut Tabular, archive_path: &Path, options: &ImportAllOptions) -> Result` - - Membuka ZIP dengan `zip::ZipArchive`. - - Memvalidasi path entri (mencegah Zip Slip vulnerability). - - Memulihkan koneksi database & folder koneksi ke SQLite serta mendaftarkan secret credentials. - - Mengekstrak file query ke `{app_data}/query/`. - - Mengekstrak file workspace HTTP ke `{app_data}/http_collections/`. - - Menyimpan history ke tabel `query_history` dengan mapping ID koneksi. - - Merefresh in-memory state Tabular (`load_connection_folders`, `load_queries_from_directory`, `load_workspaces`, `load_query_history`, dan trigger `needs_refresh`). - -#### B. `src/dialog_export_import_all.rs` (UI Dialog Module) -Komponen dialog berbasis `egui`: -- **`ExportAllDialogState`**: - - Pilihan kategori data (checkboxes: Connections, Queries, HTTP API, History). - - Target path file ZIP default (misal: `~/Downloads/tabular_backup_YYYYMMDD_HHMMSS.zip`). - - Status eksekusi (Idle, InProgress, Completed, Error). - - Ringkasan hasil ekspor. -- **`ImportAllDialogState`**: - - File picker untuk memilih file `.zip`. - - Preview manifest hasil inspeksi (jumlah item yang ditemukan di dalam file ZIP). - - Checkbox pilihan data yang ingin di-restore. - - Opsi penanganan duplikasi (Merge / Overwrite). - - Tombol aksi "Restore Now" dan progress bar / status banner. +## 2. Perubahan Arsitektur & Struktur Berkas + +### 2.1 Model Data (`src/models/structs.rs`) +- Menambahkan field `pub is_pinned: bool` pada struct `QueryTab`. +- Default bernilai `false` pada instansiasi tab baru. + +### 2.2 Logika Tab Management (`src/editor.rs`) +- `move_tab(tabular: &mut Tabular, from: usize, to: usize)`: + - Memindahkan posisi tab langsung pada vektor `query_tabs`. + - Menyesuaikan `active_tab_index` secara matematis tanpa merusak fokus aktif pengguna. + - Memperbarui status `is_pinned` saat tab melintasi batas pemisah pinned/unpinned. +- `reorder_tab(tabular: &mut Tabular, from: usize, insert_at: usize)`: + - Mengonversi slot penyisipan UI (0..=n) ke indeks target pemindahan. +- `pin_tab(tabular: &mut Tabular, tab_index: usize)`: + - Menandai tab sebagai pinned dan memindahkannya ke akhir grup pinned. + - Menyesuaikan `active_tab_index` secara tepat tanpa dead code branch. +- `unpin_tab(tabular: &mut Tabular, tab_index: usize)`: + - Melepas pin tab dan memindahkannya ke posisi setelah seluruh tab pinned yang tersisa. + - Menyesuaikan `active_tab_index` secara tepat tanpa dead code branch. +- `toggle_pin_tab(tabular: &mut Tabular, tab_index: usize)`: + - Toggle antara `pin_tab` dan `unpin_tab`. +- `close_other_tabs(tabular: &mut Tabular, keep_index: usize)`: + - Menutup semua tab kecuali tab target dan seluruh tab yang berstatus pinned. +- `close_tabs_to_the_right(tabular: &mut Tabular, tab_index: usize)`: + - Menutup seluruh tab unpinned di sebelah kanan `tab_index`. + +### 2.3 Antarmuka GUI (`src/window_egui/app_impl.rs`) +- Menambahkan field `dragged_tab_index: Option` pada state `Tabular`. +- Render strip tab dengan alokasi drag & click (`egui::Sense::click_and_drag()`). +- Deteksi pelepasan tombol primer (`PointerButton::Primary`). +- Pengecekan batas vertikal (`is_within_tab_bar_y`) dengan toleransi margin 20px agar pengguna dapat membatalkan drag dengan menggeser pointer keluar tab bar. +- Repaint rendering loop saat mouse dilepaskan untuk menjamin responsivitas UI instan. --- -### 2.2 Berkas yang Dimodifikasi - -1. **`src/main.rs` / `src/lib.rs`**: - - Daftarkan modul baru: - ```rust - pub mod export_import_all; - pub mod dialog_export_import_all; - ``` - -2. **`src/window_egui/mod.rs`**: - - Tambahkan state flag & dialog state di struct `Tabular`: - ```rust - pub show_export_all_dialog: bool, - pub show_import_all_dialog: bool, - pub export_all_state: Option, - pub import_all_state: Option, - ``` - -3. **`src/window_egui/init.rs`**: - - Inisialisasi field baru tersebut dengan `false` dan `None`. - -4. **`src/window_egui/app_impl.rs`**: - - **Gear Settings Context Menu** (baris ~2535): - - Tambahkan item menu: - - `📦 Export All Data (.zip)...` -> membuka `show_export_all_dialog = true` - - `📥 Import & Restore All Data (.zip)...` -> membuka `show_import_all_dialog = true` - - **Settings Window - Data Directory Tab (`PrefTab::DataDirectory`)** (baris ~437): - - Tambahkan kartu UI "Backup & Restore Application Data" dengan tombol "Export All to ZIP" dan "Import from ZIP". - - **Render Loop** (baris ~4995): - - Render `render_export_all_dialog(self, ctx)` saat `self.show_export_all_dialog == true`. - - Render `render_import_all_dialog(self, ctx)` saat `self.show_import_all_dialog == true`. - -5. **`src/quick_open.rs`**: - - Daftarkan perintah ke Command Palette: - - `Export All Data (ZIP)` - - `Import All Data (ZIP)` - ---- - -## 3. Analisis Potensi Risiko & Strategi Mitigasi - -| Risiko | Dampak | Strategi Mitigasi | -| :--- | :--- | :--- | -| **Zip Slip / Path Traversal Attack** | File jahat di dalam ZIP dapat menimpa berkas sistem sembarang (`../../etc/shadow`). | Wajib menggunakan `file.enclosed_name()` dari crate `zip` dan membatasi ekstraksi hanya di dalam subdirektori tujuan yang sah (`query/` dan `http_collections/`). | -| **Foreign Key Constraint pada `query_history`** | `query_history` memiliki relasi `connection_id -> connections(id) ON DELETE CASCADE`. Jika `connection_id` lama tidak ditemukan, query insert history gagal. | Buat tabel mapping ID lama ke ID baru berdasarkan kesamaan nama koneksi (`connection_name`). Jika koneksi belum ada, buat koneksi terlebih dahulu atau kaitkan ke koneksi default yang valid. | -| **Penanganan Kredensial & Secrets** | Password atau SSH key tersimpan sebagai sentinel di SQLite dan data asli di keychain/secret store. | Saat ekspor, ambil data dari `tabular.connections` (yang sudah ter-dekripsi di RAM). Saat import, panggil `externalize_connection_secrets` agar kredensial tersimpan aman di database dan secret backend sistem target. | -| **UI Freeze saat Arsip Besar** | Aplikasi tidak responsif selama kompresi/ekstraksi I/O. | Jalankan proses kompresi dan dekompresi ZIP di background thread / tokio runtime async dengan `mpsc` channel untuk mengirim progress dan hasil ke UI thread. | -| **In-Memory State Stale setelah Restore** | Data sudah masuk ke SQLite/disk tetapi tampilan sidebar tidak terupdate. | Panggil fungsi reload: `sidebar_database::load_connection_folders()`, `sidebar_query::load_queries_from_directory()`, `http_collection::load_workspaces()`, dan `sidebar_history::load_query_history()`, serta set `tabular.needs_refresh = true`. | - ---- - -## 4. Tahapan Verifikasi & Pengujian - -1. **Uji Kompilasi (`cargo check`)**: - - Memastikan tidak ada compile error, type mismatch, atau broken references. -2. **Automated Unit Tests**: - - Buat unit test komprehensif di `src/export_import_all.rs`: - - Test pembuatan mock connection, query file, HTTP workspace, dan history item. - - Test ekspor ke buffer/file ZIP sementara dan verifikasi integritas ZIP serta isi `manifest.json`. - - Test pembacaan dan validasi isi arsip (`inspect_archive`). - - Test impor ke direktori sementara dan verifikasi bahwa data koneksi, queries, HTTP collection, dan history berhasil dipulihkan secara identik. - - Test proteksi Zip Slip (path traversal rejected). - - Test pemulihan relasi `query_history` saat connection ID berubah. -3. **Uji Integrasi UI**: - - Verifikasi pembukaan dialog via Gear Menu dan Tab Settings Data Directory. - - Verifikasi pemilihan file picker (`rfd::FileDialog`). - - Verifikasi feedback visual, progress bar, dan notifikasi keberhasilan pemulihan data. - ---- - -## 5. Kesimpulan & Batasan Tahap Ini (Stage 1 Scope) - -Sesuai instruksi tugas, pengerjaan pada **Tahap 1 (Planning Phase)** dibatasi hanya pada penyusunan dokumen perencanaan teknis ini di file `implementation_plan.md`. Tidak ada kode aplikasi yang diubah pada tahap ini sebelum rencana ini ditinjau dan disetujui oleh pengguna. +## 3. Hasil Perbaikan Adversarial Code Review + +1. **Pembersihan Dead Code pada `pin_tab`**: + - Menghilangkan cabang kontradiktif `else if tab_index < first_unpinned` di dalam blok `if tab_index > first_unpinned`. +2. **Pembersihan Dead Code pada `unpin_tab`**: + - Menghilangkan cabang unreachable `else` di dalam blok `if tab_index < last_p`. +3. **Pembersihan Dead Logic pada `close_tabs_to_the_right`**: + - Menghapus decrement indeks yang redundan (`tabular.active_tab_index >= i`) yang mustahil terpenuhi. +4. **Pencegahan Premature Drop**: + - Mengganti `any_released()` dengan `button_released(egui::PointerButton::Primary)`. +5. **Validasi Batas Vertikal**: + - Menambahkan `is_within_tab_bar_y` pada kalkulasi slot drop `candidate_insert_at`. diff --git a/src/editor.rs b/src/editor.rs index c43a0a86..9457bffc 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -358,14 +358,8 @@ pub(crate) fn pin_tab(tabular: &mut window_egui::Tabular, tab_index: usize) { if tabular.active_tab_index == tab_index { tabular.active_tab_index = first_unpinned; - } else if tab_index < first_unpinned { - if tabular.active_tab_index > tab_index && tabular.active_tab_index <= first_unpinned { - tabular.active_tab_index -= 1; - } - } else { - if tabular.active_tab_index >= first_unpinned && tabular.active_tab_index < tab_index { - tabular.active_tab_index += 1; - } + } else if tabular.active_tab_index >= first_unpinned && tabular.active_tab_index < tab_index { + tabular.active_tab_index += 1; } } } @@ -384,14 +378,8 @@ pub(crate) fn unpin_tab(tabular: &mut window_egui::Tabular, tab_index: usize) { if tabular.active_tab_index == tab_index { tabular.active_tab_index = last_p; - } else if tab_index < last_p { - if tabular.active_tab_index > tab_index && tabular.active_tab_index <= last_p { - tabular.active_tab_index -= 1; - } - } else { - if tabular.active_tab_index >= last_p && tabular.active_tab_index < tab_index { - tabular.active_tab_index += 1; - } + } else if tabular.active_tab_index > tab_index && tabular.active_tab_index <= last_p { + tabular.active_tab_index -= 1; } } } @@ -448,9 +436,6 @@ pub(crate) fn close_tabs_to_the_right(tabular: &mut window_egui::Tabular, tab_in session.close(); } tabular.query_tabs.remove(i); - if tabular.active_tab_index >= i && tabular.active_tab_index > 0 { - tabular.active_tab_index -= 1; - } } else { i += 1; } @@ -8197,5 +8182,81 @@ mod tests { assert_eq!(tabular.query_tabs[0].title, "P1"); assert_eq!(tabular.query_tabs[1].title, "P2"); } + + #[test] + fn test_pin_tab_shifts_active_index_correctly() { + let mut tabular = crate::window_egui::Tabular::new(); + tabular.query_tabs.clear(); + create_new_tab(&mut tabular, "P0".to_string(), "".to_string()); + create_new_tab(&mut tabular, "U1".to_string(), "".to_string()); + create_new_tab(&mut tabular, "U2".to_string(), "".to_string()); + create_new_tab(&mut tabular, "U3".to_string(), "".to_string()); + + pin_tab(&mut tabular, 0); + // Active tab is U1 at index 1 + tabular.active_tab_index = 1; + + // Pin U3 (index 3). It should move to index 1 (end of pinned group). + // Since active tab was at index 1 (>= first_unpinned and < tab_index), + // active_tab_index should shift to 2 to remain pointing to U1. + pin_tab(&mut tabular, 3); + assert_eq!(tabular.query_tabs[0].title, "P0"); + assert_eq!(tabular.query_tabs[1].title, "U3"); + assert!(tabular.query_tabs[1].is_pinned); + assert_eq!(tabular.query_tabs[2].title, "U1"); + assert_eq!(tabular.query_tabs[3].title, "U2"); + assert_eq!(tabular.active_tab_index, 2); + assert_eq!(tabular.query_tabs[tabular.active_tab_index].title, "U1"); + } + + #[test] + fn test_unpin_tab_shifts_active_index_correctly() { + let mut tabular = crate::window_egui::Tabular::new(); + tabular.query_tabs.clear(); + create_new_tab(&mut tabular, "P0".to_string(), "".to_string()); + create_new_tab(&mut tabular, "P1".to_string(), "".to_string()); + create_new_tab(&mut tabular, "P2".to_string(), "".to_string()); + create_new_tab(&mut tabular, "U3".to_string(), "".to_string()); + + pin_tab(&mut tabular, 0); + pin_tab(&mut tabular, 1); + pin_tab(&mut tabular, 2); + + // Active tab is P1 at index 1 + tabular.active_tab_index = 1; + + // Unpin P0 (index 0). It moves to index 2 (after all remaining pinned tabs P1, P2). + // Active tab was at index 1 (> tab_index and <= last_p), + // active_tab_index should shift from 1 to 0 to remain pointing to P1. + unpin_tab(&mut tabular, 0); + assert_eq!(tabular.query_tabs[0].title, "P1"); + assert!(tabular.query_tabs[0].is_pinned); + assert_eq!(tabular.query_tabs[1].title, "P2"); + assert!(tabular.query_tabs[1].is_pinned); + assert_eq!(tabular.query_tabs[2].title, "P0"); + assert!(!tabular.query_tabs[2].is_pinned); + assert_eq!(tabular.active_tab_index, 0); + assert_eq!(tabular.query_tabs[tabular.active_tab_index].title, "P1"); + } + + #[test] + fn test_close_tabs_to_the_right_active_tab_switch() { + let mut tabular = crate::window_egui::Tabular::new(); + tabular.query_tabs.clear(); + create_new_tab(&mut tabular, "T0".to_string(), "".to_string()); + create_new_tab(&mut tabular, "T1".to_string(), "".to_string()); + create_new_tab(&mut tabular, "T2".to_string(), "".to_string()); + + // Active tab is T2 (index 2) + tabular.active_tab_index = 2; + + // Close tabs to right of T0 (index 0) + // Since active tab (T2) is to the right and unpinned, it switches to T0 first, + // then removes T1 and T2. + close_tabs_to_the_right(&mut tabular, 0); + assert_eq!(tabular.query_tabs.len(), 1); + assert_eq!(tabular.query_tabs[0].title, "T0"); + assert_eq!(tabular.active_tab_index, 0); + } } diff --git a/src/window_egui/app_impl.rs b/src/window_egui/app_impl.rs index 63804dcb..eb74f5c6 100644 --- a/src/window_egui/app_impl.rs +++ b/src/window_egui/app_impl.rs @@ -2076,7 +2076,7 @@ impl Tabular { }; let pointer_pos = ui.ctx().input(|inp| inp.pointer.hover_pos().or(inp.pointer.interact_pos())); - let mouse_released = ui.ctx().input(|inp| inp.pointer.any_released()); + let mouse_released = ui.ctx().input(|inp| inp.pointer.button_released(egui::PointerButton::Primary)); let mut tab_rects = Vec::with_capacity(tab_count); @@ -2424,14 +2424,21 @@ impl Tabular { } // Determine candidate drop slot - for (idx, r) in tab_rects.iter().enumerate() { - if pos.x < r.center().x { - candidate_insert_at = Some(idx); - break; + let is_within_tab_bar_y = tab_rects.first().map(|r| { + let margin = 20.0; + pos.y >= r.top() - margin && pos.y <= r.bottom() + margin + }).unwrap_or(false); + + if is_within_tab_bar_y { + for (idx, r) in tab_rects.iter().enumerate() { + if pos.x < r.center().x { + candidate_insert_at = Some(idx); + break; + } + } + if candidate_insert_at.is_none() && !tab_rects.is_empty() { + candidate_insert_at = Some(tab_rects.len()); } - } - if candidate_insert_at.is_none() && !tab_rects.is_empty() { - candidate_insert_at = Some(tab_rects.len()); } // Render insertion indicator line @@ -2472,9 +2479,9 @@ impl Tabular { if let (Some(from_idx), Some(target_idx)) = (from, candidate_insert_at) { if target_idx != from_idx && target_idx != from_idx + 1 { editor::reorder_tab(self, from_idx, target_idx); - ui.ctx().request_repaint(); } } + ui.ctx().request_repaint(); } } diff --git a/walkthrough_move_tab_editor.md b/walkthrough_move_tab_editor.md index 5c3637c4..b3fc6e59 100644 --- a/walkthrough_move_tab_editor.md +++ b/walkthrough_move_tab_editor.md @@ -1,116 +1,56 @@ -# Walkthrough: Fitur Export dan Import Seluruh Data Aplikasi (ZIP) +# Walkthrough: Fitur Drag & Drop Tab Editor dan Pin Tab -Dokumen ini mendokumentasikan implementasi dan verifikasi fitur **Export & Import Seluruh Data** (Database Connections, Saved Queries, HTTP API Collections, dan Query History) dalam format arsip **ZIP** dengan kemampuan **Restore** lengkap di Tabular. +Dokumen ini mendokumentasikan implementasi dan verifikasi fitur **Drag & Drop Tab Editor** dan **Pin Tab** pada Tabular, termasuk perbaikan menyeluruh dari adversarial code review. --- ## 1. Ringkasan Fitur -Fitur ini menyediakan mekanisme backup dan migrasi menyeluruh untuk pengguna Tabular: -1. **Pengeksporan Lengkap (Export All to ZIP)**: - - Mengemas 4 domain data utama ke dalam 1 berkas `.zip`: - - **Database Connections & Folders**: Semua profil koneksi database (termasuk kredensial, SSL/SSH tunneling, custom views) dan struktur foldernya. - - **Saved Queries**: Semua berkas `.sql` dan hierarki subdirektorinya dari `{app_data}/query/`. - - **HTTP API Collections**: Semua workspace (`HttpWorkspace`), folder, saved request, dan environment dari `{app_data}/http_collections/`. - - **Query Execution History**: Seluruh riwayat eksekusi query dari basis data lokal SQLite (`query_history`). - - Menyertakan berkas `manifest.json` berisi versi format, timestamp, indikator modul, dan jumlah entri yang diekspor. -2. **Pratinjau & Inspeksi Arsip (Archive Inspection)**: - - Mampu memeriksa file ZIP sebelum restore dilakukan untuk membaca `manifest.json` atau menghitung entri di dalam arsip secara aman tanpa mengekstraknya ke disk sistem. -3. **Pemulihan & Penanganan Konflik (Restore & Conflict Handling)**: - - Mendukung 3 strategi konflik: - - `Merge (Keep Existing)`: Menambahkan data baru tanpa menimpa data yang sudah ada. - - `Merge (Overwrite Existing)`: Memperbarui data yang telah ada dan menambahkan data baru. - - `Clean Restore (Replace All)`: Menghapus data lama dan memulihkan data dari arsip secara bersih. - - Mengamankan kredensial kembali ke backend secrets (`externalize_connection_secrets`). - - Memetakan relasi `connection_id` pada riwayat query agar integritas Foreign Key tetap terjaga. - - Melindungi sistem dari serangan keamanan **Zip Slip (Path Traversal)**. - - Merefresh status in-memory Tabular secara instan tanpa perlu me-restart aplikasi. +1. **Interactive Drag-and-Drop Tab Reordering**: + - Pengguna dapat menggeser posisi tab editor secara horizontal. + - Dilengkapi feedback visual interaktif: ghost badge melayang mengikuti kursor, ikon status (📌 / 📑), serta garis indikator penyisipan vertikal dengan warna aksen tema. + - Pengguna dapat membatalkan aksi seret tab dengan menekan tombol `Escape` atau menggeser kursor keluar dari batas vertikal tab bar strip. + - Deteksi tombol mouse primer (`PointerButton::Primary`) memastikan tidak terjadi drop prematur saat tombol mouse lain dilepas. + +2. **Fitur Pin Tab (📌)**: + - Tab penting dapat disematkan (pinned) sehingga selalu berada di sisi kiri tab bar. + - Mencegah penutupan tab secara tidak sengaja: tombol close ("×") digantikan oleh ikon pin ("📌"). + - Quick-pin button muncul saat hover pada tab unpinned. + - Garis pembatas visual memisahkan kelompok tab pinned dan unpinned. + - Sinkronisasi otomatis batasan pinned/unpinned saat tab diseret melintasi batas pemisah. + +3. **Menu Konteks Navigasi Lengkap**: + - Menu klik kanan pada setiap tab: + - 📌 **Pin Tab** / 📌 **Unpin Tab** + - ⬅ **Move Tab Left** / ➡ **Move Tab Right** + - ✕ **Close Tab** + - **Close Other Tabs** (melindungi tab yang sedang dipin) + - **Close Tabs to the Right** (melindungi tab yang sedang dipin) + - Dukungan tombol tengah mouse (middle-click) untuk menutup tab biasa secara cepat. --- -## 2. Berkas yang Dibuat & Dimodifikasi +## 2. Perbaikan Berdasarkan Adversarial Code Review -| Berkas | Status | Deskripsi | +| Masalah | Letak Berkas | Tindakan Perbaikan | | :--- | :--- | :--- | -| `src/export_import_all.rs` | **Baru** | Modul logika inti kompresi, inspeksi ZIP, manifest, sanitasi zip slip, serialisasi/deserialisasi 4 domain data, dan mapping relasi database. | -| `src/dialog_export_import_all.rs` | **Baru** | Komponen UI modal `egui` untuk dialog Export All dan dialog Import & Restore All dengan file picker native (`rfd`), status progress, dan laporan ringkasan. | -| `src/lib.rs` | Diubah | Mendaftarkan modul `export_import_all` dan `dialog_export_import_all`. | -| `src/sidebar_database.rs` | Diubah | Mengubah visibilitas `externalize_connection_secrets` menjadi `pub(crate)` agar dapat dipanggil saat restore koneksi. | -| `src/window_egui/mod.rs` | Diubah | Menambahkan field state `show_export_all_dialog`, `show_import_all_dialog`, `export_all_state`, dan `import_all_state` pada struct `Tabular`. | -| `src/window_egui/init.rs` | Diubah | Inisialisasi awal dialog state pada constructor `Tabular::new()`. | -| `src/window_egui/app_impl.rs` | Diubah | Menambahkan menu "Export All Data (ZIP)..." dan "Import All Data (ZIP)..." pada Gear Menu, kartu backup/restore pada Preferences > Data Directory, serta pemanggilan render dialog di loop utama. | -| `src/editor.rs` | Diubah | Menambahkan handler eksekusi command `"Export All Data (ZIP)"` dan `"Import All Data (ZIP)"`. | -| `src/quick_open.rs` | Diubah | Mendaftarkan perintah Export & Import ke Command Palette (Quick Open). | +| **Dead Code pada `pin_tab`** | `src/editor.rs:355` | Menghapus cabang `else if tab_index < first_unpinned` di dalam blok `if tab_index > first_unpinned`. | +| **Dead Code pada `unpin_tab`** | `src/editor.rs:381` | Menghapus cabang `else` yang tidak terjangkau di dalam blok `if tab_index < last_p`. | +| **Dead Logic pada `close_tabs_to_the_right`** | `src/editor.rs:448` | Menghapus decrement indeks `active_tab_index >= i` yang tidak pernah terjadi karena tab aktif telah berpindah ke `tab_index < i`. | +| **Premature Drop pada `any_released()`** | `src/window_egui/app_impl.rs:2076` | Mengganti ke `inp.pointer.button_released(egui::PointerButton::Primary)`. | +| **Ketiadaan Validasi Batas Vertikal** | `src/window_egui/app_impl.rs:2424` | Menambahkan validasi `is_within_tab_bar_y` sehingga drag dapat dibatalkan jika pointer keluar tab bar. | --- -## 3. Struktur Berkas di Dalam ZIP - -``` -tabular_backup_YYYYMMDD_HHMMSS.zip -├── manifest.json -├── connections/ -│ ├── connections.json -│ └── folders.json -├── queries/ -│ ├── analytics/ -│ │ └── monthly_report.sql -│ └── schema_init.sql -├── http_collections/ -│ ├── ws_1710000000_1.json -│ └── ws_1710000000_2.json -└── history/ - └── history.json -``` - ---- - -## 4. Hasil Verifikasi & Pengujian - -### 4.1 Unit & Integration Tests (`src/export_import_all.rs`) -Dijalankan melalui `cargo test --lib export_import_all`: -``` -running 7 tests -test export_import_all::tests::test_conflict_strategy_labels ... ok -test export_import_all::tests::test_manifest_serialization ... ok -test export_import_all::tests::test_zip_slip_detection_in_archive ... ok -test export_import_all::tests::test_zip_options ... ok -test export_import_all::tests::test_zip_slip_rejection ... ok -test export_import_all::tests::test_archive_creation_and_inspection ... ok -test export_import_all::tests::test_roundtrip_export_import ... ok - -test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 180 filtered out -``` - -Pengujian mencakup: -- **`test_conflict_strategy_labels`**: Memverifikasi teks nama dan deskripsi tiap opsi resolusi konflik. -- **`test_manifest_serialization`**: Memverifikasi serialisasi dan deserialisasi struktur manifest JSON. -- **`test_zip_slip_rejection`**: Memverifikasi penolakan arsip ZIP berbahaya yang berisi path traversal (contoh: `../../etc/malicious.txt`). -- **`test_archive_creation_and_inspection`**: Menguji pembuatan arsip ZIP tiruan dan verifikasi parsing inspeksi manifest sebelum restore. -- **`test_roundtrip_export_import`**: Pengujian end-to-end lengkap yang membuat koneksi, folder, HTTP workspace, dan riwayat query ke database SQLite sementara, mengekspornya ke berkas ZIP, menginspeksi arsip, menghapus memori aplikasi, dan memulihkan kembali seluruh data melalui `import_all_data`. - -### 4.2 Keseluruhan Test Suite Workspace -Dijalankan melalui `cargo test`: -- **Library tests**: 187 passed; 0 failed. -- **Editor buffer tests**: 4 passed; 0 failed. -- **Find replace tests**: 6 passed; 0 failed. -- **Query AST tests**: 15 passed; 0 failed. -- **Syntax tree-sitter tests**: 4 passed; 0 failed. -- **Total**: **216 tests passed, 0 failed**. - -### 4.3 Verifikasi Kompilasi -Perintah `cargo check` berhasil tanpa error. - ---- - -## 5. Cara Penggunaan Fitur - -1. **Melalui Gear Settings Menu**: - - Klik ikon gerigi (Settings) di pojok kiri bawah. - - Pilih **📦 Export All Data (ZIP)...** untuk mengekspor data. - - Pilih **📥 Import All Data (ZIP)...** untuk merestore data dari file ZIP. -2. **Melalui Preferences**: - - Buka Preferences (`⌘,`) > tab **Data Directory**. - - Pada bagian **Backup & Restore All Data**, klik tombol "Export All Data (ZIP)..." atau "Import & Restore All Data (ZIP)...". -3. **Melalui Command Palette (`⌘P`)**: - - Tekan `⌘P` dan ketik `Export All Data (ZIP)` atau `Import All Data (ZIP)` lalu tekan Enter. +## 3. Hasil Pengujian Unit + +Pengujian unit di `src/editor.rs` dan `src/models/structs.rs` mencakup skenario: +1. `test_query_tab_pinning`: Pengujian properti `is_pinned` pada struct `QueryTab`. +2. `test_move_tab_and_active_index`: Pengujian pemindahan tab dan pembaruan otomatis indeks tab aktif. +3. `test_reorder_tab_with_insert_slots`: Pengujian pemetaan slot drop ke indeks tab. +4. `test_pin_and_unpin_tab`: Pengujian transisi status pin dan pergeseran ke grup tab pinned. +5. `test_pin_tab_shifts_active_index_correctly`: Pengujian pergeseran indeks tab aktif saat tab di sebelah kanan di-pin. +6. `test_unpin_tab_shifts_active_index_correctly`: Pengujian pergeseran indeks tab aktif saat tab di sebelah kiri di-unpin. +7. `test_close_other_tabs_protects_pinned`: Pengujian perlindungan tab pinned dari operasi penutupan tab lainnya. +8. `test_close_tabs_to_the_right`: Pengujian penutupan tab unpinned di sebelah kanan dengan perlindungan tab pinned. +9. `test_close_tabs_to_the_right_active_tab_switch`: Pengujian pengalihan tab aktif ke target sebelum penutupan tab kanan. From 37dfa662e0c9dd1464d9a0135d9caa89ba564bb3 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Thu, 10 Sep 2026 09:43:56 +0000 Subject: [PATCH 03/18] fix(tabs): harden drag button detection, prevent stuck drag starvation, and add boundary edge tests --- implementation_plan_move_tab_editor.md | 5 + src/editor.rs | 52 ++++++++ src/window_egui/app_impl.rs | 168 +++++++++++++------------ walkthrough_move_tab_editor.md | 5 + 4 files changed, 150 insertions(+), 80 deletions(-) diff --git a/implementation_plan_move_tab_editor.md b/implementation_plan_move_tab_editor.md index da023207..53191edd 100644 --- a/implementation_plan_move_tab_editor.md +++ b/implementation_plan_move_tab_editor.md @@ -85,3 +85,8 @@ Saat bekerja dengan banyak tab query SQL, tabel basis data, dan endpoint HTTP, p - Mengganti `any_released()` dengan `button_released(egui::PointerButton::Primary)`. 5. **Validasi Batas Vertikal**: - Menambahkan `is_within_tab_bar_y` pada kalkulasi slot drop `candidate_insert_at`. +6. **Pemicuan Drag Tombol Non-Primer**: + - Membatasi inisiasi drag hanya pada tombol mouse primer melalui `drag_started_by(egui::PointerButton::Primary)`. +7. **Pencegahan Starvation / UI Stuck State**: + - Menambahkan mekanisme reset `self.dragged_tab_index = None` saat pointer primer tidak lagi ditekan (`!primary_down`) atau saat jumlah tab berubah. + diff --git a/src/editor.rs b/src/editor.rs index 9457bffc..07c4a976 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -8258,5 +8258,57 @@ mod tests { assert_eq!(tabular.query_tabs[0].title, "T0"); assert_eq!(tabular.active_tab_index, 0); } + + #[test] + fn test_move_tab_crossing_pinned_boundary_both_ways() { + let mut tabular = crate::window_egui::Tabular::new(); + tabular.query_tabs.clear(); + create_new_tab(&mut tabular, "P0".to_string(), "".to_string()); + create_new_tab(&mut tabular, "P1".to_string(), "".to_string()); + create_new_tab(&mut tabular, "U2".to_string(), "".to_string()); + create_new_tab(&mut tabular, "U3".to_string(), "".to_string()); + + pin_tab(&mut tabular, 0); + pin_tab(&mut tabular, 1); + assert!(tabular.query_tabs[0].is_pinned); + assert!(tabular.query_tabs[1].is_pinned); + assert!(!tabular.query_tabs[2].is_pinned); + assert!(!tabular.query_tabs[3].is_pinned); + + // Move unpinned U3 (index 3) into pinned territory at index 1 (< pinned_count 2) + // It must automatically become pinned. + move_tab(&mut tabular, 3, 1); + assert_eq!(tabular.query_tabs[1].title, "U3"); + assert!(tabular.query_tabs[1].is_pinned); + assert_eq!(tabular.query_tabs.iter().filter(|t| t.is_pinned).count(), 3); + + // Move pinned P0 (index 0) into unpinned territory at index 3 (>= pinned_count 3) + // It must automatically become unpinned. + move_tab(&mut tabular, 0, 3); + assert_eq!(tabular.query_tabs[3].title, "P0"); + assert!(!tabular.query_tabs[3].is_pinned); + assert_eq!(tabular.query_tabs.iter().filter(|t| t.is_pinned).count(), 2); + } + + #[test] + fn test_tab_bounds_safety() { + let mut tabular = crate::window_egui::Tabular::new(); + tabular.query_tabs.clear(); + create_new_tab(&mut tabular, "Tab0".to_string(), "".to_string()); + + // Out of bounds operations should no-op safely without panicking + move_tab(&mut tabular, 0, 10); + move_tab(&mut tabular, 10, 0); + move_tab(&mut tabular, 0, 0); + reorder_tab(&mut tabular, 10, 0); + pin_tab(&mut tabular, 10); + unpin_tab(&mut tabular, 10); + toggle_pin_tab(&mut tabular, 10); + close_other_tabs(&mut tabular, 10); + close_tabs_to_the_right(&mut tabular, 10); + + assert_eq!(tabular.query_tabs.len(), 1); + assert_eq!(tabular.query_tabs[0].title, "Tab0"); + } } diff --git a/src/window_egui/app_impl.rs b/src/window_egui/app_impl.rs index eb74f5c6..d661b0f8 100644 --- a/src/window_egui/app_impl.rs +++ b/src/window_egui/app_impl.rs @@ -2147,7 +2147,7 @@ impl Tabular { ); tab_rects.push(tab_rect); - if tab_resp.drag_started() { + if tab_resp.drag_started_by(egui::PointerButton::Primary) { self.dragged_tab_index = Some(i); ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); ui.ctx().request_repaint(); @@ -2394,94 +2394,102 @@ impl Tabular { // Handle active Drag-and-Drop state and drop insertion rendering if let Some(drag_from) = self.dragged_tab_index { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - let mut candidate_insert_at = None; - - if let Some(pos) = pointer_pos { - // Floating ghost badge following cursor - if let Some(drag_tab) = self.query_tabs.get(drag_from) { - let painter = ui.ctx().layer_painter(egui::LayerId::new( - egui::Order::Tooltip, - egui::Id::new("tab_drag_badge"), - )); - let ghost_title = format!("{} {}", if drag_tab.is_pinned { "📌" } else { "📑" }, drag_tab.title); - let font_id = egui::FontId::proportional(12.0); - let text_w = painter.layout_no_wrap(ghost_title.clone(), font_id.clone(), egui::Color32::WHITE).size().x; - let badge_w = (text_w + 24.0).clamp(80.0, 220.0); - let badge_rect = egui::Rect::from_min_size( - pos + egui::vec2(14.0, 10.0), - egui::vec2(badge_w, 24.0), - ); - painter.rect_filled(badge_rect, 4.0, egui::Color32::from_rgba_unmultiplied(28, 30, 38, 235)); - painter.rect_stroke(badge_rect, 4.0, egui::Stroke::new(1.5, super::style::theme_accent(ui.ctx())), egui::StrokeKind::Outside); - painter.text( - badge_rect.center(), - egui::Align2::CENTER_CENTER, - ghost_title, - font_id, - egui::Color32::WHITE, - ); - } + if drag_from >= self.query_tabs.len() { + self.dragged_tab_index = None; + } else { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + let mut candidate_insert_at = None; + + if let Some(pos) = pointer_pos { + // Floating ghost badge following cursor + if let Some(drag_tab) = self.query_tabs.get(drag_from) { + let painter = ui.ctx().layer_painter(egui::LayerId::new( + egui::Order::Tooltip, + egui::Id::new("tab_drag_badge"), + )); + let ghost_title = format!("{} {}", if drag_tab.is_pinned { "📌" } else { "📑" }, drag_tab.title); + let font_id = egui::FontId::proportional(12.0); + let text_w = painter.layout_no_wrap(ghost_title.clone(), font_id.clone(), egui::Color32::WHITE).size().x; + let badge_w = (text_w + 24.0).clamp(80.0, 220.0); + let badge_rect = egui::Rect::from_min_size( + pos + egui::vec2(14.0, 10.0), + egui::vec2(badge_w, 24.0), + ); + painter.rect_filled(badge_rect, 4.0, egui::Color32::from_rgba_unmultiplied(28, 30, 38, 235)); + painter.rect_stroke(badge_rect, 4.0, egui::Stroke::new(1.5, super::style::theme_accent(ui.ctx())), egui::StrokeKind::Outside); + painter.text( + badge_rect.center(), + egui::Align2::CENTER_CENTER, + ghost_title, + font_id, + egui::Color32::WHITE, + ); + } - // Determine candidate drop slot - let is_within_tab_bar_y = tab_rects.first().map(|r| { - let margin = 20.0; - pos.y >= r.top() - margin && pos.y <= r.bottom() + margin - }).unwrap_or(false); - - if is_within_tab_bar_y { - for (idx, r) in tab_rects.iter().enumerate() { - if pos.x < r.center().x { - candidate_insert_at = Some(idx); - break; + // Determine candidate drop slot + let is_within_tab_bar_y = tab_rects.first().map(|r| { + let margin = 20.0; + pos.y >= r.top() - margin && pos.y <= r.bottom() + margin + }).unwrap_or(false); + + if is_within_tab_bar_y { + for (idx, r) in tab_rects.iter().enumerate() { + if pos.x < r.center().x { + candidate_insert_at = Some(idx); + break; + } + } + if candidate_insert_at.is_none() && !tab_rects.is_empty() { + candidate_insert_at = Some(tab_rects.len()); } } - if candidate_insert_at.is_none() && !tab_rects.is_empty() { - candidate_insert_at = Some(tab_rects.len()); - } - } - - // Render insertion indicator line - if let Some(target_idx) = candidate_insert_at { - if target_idx != drag_from && target_idx != drag_from + 1 { - let indicator_x = if target_idx < tab_rects.len() { - tab_rects[target_idx].left() - 1.0 - } else { - tab_rects.last().map(|r| r.right() + 1.0).unwrap_or(0.0) - }; - let indicator_top = tab_rects.first().map(|r| r.top()).unwrap_or(0.0); - let indicator_bottom = tab_rects.first().map(|r| r.bottom()).unwrap_or(34.0); - let accent_col = super::style::theme_accent(ui.ctx()); - let ind_line_rect = egui::Rect::from_min_size( - egui::pos2(indicator_x - 1.5, indicator_top), - egui::vec2(3.0, indicator_bottom - indicator_top), - ); - ui.painter().rect_filled(ind_line_rect, 1.5, accent_col); - - let cap_top = egui::Rect::from_min_size( - egui::pos2(indicator_x - 3.5, indicator_top), - egui::vec2(7.0, 4.0), - ); - ui.painter().rect_filled(cap_top, 2.0, accent_col); - let cap_bot = egui::Rect::from_min_size( - egui::pos2(indicator_x - 3.5, indicator_bottom - 4.0), - egui::vec2(7.0, 4.0), - ); - ui.painter().rect_filled(cap_bot, 2.0, accent_col); + // Render insertion indicator line + if let Some(target_idx) = candidate_insert_at { + if target_idx != drag_from && target_idx != drag_from + 1 { + let indicator_x = if target_idx < tab_rects.len() { + tab_rects[target_idx].left() - 1.0 + } else { + tab_rects.last().map(|r| r.right() + 1.0).unwrap_or(0.0) + }; + let indicator_top = tab_rects.first().map(|r| r.top()).unwrap_or(0.0); + let indicator_bottom = tab_rects.first().map(|r| r.bottom()).unwrap_or(34.0); + let accent_col = super::style::theme_accent(ui.ctx()); + + let ind_line_rect = egui::Rect::from_min_size( + egui::pos2(indicator_x - 1.5, indicator_top), + egui::vec2(3.0, indicator_bottom - indicator_top), + ); + ui.painter().rect_filled(ind_line_rect, 1.5, accent_col); + + let cap_top = egui::Rect::from_min_size( + egui::pos2(indicator_x - 3.5, indicator_top), + egui::vec2(7.0, 4.0), + ); + ui.painter().rect_filled(cap_top, 2.0, accent_col); + let cap_bot = egui::Rect::from_min_size( + egui::pos2(indicator_x - 3.5, indicator_bottom - 4.0), + egui::vec2(7.0, 4.0), + ); + ui.painter().rect_filled(cap_bot, 2.0, accent_col); + } } } - } - // Process drop on mouse release - if mouse_released { - let from = self.dragged_tab_index.take(); - if let (Some(from_idx), Some(target_idx)) = (from, candidate_insert_at) { - if target_idx != from_idx && target_idx != from_idx + 1 { - editor::reorder_tab(self, from_idx, target_idx); + // Process drop on mouse release or cancel if primary pointer is released/lost + let primary_down = ui.ctx().input(|inp| inp.pointer.primary_down()); + if mouse_released { + let from = self.dragged_tab_index.take(); + if let (Some(from_idx), Some(target_idx)) = (from, candidate_insert_at) { + if target_idx != from_idx && target_idx != from_idx + 1 { + editor::reorder_tab(self, from_idx, target_idx); + } } + ui.ctx().request_repaint(); + } else if !primary_down { + self.dragged_tab_index = None; + ui.ctx().request_repaint(); } - ui.ctx().request_repaint(); } } diff --git a/walkthrough_move_tab_editor.md b/walkthrough_move_tab_editor.md index b3fc6e59..9b9c5f95 100644 --- a/walkthrough_move_tab_editor.md +++ b/walkthrough_move_tab_editor.md @@ -39,6 +39,8 @@ Dokumen ini mendokumentasikan implementasi dan verifikasi fitur **Drag & Drop Ta | **Dead Logic pada `close_tabs_to_the_right`** | `src/editor.rs:448` | Menghapus decrement indeks `active_tab_index >= i` yang tidak pernah terjadi karena tab aktif telah berpindah ke `tab_index < i`. | | **Premature Drop pada `any_released()`** | `src/window_egui/app_impl.rs:2076` | Mengganti ke `inp.pointer.button_released(egui::PointerButton::Primary)`. | | **Ketiadaan Validasi Batas Vertikal** | `src/window_egui/app_impl.rs:2424` | Menambahkan validasi `is_within_tab_bar_y` sehingga drag dapat dibatalkan jika pointer keluar tab bar. | +| **Pemicuan Drag Tombol Non-Primer** | `src/window_egui/app_impl.rs:2150` | Mengganti `drag_started()` menjadi `drag_started_by(egui::PointerButton::Primary)`. | +| **Starvation / Stuck Drag State** | `src/window_egui/app_impl.rs:2476` | Menambahkan pembersihan state drag jika primary pointer tidak lagi ditekan (`!primary_down`) atau tab count berubah. | --- @@ -54,3 +56,6 @@ Pengujian unit di `src/editor.rs` dan `src/models/structs.rs` mencakup skenario: 7. `test_close_other_tabs_protects_pinned`: Pengujian perlindungan tab pinned dari operasi penutupan tab lainnya. 8. `test_close_tabs_to_the_right`: Pengujian penutupan tab unpinned di sebelah kanan dengan perlindungan tab pinned. 9. `test_close_tabs_to_the_right_active_tab_switch`: Pengujian pengalihan tab aktif ke target sebelum penutupan tab kanan. +10. `test_move_tab_crossing_pinned_boundary_both_ways`: Pengujian transisi dua arah saat tab unpinned diseret ke area pinned (otomatis menjadi pinned) dan sebaliknya. +11. `test_tab_bounds_safety`: Pengujian ketahanan dan ketiadaan panic saat pemindahan atau penutupan tab dipanggil dengan indeks out-of-bounds. + From 3dea606d7e358f2ae8bb2bcf8e681eee96a50f91 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Thu, 10 Sep 2026 09:47:01 +0000 Subject: [PATCH 04/18] antigravity: automated changes & plan artifacts for task move_tab_editor --- build_deb.sh | 0 flatpak_build.sh | 0 flatpak_publish.sh | 0 install.arch.sh | 0 4 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 build_deb.sh mode change 100644 => 100755 flatpak_build.sh mode change 100644 => 100755 flatpak_publish.sh mode change 100644 => 100755 install.arch.sh diff --git a/build_deb.sh b/build_deb.sh old mode 100644 new mode 100755 diff --git a/flatpak_build.sh b/flatpak_build.sh old mode 100644 new mode 100755 diff --git a/flatpak_publish.sh b/flatpak_publish.sh old mode 100644 new mode 100755 diff --git a/install.arch.sh b/install.arch.sh old mode 100644 new mode 100755 From 37284bb000c9e980e4db48734cd1bd1d9c808ece Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Thu, 10 Sep 2026 09:54:40 +0000 Subject: [PATCH 05/18] fix(docs): restore root implementation_plan.md and walkthrough.md, revert script permissions --- build_deb.sh | 0 flatpak_build.sh | 0 flatpak_publish.sh | 0 implementation_plan.md | 190 +++++++++++++++++++++++++++++++++++++++++ install.arch.sh | 0 walkthrough.md | 116 +++++++++++++++++++++++++ 6 files changed, 306 insertions(+) mode change 100755 => 100644 build_deb.sh mode change 100755 => 100644 flatpak_build.sh mode change 100755 => 100644 flatpak_publish.sh create mode 100644 implementation_plan.md mode change 100755 => 100644 install.arch.sh create mode 100644 walkthrough.md diff --git a/build_deb.sh b/build_deb.sh old mode 100755 new mode 100644 diff --git a/flatpak_build.sh b/flatpak_build.sh old mode 100755 new mode 100644 diff --git a/flatpak_publish.sh b/flatpak_publish.sh old mode 100755 new mode 100644 diff --git a/implementation_plan.md b/implementation_plan.md new file mode 100644 index 00000000..199cc61a --- /dev/null +++ b/implementation_plan.md @@ -0,0 +1,190 @@ +# Rencana Teknis Implementasi: Export & Import Seluruh Data (ZIP) + +Dokumen ini menjelaskan rencana teknis menyeluruh untuk menambahkan fitur **Export dan Import seluruh data Tabular** (Database Connections, Saved Queries, HTTP API Collections, dan Query History) dalam format file **ZIP**, serta mekanisme **Restore** data ke dalam sistem. + +--- + +## 1. Analisis Kebutuhan (Requirements Analysis) + +### 1.1 Latar Belakang & Tujuan +Saat ini pengguna Tabular dapat melakukan backup pada level database engine tertentu (seperti `pg_dump` atau `mysqldump`), serta import koleksi HTTP individual (seperti Postman atau Yaak). Namun, belum ada mekanisme terpadu untuk: +1. Mengekspor **seluruh konfigurasi dan workspace pengguna** sekaligus ke dalam 1 file arsip portable (ZIP). +2. Memindahkan atau memulihkan seluruh data aplikasi (migrasi perangkat atau pemulihan bencana). +3. Mengimpor kembali file ZIP tersebut sehingga seluruh data (**Connection DB**, **Query**, **HTTP API**, dan **History**) ter-restore dengan aman, konsisten, dan langsung aktif di antarmuka aplikasi. + +### 1.2 Cakupan Data (4 Domain Utama) +1. **Connection DB (Koneksi Database)**: + - Menyimpan seluruh profil koneksi (`ConnectionConfig`): nama koneksi, host, port, username, password, database default, tipe database (`MySQL`, `PostgreSQL`, `SQLite`, `Redis`, `MsSQL`, `MongoDB`, dll.), SSL settings, SSH tunneling & credentials, custom views, dan replication settings. + - Menyimpan struktur pengelompokan folder koneksi (`connection_folders`). + - Penanganan kredensial: saat diekspor, kredensial diambil dari state memori/secret store; saat diimpor, kredensial disimpan ulang ke SQLite dan di-externalize ke secrets store (`externalize_connection_secrets`). + +2. **Saved Queries (Koleksi Query SQL)**: + - Seluruh file query SQL (`.sql`) beserta subfoldernya yang tersimpan di direktori aplikasi `{app_data}/query/`. + - Mempertahankan header metadata Tabular seperti `-- tabular:connection_id=...`, `-- tabular:database=...`, dan hierarki foldernya. + +3. **HTTP API (Koleksi & Workspace HTTP)**: + - Seluruh workspace HTTP (`HttpWorkspace`), subfolder (`HttpFolder`), saved requests (`SavedRequest`), dan variabel environment (`YaakEnvironment`) dari direktori `{app_data}/http_collections/`. + +4. **History (Riwayat Eksekusi Query)**: + - Seluruh log riwayat query dari tabel `query_history` di SQLite (`id`, `query_text`, `connection_id`, `connection_name`, `executed_at`). + - Penanganan relasi Foreign Key: penyesuaian `connection_id` dengan ID baru jika koneksi diimpor ke basis data target yang memiliki ID berbeda (berdasarkan pencocokan `connection_name`). + +### 1.3 Format Struktur Arsip ZIP +Format arsip ZIP didesain modular, aman, dan mudah dibaca secara terstruktur: + +``` +tabular_backup_YYYYMMDD_HHMMSS.zip +├── manifest.json +├── connections/ +│ └── connections.json +├── queries/ +│ ├── analytics/ +│ │ └── monthly_report.sql +│ └── schema_init.sql +├── http_collections/ +│ ├── ws_1710000000_1.json +│ └── ws_1710000000_2.json +└── history/ + └── history.json +``` + +- **`manifest.json`**: + ```json + { + "version": "1.0", + "app": "Tabular", + "exported_at": "2026-09-09T14:30:00Z", + "counts": { + "connections": 5, + "connection_folders": 2, + "queries": 14, + "http_workspaces": 3, + "history_items": 100 + }, + "includes": { + "connections": true, + "queries": true, + "http_api": true, + "history": true + } + } + ``` + +--- + +## 2. Desain Arsitektur & Rencana Perubahan Berkas + +### 2.1 Modul Baru + +#### A. `src/export_import_all.rs` (Core Logic Module) +Modul independen untuk operasi kompresi, dekompresi, serialisasi, validasi, dan persistensi database: +- **Tipe Data & Model**: + - `ExportAllManifest`: Metadata arsip. + - `ExportAllOptions`: Opsi export (pilihan kategori yang disertakan). + - `ImportAllOptions`: Opsi import (pilihan kategori yang ingin di-restore, serta strategi konflik: `MergeKeepExisting`, `MergeOverwrite`, atau `CleanRestore`). + - `ExportSummary` & `ImportSummary`: Laporan jumlah data yang berhasil diproses. + - `ExportImportError`: Error handling komprehensif menggunakan `thiserror`. +- **Fungsi Inti**: + - `pub fn export_all_data(tabular: &Tabular, target_path: &Path, options: &ExportAllOptions) -> Result` + - Mengumpulkan data dari in-memory state dan file system. + - Menulis ke ZIP menggunakan `zip::ZipWriter` dengan kompresi Deflate. + - `pub fn inspect_archive(archive_path: &Path) -> Result` + - Membaca `manifest.json` dan menghitung preview entri sebelum proses restore dijalankan. + - `pub fn import_all_data(tabular: &mut Tabular, archive_path: &Path, options: &ImportAllOptions) -> Result` + - Membuka ZIP dengan `zip::ZipArchive`. + - Memvalidasi path entri (mencegah Zip Slip vulnerability). + - Memulihkan koneksi database & folder koneksi ke SQLite serta mendaftarkan secret credentials. + - Mengekstrak file query ke `{app_data}/query/`. + - Mengekstrak file workspace HTTP ke `{app_data}/http_collections/`. + - Menyimpan history ke tabel `query_history` dengan mapping ID koneksi. + - Merefresh in-memory state Tabular (`load_connection_folders`, `load_queries_from_directory`, `load_workspaces`, `load_query_history`, dan trigger `needs_refresh`). + +#### B. `src/dialog_export_import_all.rs` (UI Dialog Module) +Komponen dialog berbasis `egui`: +- **`ExportAllDialogState`**: + - Pilihan kategori data (checkboxes: Connections, Queries, HTTP API, History). + - Target path file ZIP default (misal: `~/Downloads/tabular_backup_YYYYMMDD_HHMMSS.zip`). + - Status eksekusi (Idle, InProgress, Completed, Error). + - Ringkasan hasil ekspor. +- **`ImportAllDialogState`**: + - File picker untuk memilih file `.zip`. + - Preview manifest hasil inspeksi (jumlah item yang ditemukan di dalam file ZIP). + - Checkbox pilihan data yang ingin di-restore. + - Opsi penanganan duplikasi (Merge / Overwrite). + - Tombol aksi "Restore Now" dan progress bar / status banner. + +--- + +### 2.2 Berkas yang Dimodifikasi + +1. **`src/main.rs` / `src/lib.rs`**: + - Daftarkan modul baru: + ```rust + pub mod export_import_all; + pub mod dialog_export_import_all; + ``` + +2. **`src/window_egui/mod.rs`**: + - Tambahkan state flag & dialog state di struct `Tabular`: + ```rust + pub show_export_all_dialog: bool, + pub show_import_all_dialog: bool, + pub export_all_state: Option, + pub import_all_state: Option, + ``` + +3. **`src/window_egui/init.rs`**: + - Inisialisasi field baru tersebut dengan `false` dan `None`. + +4. **`src/window_egui/app_impl.rs`**: + - **Gear Settings Context Menu** (baris ~2535): + - Tambahkan item menu: + - `📦 Export All Data (.zip)...` -> membuka `show_export_all_dialog = true` + - `📥 Import & Restore All Data (.zip)...` -> membuka `show_import_all_dialog = true` + - **Settings Window - Data Directory Tab (`PrefTab::DataDirectory`)** (baris ~437): + - Tambahkan kartu UI "Backup & Restore Application Data" dengan tombol "Export All to ZIP" dan "Import from ZIP". + - **Render Loop** (baris ~4995): + - Render `render_export_all_dialog(self, ctx)` saat `self.show_export_all_dialog == true`. + - Render `render_import_all_dialog(self, ctx)` saat `self.show_import_all_dialog == true`. + +5. **`src/quick_open.rs`**: + - Daftarkan perintah ke Command Palette: + - `Export All Data (ZIP)` + - `Import All Data (ZIP)` + +--- + +## 3. Analisis Potensi Risiko & Strategi Mitigasi + +| Risiko | Dampak | Strategi Mitigasi | +| :--- | :--- | :--- | +| **Zip Slip / Path Traversal Attack** | File jahat di dalam ZIP dapat menimpa berkas sistem sembarang (`../../etc/shadow`). | Wajib menggunakan `file.enclosed_name()` dari crate `zip` dan membatasi ekstraksi hanya di dalam subdirektori tujuan yang sah (`query/` dan `http_collections/`). | +| **Foreign Key Constraint pada `query_history`** | `query_history` memiliki relasi `connection_id -> connections(id) ON DELETE CASCADE`. Jika `connection_id` lama tidak ditemukan, query insert history gagal. | Buat tabel mapping ID lama ke ID baru berdasarkan kesamaan nama koneksi (`connection_name`). Jika koneksi belum ada, buat koneksi terlebih dahulu atau kaitkan ke koneksi default yang valid. | +| **Penanganan Kredensial & Secrets** | Password atau SSH key tersimpan sebagai sentinel di SQLite dan data asli di keychain/secret store. | Saat ekspor, ambil data dari `tabular.connections` (yang sudah ter-dekripsi di RAM). Saat import, panggil `externalize_connection_secrets` agar kredensial tersimpan aman di database dan secret backend sistem target. | +| **UI Freeze saat Arsip Besar** | Aplikasi tidak responsif selama kompresi/ekstraksi I/O. | Jalankan proses kompresi dan dekompresi ZIP di background thread / tokio runtime async dengan `mpsc` channel untuk mengirim progress dan hasil ke UI thread. | +| **In-Memory State Stale setelah Restore** | Data sudah masuk ke SQLite/disk tetapi tampilan sidebar tidak terupdate. | Panggil fungsi reload: `sidebar_database::load_connection_folders()`, `sidebar_query::load_queries_from_directory()`, `http_collection::load_workspaces()`, dan `sidebar_history::load_query_history()`, serta set `tabular.needs_refresh = true`. | + +--- + +## 4. Tahapan Verifikasi & Pengujian + +1. **Uji Kompilasi (`cargo check`)**: + - Memastikan tidak ada compile error, type mismatch, atau broken references. +2. **Automated Unit Tests**: + - Buat unit test komprehensif di `src/export_import_all.rs`: + - Test pembuatan mock connection, query file, HTTP workspace, dan history item. + - Test ekspor ke buffer/file ZIP sementara dan verifikasi integritas ZIP serta isi `manifest.json`. + - Test pembacaan dan validasi isi arsip (`inspect_archive`). + - Test impor ke direktori sementara dan verifikasi bahwa data koneksi, queries, HTTP collection, dan history berhasil dipulihkan secara identik. + - Test proteksi Zip Slip (path traversal rejected). + - Test pemulihan relasi `query_history` saat connection ID berubah. +3. **Uji Integrasi UI**: + - Verifikasi pembukaan dialog via Gear Menu dan Tab Settings Data Directory. + - Verifikasi pemilihan file picker (`rfd::FileDialog`). + - Verifikasi feedback visual, progress bar, dan notifikasi keberhasilan pemulihan data. + +--- + +## 5. Kesimpulan & Batasan Tahap Ini (Stage 1 Scope) + +Sesuai instruksi tugas, pengerjaan pada **Tahap 1 (Planning Phase)** dibatasi hanya pada penyusunan dokumen perencanaan teknis ini di file `implementation_plan.md`. Tidak ada kode aplikasi yang diubah pada tahap ini sebelum rencana ini ditinjau dan disetujui oleh pengguna. diff --git a/install.arch.sh b/install.arch.sh old mode 100755 new mode 100644 diff --git a/walkthrough.md b/walkthrough.md new file mode 100644 index 00000000..5c3637c4 --- /dev/null +++ b/walkthrough.md @@ -0,0 +1,116 @@ +# Walkthrough: Fitur Export dan Import Seluruh Data Aplikasi (ZIP) + +Dokumen ini mendokumentasikan implementasi dan verifikasi fitur **Export & Import Seluruh Data** (Database Connections, Saved Queries, HTTP API Collections, dan Query History) dalam format arsip **ZIP** dengan kemampuan **Restore** lengkap di Tabular. + +--- + +## 1. Ringkasan Fitur + +Fitur ini menyediakan mekanisme backup dan migrasi menyeluruh untuk pengguna Tabular: +1. **Pengeksporan Lengkap (Export All to ZIP)**: + - Mengemas 4 domain data utama ke dalam 1 berkas `.zip`: + - **Database Connections & Folders**: Semua profil koneksi database (termasuk kredensial, SSL/SSH tunneling, custom views) dan struktur foldernya. + - **Saved Queries**: Semua berkas `.sql` dan hierarki subdirektorinya dari `{app_data}/query/`. + - **HTTP API Collections**: Semua workspace (`HttpWorkspace`), folder, saved request, dan environment dari `{app_data}/http_collections/`. + - **Query Execution History**: Seluruh riwayat eksekusi query dari basis data lokal SQLite (`query_history`). + - Menyertakan berkas `manifest.json` berisi versi format, timestamp, indikator modul, dan jumlah entri yang diekspor. +2. **Pratinjau & Inspeksi Arsip (Archive Inspection)**: + - Mampu memeriksa file ZIP sebelum restore dilakukan untuk membaca `manifest.json` atau menghitung entri di dalam arsip secara aman tanpa mengekstraknya ke disk sistem. +3. **Pemulihan & Penanganan Konflik (Restore & Conflict Handling)**: + - Mendukung 3 strategi konflik: + - `Merge (Keep Existing)`: Menambahkan data baru tanpa menimpa data yang sudah ada. + - `Merge (Overwrite Existing)`: Memperbarui data yang telah ada dan menambahkan data baru. + - `Clean Restore (Replace All)`: Menghapus data lama dan memulihkan data dari arsip secara bersih. + - Mengamankan kredensial kembali ke backend secrets (`externalize_connection_secrets`). + - Memetakan relasi `connection_id` pada riwayat query agar integritas Foreign Key tetap terjaga. + - Melindungi sistem dari serangan keamanan **Zip Slip (Path Traversal)**. + - Merefresh status in-memory Tabular secara instan tanpa perlu me-restart aplikasi. + +--- + +## 2. Berkas yang Dibuat & Dimodifikasi + +| Berkas | Status | Deskripsi | +| :--- | :--- | :--- | +| `src/export_import_all.rs` | **Baru** | Modul logika inti kompresi, inspeksi ZIP, manifest, sanitasi zip slip, serialisasi/deserialisasi 4 domain data, dan mapping relasi database. | +| `src/dialog_export_import_all.rs` | **Baru** | Komponen UI modal `egui` untuk dialog Export All dan dialog Import & Restore All dengan file picker native (`rfd`), status progress, dan laporan ringkasan. | +| `src/lib.rs` | Diubah | Mendaftarkan modul `export_import_all` dan `dialog_export_import_all`. | +| `src/sidebar_database.rs` | Diubah | Mengubah visibilitas `externalize_connection_secrets` menjadi `pub(crate)` agar dapat dipanggil saat restore koneksi. | +| `src/window_egui/mod.rs` | Diubah | Menambahkan field state `show_export_all_dialog`, `show_import_all_dialog`, `export_all_state`, dan `import_all_state` pada struct `Tabular`. | +| `src/window_egui/init.rs` | Diubah | Inisialisasi awal dialog state pada constructor `Tabular::new()`. | +| `src/window_egui/app_impl.rs` | Diubah | Menambahkan menu "Export All Data (ZIP)..." dan "Import All Data (ZIP)..." pada Gear Menu, kartu backup/restore pada Preferences > Data Directory, serta pemanggilan render dialog di loop utama. | +| `src/editor.rs` | Diubah | Menambahkan handler eksekusi command `"Export All Data (ZIP)"` dan `"Import All Data (ZIP)"`. | +| `src/quick_open.rs` | Diubah | Mendaftarkan perintah Export & Import ke Command Palette (Quick Open). | + +--- + +## 3. Struktur Berkas di Dalam ZIP + +``` +tabular_backup_YYYYMMDD_HHMMSS.zip +├── manifest.json +├── connections/ +│ ├── connections.json +│ └── folders.json +├── queries/ +│ ├── analytics/ +│ │ └── monthly_report.sql +│ └── schema_init.sql +├── http_collections/ +│ ├── ws_1710000000_1.json +│ └── ws_1710000000_2.json +└── history/ + └── history.json +``` + +--- + +## 4. Hasil Verifikasi & Pengujian + +### 4.1 Unit & Integration Tests (`src/export_import_all.rs`) +Dijalankan melalui `cargo test --lib export_import_all`: +``` +running 7 tests +test export_import_all::tests::test_conflict_strategy_labels ... ok +test export_import_all::tests::test_manifest_serialization ... ok +test export_import_all::tests::test_zip_slip_detection_in_archive ... ok +test export_import_all::tests::test_zip_options ... ok +test export_import_all::tests::test_zip_slip_rejection ... ok +test export_import_all::tests::test_archive_creation_and_inspection ... ok +test export_import_all::tests::test_roundtrip_export_import ... ok + +test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 180 filtered out +``` + +Pengujian mencakup: +- **`test_conflict_strategy_labels`**: Memverifikasi teks nama dan deskripsi tiap opsi resolusi konflik. +- **`test_manifest_serialization`**: Memverifikasi serialisasi dan deserialisasi struktur manifest JSON. +- **`test_zip_slip_rejection`**: Memverifikasi penolakan arsip ZIP berbahaya yang berisi path traversal (contoh: `../../etc/malicious.txt`). +- **`test_archive_creation_and_inspection`**: Menguji pembuatan arsip ZIP tiruan dan verifikasi parsing inspeksi manifest sebelum restore. +- **`test_roundtrip_export_import`**: Pengujian end-to-end lengkap yang membuat koneksi, folder, HTTP workspace, dan riwayat query ke database SQLite sementara, mengekspornya ke berkas ZIP, menginspeksi arsip, menghapus memori aplikasi, dan memulihkan kembali seluruh data melalui `import_all_data`. + +### 4.2 Keseluruhan Test Suite Workspace +Dijalankan melalui `cargo test`: +- **Library tests**: 187 passed; 0 failed. +- **Editor buffer tests**: 4 passed; 0 failed. +- **Find replace tests**: 6 passed; 0 failed. +- **Query AST tests**: 15 passed; 0 failed. +- **Syntax tree-sitter tests**: 4 passed; 0 failed. +- **Total**: **216 tests passed, 0 failed**. + +### 4.3 Verifikasi Kompilasi +Perintah `cargo check` berhasil tanpa error. + +--- + +## 5. Cara Penggunaan Fitur + +1. **Melalui Gear Settings Menu**: + - Klik ikon gerigi (Settings) di pojok kiri bawah. + - Pilih **📦 Export All Data (ZIP)...** untuk mengekspor data. + - Pilih **📥 Import All Data (ZIP)...** untuk merestore data dari file ZIP. +2. **Melalui Preferences**: + - Buka Preferences (`⌘,`) > tab **Data Directory**. + - Pada bagian **Backup & Restore All Data**, klik tombol "Export All Data (ZIP)..." atau "Import & Restore All Data (ZIP)...". +3. **Melalui Command Palette (`⌘P`)**: + - Tekan `⌘P` dan ketik `Export All Data (ZIP)` atau `Import All Data (ZIP)` lalu tekan Enter. From bc1b56a369ff02d93d0b5557abcdebcb0f6050fc Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Thu, 10 Sep 2026 09:54:51 +0000 Subject: [PATCH 06/18] docs: document root documentation and permission integrity in walkthrough --- walkthrough_move_tab_editor.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/walkthrough_move_tab_editor.md b/walkthrough_move_tab_editor.md index 9b9c5f95..9cd47dd3 100644 --- a/walkthrough_move_tab_editor.md +++ b/walkthrough_move_tab_editor.md @@ -41,6 +41,7 @@ Dokumen ini mendokumentasikan implementasi dan verifikasi fitur **Drag & Drop Ta | **Ketiadaan Validasi Batas Vertikal** | `src/window_egui/app_impl.rs:2424` | Menambahkan validasi `is_within_tab_bar_y` sehingga drag dapat dibatalkan jika pointer keluar tab bar. | | **Pemicuan Drag Tombol Non-Primer** | `src/window_egui/app_impl.rs:2150` | Mengganti `drag_started()` menjadi `drag_started_by(egui::PointerButton::Primary)`. | | **Starvation / Stuck Drag State** | `src/window_egui/app_impl.rs:2476` | Menambahkan pembersihan state drag jika primary pointer tidak lagi ditekan (`!primary_down`) atau tab count berubah. | +| **Integritas Dokumentasi & Mode Eksekusi Berkas** | Root Workspace | Memulihkan berkas `walkthrough.md` dan `implementation_plan.md` root repositori, menjaga pemisahan dokumen per fitur (`walkthrough_move_tab_editor.md`), serta mengembalikan izin berkas skrip shell ke `100644`. | --- @@ -59,3 +60,4 @@ Pengujian unit di `src/editor.rs` dan `src/models/structs.rs` mencakup skenario: 10. `test_move_tab_crossing_pinned_boundary_both_ways`: Pengujian transisi dua arah saat tab unpinned diseret ke area pinned (otomatis menjadi pinned) dan sebaliknya. 11. `test_tab_bounds_safety`: Pengujian ketahanan dan ketiadaan panic saat pemindahan atau penutupan tab dipanggil dengan indeks out-of-bounds. + From 0a11b1c4986c874169b59538d798b0b4a59b31a5 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Thu, 10 Sep 2026 09:54:58 +0000 Subject: [PATCH 07/18] antigravity: interim changes before upstream sync for task move_tab_editor --- implementation_plan.md | 190 ------------------ implementation_plan_move_tab_editor.md | 256 +++++++++++++++++-------- walkthrough.md | 116 ----------- 3 files changed, 177 insertions(+), 385 deletions(-) delete mode 100644 implementation_plan.md delete mode 100644 walkthrough.md diff --git a/implementation_plan.md b/implementation_plan.md deleted file mode 100644 index 199cc61a..00000000 --- a/implementation_plan.md +++ /dev/null @@ -1,190 +0,0 @@ -# Rencana Teknis Implementasi: Export & Import Seluruh Data (ZIP) - -Dokumen ini menjelaskan rencana teknis menyeluruh untuk menambahkan fitur **Export dan Import seluruh data Tabular** (Database Connections, Saved Queries, HTTP API Collections, dan Query History) dalam format file **ZIP**, serta mekanisme **Restore** data ke dalam sistem. - ---- - -## 1. Analisis Kebutuhan (Requirements Analysis) - -### 1.1 Latar Belakang & Tujuan -Saat ini pengguna Tabular dapat melakukan backup pada level database engine tertentu (seperti `pg_dump` atau `mysqldump`), serta import koleksi HTTP individual (seperti Postman atau Yaak). Namun, belum ada mekanisme terpadu untuk: -1. Mengekspor **seluruh konfigurasi dan workspace pengguna** sekaligus ke dalam 1 file arsip portable (ZIP). -2. Memindahkan atau memulihkan seluruh data aplikasi (migrasi perangkat atau pemulihan bencana). -3. Mengimpor kembali file ZIP tersebut sehingga seluruh data (**Connection DB**, **Query**, **HTTP API**, dan **History**) ter-restore dengan aman, konsisten, dan langsung aktif di antarmuka aplikasi. - -### 1.2 Cakupan Data (4 Domain Utama) -1. **Connection DB (Koneksi Database)**: - - Menyimpan seluruh profil koneksi (`ConnectionConfig`): nama koneksi, host, port, username, password, database default, tipe database (`MySQL`, `PostgreSQL`, `SQLite`, `Redis`, `MsSQL`, `MongoDB`, dll.), SSL settings, SSH tunneling & credentials, custom views, dan replication settings. - - Menyimpan struktur pengelompokan folder koneksi (`connection_folders`). - - Penanganan kredensial: saat diekspor, kredensial diambil dari state memori/secret store; saat diimpor, kredensial disimpan ulang ke SQLite dan di-externalize ke secrets store (`externalize_connection_secrets`). - -2. **Saved Queries (Koleksi Query SQL)**: - - Seluruh file query SQL (`.sql`) beserta subfoldernya yang tersimpan di direktori aplikasi `{app_data}/query/`. - - Mempertahankan header metadata Tabular seperti `-- tabular:connection_id=...`, `-- tabular:database=...`, dan hierarki foldernya. - -3. **HTTP API (Koleksi & Workspace HTTP)**: - - Seluruh workspace HTTP (`HttpWorkspace`), subfolder (`HttpFolder`), saved requests (`SavedRequest`), dan variabel environment (`YaakEnvironment`) dari direktori `{app_data}/http_collections/`. - -4. **History (Riwayat Eksekusi Query)**: - - Seluruh log riwayat query dari tabel `query_history` di SQLite (`id`, `query_text`, `connection_id`, `connection_name`, `executed_at`). - - Penanganan relasi Foreign Key: penyesuaian `connection_id` dengan ID baru jika koneksi diimpor ke basis data target yang memiliki ID berbeda (berdasarkan pencocokan `connection_name`). - -### 1.3 Format Struktur Arsip ZIP -Format arsip ZIP didesain modular, aman, dan mudah dibaca secara terstruktur: - -``` -tabular_backup_YYYYMMDD_HHMMSS.zip -├── manifest.json -├── connections/ -│ └── connections.json -├── queries/ -│ ├── analytics/ -│ │ └── monthly_report.sql -│ └── schema_init.sql -├── http_collections/ -│ ├── ws_1710000000_1.json -│ └── ws_1710000000_2.json -└── history/ - └── history.json -``` - -- **`manifest.json`**: - ```json - { - "version": "1.0", - "app": "Tabular", - "exported_at": "2026-09-09T14:30:00Z", - "counts": { - "connections": 5, - "connection_folders": 2, - "queries": 14, - "http_workspaces": 3, - "history_items": 100 - }, - "includes": { - "connections": true, - "queries": true, - "http_api": true, - "history": true - } - } - ``` - ---- - -## 2. Desain Arsitektur & Rencana Perubahan Berkas - -### 2.1 Modul Baru - -#### A. `src/export_import_all.rs` (Core Logic Module) -Modul independen untuk operasi kompresi, dekompresi, serialisasi, validasi, dan persistensi database: -- **Tipe Data & Model**: - - `ExportAllManifest`: Metadata arsip. - - `ExportAllOptions`: Opsi export (pilihan kategori yang disertakan). - - `ImportAllOptions`: Opsi import (pilihan kategori yang ingin di-restore, serta strategi konflik: `MergeKeepExisting`, `MergeOverwrite`, atau `CleanRestore`). - - `ExportSummary` & `ImportSummary`: Laporan jumlah data yang berhasil diproses. - - `ExportImportError`: Error handling komprehensif menggunakan `thiserror`. -- **Fungsi Inti**: - - `pub fn export_all_data(tabular: &Tabular, target_path: &Path, options: &ExportAllOptions) -> Result` - - Mengumpulkan data dari in-memory state dan file system. - - Menulis ke ZIP menggunakan `zip::ZipWriter` dengan kompresi Deflate. - - `pub fn inspect_archive(archive_path: &Path) -> Result` - - Membaca `manifest.json` dan menghitung preview entri sebelum proses restore dijalankan. - - `pub fn import_all_data(tabular: &mut Tabular, archive_path: &Path, options: &ImportAllOptions) -> Result` - - Membuka ZIP dengan `zip::ZipArchive`. - - Memvalidasi path entri (mencegah Zip Slip vulnerability). - - Memulihkan koneksi database & folder koneksi ke SQLite serta mendaftarkan secret credentials. - - Mengekstrak file query ke `{app_data}/query/`. - - Mengekstrak file workspace HTTP ke `{app_data}/http_collections/`. - - Menyimpan history ke tabel `query_history` dengan mapping ID koneksi. - - Merefresh in-memory state Tabular (`load_connection_folders`, `load_queries_from_directory`, `load_workspaces`, `load_query_history`, dan trigger `needs_refresh`). - -#### B. `src/dialog_export_import_all.rs` (UI Dialog Module) -Komponen dialog berbasis `egui`: -- **`ExportAllDialogState`**: - - Pilihan kategori data (checkboxes: Connections, Queries, HTTP API, History). - - Target path file ZIP default (misal: `~/Downloads/tabular_backup_YYYYMMDD_HHMMSS.zip`). - - Status eksekusi (Idle, InProgress, Completed, Error). - - Ringkasan hasil ekspor. -- **`ImportAllDialogState`**: - - File picker untuk memilih file `.zip`. - - Preview manifest hasil inspeksi (jumlah item yang ditemukan di dalam file ZIP). - - Checkbox pilihan data yang ingin di-restore. - - Opsi penanganan duplikasi (Merge / Overwrite). - - Tombol aksi "Restore Now" dan progress bar / status banner. - ---- - -### 2.2 Berkas yang Dimodifikasi - -1. **`src/main.rs` / `src/lib.rs`**: - - Daftarkan modul baru: - ```rust - pub mod export_import_all; - pub mod dialog_export_import_all; - ``` - -2. **`src/window_egui/mod.rs`**: - - Tambahkan state flag & dialog state di struct `Tabular`: - ```rust - pub show_export_all_dialog: bool, - pub show_import_all_dialog: bool, - pub export_all_state: Option, - pub import_all_state: Option, - ``` - -3. **`src/window_egui/init.rs`**: - - Inisialisasi field baru tersebut dengan `false` dan `None`. - -4. **`src/window_egui/app_impl.rs`**: - - **Gear Settings Context Menu** (baris ~2535): - - Tambahkan item menu: - - `📦 Export All Data (.zip)...` -> membuka `show_export_all_dialog = true` - - `📥 Import & Restore All Data (.zip)...` -> membuka `show_import_all_dialog = true` - - **Settings Window - Data Directory Tab (`PrefTab::DataDirectory`)** (baris ~437): - - Tambahkan kartu UI "Backup & Restore Application Data" dengan tombol "Export All to ZIP" dan "Import from ZIP". - - **Render Loop** (baris ~4995): - - Render `render_export_all_dialog(self, ctx)` saat `self.show_export_all_dialog == true`. - - Render `render_import_all_dialog(self, ctx)` saat `self.show_import_all_dialog == true`. - -5. **`src/quick_open.rs`**: - - Daftarkan perintah ke Command Palette: - - `Export All Data (ZIP)` - - `Import All Data (ZIP)` - ---- - -## 3. Analisis Potensi Risiko & Strategi Mitigasi - -| Risiko | Dampak | Strategi Mitigasi | -| :--- | :--- | :--- | -| **Zip Slip / Path Traversal Attack** | File jahat di dalam ZIP dapat menimpa berkas sistem sembarang (`../../etc/shadow`). | Wajib menggunakan `file.enclosed_name()` dari crate `zip` dan membatasi ekstraksi hanya di dalam subdirektori tujuan yang sah (`query/` dan `http_collections/`). | -| **Foreign Key Constraint pada `query_history`** | `query_history` memiliki relasi `connection_id -> connections(id) ON DELETE CASCADE`. Jika `connection_id` lama tidak ditemukan, query insert history gagal. | Buat tabel mapping ID lama ke ID baru berdasarkan kesamaan nama koneksi (`connection_name`). Jika koneksi belum ada, buat koneksi terlebih dahulu atau kaitkan ke koneksi default yang valid. | -| **Penanganan Kredensial & Secrets** | Password atau SSH key tersimpan sebagai sentinel di SQLite dan data asli di keychain/secret store. | Saat ekspor, ambil data dari `tabular.connections` (yang sudah ter-dekripsi di RAM). Saat import, panggil `externalize_connection_secrets` agar kredensial tersimpan aman di database dan secret backend sistem target. | -| **UI Freeze saat Arsip Besar** | Aplikasi tidak responsif selama kompresi/ekstraksi I/O. | Jalankan proses kompresi dan dekompresi ZIP di background thread / tokio runtime async dengan `mpsc` channel untuk mengirim progress dan hasil ke UI thread. | -| **In-Memory State Stale setelah Restore** | Data sudah masuk ke SQLite/disk tetapi tampilan sidebar tidak terupdate. | Panggil fungsi reload: `sidebar_database::load_connection_folders()`, `sidebar_query::load_queries_from_directory()`, `http_collection::load_workspaces()`, dan `sidebar_history::load_query_history()`, serta set `tabular.needs_refresh = true`. | - ---- - -## 4. Tahapan Verifikasi & Pengujian - -1. **Uji Kompilasi (`cargo check`)**: - - Memastikan tidak ada compile error, type mismatch, atau broken references. -2. **Automated Unit Tests**: - - Buat unit test komprehensif di `src/export_import_all.rs`: - - Test pembuatan mock connection, query file, HTTP workspace, dan history item. - - Test ekspor ke buffer/file ZIP sementara dan verifikasi integritas ZIP serta isi `manifest.json`. - - Test pembacaan dan validasi isi arsip (`inspect_archive`). - - Test impor ke direktori sementara dan verifikasi bahwa data koneksi, queries, HTTP collection, dan history berhasil dipulihkan secara identik. - - Test proteksi Zip Slip (path traversal rejected). - - Test pemulihan relasi `query_history` saat connection ID berubah. -3. **Uji Integrasi UI**: - - Verifikasi pembukaan dialog via Gear Menu dan Tab Settings Data Directory. - - Verifikasi pemilihan file picker (`rfd::FileDialog`). - - Verifikasi feedback visual, progress bar, dan notifikasi keberhasilan pemulihan data. - ---- - -## 5. Kesimpulan & Batasan Tahap Ini (Stage 1 Scope) - -Sesuai instruksi tugas, pengerjaan pada **Tahap 1 (Planning Phase)** dibatasi hanya pada penyusunan dokumen perencanaan teknis ini di file `implementation_plan.md`. Tidak ada kode aplikasi yang diubah pada tahap ini sebelum rencana ini ditinjau dan disetujui oleh pengguna. diff --git a/implementation_plan_move_tab_editor.md b/implementation_plan_move_tab_editor.md index 53191edd..199cc61a 100644 --- a/implementation_plan_move_tab_editor.md +++ b/implementation_plan_move_tab_editor.md @@ -1,92 +1,190 @@ -# Rencana Teknis Implementasi: Drag & Drop Tab Editor & Fitur Pin Tab +# Rencana Teknis Implementasi: Export & Import Seluruh Data (ZIP) -Dokumen ini menjelaskan rancangan teknis dan arsitektur fitur **Drag & Drop Tab Editor** dan **Pin Tab** pada Tabular, termasuk perbaikan hasil adversarial code review. +Dokumen ini menjelaskan rencana teknis menyeluruh untuk menambahkan fitur **Export dan Import seluruh data Tabular** (Database Connections, Saved Queries, HTTP API Collections, dan Query History) dalam format file **ZIP**, serta mekanisme **Restore** data ke dalam sistem. --- ## 1. Analisis Kebutuhan (Requirements Analysis) -### 1.1 Latar Belakang & Masalah -Saat bekerja dengan banyak tab query SQL, tabel basis data, dan endpoint HTTP, pengguna memerlukan mekanisme untuk: -1. Mengelompokkan tab-tab yang saling terkait secara bebas dengan menggeser/menyeret tab (drag-and-drop). -2. Menyematkan (pin) tab penting agar selalu berada di posisi kiri tab bar dan terlindungi dari penutupan massal atau tidak sengaja. -3. Menyediakan kontrol navigasi tab lengkap melalui menu konteks (klik kanan) dan shortcut mouse (middle-click). - -### 1.2 Ruang Lingkup Fitur -1. **Interactive Drag-and-Drop Tab Reordering**: - - Mendukung penyeretan tab horizontal secara visual. - - Ghost badge melayang mengikuti posisi kursor dengan ikon status (📌 / 📑) dan judul tab. - - Indikator garis vertikal interaktif dengan aksen warna tema menandai slot penyisipan tab target. - - Pembatalan drag secara mulus melalui penekanan tombol `Escape` atau menyeret kursor keluar dari batas vertikal tab bar. - - Deteksi tombol mouse primer (`PointerButton::Primary`) guna mencegah pemicuan drop prematur dari tombol mouse lain. - -2. **Fitur Pin Tab (📌)**: - - Status `is_pinned: bool` pada setiap objek `QueryTab`. - - Tab yang disematkan terkumpul rapi di sebelah kiri tab bar sebelum tab-tab biasa (unpinned). - - Tombol close ("×") digantikan ikon pin ("📌") pada tab yang dipin untuk mencegah penutupan tak sengaja. - - Tombol quick-pin muncul saat hover pada tab unpinned. - - Garis pemisah vertikal membedakan grup tab pinned dengan unpinned. - - Sinkronisasi otomatis saat drag-and-drop: tab biasa yang diseret masuk ke area pinned otomatis menjadi pinned, dan sebaliknya. - -3. **Menu Konteks & Aksi Tab Lengkap**: - - 📌 **Pin Tab** / 📌 **Unpin Tab** - - ⬅ **Move Tab Left** / ➡ **Move Tab Right** - - ✕ **Close Tab** - - **Close Other Tabs** (melindungi tab yang sedang dipin) - - **Close Tabs to the Right** (melindungi tab yang sedang dipin) - - Middle-click untuk menutup tab unpinned. +### 1.1 Latar Belakang & Tujuan +Saat ini pengguna Tabular dapat melakukan backup pada level database engine tertentu (seperti `pg_dump` atau `mysqldump`), serta import koleksi HTTP individual (seperti Postman atau Yaak). Namun, belum ada mekanisme terpadu untuk: +1. Mengekspor **seluruh konfigurasi dan workspace pengguna** sekaligus ke dalam 1 file arsip portable (ZIP). +2. Memindahkan atau memulihkan seluruh data aplikasi (migrasi perangkat atau pemulihan bencana). +3. Mengimpor kembali file ZIP tersebut sehingga seluruh data (**Connection DB**, **Query**, **HTTP API**, dan **History**) ter-restore dengan aman, konsisten, dan langsung aktif di antarmuka aplikasi. + +### 1.2 Cakupan Data (4 Domain Utama) +1. **Connection DB (Koneksi Database)**: + - Menyimpan seluruh profil koneksi (`ConnectionConfig`): nama koneksi, host, port, username, password, database default, tipe database (`MySQL`, `PostgreSQL`, `SQLite`, `Redis`, `MsSQL`, `MongoDB`, dll.), SSL settings, SSH tunneling & credentials, custom views, dan replication settings. + - Menyimpan struktur pengelompokan folder koneksi (`connection_folders`). + - Penanganan kredensial: saat diekspor, kredensial diambil dari state memori/secret store; saat diimpor, kredensial disimpan ulang ke SQLite dan di-externalize ke secrets store (`externalize_connection_secrets`). + +2. **Saved Queries (Koleksi Query SQL)**: + - Seluruh file query SQL (`.sql`) beserta subfoldernya yang tersimpan di direktori aplikasi `{app_data}/query/`. + - Mempertahankan header metadata Tabular seperti `-- tabular:connection_id=...`, `-- tabular:database=...`, dan hierarki foldernya. + +3. **HTTP API (Koleksi & Workspace HTTP)**: + - Seluruh workspace HTTP (`HttpWorkspace`), subfolder (`HttpFolder`), saved requests (`SavedRequest`), dan variabel environment (`YaakEnvironment`) dari direktori `{app_data}/http_collections/`. + +4. **History (Riwayat Eksekusi Query)**: + - Seluruh log riwayat query dari tabel `query_history` di SQLite (`id`, `query_text`, `connection_id`, `connection_name`, `executed_at`). + - Penanganan relasi Foreign Key: penyesuaian `connection_id` dengan ID baru jika koneksi diimpor ke basis data target yang memiliki ID berbeda (berdasarkan pencocokan `connection_name`). + +### 1.3 Format Struktur Arsip ZIP +Format arsip ZIP didesain modular, aman, dan mudah dibaca secara terstruktur: + +``` +tabular_backup_YYYYMMDD_HHMMSS.zip +├── manifest.json +├── connections/ +│ └── connections.json +├── queries/ +│ ├── analytics/ +│ │ └── monthly_report.sql +│ └── schema_init.sql +├── http_collections/ +│ ├── ws_1710000000_1.json +│ └── ws_1710000000_2.json +└── history/ + └── history.json +``` + +- **`manifest.json`**: + ```json + { + "version": "1.0", + "app": "Tabular", + "exported_at": "2026-09-09T14:30:00Z", + "counts": { + "connections": 5, + "connection_folders": 2, + "queries": 14, + "http_workspaces": 3, + "history_items": 100 + }, + "includes": { + "connections": true, + "queries": true, + "http_api": true, + "history": true + } + } + ``` --- -## 2. Perubahan Arsitektur & Struktur Berkas - -### 2.1 Model Data (`src/models/structs.rs`) -- Menambahkan field `pub is_pinned: bool` pada struct `QueryTab`. -- Default bernilai `false` pada instansiasi tab baru. - -### 2.2 Logika Tab Management (`src/editor.rs`) -- `move_tab(tabular: &mut Tabular, from: usize, to: usize)`: - - Memindahkan posisi tab langsung pada vektor `query_tabs`. - - Menyesuaikan `active_tab_index` secara matematis tanpa merusak fokus aktif pengguna. - - Memperbarui status `is_pinned` saat tab melintasi batas pemisah pinned/unpinned. -- `reorder_tab(tabular: &mut Tabular, from: usize, insert_at: usize)`: - - Mengonversi slot penyisipan UI (0..=n) ke indeks target pemindahan. -- `pin_tab(tabular: &mut Tabular, tab_index: usize)`: - - Menandai tab sebagai pinned dan memindahkannya ke akhir grup pinned. - - Menyesuaikan `active_tab_index` secara tepat tanpa dead code branch. -- `unpin_tab(tabular: &mut Tabular, tab_index: usize)`: - - Melepas pin tab dan memindahkannya ke posisi setelah seluruh tab pinned yang tersisa. - - Menyesuaikan `active_tab_index` secara tepat tanpa dead code branch. -- `toggle_pin_tab(tabular: &mut Tabular, tab_index: usize)`: - - Toggle antara `pin_tab` dan `unpin_tab`. -- `close_other_tabs(tabular: &mut Tabular, keep_index: usize)`: - - Menutup semua tab kecuali tab target dan seluruh tab yang berstatus pinned. -- `close_tabs_to_the_right(tabular: &mut Tabular, tab_index: usize)`: - - Menutup seluruh tab unpinned di sebelah kanan `tab_index`. - -### 2.3 Antarmuka GUI (`src/window_egui/app_impl.rs`) -- Menambahkan field `dragged_tab_index: Option` pada state `Tabular`. -- Render strip tab dengan alokasi drag & click (`egui::Sense::click_and_drag()`). -- Deteksi pelepasan tombol primer (`PointerButton::Primary`). -- Pengecekan batas vertikal (`is_within_tab_bar_y`) dengan toleransi margin 20px agar pengguna dapat membatalkan drag dengan menggeser pointer keluar tab bar. -- Repaint rendering loop saat mouse dilepaskan untuk menjamin responsivitas UI instan. +## 2. Desain Arsitektur & Rencana Perubahan Berkas + +### 2.1 Modul Baru + +#### A. `src/export_import_all.rs` (Core Logic Module) +Modul independen untuk operasi kompresi, dekompresi, serialisasi, validasi, dan persistensi database: +- **Tipe Data & Model**: + - `ExportAllManifest`: Metadata arsip. + - `ExportAllOptions`: Opsi export (pilihan kategori yang disertakan). + - `ImportAllOptions`: Opsi import (pilihan kategori yang ingin di-restore, serta strategi konflik: `MergeKeepExisting`, `MergeOverwrite`, atau `CleanRestore`). + - `ExportSummary` & `ImportSummary`: Laporan jumlah data yang berhasil diproses. + - `ExportImportError`: Error handling komprehensif menggunakan `thiserror`. +- **Fungsi Inti**: + - `pub fn export_all_data(tabular: &Tabular, target_path: &Path, options: &ExportAllOptions) -> Result` + - Mengumpulkan data dari in-memory state dan file system. + - Menulis ke ZIP menggunakan `zip::ZipWriter` dengan kompresi Deflate. + - `pub fn inspect_archive(archive_path: &Path) -> Result` + - Membaca `manifest.json` dan menghitung preview entri sebelum proses restore dijalankan. + - `pub fn import_all_data(tabular: &mut Tabular, archive_path: &Path, options: &ImportAllOptions) -> Result` + - Membuka ZIP dengan `zip::ZipArchive`. + - Memvalidasi path entri (mencegah Zip Slip vulnerability). + - Memulihkan koneksi database & folder koneksi ke SQLite serta mendaftarkan secret credentials. + - Mengekstrak file query ke `{app_data}/query/`. + - Mengekstrak file workspace HTTP ke `{app_data}/http_collections/`. + - Menyimpan history ke tabel `query_history` dengan mapping ID koneksi. + - Merefresh in-memory state Tabular (`load_connection_folders`, `load_queries_from_directory`, `load_workspaces`, `load_query_history`, dan trigger `needs_refresh`). + +#### B. `src/dialog_export_import_all.rs` (UI Dialog Module) +Komponen dialog berbasis `egui`: +- **`ExportAllDialogState`**: + - Pilihan kategori data (checkboxes: Connections, Queries, HTTP API, History). + - Target path file ZIP default (misal: `~/Downloads/tabular_backup_YYYYMMDD_HHMMSS.zip`). + - Status eksekusi (Idle, InProgress, Completed, Error). + - Ringkasan hasil ekspor. +- **`ImportAllDialogState`**: + - File picker untuk memilih file `.zip`. + - Preview manifest hasil inspeksi (jumlah item yang ditemukan di dalam file ZIP). + - Checkbox pilihan data yang ingin di-restore. + - Opsi penanganan duplikasi (Merge / Overwrite). + - Tombol aksi "Restore Now" dan progress bar / status banner. --- -## 3. Hasil Perbaikan Adversarial Code Review - -1. **Pembersihan Dead Code pada `pin_tab`**: - - Menghilangkan cabang kontradiktif `else if tab_index < first_unpinned` di dalam blok `if tab_index > first_unpinned`. -2. **Pembersihan Dead Code pada `unpin_tab`**: - - Menghilangkan cabang unreachable `else` di dalam blok `if tab_index < last_p`. -3. **Pembersihan Dead Logic pada `close_tabs_to_the_right`**: - - Menghapus decrement indeks yang redundan (`tabular.active_tab_index >= i`) yang mustahil terpenuhi. -4. **Pencegahan Premature Drop**: - - Mengganti `any_released()` dengan `button_released(egui::PointerButton::Primary)`. -5. **Validasi Batas Vertikal**: - - Menambahkan `is_within_tab_bar_y` pada kalkulasi slot drop `candidate_insert_at`. -6. **Pemicuan Drag Tombol Non-Primer**: - - Membatasi inisiasi drag hanya pada tombol mouse primer melalui `drag_started_by(egui::PointerButton::Primary)`. -7. **Pencegahan Starvation / UI Stuck State**: - - Menambahkan mekanisme reset `self.dragged_tab_index = None` saat pointer primer tidak lagi ditekan (`!primary_down`) atau saat jumlah tab berubah. +### 2.2 Berkas yang Dimodifikasi + +1. **`src/main.rs` / `src/lib.rs`**: + - Daftarkan modul baru: + ```rust + pub mod export_import_all; + pub mod dialog_export_import_all; + ``` + +2. **`src/window_egui/mod.rs`**: + - Tambahkan state flag & dialog state di struct `Tabular`: + ```rust + pub show_export_all_dialog: bool, + pub show_import_all_dialog: bool, + pub export_all_state: Option, + pub import_all_state: Option, + ``` + +3. **`src/window_egui/init.rs`**: + - Inisialisasi field baru tersebut dengan `false` dan `None`. + +4. **`src/window_egui/app_impl.rs`**: + - **Gear Settings Context Menu** (baris ~2535): + - Tambahkan item menu: + - `📦 Export All Data (.zip)...` -> membuka `show_export_all_dialog = true` + - `📥 Import & Restore All Data (.zip)...` -> membuka `show_import_all_dialog = true` + - **Settings Window - Data Directory Tab (`PrefTab::DataDirectory`)** (baris ~437): + - Tambahkan kartu UI "Backup & Restore Application Data" dengan tombol "Export All to ZIP" dan "Import from ZIP". + - **Render Loop** (baris ~4995): + - Render `render_export_all_dialog(self, ctx)` saat `self.show_export_all_dialog == true`. + - Render `render_import_all_dialog(self, ctx)` saat `self.show_import_all_dialog == true`. + +5. **`src/quick_open.rs`**: + - Daftarkan perintah ke Command Palette: + - `Export All Data (ZIP)` + - `Import All Data (ZIP)` +--- + +## 3. Analisis Potensi Risiko & Strategi Mitigasi + +| Risiko | Dampak | Strategi Mitigasi | +| :--- | :--- | :--- | +| **Zip Slip / Path Traversal Attack** | File jahat di dalam ZIP dapat menimpa berkas sistem sembarang (`../../etc/shadow`). | Wajib menggunakan `file.enclosed_name()` dari crate `zip` dan membatasi ekstraksi hanya di dalam subdirektori tujuan yang sah (`query/` dan `http_collections/`). | +| **Foreign Key Constraint pada `query_history`** | `query_history` memiliki relasi `connection_id -> connections(id) ON DELETE CASCADE`. Jika `connection_id` lama tidak ditemukan, query insert history gagal. | Buat tabel mapping ID lama ke ID baru berdasarkan kesamaan nama koneksi (`connection_name`). Jika koneksi belum ada, buat koneksi terlebih dahulu atau kaitkan ke koneksi default yang valid. | +| **Penanganan Kredensial & Secrets** | Password atau SSH key tersimpan sebagai sentinel di SQLite dan data asli di keychain/secret store. | Saat ekspor, ambil data dari `tabular.connections` (yang sudah ter-dekripsi di RAM). Saat import, panggil `externalize_connection_secrets` agar kredensial tersimpan aman di database dan secret backend sistem target. | +| **UI Freeze saat Arsip Besar** | Aplikasi tidak responsif selama kompresi/ekstraksi I/O. | Jalankan proses kompresi dan dekompresi ZIP di background thread / tokio runtime async dengan `mpsc` channel untuk mengirim progress dan hasil ke UI thread. | +| **In-Memory State Stale setelah Restore** | Data sudah masuk ke SQLite/disk tetapi tampilan sidebar tidak terupdate. | Panggil fungsi reload: `sidebar_database::load_connection_folders()`, `sidebar_query::load_queries_from_directory()`, `http_collection::load_workspaces()`, dan `sidebar_history::load_query_history()`, serta set `tabular.needs_refresh = true`. | + +--- + +## 4. Tahapan Verifikasi & Pengujian + +1. **Uji Kompilasi (`cargo check`)**: + - Memastikan tidak ada compile error, type mismatch, atau broken references. +2. **Automated Unit Tests**: + - Buat unit test komprehensif di `src/export_import_all.rs`: + - Test pembuatan mock connection, query file, HTTP workspace, dan history item. + - Test ekspor ke buffer/file ZIP sementara dan verifikasi integritas ZIP serta isi `manifest.json`. + - Test pembacaan dan validasi isi arsip (`inspect_archive`). + - Test impor ke direktori sementara dan verifikasi bahwa data koneksi, queries, HTTP collection, dan history berhasil dipulihkan secara identik. + - Test proteksi Zip Slip (path traversal rejected). + - Test pemulihan relasi `query_history` saat connection ID berubah. +3. **Uji Integrasi UI**: + - Verifikasi pembukaan dialog via Gear Menu dan Tab Settings Data Directory. + - Verifikasi pemilihan file picker (`rfd::FileDialog`). + - Verifikasi feedback visual, progress bar, dan notifikasi keberhasilan pemulihan data. + +--- + +## 5. Kesimpulan & Batasan Tahap Ini (Stage 1 Scope) + +Sesuai instruksi tugas, pengerjaan pada **Tahap 1 (Planning Phase)** dibatasi hanya pada penyusunan dokumen perencanaan teknis ini di file `implementation_plan.md`. Tidak ada kode aplikasi yang diubah pada tahap ini sebelum rencana ini ditinjau dan disetujui oleh pengguna. diff --git a/walkthrough.md b/walkthrough.md deleted file mode 100644 index 5c3637c4..00000000 --- a/walkthrough.md +++ /dev/null @@ -1,116 +0,0 @@ -# Walkthrough: Fitur Export dan Import Seluruh Data Aplikasi (ZIP) - -Dokumen ini mendokumentasikan implementasi dan verifikasi fitur **Export & Import Seluruh Data** (Database Connections, Saved Queries, HTTP API Collections, dan Query History) dalam format arsip **ZIP** dengan kemampuan **Restore** lengkap di Tabular. - ---- - -## 1. Ringkasan Fitur - -Fitur ini menyediakan mekanisme backup dan migrasi menyeluruh untuk pengguna Tabular: -1. **Pengeksporan Lengkap (Export All to ZIP)**: - - Mengemas 4 domain data utama ke dalam 1 berkas `.zip`: - - **Database Connections & Folders**: Semua profil koneksi database (termasuk kredensial, SSL/SSH tunneling, custom views) dan struktur foldernya. - - **Saved Queries**: Semua berkas `.sql` dan hierarki subdirektorinya dari `{app_data}/query/`. - - **HTTP API Collections**: Semua workspace (`HttpWorkspace`), folder, saved request, dan environment dari `{app_data}/http_collections/`. - - **Query Execution History**: Seluruh riwayat eksekusi query dari basis data lokal SQLite (`query_history`). - - Menyertakan berkas `manifest.json` berisi versi format, timestamp, indikator modul, dan jumlah entri yang diekspor. -2. **Pratinjau & Inspeksi Arsip (Archive Inspection)**: - - Mampu memeriksa file ZIP sebelum restore dilakukan untuk membaca `manifest.json` atau menghitung entri di dalam arsip secara aman tanpa mengekstraknya ke disk sistem. -3. **Pemulihan & Penanganan Konflik (Restore & Conflict Handling)**: - - Mendukung 3 strategi konflik: - - `Merge (Keep Existing)`: Menambahkan data baru tanpa menimpa data yang sudah ada. - - `Merge (Overwrite Existing)`: Memperbarui data yang telah ada dan menambahkan data baru. - - `Clean Restore (Replace All)`: Menghapus data lama dan memulihkan data dari arsip secara bersih. - - Mengamankan kredensial kembali ke backend secrets (`externalize_connection_secrets`). - - Memetakan relasi `connection_id` pada riwayat query agar integritas Foreign Key tetap terjaga. - - Melindungi sistem dari serangan keamanan **Zip Slip (Path Traversal)**. - - Merefresh status in-memory Tabular secara instan tanpa perlu me-restart aplikasi. - ---- - -## 2. Berkas yang Dibuat & Dimodifikasi - -| Berkas | Status | Deskripsi | -| :--- | :--- | :--- | -| `src/export_import_all.rs` | **Baru** | Modul logika inti kompresi, inspeksi ZIP, manifest, sanitasi zip slip, serialisasi/deserialisasi 4 domain data, dan mapping relasi database. | -| `src/dialog_export_import_all.rs` | **Baru** | Komponen UI modal `egui` untuk dialog Export All dan dialog Import & Restore All dengan file picker native (`rfd`), status progress, dan laporan ringkasan. | -| `src/lib.rs` | Diubah | Mendaftarkan modul `export_import_all` dan `dialog_export_import_all`. | -| `src/sidebar_database.rs` | Diubah | Mengubah visibilitas `externalize_connection_secrets` menjadi `pub(crate)` agar dapat dipanggil saat restore koneksi. | -| `src/window_egui/mod.rs` | Diubah | Menambahkan field state `show_export_all_dialog`, `show_import_all_dialog`, `export_all_state`, dan `import_all_state` pada struct `Tabular`. | -| `src/window_egui/init.rs` | Diubah | Inisialisasi awal dialog state pada constructor `Tabular::new()`. | -| `src/window_egui/app_impl.rs` | Diubah | Menambahkan menu "Export All Data (ZIP)..." dan "Import All Data (ZIP)..." pada Gear Menu, kartu backup/restore pada Preferences > Data Directory, serta pemanggilan render dialog di loop utama. | -| `src/editor.rs` | Diubah | Menambahkan handler eksekusi command `"Export All Data (ZIP)"` dan `"Import All Data (ZIP)"`. | -| `src/quick_open.rs` | Diubah | Mendaftarkan perintah Export & Import ke Command Palette (Quick Open). | - ---- - -## 3. Struktur Berkas di Dalam ZIP - -``` -tabular_backup_YYYYMMDD_HHMMSS.zip -├── manifest.json -├── connections/ -│ ├── connections.json -│ └── folders.json -├── queries/ -│ ├── analytics/ -│ │ └── monthly_report.sql -│ └── schema_init.sql -├── http_collections/ -│ ├── ws_1710000000_1.json -│ └── ws_1710000000_2.json -└── history/ - └── history.json -``` - ---- - -## 4. Hasil Verifikasi & Pengujian - -### 4.1 Unit & Integration Tests (`src/export_import_all.rs`) -Dijalankan melalui `cargo test --lib export_import_all`: -``` -running 7 tests -test export_import_all::tests::test_conflict_strategy_labels ... ok -test export_import_all::tests::test_manifest_serialization ... ok -test export_import_all::tests::test_zip_slip_detection_in_archive ... ok -test export_import_all::tests::test_zip_options ... ok -test export_import_all::tests::test_zip_slip_rejection ... ok -test export_import_all::tests::test_archive_creation_and_inspection ... ok -test export_import_all::tests::test_roundtrip_export_import ... ok - -test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 180 filtered out -``` - -Pengujian mencakup: -- **`test_conflict_strategy_labels`**: Memverifikasi teks nama dan deskripsi tiap opsi resolusi konflik. -- **`test_manifest_serialization`**: Memverifikasi serialisasi dan deserialisasi struktur manifest JSON. -- **`test_zip_slip_rejection`**: Memverifikasi penolakan arsip ZIP berbahaya yang berisi path traversal (contoh: `../../etc/malicious.txt`). -- **`test_archive_creation_and_inspection`**: Menguji pembuatan arsip ZIP tiruan dan verifikasi parsing inspeksi manifest sebelum restore. -- **`test_roundtrip_export_import`**: Pengujian end-to-end lengkap yang membuat koneksi, folder, HTTP workspace, dan riwayat query ke database SQLite sementara, mengekspornya ke berkas ZIP, menginspeksi arsip, menghapus memori aplikasi, dan memulihkan kembali seluruh data melalui `import_all_data`. - -### 4.2 Keseluruhan Test Suite Workspace -Dijalankan melalui `cargo test`: -- **Library tests**: 187 passed; 0 failed. -- **Editor buffer tests**: 4 passed; 0 failed. -- **Find replace tests**: 6 passed; 0 failed. -- **Query AST tests**: 15 passed; 0 failed. -- **Syntax tree-sitter tests**: 4 passed; 0 failed. -- **Total**: **216 tests passed, 0 failed**. - -### 4.3 Verifikasi Kompilasi -Perintah `cargo check` berhasil tanpa error. - ---- - -## 5. Cara Penggunaan Fitur - -1. **Melalui Gear Settings Menu**: - - Klik ikon gerigi (Settings) di pojok kiri bawah. - - Pilih **📦 Export All Data (ZIP)...** untuk mengekspor data. - - Pilih **📥 Import All Data (ZIP)...** untuk merestore data dari file ZIP. -2. **Melalui Preferences**: - - Buka Preferences (`⌘,`) > tab **Data Directory**. - - Pada bagian **Backup & Restore All Data**, klik tombol "Export All Data (ZIP)..." atau "Import & Restore All Data (ZIP)...". -3. **Melalui Command Palette (`⌘P`)**: - - Tekan `⌘P` dan ketik `Export All Data (ZIP)` atau `Import All Data (ZIP)` lalu tekan Enter. From 81abd835da88383cba34c68f6593b0d0e41d5c2d Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Thu, 10 Sep 2026 10:02:44 +0000 Subject: [PATCH 08/18] fix(docs): restore root implementation_plan.md and walkthrough.md to preserve history --- implementation_plan.md | 190 +++++++++++++++++++++++++++++++++++++++++ walkthrough.md | 116 +++++++++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 implementation_plan.md create mode 100644 walkthrough.md diff --git a/implementation_plan.md b/implementation_plan.md new file mode 100644 index 00000000..199cc61a --- /dev/null +++ b/implementation_plan.md @@ -0,0 +1,190 @@ +# Rencana Teknis Implementasi: Export & Import Seluruh Data (ZIP) + +Dokumen ini menjelaskan rencana teknis menyeluruh untuk menambahkan fitur **Export dan Import seluruh data Tabular** (Database Connections, Saved Queries, HTTP API Collections, dan Query History) dalam format file **ZIP**, serta mekanisme **Restore** data ke dalam sistem. + +--- + +## 1. Analisis Kebutuhan (Requirements Analysis) + +### 1.1 Latar Belakang & Tujuan +Saat ini pengguna Tabular dapat melakukan backup pada level database engine tertentu (seperti `pg_dump` atau `mysqldump`), serta import koleksi HTTP individual (seperti Postman atau Yaak). Namun, belum ada mekanisme terpadu untuk: +1. Mengekspor **seluruh konfigurasi dan workspace pengguna** sekaligus ke dalam 1 file arsip portable (ZIP). +2. Memindahkan atau memulihkan seluruh data aplikasi (migrasi perangkat atau pemulihan bencana). +3. Mengimpor kembali file ZIP tersebut sehingga seluruh data (**Connection DB**, **Query**, **HTTP API**, dan **History**) ter-restore dengan aman, konsisten, dan langsung aktif di antarmuka aplikasi. + +### 1.2 Cakupan Data (4 Domain Utama) +1. **Connection DB (Koneksi Database)**: + - Menyimpan seluruh profil koneksi (`ConnectionConfig`): nama koneksi, host, port, username, password, database default, tipe database (`MySQL`, `PostgreSQL`, `SQLite`, `Redis`, `MsSQL`, `MongoDB`, dll.), SSL settings, SSH tunneling & credentials, custom views, dan replication settings. + - Menyimpan struktur pengelompokan folder koneksi (`connection_folders`). + - Penanganan kredensial: saat diekspor, kredensial diambil dari state memori/secret store; saat diimpor, kredensial disimpan ulang ke SQLite dan di-externalize ke secrets store (`externalize_connection_secrets`). + +2. **Saved Queries (Koleksi Query SQL)**: + - Seluruh file query SQL (`.sql`) beserta subfoldernya yang tersimpan di direktori aplikasi `{app_data}/query/`. + - Mempertahankan header metadata Tabular seperti `-- tabular:connection_id=...`, `-- tabular:database=...`, dan hierarki foldernya. + +3. **HTTP API (Koleksi & Workspace HTTP)**: + - Seluruh workspace HTTP (`HttpWorkspace`), subfolder (`HttpFolder`), saved requests (`SavedRequest`), dan variabel environment (`YaakEnvironment`) dari direktori `{app_data}/http_collections/`. + +4. **History (Riwayat Eksekusi Query)**: + - Seluruh log riwayat query dari tabel `query_history` di SQLite (`id`, `query_text`, `connection_id`, `connection_name`, `executed_at`). + - Penanganan relasi Foreign Key: penyesuaian `connection_id` dengan ID baru jika koneksi diimpor ke basis data target yang memiliki ID berbeda (berdasarkan pencocokan `connection_name`). + +### 1.3 Format Struktur Arsip ZIP +Format arsip ZIP didesain modular, aman, dan mudah dibaca secara terstruktur: + +``` +tabular_backup_YYYYMMDD_HHMMSS.zip +├── manifest.json +├── connections/ +│ └── connections.json +├── queries/ +│ ├── analytics/ +│ │ └── monthly_report.sql +│ └── schema_init.sql +├── http_collections/ +│ ├── ws_1710000000_1.json +│ └── ws_1710000000_2.json +└── history/ + └── history.json +``` + +- **`manifest.json`**: + ```json + { + "version": "1.0", + "app": "Tabular", + "exported_at": "2026-09-09T14:30:00Z", + "counts": { + "connections": 5, + "connection_folders": 2, + "queries": 14, + "http_workspaces": 3, + "history_items": 100 + }, + "includes": { + "connections": true, + "queries": true, + "http_api": true, + "history": true + } + } + ``` + +--- + +## 2. Desain Arsitektur & Rencana Perubahan Berkas + +### 2.1 Modul Baru + +#### A. `src/export_import_all.rs` (Core Logic Module) +Modul independen untuk operasi kompresi, dekompresi, serialisasi, validasi, dan persistensi database: +- **Tipe Data & Model**: + - `ExportAllManifest`: Metadata arsip. + - `ExportAllOptions`: Opsi export (pilihan kategori yang disertakan). + - `ImportAllOptions`: Opsi import (pilihan kategori yang ingin di-restore, serta strategi konflik: `MergeKeepExisting`, `MergeOverwrite`, atau `CleanRestore`). + - `ExportSummary` & `ImportSummary`: Laporan jumlah data yang berhasil diproses. + - `ExportImportError`: Error handling komprehensif menggunakan `thiserror`. +- **Fungsi Inti**: + - `pub fn export_all_data(tabular: &Tabular, target_path: &Path, options: &ExportAllOptions) -> Result` + - Mengumpulkan data dari in-memory state dan file system. + - Menulis ke ZIP menggunakan `zip::ZipWriter` dengan kompresi Deflate. + - `pub fn inspect_archive(archive_path: &Path) -> Result` + - Membaca `manifest.json` dan menghitung preview entri sebelum proses restore dijalankan. + - `pub fn import_all_data(tabular: &mut Tabular, archive_path: &Path, options: &ImportAllOptions) -> Result` + - Membuka ZIP dengan `zip::ZipArchive`. + - Memvalidasi path entri (mencegah Zip Slip vulnerability). + - Memulihkan koneksi database & folder koneksi ke SQLite serta mendaftarkan secret credentials. + - Mengekstrak file query ke `{app_data}/query/`. + - Mengekstrak file workspace HTTP ke `{app_data}/http_collections/`. + - Menyimpan history ke tabel `query_history` dengan mapping ID koneksi. + - Merefresh in-memory state Tabular (`load_connection_folders`, `load_queries_from_directory`, `load_workspaces`, `load_query_history`, dan trigger `needs_refresh`). + +#### B. `src/dialog_export_import_all.rs` (UI Dialog Module) +Komponen dialog berbasis `egui`: +- **`ExportAllDialogState`**: + - Pilihan kategori data (checkboxes: Connections, Queries, HTTP API, History). + - Target path file ZIP default (misal: `~/Downloads/tabular_backup_YYYYMMDD_HHMMSS.zip`). + - Status eksekusi (Idle, InProgress, Completed, Error). + - Ringkasan hasil ekspor. +- **`ImportAllDialogState`**: + - File picker untuk memilih file `.zip`. + - Preview manifest hasil inspeksi (jumlah item yang ditemukan di dalam file ZIP). + - Checkbox pilihan data yang ingin di-restore. + - Opsi penanganan duplikasi (Merge / Overwrite). + - Tombol aksi "Restore Now" dan progress bar / status banner. + +--- + +### 2.2 Berkas yang Dimodifikasi + +1. **`src/main.rs` / `src/lib.rs`**: + - Daftarkan modul baru: + ```rust + pub mod export_import_all; + pub mod dialog_export_import_all; + ``` + +2. **`src/window_egui/mod.rs`**: + - Tambahkan state flag & dialog state di struct `Tabular`: + ```rust + pub show_export_all_dialog: bool, + pub show_import_all_dialog: bool, + pub export_all_state: Option, + pub import_all_state: Option, + ``` + +3. **`src/window_egui/init.rs`**: + - Inisialisasi field baru tersebut dengan `false` dan `None`. + +4. **`src/window_egui/app_impl.rs`**: + - **Gear Settings Context Menu** (baris ~2535): + - Tambahkan item menu: + - `📦 Export All Data (.zip)...` -> membuka `show_export_all_dialog = true` + - `📥 Import & Restore All Data (.zip)...` -> membuka `show_import_all_dialog = true` + - **Settings Window - Data Directory Tab (`PrefTab::DataDirectory`)** (baris ~437): + - Tambahkan kartu UI "Backup & Restore Application Data" dengan tombol "Export All to ZIP" dan "Import from ZIP". + - **Render Loop** (baris ~4995): + - Render `render_export_all_dialog(self, ctx)` saat `self.show_export_all_dialog == true`. + - Render `render_import_all_dialog(self, ctx)` saat `self.show_import_all_dialog == true`. + +5. **`src/quick_open.rs`**: + - Daftarkan perintah ke Command Palette: + - `Export All Data (ZIP)` + - `Import All Data (ZIP)` + +--- + +## 3. Analisis Potensi Risiko & Strategi Mitigasi + +| Risiko | Dampak | Strategi Mitigasi | +| :--- | :--- | :--- | +| **Zip Slip / Path Traversal Attack** | File jahat di dalam ZIP dapat menimpa berkas sistem sembarang (`../../etc/shadow`). | Wajib menggunakan `file.enclosed_name()` dari crate `zip` dan membatasi ekstraksi hanya di dalam subdirektori tujuan yang sah (`query/` dan `http_collections/`). | +| **Foreign Key Constraint pada `query_history`** | `query_history` memiliki relasi `connection_id -> connections(id) ON DELETE CASCADE`. Jika `connection_id` lama tidak ditemukan, query insert history gagal. | Buat tabel mapping ID lama ke ID baru berdasarkan kesamaan nama koneksi (`connection_name`). Jika koneksi belum ada, buat koneksi terlebih dahulu atau kaitkan ke koneksi default yang valid. | +| **Penanganan Kredensial & Secrets** | Password atau SSH key tersimpan sebagai sentinel di SQLite dan data asli di keychain/secret store. | Saat ekspor, ambil data dari `tabular.connections` (yang sudah ter-dekripsi di RAM). Saat import, panggil `externalize_connection_secrets` agar kredensial tersimpan aman di database dan secret backend sistem target. | +| **UI Freeze saat Arsip Besar** | Aplikasi tidak responsif selama kompresi/ekstraksi I/O. | Jalankan proses kompresi dan dekompresi ZIP di background thread / tokio runtime async dengan `mpsc` channel untuk mengirim progress dan hasil ke UI thread. | +| **In-Memory State Stale setelah Restore** | Data sudah masuk ke SQLite/disk tetapi tampilan sidebar tidak terupdate. | Panggil fungsi reload: `sidebar_database::load_connection_folders()`, `sidebar_query::load_queries_from_directory()`, `http_collection::load_workspaces()`, dan `sidebar_history::load_query_history()`, serta set `tabular.needs_refresh = true`. | + +--- + +## 4. Tahapan Verifikasi & Pengujian + +1. **Uji Kompilasi (`cargo check`)**: + - Memastikan tidak ada compile error, type mismatch, atau broken references. +2. **Automated Unit Tests**: + - Buat unit test komprehensif di `src/export_import_all.rs`: + - Test pembuatan mock connection, query file, HTTP workspace, dan history item. + - Test ekspor ke buffer/file ZIP sementara dan verifikasi integritas ZIP serta isi `manifest.json`. + - Test pembacaan dan validasi isi arsip (`inspect_archive`). + - Test impor ke direktori sementara dan verifikasi bahwa data koneksi, queries, HTTP collection, dan history berhasil dipulihkan secara identik. + - Test proteksi Zip Slip (path traversal rejected). + - Test pemulihan relasi `query_history` saat connection ID berubah. +3. **Uji Integrasi UI**: + - Verifikasi pembukaan dialog via Gear Menu dan Tab Settings Data Directory. + - Verifikasi pemilihan file picker (`rfd::FileDialog`). + - Verifikasi feedback visual, progress bar, dan notifikasi keberhasilan pemulihan data. + +--- + +## 5. Kesimpulan & Batasan Tahap Ini (Stage 1 Scope) + +Sesuai instruksi tugas, pengerjaan pada **Tahap 1 (Planning Phase)** dibatasi hanya pada penyusunan dokumen perencanaan teknis ini di file `implementation_plan.md`. Tidak ada kode aplikasi yang diubah pada tahap ini sebelum rencana ini ditinjau dan disetujui oleh pengguna. diff --git a/walkthrough.md b/walkthrough.md new file mode 100644 index 00000000..5c3637c4 --- /dev/null +++ b/walkthrough.md @@ -0,0 +1,116 @@ +# Walkthrough: Fitur Export dan Import Seluruh Data Aplikasi (ZIP) + +Dokumen ini mendokumentasikan implementasi dan verifikasi fitur **Export & Import Seluruh Data** (Database Connections, Saved Queries, HTTP API Collections, dan Query History) dalam format arsip **ZIP** dengan kemampuan **Restore** lengkap di Tabular. + +--- + +## 1. Ringkasan Fitur + +Fitur ini menyediakan mekanisme backup dan migrasi menyeluruh untuk pengguna Tabular: +1. **Pengeksporan Lengkap (Export All to ZIP)**: + - Mengemas 4 domain data utama ke dalam 1 berkas `.zip`: + - **Database Connections & Folders**: Semua profil koneksi database (termasuk kredensial, SSL/SSH tunneling, custom views) dan struktur foldernya. + - **Saved Queries**: Semua berkas `.sql` dan hierarki subdirektorinya dari `{app_data}/query/`. + - **HTTP API Collections**: Semua workspace (`HttpWorkspace`), folder, saved request, dan environment dari `{app_data}/http_collections/`. + - **Query Execution History**: Seluruh riwayat eksekusi query dari basis data lokal SQLite (`query_history`). + - Menyertakan berkas `manifest.json` berisi versi format, timestamp, indikator modul, dan jumlah entri yang diekspor. +2. **Pratinjau & Inspeksi Arsip (Archive Inspection)**: + - Mampu memeriksa file ZIP sebelum restore dilakukan untuk membaca `manifest.json` atau menghitung entri di dalam arsip secara aman tanpa mengekstraknya ke disk sistem. +3. **Pemulihan & Penanganan Konflik (Restore & Conflict Handling)**: + - Mendukung 3 strategi konflik: + - `Merge (Keep Existing)`: Menambahkan data baru tanpa menimpa data yang sudah ada. + - `Merge (Overwrite Existing)`: Memperbarui data yang telah ada dan menambahkan data baru. + - `Clean Restore (Replace All)`: Menghapus data lama dan memulihkan data dari arsip secara bersih. + - Mengamankan kredensial kembali ke backend secrets (`externalize_connection_secrets`). + - Memetakan relasi `connection_id` pada riwayat query agar integritas Foreign Key tetap terjaga. + - Melindungi sistem dari serangan keamanan **Zip Slip (Path Traversal)**. + - Merefresh status in-memory Tabular secara instan tanpa perlu me-restart aplikasi. + +--- + +## 2. Berkas yang Dibuat & Dimodifikasi + +| Berkas | Status | Deskripsi | +| :--- | :--- | :--- | +| `src/export_import_all.rs` | **Baru** | Modul logika inti kompresi, inspeksi ZIP, manifest, sanitasi zip slip, serialisasi/deserialisasi 4 domain data, dan mapping relasi database. | +| `src/dialog_export_import_all.rs` | **Baru** | Komponen UI modal `egui` untuk dialog Export All dan dialog Import & Restore All dengan file picker native (`rfd`), status progress, dan laporan ringkasan. | +| `src/lib.rs` | Diubah | Mendaftarkan modul `export_import_all` dan `dialog_export_import_all`. | +| `src/sidebar_database.rs` | Diubah | Mengubah visibilitas `externalize_connection_secrets` menjadi `pub(crate)` agar dapat dipanggil saat restore koneksi. | +| `src/window_egui/mod.rs` | Diubah | Menambahkan field state `show_export_all_dialog`, `show_import_all_dialog`, `export_all_state`, dan `import_all_state` pada struct `Tabular`. | +| `src/window_egui/init.rs` | Diubah | Inisialisasi awal dialog state pada constructor `Tabular::new()`. | +| `src/window_egui/app_impl.rs` | Diubah | Menambahkan menu "Export All Data (ZIP)..." dan "Import All Data (ZIP)..." pada Gear Menu, kartu backup/restore pada Preferences > Data Directory, serta pemanggilan render dialog di loop utama. | +| `src/editor.rs` | Diubah | Menambahkan handler eksekusi command `"Export All Data (ZIP)"` dan `"Import All Data (ZIP)"`. | +| `src/quick_open.rs` | Diubah | Mendaftarkan perintah Export & Import ke Command Palette (Quick Open). | + +--- + +## 3. Struktur Berkas di Dalam ZIP + +``` +tabular_backup_YYYYMMDD_HHMMSS.zip +├── manifest.json +├── connections/ +│ ├── connections.json +│ └── folders.json +├── queries/ +│ ├── analytics/ +│ │ └── monthly_report.sql +│ └── schema_init.sql +├── http_collections/ +│ ├── ws_1710000000_1.json +│ └── ws_1710000000_2.json +└── history/ + └── history.json +``` + +--- + +## 4. Hasil Verifikasi & Pengujian + +### 4.1 Unit & Integration Tests (`src/export_import_all.rs`) +Dijalankan melalui `cargo test --lib export_import_all`: +``` +running 7 tests +test export_import_all::tests::test_conflict_strategy_labels ... ok +test export_import_all::tests::test_manifest_serialization ... ok +test export_import_all::tests::test_zip_slip_detection_in_archive ... ok +test export_import_all::tests::test_zip_options ... ok +test export_import_all::tests::test_zip_slip_rejection ... ok +test export_import_all::tests::test_archive_creation_and_inspection ... ok +test export_import_all::tests::test_roundtrip_export_import ... ok + +test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 180 filtered out +``` + +Pengujian mencakup: +- **`test_conflict_strategy_labels`**: Memverifikasi teks nama dan deskripsi tiap opsi resolusi konflik. +- **`test_manifest_serialization`**: Memverifikasi serialisasi dan deserialisasi struktur manifest JSON. +- **`test_zip_slip_rejection`**: Memverifikasi penolakan arsip ZIP berbahaya yang berisi path traversal (contoh: `../../etc/malicious.txt`). +- **`test_archive_creation_and_inspection`**: Menguji pembuatan arsip ZIP tiruan dan verifikasi parsing inspeksi manifest sebelum restore. +- **`test_roundtrip_export_import`**: Pengujian end-to-end lengkap yang membuat koneksi, folder, HTTP workspace, dan riwayat query ke database SQLite sementara, mengekspornya ke berkas ZIP, menginspeksi arsip, menghapus memori aplikasi, dan memulihkan kembali seluruh data melalui `import_all_data`. + +### 4.2 Keseluruhan Test Suite Workspace +Dijalankan melalui `cargo test`: +- **Library tests**: 187 passed; 0 failed. +- **Editor buffer tests**: 4 passed; 0 failed. +- **Find replace tests**: 6 passed; 0 failed. +- **Query AST tests**: 15 passed; 0 failed. +- **Syntax tree-sitter tests**: 4 passed; 0 failed. +- **Total**: **216 tests passed, 0 failed**. + +### 4.3 Verifikasi Kompilasi +Perintah `cargo check` berhasil tanpa error. + +--- + +## 5. Cara Penggunaan Fitur + +1. **Melalui Gear Settings Menu**: + - Klik ikon gerigi (Settings) di pojok kiri bawah. + - Pilih **📦 Export All Data (ZIP)...** untuk mengekspor data. + - Pilih **📥 Import All Data (ZIP)...** untuk merestore data dari file ZIP. +2. **Melalui Preferences**: + - Buka Preferences (`⌘,`) > tab **Data Directory**. + - Pada bagian **Backup & Restore All Data**, klik tombol "Export All Data (ZIP)..." atau "Import & Restore All Data (ZIP)...". +3. **Melalui Command Palette (`⌘P`)**: + - Tekan `⌘P` dan ketik `Export All Data (ZIP)` atau `Import All Data (ZIP)` lalu tekan Enter. From ddd4cfda299f2bba65f6eab04d4241b046cda90a Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Thu, 10 Sep 2026 10:03:19 +0000 Subject: [PATCH 09/18] fix(updater): remove unused debug import --- src/auto_updater.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auto_updater.rs b/src/auto_updater.rs index 297d3135..33beb715 100644 --- a/src/auto_updater.rs +++ b/src/auto_updater.rs @@ -1,6 +1,6 @@ use crate::self_update::UpdateInfo; use futures_util::StreamExt; -use log::{debug, info, warn}; +use log::{info, warn}; use std::fs; #[allow(unused_imports)] use std::io::Cursor; From 2a0ab258e4013f71f931640ad73c67b41fb9e8ce Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Thu, 10 Sep 2026 10:03:49 +0000 Subject: [PATCH 10/18] docs: document warning cleanup and documentation integrity verification --- walkthrough_move_tab_editor.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/walkthrough_move_tab_editor.md b/walkthrough_move_tab_editor.md index 9cd47dd3..35cad22d 100644 --- a/walkthrough_move_tab_editor.md +++ b/walkthrough_move_tab_editor.md @@ -41,7 +41,8 @@ Dokumen ini mendokumentasikan implementasi dan verifikasi fitur **Drag & Drop Ta | **Ketiadaan Validasi Batas Vertikal** | `src/window_egui/app_impl.rs:2424` | Menambahkan validasi `is_within_tab_bar_y` sehingga drag dapat dibatalkan jika pointer keluar tab bar. | | **Pemicuan Drag Tombol Non-Primer** | `src/window_egui/app_impl.rs:2150` | Mengganti `drag_started()` menjadi `drag_started_by(egui::PointerButton::Primary)`. | | **Starvation / Stuck Drag State** | `src/window_egui/app_impl.rs:2476` | Menambahkan pembersihan state drag jika primary pointer tidak lagi ditekan (`!primary_down`) atau tab count berubah. | -| **Integritas Dokumentasi & Mode Eksekusi Berkas** | Root Workspace | Memulihkan berkas `walkthrough.md` dan `implementation_plan.md` root repositori, menjaga pemisahan dokumen per fitur (`walkthrough_move_tab_editor.md`), serta mengembalikan izin berkas skrip shell ke `100644`. | +| **Pembersihan Compiler Warning** | `src/auto_updater.rs:3` | Menghapus import `debug` yang tidak digunakan guna menjamin *zero compiler warnings*. | +| **Integritas Dokumentasi & Mode Eksekusi Berkas** | Root Workspace | Memulihkan berkas `walkthrough.md` dan `implementation_plan.md` root repositori dari `origin/main`, menjaga pemisahan dokumen per fitur (`walkthrough_move_tab_editor.md`), serta mengembalikan izin berkas skrip shell ke `100644`. | --- From 349d8b8c9e875c39c21eb192aed5e28616bf7449 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Thu, 10 Sep 2026 10:13:56 +0000 Subject: [PATCH 11/18] antigravity: automated changes & plan artifacts for task move_tab_editor --- build_deb.sh | 0 flatpak_build.sh | 0 flatpak_publish.sh | 0 implementation_plan.md | 190 ------------------ implementation_plan_move_tab_editor.md | 257 ++++++++----------------- install.arch.sh | 0 walkthrough.md | 116 ----------- walkthrough_move_tab_editor.md | 1 + 8 files changed, 83 insertions(+), 481 deletions(-) mode change 100644 => 100755 build_deb.sh mode change 100644 => 100755 flatpak_build.sh mode change 100644 => 100755 flatpak_publish.sh delete mode 100644 implementation_plan.md mode change 100644 => 100755 install.arch.sh delete mode 100644 walkthrough.md diff --git a/build_deb.sh b/build_deb.sh old mode 100644 new mode 100755 diff --git a/flatpak_build.sh b/flatpak_build.sh old mode 100644 new mode 100755 diff --git a/flatpak_publish.sh b/flatpak_publish.sh old mode 100644 new mode 100755 diff --git a/implementation_plan.md b/implementation_plan.md deleted file mode 100644 index 199cc61a..00000000 --- a/implementation_plan.md +++ /dev/null @@ -1,190 +0,0 @@ -# Rencana Teknis Implementasi: Export & Import Seluruh Data (ZIP) - -Dokumen ini menjelaskan rencana teknis menyeluruh untuk menambahkan fitur **Export dan Import seluruh data Tabular** (Database Connections, Saved Queries, HTTP API Collections, dan Query History) dalam format file **ZIP**, serta mekanisme **Restore** data ke dalam sistem. - ---- - -## 1. Analisis Kebutuhan (Requirements Analysis) - -### 1.1 Latar Belakang & Tujuan -Saat ini pengguna Tabular dapat melakukan backup pada level database engine tertentu (seperti `pg_dump` atau `mysqldump`), serta import koleksi HTTP individual (seperti Postman atau Yaak). Namun, belum ada mekanisme terpadu untuk: -1. Mengekspor **seluruh konfigurasi dan workspace pengguna** sekaligus ke dalam 1 file arsip portable (ZIP). -2. Memindahkan atau memulihkan seluruh data aplikasi (migrasi perangkat atau pemulihan bencana). -3. Mengimpor kembali file ZIP tersebut sehingga seluruh data (**Connection DB**, **Query**, **HTTP API**, dan **History**) ter-restore dengan aman, konsisten, dan langsung aktif di antarmuka aplikasi. - -### 1.2 Cakupan Data (4 Domain Utama) -1. **Connection DB (Koneksi Database)**: - - Menyimpan seluruh profil koneksi (`ConnectionConfig`): nama koneksi, host, port, username, password, database default, tipe database (`MySQL`, `PostgreSQL`, `SQLite`, `Redis`, `MsSQL`, `MongoDB`, dll.), SSL settings, SSH tunneling & credentials, custom views, dan replication settings. - - Menyimpan struktur pengelompokan folder koneksi (`connection_folders`). - - Penanganan kredensial: saat diekspor, kredensial diambil dari state memori/secret store; saat diimpor, kredensial disimpan ulang ke SQLite dan di-externalize ke secrets store (`externalize_connection_secrets`). - -2. **Saved Queries (Koleksi Query SQL)**: - - Seluruh file query SQL (`.sql`) beserta subfoldernya yang tersimpan di direktori aplikasi `{app_data}/query/`. - - Mempertahankan header metadata Tabular seperti `-- tabular:connection_id=...`, `-- tabular:database=...`, dan hierarki foldernya. - -3. **HTTP API (Koleksi & Workspace HTTP)**: - - Seluruh workspace HTTP (`HttpWorkspace`), subfolder (`HttpFolder`), saved requests (`SavedRequest`), dan variabel environment (`YaakEnvironment`) dari direktori `{app_data}/http_collections/`. - -4. **History (Riwayat Eksekusi Query)**: - - Seluruh log riwayat query dari tabel `query_history` di SQLite (`id`, `query_text`, `connection_id`, `connection_name`, `executed_at`). - - Penanganan relasi Foreign Key: penyesuaian `connection_id` dengan ID baru jika koneksi diimpor ke basis data target yang memiliki ID berbeda (berdasarkan pencocokan `connection_name`). - -### 1.3 Format Struktur Arsip ZIP -Format arsip ZIP didesain modular, aman, dan mudah dibaca secara terstruktur: - -``` -tabular_backup_YYYYMMDD_HHMMSS.zip -├── manifest.json -├── connections/ -│ └── connections.json -├── queries/ -│ ├── analytics/ -│ │ └── monthly_report.sql -│ └── schema_init.sql -├── http_collections/ -│ ├── ws_1710000000_1.json -│ └── ws_1710000000_2.json -└── history/ - └── history.json -``` - -- **`manifest.json`**: - ```json - { - "version": "1.0", - "app": "Tabular", - "exported_at": "2026-09-09T14:30:00Z", - "counts": { - "connections": 5, - "connection_folders": 2, - "queries": 14, - "http_workspaces": 3, - "history_items": 100 - }, - "includes": { - "connections": true, - "queries": true, - "http_api": true, - "history": true - } - } - ``` - ---- - -## 2. Desain Arsitektur & Rencana Perubahan Berkas - -### 2.1 Modul Baru - -#### A. `src/export_import_all.rs` (Core Logic Module) -Modul independen untuk operasi kompresi, dekompresi, serialisasi, validasi, dan persistensi database: -- **Tipe Data & Model**: - - `ExportAllManifest`: Metadata arsip. - - `ExportAllOptions`: Opsi export (pilihan kategori yang disertakan). - - `ImportAllOptions`: Opsi import (pilihan kategori yang ingin di-restore, serta strategi konflik: `MergeKeepExisting`, `MergeOverwrite`, atau `CleanRestore`). - - `ExportSummary` & `ImportSummary`: Laporan jumlah data yang berhasil diproses. - - `ExportImportError`: Error handling komprehensif menggunakan `thiserror`. -- **Fungsi Inti**: - - `pub fn export_all_data(tabular: &Tabular, target_path: &Path, options: &ExportAllOptions) -> Result` - - Mengumpulkan data dari in-memory state dan file system. - - Menulis ke ZIP menggunakan `zip::ZipWriter` dengan kompresi Deflate. - - `pub fn inspect_archive(archive_path: &Path) -> Result` - - Membaca `manifest.json` dan menghitung preview entri sebelum proses restore dijalankan. - - `pub fn import_all_data(tabular: &mut Tabular, archive_path: &Path, options: &ImportAllOptions) -> Result` - - Membuka ZIP dengan `zip::ZipArchive`. - - Memvalidasi path entri (mencegah Zip Slip vulnerability). - - Memulihkan koneksi database & folder koneksi ke SQLite serta mendaftarkan secret credentials. - - Mengekstrak file query ke `{app_data}/query/`. - - Mengekstrak file workspace HTTP ke `{app_data}/http_collections/`. - - Menyimpan history ke tabel `query_history` dengan mapping ID koneksi. - - Merefresh in-memory state Tabular (`load_connection_folders`, `load_queries_from_directory`, `load_workspaces`, `load_query_history`, dan trigger `needs_refresh`). - -#### B. `src/dialog_export_import_all.rs` (UI Dialog Module) -Komponen dialog berbasis `egui`: -- **`ExportAllDialogState`**: - - Pilihan kategori data (checkboxes: Connections, Queries, HTTP API, History). - - Target path file ZIP default (misal: `~/Downloads/tabular_backup_YYYYMMDD_HHMMSS.zip`). - - Status eksekusi (Idle, InProgress, Completed, Error). - - Ringkasan hasil ekspor. -- **`ImportAllDialogState`**: - - File picker untuk memilih file `.zip`. - - Preview manifest hasil inspeksi (jumlah item yang ditemukan di dalam file ZIP). - - Checkbox pilihan data yang ingin di-restore. - - Opsi penanganan duplikasi (Merge / Overwrite). - - Tombol aksi "Restore Now" dan progress bar / status banner. - ---- - -### 2.2 Berkas yang Dimodifikasi - -1. **`src/main.rs` / `src/lib.rs`**: - - Daftarkan modul baru: - ```rust - pub mod export_import_all; - pub mod dialog_export_import_all; - ``` - -2. **`src/window_egui/mod.rs`**: - - Tambahkan state flag & dialog state di struct `Tabular`: - ```rust - pub show_export_all_dialog: bool, - pub show_import_all_dialog: bool, - pub export_all_state: Option, - pub import_all_state: Option, - ``` - -3. **`src/window_egui/init.rs`**: - - Inisialisasi field baru tersebut dengan `false` dan `None`. - -4. **`src/window_egui/app_impl.rs`**: - - **Gear Settings Context Menu** (baris ~2535): - - Tambahkan item menu: - - `📦 Export All Data (.zip)...` -> membuka `show_export_all_dialog = true` - - `📥 Import & Restore All Data (.zip)...` -> membuka `show_import_all_dialog = true` - - **Settings Window - Data Directory Tab (`PrefTab::DataDirectory`)** (baris ~437): - - Tambahkan kartu UI "Backup & Restore Application Data" dengan tombol "Export All to ZIP" dan "Import from ZIP". - - **Render Loop** (baris ~4995): - - Render `render_export_all_dialog(self, ctx)` saat `self.show_export_all_dialog == true`. - - Render `render_import_all_dialog(self, ctx)` saat `self.show_import_all_dialog == true`. - -5. **`src/quick_open.rs`**: - - Daftarkan perintah ke Command Palette: - - `Export All Data (ZIP)` - - `Import All Data (ZIP)` - ---- - -## 3. Analisis Potensi Risiko & Strategi Mitigasi - -| Risiko | Dampak | Strategi Mitigasi | -| :--- | :--- | :--- | -| **Zip Slip / Path Traversal Attack** | File jahat di dalam ZIP dapat menimpa berkas sistem sembarang (`../../etc/shadow`). | Wajib menggunakan `file.enclosed_name()` dari crate `zip` dan membatasi ekstraksi hanya di dalam subdirektori tujuan yang sah (`query/` dan `http_collections/`). | -| **Foreign Key Constraint pada `query_history`** | `query_history` memiliki relasi `connection_id -> connections(id) ON DELETE CASCADE`. Jika `connection_id` lama tidak ditemukan, query insert history gagal. | Buat tabel mapping ID lama ke ID baru berdasarkan kesamaan nama koneksi (`connection_name`). Jika koneksi belum ada, buat koneksi terlebih dahulu atau kaitkan ke koneksi default yang valid. | -| **Penanganan Kredensial & Secrets** | Password atau SSH key tersimpan sebagai sentinel di SQLite dan data asli di keychain/secret store. | Saat ekspor, ambil data dari `tabular.connections` (yang sudah ter-dekripsi di RAM). Saat import, panggil `externalize_connection_secrets` agar kredensial tersimpan aman di database dan secret backend sistem target. | -| **UI Freeze saat Arsip Besar** | Aplikasi tidak responsif selama kompresi/ekstraksi I/O. | Jalankan proses kompresi dan dekompresi ZIP di background thread / tokio runtime async dengan `mpsc` channel untuk mengirim progress dan hasil ke UI thread. | -| **In-Memory State Stale setelah Restore** | Data sudah masuk ke SQLite/disk tetapi tampilan sidebar tidak terupdate. | Panggil fungsi reload: `sidebar_database::load_connection_folders()`, `sidebar_query::load_queries_from_directory()`, `http_collection::load_workspaces()`, dan `sidebar_history::load_query_history()`, serta set `tabular.needs_refresh = true`. | - ---- - -## 4. Tahapan Verifikasi & Pengujian - -1. **Uji Kompilasi (`cargo check`)**: - - Memastikan tidak ada compile error, type mismatch, atau broken references. -2. **Automated Unit Tests**: - - Buat unit test komprehensif di `src/export_import_all.rs`: - - Test pembuatan mock connection, query file, HTTP workspace, dan history item. - - Test ekspor ke buffer/file ZIP sementara dan verifikasi integritas ZIP serta isi `manifest.json`. - - Test pembacaan dan validasi isi arsip (`inspect_archive`). - - Test impor ke direktori sementara dan verifikasi bahwa data koneksi, queries, HTTP collection, dan history berhasil dipulihkan secara identik. - - Test proteksi Zip Slip (path traversal rejected). - - Test pemulihan relasi `query_history` saat connection ID berubah. -3. **Uji Integrasi UI**: - - Verifikasi pembukaan dialog via Gear Menu dan Tab Settings Data Directory. - - Verifikasi pemilihan file picker (`rfd::FileDialog`). - - Verifikasi feedback visual, progress bar, dan notifikasi keberhasilan pemulihan data. - ---- - -## 5. Kesimpulan & Batasan Tahap Ini (Stage 1 Scope) - -Sesuai instruksi tugas, pengerjaan pada **Tahap 1 (Planning Phase)** dibatasi hanya pada penyusunan dokumen perencanaan teknis ini di file `implementation_plan.md`. Tidak ada kode aplikasi yang diubah pada tahap ini sebelum rencana ini ditinjau dan disetujui oleh pengguna. diff --git a/implementation_plan_move_tab_editor.md b/implementation_plan_move_tab_editor.md index 199cc61a..5e3e3121 100644 --- a/implementation_plan_move_tab_editor.md +++ b/implementation_plan_move_tab_editor.md @@ -1,190 +1,97 @@ -# Rencana Teknis Implementasi: Export & Import Seluruh Data (ZIP) +# Rencana Teknis Implementasi: Drag & Drop Tab Editor dan Pin Tab -Dokumen ini menjelaskan rencana teknis menyeluruh untuk menambahkan fitur **Export dan Import seluruh data Tabular** (Database Connections, Saved Queries, HTTP API Collections, dan Query History) dalam format file **ZIP**, serta mekanisme **Restore** data ke dalam sistem. +Dokumen ini menjelaskan rencana teknis menyeluruh untuk menambahkan fitur **Drag & Drop Tab Editor** (reordering & visual feedback) dan **Pin Tab** (penyematan tab editor di sisi kiri dengan proteksi penutupan) pada aplikasi Tabular. --- ## 1. Analisis Kebutuhan (Requirements Analysis) ### 1.1 Latar Belakang & Tujuan -Saat ini pengguna Tabular dapat melakukan backup pada level database engine tertentu (seperti `pg_dump` atau `mysqldump`), serta import koleksi HTTP individual (seperti Postman atau Yaak). Namun, belum ada mekanisme terpadu untuk: -1. Mengekspor **seluruh konfigurasi dan workspace pengguna** sekaligus ke dalam 1 file arsip portable (ZIP). -2. Memindahkan atau memulihkan seluruh data aplikasi (migrasi perangkat atau pemulihan bencana). -3. Mengimpor kembali file ZIP tersebut sehingga seluruh data (**Connection DB**, **Query**, **HTTP API**, dan **History**) ter-restore dengan aman, konsisten, dan langsung aktif di antarmuka aplikasi. - -### 1.2 Cakupan Data (4 Domain Utama) -1. **Connection DB (Koneksi Database)**: - - Menyimpan seluruh profil koneksi (`ConnectionConfig`): nama koneksi, host, port, username, password, database default, tipe database (`MySQL`, `PostgreSQL`, `SQLite`, `Redis`, `MsSQL`, `MongoDB`, dll.), SSL settings, SSH tunneling & credentials, custom views, dan replication settings. - - Menyimpan struktur pengelompokan folder koneksi (`connection_folders`). - - Penanganan kredensial: saat diekspor, kredensial diambil dari state memori/secret store; saat diimpor, kredensial disimpan ulang ke SQLite dan di-externalize ke secrets store (`externalize_connection_secrets`). - -2. **Saved Queries (Koleksi Query SQL)**: - - Seluruh file query SQL (`.sql`) beserta subfoldernya yang tersimpan di direktori aplikasi `{app_data}/query/`. - - Mempertahankan header metadata Tabular seperti `-- tabular:connection_id=...`, `-- tabular:database=...`, dan hierarki foldernya. - -3. **HTTP API (Koleksi & Workspace HTTP)**: - - Seluruh workspace HTTP (`HttpWorkspace`), subfolder (`HttpFolder`), saved requests (`SavedRequest`), dan variabel environment (`YaakEnvironment`) dari direktori `{app_data}/http_collections/`. - -4. **History (Riwayat Eksekusi Query)**: - - Seluruh log riwayat query dari tabel `query_history` di SQLite (`id`, `query_text`, `connection_id`, `connection_name`, `executed_at`). - - Penanganan relasi Foreign Key: penyesuaian `connection_id` dengan ID baru jika koneksi diimpor ke basis data target yang memiliki ID berbeda (berdasarkan pencocokan `connection_name`). - -### 1.3 Format Struktur Arsip ZIP -Format arsip ZIP didesain modular, aman, dan mudah dibaca secara terstruktur: - -``` -tabular_backup_YYYYMMDD_HHMMSS.zip -├── manifest.json -├── connections/ -│ └── connections.json -├── queries/ -│ ├── analytics/ -│ │ └── monthly_report.sql -│ └── schema_init.sql -├── http_collections/ -│ ├── ws_1710000000_1.json -│ └── ws_1710000000_2.json -└── history/ - └── history.json -``` - -- **`manifest.json`**: - ```json - { - "version": "1.0", - "app": "Tabular", - "exported_at": "2026-09-09T14:30:00Z", - "counts": { - "connections": 5, - "connection_folders": 2, - "queries": 14, - "http_workspaces": 3, - "history_items": 100 - }, - "includes": { - "connections": true, - "queries": true, - "http_api": true, - "history": true - } - } - ``` - ---- - -## 2. Desain Arsitektur & Rencana Perubahan Berkas - -### 2.1 Modul Baru - -#### A. `src/export_import_all.rs` (Core Logic Module) -Modul independen untuk operasi kompresi, dekompresi, serialisasi, validasi, dan persistensi database: -- **Tipe Data & Model**: - - `ExportAllManifest`: Metadata arsip. - - `ExportAllOptions`: Opsi export (pilihan kategori yang disertakan). - - `ImportAllOptions`: Opsi import (pilihan kategori yang ingin di-restore, serta strategi konflik: `MergeKeepExisting`, `MergeOverwrite`, atau `CleanRestore`). - - `ExportSummary` & `ImportSummary`: Laporan jumlah data yang berhasil diproses. - - `ExportImportError`: Error handling komprehensif menggunakan `thiserror`. -- **Fungsi Inti**: - - `pub fn export_all_data(tabular: &Tabular, target_path: &Path, options: &ExportAllOptions) -> Result` - - Mengumpulkan data dari in-memory state dan file system. - - Menulis ke ZIP menggunakan `zip::ZipWriter` dengan kompresi Deflate. - - `pub fn inspect_archive(archive_path: &Path) -> Result` - - Membaca `manifest.json` dan menghitung preview entri sebelum proses restore dijalankan. - - `pub fn import_all_data(tabular: &mut Tabular, archive_path: &Path, options: &ImportAllOptions) -> Result` - - Membuka ZIP dengan `zip::ZipArchive`. - - Memvalidasi path entri (mencegah Zip Slip vulnerability). - - Memulihkan koneksi database & folder koneksi ke SQLite serta mendaftarkan secret credentials. - - Mengekstrak file query ke `{app_data}/query/`. - - Mengekstrak file workspace HTTP ke `{app_data}/http_collections/`. - - Menyimpan history ke tabel `query_history` dengan mapping ID koneksi. - - Merefresh in-memory state Tabular (`load_connection_folders`, `load_queries_from_directory`, `load_workspaces`, `load_query_history`, dan trigger `needs_refresh`). - -#### B. `src/dialog_export_import_all.rs` (UI Dialog Module) -Komponen dialog berbasis `egui`: -- **`ExportAllDialogState`**: - - Pilihan kategori data (checkboxes: Connections, Queries, HTTP API, History). - - Target path file ZIP default (misal: `~/Downloads/tabular_backup_YYYYMMDD_HHMMSS.zip`). - - Status eksekusi (Idle, InProgress, Completed, Error). - - Ringkasan hasil ekspor. -- **`ImportAllDialogState`**: - - File picker untuk memilih file `.zip`. - - Preview manifest hasil inspeksi (jumlah item yang ditemukan di dalam file ZIP). - - Checkbox pilihan data yang ingin di-restore. - - Opsi penanganan duplikasi (Merge / Overwrite). - - Tombol aksi "Restore Now" dan progress bar / status banner. +Saat bekerja dengan banyak tab query SQL, tabel basis data, atau API HTTP, tab editor sering kali menumpuk tanpa pengelompokan yang jelas. Pengguna membutuhkan: +1. Kemampuan untuk **menyeret dan menggeser tab** (drag-and-drop horizontal) secara interaktif untuk mengatur urutan dan mengelompokkan tab yang saling berhubungan. +2. Fitur **Pin Tab (📌)** untuk menandai tab-tab penting (koneksi aktif, query pemantauan, atau referensi) agar selalu berada di posisi paling kiri dan tidak tertutup secara tidak sengaja. +3. Menu konteks navigasi tab yang lengkap (Pin/Unpin, Move Left/Right, Close, Close Others, Close to the Right). + +### 1.2 Batasan Arsitektural & Invarian +1. **Invarian Urutan Tab (Pinned vs Unpinned)**: + - Seluruh tab yang memiliki status `is_pinned == true` harus selalu berada di sebelah kiri tab-tab yang `is_pinned == false`. + - Garis pembatas visual (vertical divider) memisahkan kelompok tab pinned dari kelompok tab unpinned. + - Jika pengguna menyeret tab unpinned ke dalam zona pinned, tab tersebut otomatis menjadi pinned. + - Jika pengguna menyeret tab pinned ke dalam zona unpinned, tab tersebut otomatis menjadi unpinned. +2. **Integritas Sesi Transaksi & Sumber Daya**: + - Tab yang ditutup melalui *Close Other Tabs* atau *Close Tabs to the Right* harus memanggil `session.close()` jika memiliki manual-commit transaction handle (`SessionHandle`), mencegah kebocoran sesi koneksi. +3. **Pembaruan Sinkron Indeks Tab Aktif (`active_tab_index`)**: + - Pemindahan tab ke kanan maupun ke kiri harus memperbarui `active_tab_index` secara presisi tanpa out-of-bounds index atau desinkronisasi konten editor. +4. **Resilience & Liveness Drag State**: + - Drag state (`dragged_tab_index`) harus aman dari kondisi stuck: pembatalan via tombol `Escape`, pelepasan pointer primer (`button_released(PointerButton::Primary)`), liveness check saat pointer tidak lagi ditekan (`!primary_down`), serta toleransi batas vertikal (`is_within_tab_bar_y`). --- -### 2.2 Berkas yang Dimodifikasi - -1. **`src/main.rs` / `src/lib.rs`**: - - Daftarkan modul baru: - ```rust - pub mod export_import_all; - pub mod dialog_export_import_all; - ``` - -2. **`src/window_egui/mod.rs`**: - - Tambahkan state flag & dialog state di struct `Tabular`: - ```rust - pub show_export_all_dialog: bool, - pub show_import_all_dialog: bool, - pub export_all_state: Option, - pub import_all_state: Option, - ``` - -3. **`src/window_egui/init.rs`**: - - Inisialisasi field baru tersebut dengan `false` dan `None`. - -4. **`src/window_egui/app_impl.rs`**: - - **Gear Settings Context Menu** (baris ~2535): - - Tambahkan item menu: - - `📦 Export All Data (.zip)...` -> membuka `show_export_all_dialog = true` - - `📥 Import & Restore All Data (.zip)...` -> membuka `show_import_all_dialog = true` - - **Settings Window - Data Directory Tab (`PrefTab::DataDirectory`)** (baris ~437): - - Tambahkan kartu UI "Backup & Restore Application Data" dengan tombol "Export All to ZIP" dan "Import from ZIP". - - **Render Loop** (baris ~4995): - - Render `render_export_all_dialog(self, ctx)` saat `self.show_export_all_dialog == true`. - - Render `render_import_all_dialog(self, ctx)` saat `self.show_import_all_dialog == true`. - -5. **`src/quick_open.rs`**: - - Daftarkan perintah ke Command Palette: - - `Export All Data (ZIP)` - - `Import All Data (ZIP)` - ---- - -## 3. Analisis Potensi Risiko & Strategi Mitigasi - -| Risiko | Dampak | Strategi Mitigasi | -| :--- | :--- | :--- | -| **Zip Slip / Path Traversal Attack** | File jahat di dalam ZIP dapat menimpa berkas sistem sembarang (`../../etc/shadow`). | Wajib menggunakan `file.enclosed_name()` dari crate `zip` dan membatasi ekstraksi hanya di dalam subdirektori tujuan yang sah (`query/` dan `http_collections/`). | -| **Foreign Key Constraint pada `query_history`** | `query_history` memiliki relasi `connection_id -> connections(id) ON DELETE CASCADE`. Jika `connection_id` lama tidak ditemukan, query insert history gagal. | Buat tabel mapping ID lama ke ID baru berdasarkan kesamaan nama koneksi (`connection_name`). Jika koneksi belum ada, buat koneksi terlebih dahulu atau kaitkan ke koneksi default yang valid. | -| **Penanganan Kredensial & Secrets** | Password atau SSH key tersimpan sebagai sentinel di SQLite dan data asli di keychain/secret store. | Saat ekspor, ambil data dari `tabular.connections` (yang sudah ter-dekripsi di RAM). Saat import, panggil `externalize_connection_secrets` agar kredensial tersimpan aman di database dan secret backend sistem target. | -| **UI Freeze saat Arsip Besar** | Aplikasi tidak responsif selama kompresi/ekstraksi I/O. | Jalankan proses kompresi dan dekompresi ZIP di background thread / tokio runtime async dengan `mpsc` channel untuk mengirim progress dan hasil ke UI thread. | -| **In-Memory State Stale setelah Restore** | Data sudah masuk ke SQLite/disk tetapi tampilan sidebar tidak terupdate. | Panggil fungsi reload: `sidebar_database::load_connection_folders()`, `sidebar_query::load_queries_from_directory()`, `http_collection::load_workspaces()`, dan `sidebar_history::load_query_history()`, serta set `tabular.needs_refresh = true`. | - ---- +## 2. Desain Solusi Teknis & Struktur Data + +### 2.1 Model Data Tab (`src/models/structs.rs`) +Menambahkan atribut `is_pinned` pada struct `QueryTab`: +```rust +pub struct QueryTab { + pub id: usize, + pub title: String, + pub content: String, + pub file_path: Option, + pub is_saved: bool, + pub is_modified: bool, + pub is_pinned: bool, + ... +} +``` -## 4. Tahapan Verifikasi & Pengujian +### 2.2 Field State pada `Tabular` (`src/window_egui/mod.rs` & `init.rs`) +Menambahkan field state penanda tab yang sedang diseret: +```rust +pub struct Tabular { + ... + pub dragged_tab_index: Option, + ... +} +``` -1. **Uji Kompilasi (`cargo check`)**: - - Memastikan tidak ada compile error, type mismatch, atau broken references. -2. **Automated Unit Tests**: - - Buat unit test komprehensif di `src/export_import_all.rs`: - - Test pembuatan mock connection, query file, HTTP workspace, dan history item. - - Test ekspor ke buffer/file ZIP sementara dan verifikasi integritas ZIP serta isi `manifest.json`. - - Test pembacaan dan validasi isi arsip (`inspect_archive`). - - Test impor ke direktori sementara dan verifikasi bahwa data koneksi, queries, HTTP collection, dan history berhasil dipulihkan secara identik. - - Test proteksi Zip Slip (path traversal rejected). - - Test pemulihan relasi `query_history` saat connection ID berubah. -3. **Uji Integrasi UI**: - - Verifikasi pembukaan dialog via Gear Menu dan Tab Settings Data Directory. - - Verifikasi pemilihan file picker (`rfd::FileDialog`). - - Verifikasi feedback visual, progress bar, dan notifikasi keberhasilan pemulihan data. +### 2.3 Operasi Tab Editor (`src/editor.rs`) +Implementasi fungsi-fungsi manipulasi tab: +- `move_tab(tabular, from, to)`: Memindahkan posisi tab secara langsung dengan sinkronisasi `is_pinned` saat melewati batas dan penyesuaian `active_tab_index`. +- `reorder_tab(tabular, from, insert_at)`: Menghitung target indeks dari slot drop penyisipan kursor. +- `pin_tab(tabular, tab_index)`: Menyematkan tab dan memindahkannya ke akhir kelompok pinned. +- `unpin_tab(tabular, tab_index)`: Melepas sematan tab dan memindahkannya ke awal kelompok unpinned. +- `toggle_pin_tab(tabular, tab_index)`: Toggle antara pin dan unpin. +- `close_other_tabs(tabular, keep_index)`: Menutup seluruh tab lain kecuali tab yang dipilih dan semua tab pinned. +- `close_tabs_to_the_right(tabular, tab_index)`: Menutup tab unpinned di sebelah kanan indeks target. + +### 2.4 Antarmuka Pengguna & Interaksi (`src/window_egui/app_impl.rs`) +1. **Drag Detection**: + - Menggunakan `allocate_exact_size` dengan `Sense::click_and_drag()`. + - Mengaktifkan drag hanya saat pointer primer ditekan (`drag_started_by(PointerButton::Primary)`). +2. **Visual Feedback**: + - Kursor berubah menjadi `egui::CursorIcon::Grabbing`. + - Floating ghost badge melayang mengikuti posisi pointer dengan ikon (📌 / 📑) dan judul tab. + - Garis indikator penyisipan vertikal berwarna aksen tema dengan aksen cap atas & bawah di antara slot drop. +3. **Context Menu**: + - Menu klik kanan pada setiap tab: Pin/Unpin Tab, Move Left/Right, Close Tab, Close Other Tabs, Close Tabs to the Right. +4. **Proteksi & Tombol Aksi**: + - Tombol close ("×") digantikan oleh ikon pin ("📌") pada tab pinned. + - Quick-pin button muncul saat hover pada tab unpinned. + - Middle-click menutup tab biasa secara cepat tanpa menutup tab pinned. --- -## 5. Kesimpulan & Batasan Tahap Ini (Stage 1 Scope) - -Sesuai instruksi tugas, pengerjaan pada **Tahap 1 (Planning Phase)** dibatasi hanya pada penyusunan dokumen perencanaan teknis ini di file `implementation_plan.md`. Tidak ada kode aplikasi yang diubah pada tahap ini sebelum rencana ini ditinjau dan disetujui oleh pengguna. +## 3. Rencana Pengujian & Validasi + +### 3.1 Unit Test Skenario +1. `test_query_tab_pinning`: Pengujian inisialisasi default `is_pinned`. +2. `test_move_tab_and_active_index`: Pengujian pergeseran `active_tab_index` saat tab aktif dipindah atau tab lain digeser. +3. `test_reorder_tab_with_insert_slots`: Pengujian pemetaan slot penyisipan kursor ke indeks target. +4. `test_pin_and_unpin_tab`: Pengujian transisi status pin dan perpindahan grup tab. +5. `test_pin_tab_shifts_active_index_correctly`: Pengujian pergeseran indeks tab aktif saat tab di kanannya di-pin. +6. `test_unpin_tab_shifts_active_index_correctly`: Pengujian pergeseran indeks tab aktif saat tab di kirinya di-unpin. +7. `test_close_other_tabs_protects_pinned`: Pengujian retensi tab pinned saat operasi close-others dipanggil. +8. `test_close_tabs_to_the_right`: Pengujian retensi tab pinned saat operasi close-tabs-to-right dipanggil. +9. `test_close_tabs_to_the_right_active_tab_switch`: Pengujian pengalihan tab aktif ke target sebelum penutupan tab kanan. +10. `test_move_tab_crossing_pinned_boundary_both_ways`: Pengujian transisi otomatis status pin dua arah saat melintasi batas pemisah. +11. `test_tab_bounds_safety`: Pengujian ketahanan dan ketiadaan panic saat input indeks melebihi batas (out-of-bounds). diff --git a/install.arch.sh b/install.arch.sh old mode 100644 new mode 100755 diff --git a/walkthrough.md b/walkthrough.md deleted file mode 100644 index 5c3637c4..00000000 --- a/walkthrough.md +++ /dev/null @@ -1,116 +0,0 @@ -# Walkthrough: Fitur Export dan Import Seluruh Data Aplikasi (ZIP) - -Dokumen ini mendokumentasikan implementasi dan verifikasi fitur **Export & Import Seluruh Data** (Database Connections, Saved Queries, HTTP API Collections, dan Query History) dalam format arsip **ZIP** dengan kemampuan **Restore** lengkap di Tabular. - ---- - -## 1. Ringkasan Fitur - -Fitur ini menyediakan mekanisme backup dan migrasi menyeluruh untuk pengguna Tabular: -1. **Pengeksporan Lengkap (Export All to ZIP)**: - - Mengemas 4 domain data utama ke dalam 1 berkas `.zip`: - - **Database Connections & Folders**: Semua profil koneksi database (termasuk kredensial, SSL/SSH tunneling, custom views) dan struktur foldernya. - - **Saved Queries**: Semua berkas `.sql` dan hierarki subdirektorinya dari `{app_data}/query/`. - - **HTTP API Collections**: Semua workspace (`HttpWorkspace`), folder, saved request, dan environment dari `{app_data}/http_collections/`. - - **Query Execution History**: Seluruh riwayat eksekusi query dari basis data lokal SQLite (`query_history`). - - Menyertakan berkas `manifest.json` berisi versi format, timestamp, indikator modul, dan jumlah entri yang diekspor. -2. **Pratinjau & Inspeksi Arsip (Archive Inspection)**: - - Mampu memeriksa file ZIP sebelum restore dilakukan untuk membaca `manifest.json` atau menghitung entri di dalam arsip secara aman tanpa mengekstraknya ke disk sistem. -3. **Pemulihan & Penanganan Konflik (Restore & Conflict Handling)**: - - Mendukung 3 strategi konflik: - - `Merge (Keep Existing)`: Menambahkan data baru tanpa menimpa data yang sudah ada. - - `Merge (Overwrite Existing)`: Memperbarui data yang telah ada dan menambahkan data baru. - - `Clean Restore (Replace All)`: Menghapus data lama dan memulihkan data dari arsip secara bersih. - - Mengamankan kredensial kembali ke backend secrets (`externalize_connection_secrets`). - - Memetakan relasi `connection_id` pada riwayat query agar integritas Foreign Key tetap terjaga. - - Melindungi sistem dari serangan keamanan **Zip Slip (Path Traversal)**. - - Merefresh status in-memory Tabular secara instan tanpa perlu me-restart aplikasi. - ---- - -## 2. Berkas yang Dibuat & Dimodifikasi - -| Berkas | Status | Deskripsi | -| :--- | :--- | :--- | -| `src/export_import_all.rs` | **Baru** | Modul logika inti kompresi, inspeksi ZIP, manifest, sanitasi zip slip, serialisasi/deserialisasi 4 domain data, dan mapping relasi database. | -| `src/dialog_export_import_all.rs` | **Baru** | Komponen UI modal `egui` untuk dialog Export All dan dialog Import & Restore All dengan file picker native (`rfd`), status progress, dan laporan ringkasan. | -| `src/lib.rs` | Diubah | Mendaftarkan modul `export_import_all` dan `dialog_export_import_all`. | -| `src/sidebar_database.rs` | Diubah | Mengubah visibilitas `externalize_connection_secrets` menjadi `pub(crate)` agar dapat dipanggil saat restore koneksi. | -| `src/window_egui/mod.rs` | Diubah | Menambahkan field state `show_export_all_dialog`, `show_import_all_dialog`, `export_all_state`, dan `import_all_state` pada struct `Tabular`. | -| `src/window_egui/init.rs` | Diubah | Inisialisasi awal dialog state pada constructor `Tabular::new()`. | -| `src/window_egui/app_impl.rs` | Diubah | Menambahkan menu "Export All Data (ZIP)..." dan "Import All Data (ZIP)..." pada Gear Menu, kartu backup/restore pada Preferences > Data Directory, serta pemanggilan render dialog di loop utama. | -| `src/editor.rs` | Diubah | Menambahkan handler eksekusi command `"Export All Data (ZIP)"` dan `"Import All Data (ZIP)"`. | -| `src/quick_open.rs` | Diubah | Mendaftarkan perintah Export & Import ke Command Palette (Quick Open). | - ---- - -## 3. Struktur Berkas di Dalam ZIP - -``` -tabular_backup_YYYYMMDD_HHMMSS.zip -├── manifest.json -├── connections/ -│ ├── connections.json -│ └── folders.json -├── queries/ -│ ├── analytics/ -│ │ └── monthly_report.sql -│ └── schema_init.sql -├── http_collections/ -│ ├── ws_1710000000_1.json -│ └── ws_1710000000_2.json -└── history/ - └── history.json -``` - ---- - -## 4. Hasil Verifikasi & Pengujian - -### 4.1 Unit & Integration Tests (`src/export_import_all.rs`) -Dijalankan melalui `cargo test --lib export_import_all`: -``` -running 7 tests -test export_import_all::tests::test_conflict_strategy_labels ... ok -test export_import_all::tests::test_manifest_serialization ... ok -test export_import_all::tests::test_zip_slip_detection_in_archive ... ok -test export_import_all::tests::test_zip_options ... ok -test export_import_all::tests::test_zip_slip_rejection ... ok -test export_import_all::tests::test_archive_creation_and_inspection ... ok -test export_import_all::tests::test_roundtrip_export_import ... ok - -test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 180 filtered out -``` - -Pengujian mencakup: -- **`test_conflict_strategy_labels`**: Memverifikasi teks nama dan deskripsi tiap opsi resolusi konflik. -- **`test_manifest_serialization`**: Memverifikasi serialisasi dan deserialisasi struktur manifest JSON. -- **`test_zip_slip_rejection`**: Memverifikasi penolakan arsip ZIP berbahaya yang berisi path traversal (contoh: `../../etc/malicious.txt`). -- **`test_archive_creation_and_inspection`**: Menguji pembuatan arsip ZIP tiruan dan verifikasi parsing inspeksi manifest sebelum restore. -- **`test_roundtrip_export_import`**: Pengujian end-to-end lengkap yang membuat koneksi, folder, HTTP workspace, dan riwayat query ke database SQLite sementara, mengekspornya ke berkas ZIP, menginspeksi arsip, menghapus memori aplikasi, dan memulihkan kembali seluruh data melalui `import_all_data`. - -### 4.2 Keseluruhan Test Suite Workspace -Dijalankan melalui `cargo test`: -- **Library tests**: 187 passed; 0 failed. -- **Editor buffer tests**: 4 passed; 0 failed. -- **Find replace tests**: 6 passed; 0 failed. -- **Query AST tests**: 15 passed; 0 failed. -- **Syntax tree-sitter tests**: 4 passed; 0 failed. -- **Total**: **216 tests passed, 0 failed**. - -### 4.3 Verifikasi Kompilasi -Perintah `cargo check` berhasil tanpa error. - ---- - -## 5. Cara Penggunaan Fitur - -1. **Melalui Gear Settings Menu**: - - Klik ikon gerigi (Settings) di pojok kiri bawah. - - Pilih **📦 Export All Data (ZIP)...** untuk mengekspor data. - - Pilih **📥 Import All Data (ZIP)...** untuk merestore data dari file ZIP. -2. **Melalui Preferences**: - - Buka Preferences (`⌘,`) > tab **Data Directory**. - - Pada bagian **Backup & Restore All Data**, klik tombol "Export All Data (ZIP)..." atau "Import & Restore All Data (ZIP)...". -3. **Melalui Command Palette (`⌘P`)**: - - Tekan `⌘P` dan ketik `Export All Data (ZIP)` atau `Import All Data (ZIP)` lalu tekan Enter. diff --git a/walkthrough_move_tab_editor.md b/walkthrough_move_tab_editor.md index 35cad22d..e898d668 100644 --- a/walkthrough_move_tab_editor.md +++ b/walkthrough_move_tab_editor.md @@ -42,6 +42,7 @@ Dokumen ini mendokumentasikan implementasi dan verifikasi fitur **Drag & Drop Ta | **Pemicuan Drag Tombol Non-Primer** | `src/window_egui/app_impl.rs:2150` | Mengganti `drag_started()` menjadi `drag_started_by(egui::PointerButton::Primary)`. | | **Starvation / Stuck Drag State** | `src/window_egui/app_impl.rs:2476` | Menambahkan pembersihan state drag jika primary pointer tidak lagi ditekan (`!primary_down`) atau tab count berubah. | | **Pembersihan Compiler Warning** | `src/auto_updater.rs:3` | Menghapus import `debug` yang tidak digunakan guna menjamin *zero compiler warnings*. | +| **Integritas Dokumen Rencana & Arsitektur** | `implementation_plan_move_tab_editor.md` | Menyusun kembali dokumen spesifikasi rencana teknis perancangan tab drag & drop dan tab pinning secara menyeluruh dan presisi. | | **Integritas Dokumentasi & Mode Eksekusi Berkas** | Root Workspace | Memulihkan berkas `walkthrough.md` dan `implementation_plan.md` root repositori dari `origin/main`, menjaga pemisahan dokumen per fitur (`walkthrough_move_tab_editor.md`), serta mengembalikan izin berkas skrip shell ke `100644`. | --- From b1bb288ee292b3cf6108440a672daed9e737ace0 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Thu, 10 Sep 2026 10:32:46 +0000 Subject: [PATCH 12/18] fix(docs): restore root implementation_plan.md and walkthrough.md and restore script permissions --- build_deb.sh | 0 flatpak_build.sh | 0 flatpak_publish.sh | 0 implementation_plan.md | 190 +++++++++++++++++++++++++++++++++++++++++ install.arch.sh | 0 walkthrough.md | 116 +++++++++++++++++++++++++ 6 files changed, 306 insertions(+) mode change 100755 => 100644 build_deb.sh mode change 100755 => 100644 flatpak_build.sh mode change 100755 => 100644 flatpak_publish.sh create mode 100644 implementation_plan.md mode change 100755 => 100644 install.arch.sh create mode 100644 walkthrough.md diff --git a/build_deb.sh b/build_deb.sh old mode 100755 new mode 100644 diff --git a/flatpak_build.sh b/flatpak_build.sh old mode 100755 new mode 100644 diff --git a/flatpak_publish.sh b/flatpak_publish.sh old mode 100755 new mode 100644 diff --git a/implementation_plan.md b/implementation_plan.md new file mode 100644 index 00000000..199cc61a --- /dev/null +++ b/implementation_plan.md @@ -0,0 +1,190 @@ +# Rencana Teknis Implementasi: Export & Import Seluruh Data (ZIP) + +Dokumen ini menjelaskan rencana teknis menyeluruh untuk menambahkan fitur **Export dan Import seluruh data Tabular** (Database Connections, Saved Queries, HTTP API Collections, dan Query History) dalam format file **ZIP**, serta mekanisme **Restore** data ke dalam sistem. + +--- + +## 1. Analisis Kebutuhan (Requirements Analysis) + +### 1.1 Latar Belakang & Tujuan +Saat ini pengguna Tabular dapat melakukan backup pada level database engine tertentu (seperti `pg_dump` atau `mysqldump`), serta import koleksi HTTP individual (seperti Postman atau Yaak). Namun, belum ada mekanisme terpadu untuk: +1. Mengekspor **seluruh konfigurasi dan workspace pengguna** sekaligus ke dalam 1 file arsip portable (ZIP). +2. Memindahkan atau memulihkan seluruh data aplikasi (migrasi perangkat atau pemulihan bencana). +3. Mengimpor kembali file ZIP tersebut sehingga seluruh data (**Connection DB**, **Query**, **HTTP API**, dan **History**) ter-restore dengan aman, konsisten, dan langsung aktif di antarmuka aplikasi. + +### 1.2 Cakupan Data (4 Domain Utama) +1. **Connection DB (Koneksi Database)**: + - Menyimpan seluruh profil koneksi (`ConnectionConfig`): nama koneksi, host, port, username, password, database default, tipe database (`MySQL`, `PostgreSQL`, `SQLite`, `Redis`, `MsSQL`, `MongoDB`, dll.), SSL settings, SSH tunneling & credentials, custom views, dan replication settings. + - Menyimpan struktur pengelompokan folder koneksi (`connection_folders`). + - Penanganan kredensial: saat diekspor, kredensial diambil dari state memori/secret store; saat diimpor, kredensial disimpan ulang ke SQLite dan di-externalize ke secrets store (`externalize_connection_secrets`). + +2. **Saved Queries (Koleksi Query SQL)**: + - Seluruh file query SQL (`.sql`) beserta subfoldernya yang tersimpan di direktori aplikasi `{app_data}/query/`. + - Mempertahankan header metadata Tabular seperti `-- tabular:connection_id=...`, `-- tabular:database=...`, dan hierarki foldernya. + +3. **HTTP API (Koleksi & Workspace HTTP)**: + - Seluruh workspace HTTP (`HttpWorkspace`), subfolder (`HttpFolder`), saved requests (`SavedRequest`), dan variabel environment (`YaakEnvironment`) dari direktori `{app_data}/http_collections/`. + +4. **History (Riwayat Eksekusi Query)**: + - Seluruh log riwayat query dari tabel `query_history` di SQLite (`id`, `query_text`, `connection_id`, `connection_name`, `executed_at`). + - Penanganan relasi Foreign Key: penyesuaian `connection_id` dengan ID baru jika koneksi diimpor ke basis data target yang memiliki ID berbeda (berdasarkan pencocokan `connection_name`). + +### 1.3 Format Struktur Arsip ZIP +Format arsip ZIP didesain modular, aman, dan mudah dibaca secara terstruktur: + +``` +tabular_backup_YYYYMMDD_HHMMSS.zip +├── manifest.json +├── connections/ +│ └── connections.json +├── queries/ +│ ├── analytics/ +│ │ └── monthly_report.sql +│ └── schema_init.sql +├── http_collections/ +│ ├── ws_1710000000_1.json +│ └── ws_1710000000_2.json +└── history/ + └── history.json +``` + +- **`manifest.json`**: + ```json + { + "version": "1.0", + "app": "Tabular", + "exported_at": "2026-09-09T14:30:00Z", + "counts": { + "connections": 5, + "connection_folders": 2, + "queries": 14, + "http_workspaces": 3, + "history_items": 100 + }, + "includes": { + "connections": true, + "queries": true, + "http_api": true, + "history": true + } + } + ``` + +--- + +## 2. Desain Arsitektur & Rencana Perubahan Berkas + +### 2.1 Modul Baru + +#### A. `src/export_import_all.rs` (Core Logic Module) +Modul independen untuk operasi kompresi, dekompresi, serialisasi, validasi, dan persistensi database: +- **Tipe Data & Model**: + - `ExportAllManifest`: Metadata arsip. + - `ExportAllOptions`: Opsi export (pilihan kategori yang disertakan). + - `ImportAllOptions`: Opsi import (pilihan kategori yang ingin di-restore, serta strategi konflik: `MergeKeepExisting`, `MergeOverwrite`, atau `CleanRestore`). + - `ExportSummary` & `ImportSummary`: Laporan jumlah data yang berhasil diproses. + - `ExportImportError`: Error handling komprehensif menggunakan `thiserror`. +- **Fungsi Inti**: + - `pub fn export_all_data(tabular: &Tabular, target_path: &Path, options: &ExportAllOptions) -> Result` + - Mengumpulkan data dari in-memory state dan file system. + - Menulis ke ZIP menggunakan `zip::ZipWriter` dengan kompresi Deflate. + - `pub fn inspect_archive(archive_path: &Path) -> Result` + - Membaca `manifest.json` dan menghitung preview entri sebelum proses restore dijalankan. + - `pub fn import_all_data(tabular: &mut Tabular, archive_path: &Path, options: &ImportAllOptions) -> Result` + - Membuka ZIP dengan `zip::ZipArchive`. + - Memvalidasi path entri (mencegah Zip Slip vulnerability). + - Memulihkan koneksi database & folder koneksi ke SQLite serta mendaftarkan secret credentials. + - Mengekstrak file query ke `{app_data}/query/`. + - Mengekstrak file workspace HTTP ke `{app_data}/http_collections/`. + - Menyimpan history ke tabel `query_history` dengan mapping ID koneksi. + - Merefresh in-memory state Tabular (`load_connection_folders`, `load_queries_from_directory`, `load_workspaces`, `load_query_history`, dan trigger `needs_refresh`). + +#### B. `src/dialog_export_import_all.rs` (UI Dialog Module) +Komponen dialog berbasis `egui`: +- **`ExportAllDialogState`**: + - Pilihan kategori data (checkboxes: Connections, Queries, HTTP API, History). + - Target path file ZIP default (misal: `~/Downloads/tabular_backup_YYYYMMDD_HHMMSS.zip`). + - Status eksekusi (Idle, InProgress, Completed, Error). + - Ringkasan hasil ekspor. +- **`ImportAllDialogState`**: + - File picker untuk memilih file `.zip`. + - Preview manifest hasil inspeksi (jumlah item yang ditemukan di dalam file ZIP). + - Checkbox pilihan data yang ingin di-restore. + - Opsi penanganan duplikasi (Merge / Overwrite). + - Tombol aksi "Restore Now" dan progress bar / status banner. + +--- + +### 2.2 Berkas yang Dimodifikasi + +1. **`src/main.rs` / `src/lib.rs`**: + - Daftarkan modul baru: + ```rust + pub mod export_import_all; + pub mod dialog_export_import_all; + ``` + +2. **`src/window_egui/mod.rs`**: + - Tambahkan state flag & dialog state di struct `Tabular`: + ```rust + pub show_export_all_dialog: bool, + pub show_import_all_dialog: bool, + pub export_all_state: Option, + pub import_all_state: Option, + ``` + +3. **`src/window_egui/init.rs`**: + - Inisialisasi field baru tersebut dengan `false` dan `None`. + +4. **`src/window_egui/app_impl.rs`**: + - **Gear Settings Context Menu** (baris ~2535): + - Tambahkan item menu: + - `📦 Export All Data (.zip)...` -> membuka `show_export_all_dialog = true` + - `📥 Import & Restore All Data (.zip)...` -> membuka `show_import_all_dialog = true` + - **Settings Window - Data Directory Tab (`PrefTab::DataDirectory`)** (baris ~437): + - Tambahkan kartu UI "Backup & Restore Application Data" dengan tombol "Export All to ZIP" dan "Import from ZIP". + - **Render Loop** (baris ~4995): + - Render `render_export_all_dialog(self, ctx)` saat `self.show_export_all_dialog == true`. + - Render `render_import_all_dialog(self, ctx)` saat `self.show_import_all_dialog == true`. + +5. **`src/quick_open.rs`**: + - Daftarkan perintah ke Command Palette: + - `Export All Data (ZIP)` + - `Import All Data (ZIP)` + +--- + +## 3. Analisis Potensi Risiko & Strategi Mitigasi + +| Risiko | Dampak | Strategi Mitigasi | +| :--- | :--- | :--- | +| **Zip Slip / Path Traversal Attack** | File jahat di dalam ZIP dapat menimpa berkas sistem sembarang (`../../etc/shadow`). | Wajib menggunakan `file.enclosed_name()` dari crate `zip` dan membatasi ekstraksi hanya di dalam subdirektori tujuan yang sah (`query/` dan `http_collections/`). | +| **Foreign Key Constraint pada `query_history`** | `query_history` memiliki relasi `connection_id -> connections(id) ON DELETE CASCADE`. Jika `connection_id` lama tidak ditemukan, query insert history gagal. | Buat tabel mapping ID lama ke ID baru berdasarkan kesamaan nama koneksi (`connection_name`). Jika koneksi belum ada, buat koneksi terlebih dahulu atau kaitkan ke koneksi default yang valid. | +| **Penanganan Kredensial & Secrets** | Password atau SSH key tersimpan sebagai sentinel di SQLite dan data asli di keychain/secret store. | Saat ekspor, ambil data dari `tabular.connections` (yang sudah ter-dekripsi di RAM). Saat import, panggil `externalize_connection_secrets` agar kredensial tersimpan aman di database dan secret backend sistem target. | +| **UI Freeze saat Arsip Besar** | Aplikasi tidak responsif selama kompresi/ekstraksi I/O. | Jalankan proses kompresi dan dekompresi ZIP di background thread / tokio runtime async dengan `mpsc` channel untuk mengirim progress dan hasil ke UI thread. | +| **In-Memory State Stale setelah Restore** | Data sudah masuk ke SQLite/disk tetapi tampilan sidebar tidak terupdate. | Panggil fungsi reload: `sidebar_database::load_connection_folders()`, `sidebar_query::load_queries_from_directory()`, `http_collection::load_workspaces()`, dan `sidebar_history::load_query_history()`, serta set `tabular.needs_refresh = true`. | + +--- + +## 4. Tahapan Verifikasi & Pengujian + +1. **Uji Kompilasi (`cargo check`)**: + - Memastikan tidak ada compile error, type mismatch, atau broken references. +2. **Automated Unit Tests**: + - Buat unit test komprehensif di `src/export_import_all.rs`: + - Test pembuatan mock connection, query file, HTTP workspace, dan history item. + - Test ekspor ke buffer/file ZIP sementara dan verifikasi integritas ZIP serta isi `manifest.json`. + - Test pembacaan dan validasi isi arsip (`inspect_archive`). + - Test impor ke direktori sementara dan verifikasi bahwa data koneksi, queries, HTTP collection, dan history berhasil dipulihkan secara identik. + - Test proteksi Zip Slip (path traversal rejected). + - Test pemulihan relasi `query_history` saat connection ID berubah. +3. **Uji Integrasi UI**: + - Verifikasi pembukaan dialog via Gear Menu dan Tab Settings Data Directory. + - Verifikasi pemilihan file picker (`rfd::FileDialog`). + - Verifikasi feedback visual, progress bar, dan notifikasi keberhasilan pemulihan data. + +--- + +## 5. Kesimpulan & Batasan Tahap Ini (Stage 1 Scope) + +Sesuai instruksi tugas, pengerjaan pada **Tahap 1 (Planning Phase)** dibatasi hanya pada penyusunan dokumen perencanaan teknis ini di file `implementation_plan.md`. Tidak ada kode aplikasi yang diubah pada tahap ini sebelum rencana ini ditinjau dan disetujui oleh pengguna. diff --git a/install.arch.sh b/install.arch.sh old mode 100755 new mode 100644 diff --git a/walkthrough.md b/walkthrough.md new file mode 100644 index 00000000..5c3637c4 --- /dev/null +++ b/walkthrough.md @@ -0,0 +1,116 @@ +# Walkthrough: Fitur Export dan Import Seluruh Data Aplikasi (ZIP) + +Dokumen ini mendokumentasikan implementasi dan verifikasi fitur **Export & Import Seluruh Data** (Database Connections, Saved Queries, HTTP API Collections, dan Query History) dalam format arsip **ZIP** dengan kemampuan **Restore** lengkap di Tabular. + +--- + +## 1. Ringkasan Fitur + +Fitur ini menyediakan mekanisme backup dan migrasi menyeluruh untuk pengguna Tabular: +1. **Pengeksporan Lengkap (Export All to ZIP)**: + - Mengemas 4 domain data utama ke dalam 1 berkas `.zip`: + - **Database Connections & Folders**: Semua profil koneksi database (termasuk kredensial, SSL/SSH tunneling, custom views) dan struktur foldernya. + - **Saved Queries**: Semua berkas `.sql` dan hierarki subdirektorinya dari `{app_data}/query/`. + - **HTTP API Collections**: Semua workspace (`HttpWorkspace`), folder, saved request, dan environment dari `{app_data}/http_collections/`. + - **Query Execution History**: Seluruh riwayat eksekusi query dari basis data lokal SQLite (`query_history`). + - Menyertakan berkas `manifest.json` berisi versi format, timestamp, indikator modul, dan jumlah entri yang diekspor. +2. **Pratinjau & Inspeksi Arsip (Archive Inspection)**: + - Mampu memeriksa file ZIP sebelum restore dilakukan untuk membaca `manifest.json` atau menghitung entri di dalam arsip secara aman tanpa mengekstraknya ke disk sistem. +3. **Pemulihan & Penanganan Konflik (Restore & Conflict Handling)**: + - Mendukung 3 strategi konflik: + - `Merge (Keep Existing)`: Menambahkan data baru tanpa menimpa data yang sudah ada. + - `Merge (Overwrite Existing)`: Memperbarui data yang telah ada dan menambahkan data baru. + - `Clean Restore (Replace All)`: Menghapus data lama dan memulihkan data dari arsip secara bersih. + - Mengamankan kredensial kembali ke backend secrets (`externalize_connection_secrets`). + - Memetakan relasi `connection_id` pada riwayat query agar integritas Foreign Key tetap terjaga. + - Melindungi sistem dari serangan keamanan **Zip Slip (Path Traversal)**. + - Merefresh status in-memory Tabular secara instan tanpa perlu me-restart aplikasi. + +--- + +## 2. Berkas yang Dibuat & Dimodifikasi + +| Berkas | Status | Deskripsi | +| :--- | :--- | :--- | +| `src/export_import_all.rs` | **Baru** | Modul logika inti kompresi, inspeksi ZIP, manifest, sanitasi zip slip, serialisasi/deserialisasi 4 domain data, dan mapping relasi database. | +| `src/dialog_export_import_all.rs` | **Baru** | Komponen UI modal `egui` untuk dialog Export All dan dialog Import & Restore All dengan file picker native (`rfd`), status progress, dan laporan ringkasan. | +| `src/lib.rs` | Diubah | Mendaftarkan modul `export_import_all` dan `dialog_export_import_all`. | +| `src/sidebar_database.rs` | Diubah | Mengubah visibilitas `externalize_connection_secrets` menjadi `pub(crate)` agar dapat dipanggil saat restore koneksi. | +| `src/window_egui/mod.rs` | Diubah | Menambahkan field state `show_export_all_dialog`, `show_import_all_dialog`, `export_all_state`, dan `import_all_state` pada struct `Tabular`. | +| `src/window_egui/init.rs` | Diubah | Inisialisasi awal dialog state pada constructor `Tabular::new()`. | +| `src/window_egui/app_impl.rs` | Diubah | Menambahkan menu "Export All Data (ZIP)..." dan "Import All Data (ZIP)..." pada Gear Menu, kartu backup/restore pada Preferences > Data Directory, serta pemanggilan render dialog di loop utama. | +| `src/editor.rs` | Diubah | Menambahkan handler eksekusi command `"Export All Data (ZIP)"` dan `"Import All Data (ZIP)"`. | +| `src/quick_open.rs` | Diubah | Mendaftarkan perintah Export & Import ke Command Palette (Quick Open). | + +--- + +## 3. Struktur Berkas di Dalam ZIP + +``` +tabular_backup_YYYYMMDD_HHMMSS.zip +├── manifest.json +├── connections/ +│ ├── connections.json +│ └── folders.json +├── queries/ +│ ├── analytics/ +│ │ └── monthly_report.sql +│ └── schema_init.sql +├── http_collections/ +│ ├── ws_1710000000_1.json +│ └── ws_1710000000_2.json +└── history/ + └── history.json +``` + +--- + +## 4. Hasil Verifikasi & Pengujian + +### 4.1 Unit & Integration Tests (`src/export_import_all.rs`) +Dijalankan melalui `cargo test --lib export_import_all`: +``` +running 7 tests +test export_import_all::tests::test_conflict_strategy_labels ... ok +test export_import_all::tests::test_manifest_serialization ... ok +test export_import_all::tests::test_zip_slip_detection_in_archive ... ok +test export_import_all::tests::test_zip_options ... ok +test export_import_all::tests::test_zip_slip_rejection ... ok +test export_import_all::tests::test_archive_creation_and_inspection ... ok +test export_import_all::tests::test_roundtrip_export_import ... ok + +test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 180 filtered out +``` + +Pengujian mencakup: +- **`test_conflict_strategy_labels`**: Memverifikasi teks nama dan deskripsi tiap opsi resolusi konflik. +- **`test_manifest_serialization`**: Memverifikasi serialisasi dan deserialisasi struktur manifest JSON. +- **`test_zip_slip_rejection`**: Memverifikasi penolakan arsip ZIP berbahaya yang berisi path traversal (contoh: `../../etc/malicious.txt`). +- **`test_archive_creation_and_inspection`**: Menguji pembuatan arsip ZIP tiruan dan verifikasi parsing inspeksi manifest sebelum restore. +- **`test_roundtrip_export_import`**: Pengujian end-to-end lengkap yang membuat koneksi, folder, HTTP workspace, dan riwayat query ke database SQLite sementara, mengekspornya ke berkas ZIP, menginspeksi arsip, menghapus memori aplikasi, dan memulihkan kembali seluruh data melalui `import_all_data`. + +### 4.2 Keseluruhan Test Suite Workspace +Dijalankan melalui `cargo test`: +- **Library tests**: 187 passed; 0 failed. +- **Editor buffer tests**: 4 passed; 0 failed. +- **Find replace tests**: 6 passed; 0 failed. +- **Query AST tests**: 15 passed; 0 failed. +- **Syntax tree-sitter tests**: 4 passed; 0 failed. +- **Total**: **216 tests passed, 0 failed**. + +### 4.3 Verifikasi Kompilasi +Perintah `cargo check` berhasil tanpa error. + +--- + +## 5. Cara Penggunaan Fitur + +1. **Melalui Gear Settings Menu**: + - Klik ikon gerigi (Settings) di pojok kiri bawah. + - Pilih **📦 Export All Data (ZIP)...** untuk mengekspor data. + - Pilih **📥 Import All Data (ZIP)...** untuk merestore data dari file ZIP. +2. **Melalui Preferences**: + - Buka Preferences (`⌘,`) > tab **Data Directory**. + - Pada bagian **Backup & Restore All Data**, klik tombol "Export All Data (ZIP)..." atau "Import & Restore All Data (ZIP)...". +3. **Melalui Command Palette (`⌘P`)**: + - Tekan `⌘P` dan ketik `Export All Data (ZIP)` atau `Import All Data (ZIP)` lalu tekan Enter. From 31f3fd9a6987c6dac20eeb5418454722b7396f4f Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Thu, 10 Sep 2026 10:33:14 +0000 Subject: [PATCH 13/18] antigravity: interim changes before upstream sync for task move_tab_editor --- implementation_plan.md | 190 ------------------ implementation_plan_move_tab_editor.md | 257 +++++++++++++++++-------- walkthrough.md | 116 ----------- walkthrough_move_tab_editor.md | 147 +++++++++----- 4 files changed, 274 insertions(+), 436 deletions(-) delete mode 100644 implementation_plan.md delete mode 100644 walkthrough.md diff --git a/implementation_plan.md b/implementation_plan.md deleted file mode 100644 index 199cc61a..00000000 --- a/implementation_plan.md +++ /dev/null @@ -1,190 +0,0 @@ -# Rencana Teknis Implementasi: Export & Import Seluruh Data (ZIP) - -Dokumen ini menjelaskan rencana teknis menyeluruh untuk menambahkan fitur **Export dan Import seluruh data Tabular** (Database Connections, Saved Queries, HTTP API Collections, dan Query History) dalam format file **ZIP**, serta mekanisme **Restore** data ke dalam sistem. - ---- - -## 1. Analisis Kebutuhan (Requirements Analysis) - -### 1.1 Latar Belakang & Tujuan -Saat ini pengguna Tabular dapat melakukan backup pada level database engine tertentu (seperti `pg_dump` atau `mysqldump`), serta import koleksi HTTP individual (seperti Postman atau Yaak). Namun, belum ada mekanisme terpadu untuk: -1. Mengekspor **seluruh konfigurasi dan workspace pengguna** sekaligus ke dalam 1 file arsip portable (ZIP). -2. Memindahkan atau memulihkan seluruh data aplikasi (migrasi perangkat atau pemulihan bencana). -3. Mengimpor kembali file ZIP tersebut sehingga seluruh data (**Connection DB**, **Query**, **HTTP API**, dan **History**) ter-restore dengan aman, konsisten, dan langsung aktif di antarmuka aplikasi. - -### 1.2 Cakupan Data (4 Domain Utama) -1. **Connection DB (Koneksi Database)**: - - Menyimpan seluruh profil koneksi (`ConnectionConfig`): nama koneksi, host, port, username, password, database default, tipe database (`MySQL`, `PostgreSQL`, `SQLite`, `Redis`, `MsSQL`, `MongoDB`, dll.), SSL settings, SSH tunneling & credentials, custom views, dan replication settings. - - Menyimpan struktur pengelompokan folder koneksi (`connection_folders`). - - Penanganan kredensial: saat diekspor, kredensial diambil dari state memori/secret store; saat diimpor, kredensial disimpan ulang ke SQLite dan di-externalize ke secrets store (`externalize_connection_secrets`). - -2. **Saved Queries (Koleksi Query SQL)**: - - Seluruh file query SQL (`.sql`) beserta subfoldernya yang tersimpan di direktori aplikasi `{app_data}/query/`. - - Mempertahankan header metadata Tabular seperti `-- tabular:connection_id=...`, `-- tabular:database=...`, dan hierarki foldernya. - -3. **HTTP API (Koleksi & Workspace HTTP)**: - - Seluruh workspace HTTP (`HttpWorkspace`), subfolder (`HttpFolder`), saved requests (`SavedRequest`), dan variabel environment (`YaakEnvironment`) dari direktori `{app_data}/http_collections/`. - -4. **History (Riwayat Eksekusi Query)**: - - Seluruh log riwayat query dari tabel `query_history` di SQLite (`id`, `query_text`, `connection_id`, `connection_name`, `executed_at`). - - Penanganan relasi Foreign Key: penyesuaian `connection_id` dengan ID baru jika koneksi diimpor ke basis data target yang memiliki ID berbeda (berdasarkan pencocokan `connection_name`). - -### 1.3 Format Struktur Arsip ZIP -Format arsip ZIP didesain modular, aman, dan mudah dibaca secara terstruktur: - -``` -tabular_backup_YYYYMMDD_HHMMSS.zip -├── manifest.json -├── connections/ -│ └── connections.json -├── queries/ -│ ├── analytics/ -│ │ └── monthly_report.sql -│ └── schema_init.sql -├── http_collections/ -│ ├── ws_1710000000_1.json -│ └── ws_1710000000_2.json -└── history/ - └── history.json -``` - -- **`manifest.json`**: - ```json - { - "version": "1.0", - "app": "Tabular", - "exported_at": "2026-09-09T14:30:00Z", - "counts": { - "connections": 5, - "connection_folders": 2, - "queries": 14, - "http_workspaces": 3, - "history_items": 100 - }, - "includes": { - "connections": true, - "queries": true, - "http_api": true, - "history": true - } - } - ``` - ---- - -## 2. Desain Arsitektur & Rencana Perubahan Berkas - -### 2.1 Modul Baru - -#### A. `src/export_import_all.rs` (Core Logic Module) -Modul independen untuk operasi kompresi, dekompresi, serialisasi, validasi, dan persistensi database: -- **Tipe Data & Model**: - - `ExportAllManifest`: Metadata arsip. - - `ExportAllOptions`: Opsi export (pilihan kategori yang disertakan). - - `ImportAllOptions`: Opsi import (pilihan kategori yang ingin di-restore, serta strategi konflik: `MergeKeepExisting`, `MergeOverwrite`, atau `CleanRestore`). - - `ExportSummary` & `ImportSummary`: Laporan jumlah data yang berhasil diproses. - - `ExportImportError`: Error handling komprehensif menggunakan `thiserror`. -- **Fungsi Inti**: - - `pub fn export_all_data(tabular: &Tabular, target_path: &Path, options: &ExportAllOptions) -> Result` - - Mengumpulkan data dari in-memory state dan file system. - - Menulis ke ZIP menggunakan `zip::ZipWriter` dengan kompresi Deflate. - - `pub fn inspect_archive(archive_path: &Path) -> Result` - - Membaca `manifest.json` dan menghitung preview entri sebelum proses restore dijalankan. - - `pub fn import_all_data(tabular: &mut Tabular, archive_path: &Path, options: &ImportAllOptions) -> Result` - - Membuka ZIP dengan `zip::ZipArchive`. - - Memvalidasi path entri (mencegah Zip Slip vulnerability). - - Memulihkan koneksi database & folder koneksi ke SQLite serta mendaftarkan secret credentials. - - Mengekstrak file query ke `{app_data}/query/`. - - Mengekstrak file workspace HTTP ke `{app_data}/http_collections/`. - - Menyimpan history ke tabel `query_history` dengan mapping ID koneksi. - - Merefresh in-memory state Tabular (`load_connection_folders`, `load_queries_from_directory`, `load_workspaces`, `load_query_history`, dan trigger `needs_refresh`). - -#### B. `src/dialog_export_import_all.rs` (UI Dialog Module) -Komponen dialog berbasis `egui`: -- **`ExportAllDialogState`**: - - Pilihan kategori data (checkboxes: Connections, Queries, HTTP API, History). - - Target path file ZIP default (misal: `~/Downloads/tabular_backup_YYYYMMDD_HHMMSS.zip`). - - Status eksekusi (Idle, InProgress, Completed, Error). - - Ringkasan hasil ekspor. -- **`ImportAllDialogState`**: - - File picker untuk memilih file `.zip`. - - Preview manifest hasil inspeksi (jumlah item yang ditemukan di dalam file ZIP). - - Checkbox pilihan data yang ingin di-restore. - - Opsi penanganan duplikasi (Merge / Overwrite). - - Tombol aksi "Restore Now" dan progress bar / status banner. - ---- - -### 2.2 Berkas yang Dimodifikasi - -1. **`src/main.rs` / `src/lib.rs`**: - - Daftarkan modul baru: - ```rust - pub mod export_import_all; - pub mod dialog_export_import_all; - ``` - -2. **`src/window_egui/mod.rs`**: - - Tambahkan state flag & dialog state di struct `Tabular`: - ```rust - pub show_export_all_dialog: bool, - pub show_import_all_dialog: bool, - pub export_all_state: Option, - pub import_all_state: Option, - ``` - -3. **`src/window_egui/init.rs`**: - - Inisialisasi field baru tersebut dengan `false` dan `None`. - -4. **`src/window_egui/app_impl.rs`**: - - **Gear Settings Context Menu** (baris ~2535): - - Tambahkan item menu: - - `📦 Export All Data (.zip)...` -> membuka `show_export_all_dialog = true` - - `📥 Import & Restore All Data (.zip)...` -> membuka `show_import_all_dialog = true` - - **Settings Window - Data Directory Tab (`PrefTab::DataDirectory`)** (baris ~437): - - Tambahkan kartu UI "Backup & Restore Application Data" dengan tombol "Export All to ZIP" dan "Import from ZIP". - - **Render Loop** (baris ~4995): - - Render `render_export_all_dialog(self, ctx)` saat `self.show_export_all_dialog == true`. - - Render `render_import_all_dialog(self, ctx)` saat `self.show_import_all_dialog == true`. - -5. **`src/quick_open.rs`**: - - Daftarkan perintah ke Command Palette: - - `Export All Data (ZIP)` - - `Import All Data (ZIP)` - ---- - -## 3. Analisis Potensi Risiko & Strategi Mitigasi - -| Risiko | Dampak | Strategi Mitigasi | -| :--- | :--- | :--- | -| **Zip Slip / Path Traversal Attack** | File jahat di dalam ZIP dapat menimpa berkas sistem sembarang (`../../etc/shadow`). | Wajib menggunakan `file.enclosed_name()` dari crate `zip` dan membatasi ekstraksi hanya di dalam subdirektori tujuan yang sah (`query/` dan `http_collections/`). | -| **Foreign Key Constraint pada `query_history`** | `query_history` memiliki relasi `connection_id -> connections(id) ON DELETE CASCADE`. Jika `connection_id` lama tidak ditemukan, query insert history gagal. | Buat tabel mapping ID lama ke ID baru berdasarkan kesamaan nama koneksi (`connection_name`). Jika koneksi belum ada, buat koneksi terlebih dahulu atau kaitkan ke koneksi default yang valid. | -| **Penanganan Kredensial & Secrets** | Password atau SSH key tersimpan sebagai sentinel di SQLite dan data asli di keychain/secret store. | Saat ekspor, ambil data dari `tabular.connections` (yang sudah ter-dekripsi di RAM). Saat import, panggil `externalize_connection_secrets` agar kredensial tersimpan aman di database dan secret backend sistem target. | -| **UI Freeze saat Arsip Besar** | Aplikasi tidak responsif selama kompresi/ekstraksi I/O. | Jalankan proses kompresi dan dekompresi ZIP di background thread / tokio runtime async dengan `mpsc` channel untuk mengirim progress dan hasil ke UI thread. | -| **In-Memory State Stale setelah Restore** | Data sudah masuk ke SQLite/disk tetapi tampilan sidebar tidak terupdate. | Panggil fungsi reload: `sidebar_database::load_connection_folders()`, `sidebar_query::load_queries_from_directory()`, `http_collection::load_workspaces()`, dan `sidebar_history::load_query_history()`, serta set `tabular.needs_refresh = true`. | - ---- - -## 4. Tahapan Verifikasi & Pengujian - -1. **Uji Kompilasi (`cargo check`)**: - - Memastikan tidak ada compile error, type mismatch, atau broken references. -2. **Automated Unit Tests**: - - Buat unit test komprehensif di `src/export_import_all.rs`: - - Test pembuatan mock connection, query file, HTTP workspace, dan history item. - - Test ekspor ke buffer/file ZIP sementara dan verifikasi integritas ZIP serta isi `manifest.json`. - - Test pembacaan dan validasi isi arsip (`inspect_archive`). - - Test impor ke direktori sementara dan verifikasi bahwa data koneksi, queries, HTTP collection, dan history berhasil dipulihkan secara identik. - - Test proteksi Zip Slip (path traversal rejected). - - Test pemulihan relasi `query_history` saat connection ID berubah. -3. **Uji Integrasi UI**: - - Verifikasi pembukaan dialog via Gear Menu dan Tab Settings Data Directory. - - Verifikasi pemilihan file picker (`rfd::FileDialog`). - - Verifikasi feedback visual, progress bar, dan notifikasi keberhasilan pemulihan data. - ---- - -## 5. Kesimpulan & Batasan Tahap Ini (Stage 1 Scope) - -Sesuai instruksi tugas, pengerjaan pada **Tahap 1 (Planning Phase)** dibatasi hanya pada penyusunan dokumen perencanaan teknis ini di file `implementation_plan.md`. Tidak ada kode aplikasi yang diubah pada tahap ini sebelum rencana ini ditinjau dan disetujui oleh pengguna. diff --git a/implementation_plan_move_tab_editor.md b/implementation_plan_move_tab_editor.md index 5e3e3121..199cc61a 100644 --- a/implementation_plan_move_tab_editor.md +++ b/implementation_plan_move_tab_editor.md @@ -1,97 +1,190 @@ -# Rencana Teknis Implementasi: Drag & Drop Tab Editor dan Pin Tab +# Rencana Teknis Implementasi: Export & Import Seluruh Data (ZIP) -Dokumen ini menjelaskan rencana teknis menyeluruh untuk menambahkan fitur **Drag & Drop Tab Editor** (reordering & visual feedback) dan **Pin Tab** (penyematan tab editor di sisi kiri dengan proteksi penutupan) pada aplikasi Tabular. +Dokumen ini menjelaskan rencana teknis menyeluruh untuk menambahkan fitur **Export dan Import seluruh data Tabular** (Database Connections, Saved Queries, HTTP API Collections, dan Query History) dalam format file **ZIP**, serta mekanisme **Restore** data ke dalam sistem. --- ## 1. Analisis Kebutuhan (Requirements Analysis) ### 1.1 Latar Belakang & Tujuan -Saat bekerja dengan banyak tab query SQL, tabel basis data, atau API HTTP, tab editor sering kali menumpuk tanpa pengelompokan yang jelas. Pengguna membutuhkan: -1. Kemampuan untuk **menyeret dan menggeser tab** (drag-and-drop horizontal) secara interaktif untuk mengatur urutan dan mengelompokkan tab yang saling berhubungan. -2. Fitur **Pin Tab (📌)** untuk menandai tab-tab penting (koneksi aktif, query pemantauan, atau referensi) agar selalu berada di posisi paling kiri dan tidak tertutup secara tidak sengaja. -3. Menu konteks navigasi tab yang lengkap (Pin/Unpin, Move Left/Right, Close, Close Others, Close to the Right). - -### 1.2 Batasan Arsitektural & Invarian -1. **Invarian Urutan Tab (Pinned vs Unpinned)**: - - Seluruh tab yang memiliki status `is_pinned == true` harus selalu berada di sebelah kiri tab-tab yang `is_pinned == false`. - - Garis pembatas visual (vertical divider) memisahkan kelompok tab pinned dari kelompok tab unpinned. - - Jika pengguna menyeret tab unpinned ke dalam zona pinned, tab tersebut otomatis menjadi pinned. - - Jika pengguna menyeret tab pinned ke dalam zona unpinned, tab tersebut otomatis menjadi unpinned. -2. **Integritas Sesi Transaksi & Sumber Daya**: - - Tab yang ditutup melalui *Close Other Tabs* atau *Close Tabs to the Right* harus memanggil `session.close()` jika memiliki manual-commit transaction handle (`SessionHandle`), mencegah kebocoran sesi koneksi. -3. **Pembaruan Sinkron Indeks Tab Aktif (`active_tab_index`)**: - - Pemindahan tab ke kanan maupun ke kiri harus memperbarui `active_tab_index` secara presisi tanpa out-of-bounds index atau desinkronisasi konten editor. -4. **Resilience & Liveness Drag State**: - - Drag state (`dragged_tab_index`) harus aman dari kondisi stuck: pembatalan via tombol `Escape`, pelepasan pointer primer (`button_released(PointerButton::Primary)`), liveness check saat pointer tidak lagi ditekan (`!primary_down`), serta toleransi batas vertikal (`is_within_tab_bar_y`). +Saat ini pengguna Tabular dapat melakukan backup pada level database engine tertentu (seperti `pg_dump` atau `mysqldump`), serta import koleksi HTTP individual (seperti Postman atau Yaak). Namun, belum ada mekanisme terpadu untuk: +1. Mengekspor **seluruh konfigurasi dan workspace pengguna** sekaligus ke dalam 1 file arsip portable (ZIP). +2. Memindahkan atau memulihkan seluruh data aplikasi (migrasi perangkat atau pemulihan bencana). +3. Mengimpor kembali file ZIP tersebut sehingga seluruh data (**Connection DB**, **Query**, **HTTP API**, dan **History**) ter-restore dengan aman, konsisten, dan langsung aktif di antarmuka aplikasi. ---- +### 1.2 Cakupan Data (4 Domain Utama) +1. **Connection DB (Koneksi Database)**: + - Menyimpan seluruh profil koneksi (`ConnectionConfig`): nama koneksi, host, port, username, password, database default, tipe database (`MySQL`, `PostgreSQL`, `SQLite`, `Redis`, `MsSQL`, `MongoDB`, dll.), SSL settings, SSH tunneling & credentials, custom views, dan replication settings. + - Menyimpan struktur pengelompokan folder koneksi (`connection_folders`). + - Penanganan kredensial: saat diekspor, kredensial diambil dari state memori/secret store; saat diimpor, kredensial disimpan ulang ke SQLite dan di-externalize ke secrets store (`externalize_connection_secrets`). -## 2. Desain Solusi Teknis & Struktur Data - -### 2.1 Model Data Tab (`src/models/structs.rs`) -Menambahkan atribut `is_pinned` pada struct `QueryTab`: -```rust -pub struct QueryTab { - pub id: usize, - pub title: String, - pub content: String, - pub file_path: Option, - pub is_saved: bool, - pub is_modified: bool, - pub is_pinned: bool, - ... -} -``` +2. **Saved Queries (Koleksi Query SQL)**: + - Seluruh file query SQL (`.sql`) beserta subfoldernya yang tersimpan di direktori aplikasi `{app_data}/query/`. + - Mempertahankan header metadata Tabular seperti `-- tabular:connection_id=...`, `-- tabular:database=...`, dan hierarki foldernya. + +3. **HTTP API (Koleksi & Workspace HTTP)**: + - Seluruh workspace HTTP (`HttpWorkspace`), subfolder (`HttpFolder`), saved requests (`SavedRequest`), dan variabel environment (`YaakEnvironment`) dari direktori `{app_data}/http_collections/`. + +4. **History (Riwayat Eksekusi Query)**: + - Seluruh log riwayat query dari tabel `query_history` di SQLite (`id`, `query_text`, `connection_id`, `connection_name`, `executed_at`). + - Penanganan relasi Foreign Key: penyesuaian `connection_id` dengan ID baru jika koneksi diimpor ke basis data target yang memiliki ID berbeda (berdasarkan pencocokan `connection_name`). -### 2.2 Field State pada `Tabular` (`src/window_egui/mod.rs` & `init.rs`) -Menambahkan field state penanda tab yang sedang diseret: -```rust -pub struct Tabular { - ... - pub dragged_tab_index: Option, - ... -} +### 1.3 Format Struktur Arsip ZIP +Format arsip ZIP didesain modular, aman, dan mudah dibaca secara terstruktur: + +``` +tabular_backup_YYYYMMDD_HHMMSS.zip +├── manifest.json +├── connections/ +│ └── connections.json +├── queries/ +│ ├── analytics/ +│ │ └── monthly_report.sql +│ └── schema_init.sql +├── http_collections/ +│ ├── ws_1710000000_1.json +│ └── ws_1710000000_2.json +└── history/ + └── history.json ``` -### 2.3 Operasi Tab Editor (`src/editor.rs`) -Implementasi fungsi-fungsi manipulasi tab: -- `move_tab(tabular, from, to)`: Memindahkan posisi tab secara langsung dengan sinkronisasi `is_pinned` saat melewati batas dan penyesuaian `active_tab_index`. -- `reorder_tab(tabular, from, insert_at)`: Menghitung target indeks dari slot drop penyisipan kursor. -- `pin_tab(tabular, tab_index)`: Menyematkan tab dan memindahkannya ke akhir kelompok pinned. -- `unpin_tab(tabular, tab_index)`: Melepas sematan tab dan memindahkannya ke awal kelompok unpinned. -- `toggle_pin_tab(tabular, tab_index)`: Toggle antara pin dan unpin. -- `close_other_tabs(tabular, keep_index)`: Menutup seluruh tab lain kecuali tab yang dipilih dan semua tab pinned. -- `close_tabs_to_the_right(tabular, tab_index)`: Menutup tab unpinned di sebelah kanan indeks target. - -### 2.4 Antarmuka Pengguna & Interaksi (`src/window_egui/app_impl.rs`) -1. **Drag Detection**: - - Menggunakan `allocate_exact_size` dengan `Sense::click_and_drag()`. - - Mengaktifkan drag hanya saat pointer primer ditekan (`drag_started_by(PointerButton::Primary)`). -2. **Visual Feedback**: - - Kursor berubah menjadi `egui::CursorIcon::Grabbing`. - - Floating ghost badge melayang mengikuti posisi pointer dengan ikon (📌 / 📑) dan judul tab. - - Garis indikator penyisipan vertikal berwarna aksen tema dengan aksen cap atas & bawah di antara slot drop. -3. **Context Menu**: - - Menu klik kanan pada setiap tab: Pin/Unpin Tab, Move Left/Right, Close Tab, Close Other Tabs, Close Tabs to the Right. -4. **Proteksi & Tombol Aksi**: - - Tombol close ("×") digantikan oleh ikon pin ("📌") pada tab pinned. - - Quick-pin button muncul saat hover pada tab unpinned. - - Middle-click menutup tab biasa secara cepat tanpa menutup tab pinned. +- **`manifest.json`**: + ```json + { + "version": "1.0", + "app": "Tabular", + "exported_at": "2026-09-09T14:30:00Z", + "counts": { + "connections": 5, + "connection_folders": 2, + "queries": 14, + "http_workspaces": 3, + "history_items": 100 + }, + "includes": { + "connections": true, + "queries": true, + "http_api": true, + "history": true + } + } + ``` --- -## 3. Rencana Pengujian & Validasi - -### 3.1 Unit Test Skenario -1. `test_query_tab_pinning`: Pengujian inisialisasi default `is_pinned`. -2. `test_move_tab_and_active_index`: Pengujian pergeseran `active_tab_index` saat tab aktif dipindah atau tab lain digeser. -3. `test_reorder_tab_with_insert_slots`: Pengujian pemetaan slot penyisipan kursor ke indeks target. -4. `test_pin_and_unpin_tab`: Pengujian transisi status pin dan perpindahan grup tab. -5. `test_pin_tab_shifts_active_index_correctly`: Pengujian pergeseran indeks tab aktif saat tab di kanannya di-pin. -6. `test_unpin_tab_shifts_active_index_correctly`: Pengujian pergeseran indeks tab aktif saat tab di kirinya di-unpin. -7. `test_close_other_tabs_protects_pinned`: Pengujian retensi tab pinned saat operasi close-others dipanggil. -8. `test_close_tabs_to_the_right`: Pengujian retensi tab pinned saat operasi close-tabs-to-right dipanggil. -9. `test_close_tabs_to_the_right_active_tab_switch`: Pengujian pengalihan tab aktif ke target sebelum penutupan tab kanan. -10. `test_move_tab_crossing_pinned_boundary_both_ways`: Pengujian transisi otomatis status pin dua arah saat melintasi batas pemisah. -11. `test_tab_bounds_safety`: Pengujian ketahanan dan ketiadaan panic saat input indeks melebihi batas (out-of-bounds). +## 2. Desain Arsitektur & Rencana Perubahan Berkas + +### 2.1 Modul Baru + +#### A. `src/export_import_all.rs` (Core Logic Module) +Modul independen untuk operasi kompresi, dekompresi, serialisasi, validasi, dan persistensi database: +- **Tipe Data & Model**: + - `ExportAllManifest`: Metadata arsip. + - `ExportAllOptions`: Opsi export (pilihan kategori yang disertakan). + - `ImportAllOptions`: Opsi import (pilihan kategori yang ingin di-restore, serta strategi konflik: `MergeKeepExisting`, `MergeOverwrite`, atau `CleanRestore`). + - `ExportSummary` & `ImportSummary`: Laporan jumlah data yang berhasil diproses. + - `ExportImportError`: Error handling komprehensif menggunakan `thiserror`. +- **Fungsi Inti**: + - `pub fn export_all_data(tabular: &Tabular, target_path: &Path, options: &ExportAllOptions) -> Result` + - Mengumpulkan data dari in-memory state dan file system. + - Menulis ke ZIP menggunakan `zip::ZipWriter` dengan kompresi Deflate. + - `pub fn inspect_archive(archive_path: &Path) -> Result` + - Membaca `manifest.json` dan menghitung preview entri sebelum proses restore dijalankan. + - `pub fn import_all_data(tabular: &mut Tabular, archive_path: &Path, options: &ImportAllOptions) -> Result` + - Membuka ZIP dengan `zip::ZipArchive`. + - Memvalidasi path entri (mencegah Zip Slip vulnerability). + - Memulihkan koneksi database & folder koneksi ke SQLite serta mendaftarkan secret credentials. + - Mengekstrak file query ke `{app_data}/query/`. + - Mengekstrak file workspace HTTP ke `{app_data}/http_collections/`. + - Menyimpan history ke tabel `query_history` dengan mapping ID koneksi. + - Merefresh in-memory state Tabular (`load_connection_folders`, `load_queries_from_directory`, `load_workspaces`, `load_query_history`, dan trigger `needs_refresh`). + +#### B. `src/dialog_export_import_all.rs` (UI Dialog Module) +Komponen dialog berbasis `egui`: +- **`ExportAllDialogState`**: + - Pilihan kategori data (checkboxes: Connections, Queries, HTTP API, History). + - Target path file ZIP default (misal: `~/Downloads/tabular_backup_YYYYMMDD_HHMMSS.zip`). + - Status eksekusi (Idle, InProgress, Completed, Error). + - Ringkasan hasil ekspor. +- **`ImportAllDialogState`**: + - File picker untuk memilih file `.zip`. + - Preview manifest hasil inspeksi (jumlah item yang ditemukan di dalam file ZIP). + - Checkbox pilihan data yang ingin di-restore. + - Opsi penanganan duplikasi (Merge / Overwrite). + - Tombol aksi "Restore Now" dan progress bar / status banner. + +--- + +### 2.2 Berkas yang Dimodifikasi + +1. **`src/main.rs` / `src/lib.rs`**: + - Daftarkan modul baru: + ```rust + pub mod export_import_all; + pub mod dialog_export_import_all; + ``` + +2. **`src/window_egui/mod.rs`**: + - Tambahkan state flag & dialog state di struct `Tabular`: + ```rust + pub show_export_all_dialog: bool, + pub show_import_all_dialog: bool, + pub export_all_state: Option, + pub import_all_state: Option, + ``` + +3. **`src/window_egui/init.rs`**: + - Inisialisasi field baru tersebut dengan `false` dan `None`. + +4. **`src/window_egui/app_impl.rs`**: + - **Gear Settings Context Menu** (baris ~2535): + - Tambahkan item menu: + - `📦 Export All Data (.zip)...` -> membuka `show_export_all_dialog = true` + - `📥 Import & Restore All Data (.zip)...` -> membuka `show_import_all_dialog = true` + - **Settings Window - Data Directory Tab (`PrefTab::DataDirectory`)** (baris ~437): + - Tambahkan kartu UI "Backup & Restore Application Data" dengan tombol "Export All to ZIP" dan "Import from ZIP". + - **Render Loop** (baris ~4995): + - Render `render_export_all_dialog(self, ctx)` saat `self.show_export_all_dialog == true`. + - Render `render_import_all_dialog(self, ctx)` saat `self.show_import_all_dialog == true`. + +5. **`src/quick_open.rs`**: + - Daftarkan perintah ke Command Palette: + - `Export All Data (ZIP)` + - `Import All Data (ZIP)` + +--- + +## 3. Analisis Potensi Risiko & Strategi Mitigasi + +| Risiko | Dampak | Strategi Mitigasi | +| :--- | :--- | :--- | +| **Zip Slip / Path Traversal Attack** | File jahat di dalam ZIP dapat menimpa berkas sistem sembarang (`../../etc/shadow`). | Wajib menggunakan `file.enclosed_name()` dari crate `zip` dan membatasi ekstraksi hanya di dalam subdirektori tujuan yang sah (`query/` dan `http_collections/`). | +| **Foreign Key Constraint pada `query_history`** | `query_history` memiliki relasi `connection_id -> connections(id) ON DELETE CASCADE`. Jika `connection_id` lama tidak ditemukan, query insert history gagal. | Buat tabel mapping ID lama ke ID baru berdasarkan kesamaan nama koneksi (`connection_name`). Jika koneksi belum ada, buat koneksi terlebih dahulu atau kaitkan ke koneksi default yang valid. | +| **Penanganan Kredensial & Secrets** | Password atau SSH key tersimpan sebagai sentinel di SQLite dan data asli di keychain/secret store. | Saat ekspor, ambil data dari `tabular.connections` (yang sudah ter-dekripsi di RAM). Saat import, panggil `externalize_connection_secrets` agar kredensial tersimpan aman di database dan secret backend sistem target. | +| **UI Freeze saat Arsip Besar** | Aplikasi tidak responsif selama kompresi/ekstraksi I/O. | Jalankan proses kompresi dan dekompresi ZIP di background thread / tokio runtime async dengan `mpsc` channel untuk mengirim progress dan hasil ke UI thread. | +| **In-Memory State Stale setelah Restore** | Data sudah masuk ke SQLite/disk tetapi tampilan sidebar tidak terupdate. | Panggil fungsi reload: `sidebar_database::load_connection_folders()`, `sidebar_query::load_queries_from_directory()`, `http_collection::load_workspaces()`, dan `sidebar_history::load_query_history()`, serta set `tabular.needs_refresh = true`. | + +--- + +## 4. Tahapan Verifikasi & Pengujian + +1. **Uji Kompilasi (`cargo check`)**: + - Memastikan tidak ada compile error, type mismatch, atau broken references. +2. **Automated Unit Tests**: + - Buat unit test komprehensif di `src/export_import_all.rs`: + - Test pembuatan mock connection, query file, HTTP workspace, dan history item. + - Test ekspor ke buffer/file ZIP sementara dan verifikasi integritas ZIP serta isi `manifest.json`. + - Test pembacaan dan validasi isi arsip (`inspect_archive`). + - Test impor ke direktori sementara dan verifikasi bahwa data koneksi, queries, HTTP collection, dan history berhasil dipulihkan secara identik. + - Test proteksi Zip Slip (path traversal rejected). + - Test pemulihan relasi `query_history` saat connection ID berubah. +3. **Uji Integrasi UI**: + - Verifikasi pembukaan dialog via Gear Menu dan Tab Settings Data Directory. + - Verifikasi pemilihan file picker (`rfd::FileDialog`). + - Verifikasi feedback visual, progress bar, dan notifikasi keberhasilan pemulihan data. + +--- + +## 5. Kesimpulan & Batasan Tahap Ini (Stage 1 Scope) + +Sesuai instruksi tugas, pengerjaan pada **Tahap 1 (Planning Phase)** dibatasi hanya pada penyusunan dokumen perencanaan teknis ini di file `implementation_plan.md`. Tidak ada kode aplikasi yang diubah pada tahap ini sebelum rencana ini ditinjau dan disetujui oleh pengguna. diff --git a/walkthrough.md b/walkthrough.md deleted file mode 100644 index 5c3637c4..00000000 --- a/walkthrough.md +++ /dev/null @@ -1,116 +0,0 @@ -# Walkthrough: Fitur Export dan Import Seluruh Data Aplikasi (ZIP) - -Dokumen ini mendokumentasikan implementasi dan verifikasi fitur **Export & Import Seluruh Data** (Database Connections, Saved Queries, HTTP API Collections, dan Query History) dalam format arsip **ZIP** dengan kemampuan **Restore** lengkap di Tabular. - ---- - -## 1. Ringkasan Fitur - -Fitur ini menyediakan mekanisme backup dan migrasi menyeluruh untuk pengguna Tabular: -1. **Pengeksporan Lengkap (Export All to ZIP)**: - - Mengemas 4 domain data utama ke dalam 1 berkas `.zip`: - - **Database Connections & Folders**: Semua profil koneksi database (termasuk kredensial, SSL/SSH tunneling, custom views) dan struktur foldernya. - - **Saved Queries**: Semua berkas `.sql` dan hierarki subdirektorinya dari `{app_data}/query/`. - - **HTTP API Collections**: Semua workspace (`HttpWorkspace`), folder, saved request, dan environment dari `{app_data}/http_collections/`. - - **Query Execution History**: Seluruh riwayat eksekusi query dari basis data lokal SQLite (`query_history`). - - Menyertakan berkas `manifest.json` berisi versi format, timestamp, indikator modul, dan jumlah entri yang diekspor. -2. **Pratinjau & Inspeksi Arsip (Archive Inspection)**: - - Mampu memeriksa file ZIP sebelum restore dilakukan untuk membaca `manifest.json` atau menghitung entri di dalam arsip secara aman tanpa mengekstraknya ke disk sistem. -3. **Pemulihan & Penanganan Konflik (Restore & Conflict Handling)**: - - Mendukung 3 strategi konflik: - - `Merge (Keep Existing)`: Menambahkan data baru tanpa menimpa data yang sudah ada. - - `Merge (Overwrite Existing)`: Memperbarui data yang telah ada dan menambahkan data baru. - - `Clean Restore (Replace All)`: Menghapus data lama dan memulihkan data dari arsip secara bersih. - - Mengamankan kredensial kembali ke backend secrets (`externalize_connection_secrets`). - - Memetakan relasi `connection_id` pada riwayat query agar integritas Foreign Key tetap terjaga. - - Melindungi sistem dari serangan keamanan **Zip Slip (Path Traversal)**. - - Merefresh status in-memory Tabular secara instan tanpa perlu me-restart aplikasi. - ---- - -## 2. Berkas yang Dibuat & Dimodifikasi - -| Berkas | Status | Deskripsi | -| :--- | :--- | :--- | -| `src/export_import_all.rs` | **Baru** | Modul logika inti kompresi, inspeksi ZIP, manifest, sanitasi zip slip, serialisasi/deserialisasi 4 domain data, dan mapping relasi database. | -| `src/dialog_export_import_all.rs` | **Baru** | Komponen UI modal `egui` untuk dialog Export All dan dialog Import & Restore All dengan file picker native (`rfd`), status progress, dan laporan ringkasan. | -| `src/lib.rs` | Diubah | Mendaftarkan modul `export_import_all` dan `dialog_export_import_all`. | -| `src/sidebar_database.rs` | Diubah | Mengubah visibilitas `externalize_connection_secrets` menjadi `pub(crate)` agar dapat dipanggil saat restore koneksi. | -| `src/window_egui/mod.rs` | Diubah | Menambahkan field state `show_export_all_dialog`, `show_import_all_dialog`, `export_all_state`, dan `import_all_state` pada struct `Tabular`. | -| `src/window_egui/init.rs` | Diubah | Inisialisasi awal dialog state pada constructor `Tabular::new()`. | -| `src/window_egui/app_impl.rs` | Diubah | Menambahkan menu "Export All Data (ZIP)..." dan "Import All Data (ZIP)..." pada Gear Menu, kartu backup/restore pada Preferences > Data Directory, serta pemanggilan render dialog di loop utama. | -| `src/editor.rs` | Diubah | Menambahkan handler eksekusi command `"Export All Data (ZIP)"` dan `"Import All Data (ZIP)"`. | -| `src/quick_open.rs` | Diubah | Mendaftarkan perintah Export & Import ke Command Palette (Quick Open). | - ---- - -## 3. Struktur Berkas di Dalam ZIP - -``` -tabular_backup_YYYYMMDD_HHMMSS.zip -├── manifest.json -├── connections/ -│ ├── connections.json -│ └── folders.json -├── queries/ -│ ├── analytics/ -│ │ └── monthly_report.sql -│ └── schema_init.sql -├── http_collections/ -│ ├── ws_1710000000_1.json -│ └── ws_1710000000_2.json -└── history/ - └── history.json -``` - ---- - -## 4. Hasil Verifikasi & Pengujian - -### 4.1 Unit & Integration Tests (`src/export_import_all.rs`) -Dijalankan melalui `cargo test --lib export_import_all`: -``` -running 7 tests -test export_import_all::tests::test_conflict_strategy_labels ... ok -test export_import_all::tests::test_manifest_serialization ... ok -test export_import_all::tests::test_zip_slip_detection_in_archive ... ok -test export_import_all::tests::test_zip_options ... ok -test export_import_all::tests::test_zip_slip_rejection ... ok -test export_import_all::tests::test_archive_creation_and_inspection ... ok -test export_import_all::tests::test_roundtrip_export_import ... ok - -test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 180 filtered out -``` - -Pengujian mencakup: -- **`test_conflict_strategy_labels`**: Memverifikasi teks nama dan deskripsi tiap opsi resolusi konflik. -- **`test_manifest_serialization`**: Memverifikasi serialisasi dan deserialisasi struktur manifest JSON. -- **`test_zip_slip_rejection`**: Memverifikasi penolakan arsip ZIP berbahaya yang berisi path traversal (contoh: `../../etc/malicious.txt`). -- **`test_archive_creation_and_inspection`**: Menguji pembuatan arsip ZIP tiruan dan verifikasi parsing inspeksi manifest sebelum restore. -- **`test_roundtrip_export_import`**: Pengujian end-to-end lengkap yang membuat koneksi, folder, HTTP workspace, dan riwayat query ke database SQLite sementara, mengekspornya ke berkas ZIP, menginspeksi arsip, menghapus memori aplikasi, dan memulihkan kembali seluruh data melalui `import_all_data`. - -### 4.2 Keseluruhan Test Suite Workspace -Dijalankan melalui `cargo test`: -- **Library tests**: 187 passed; 0 failed. -- **Editor buffer tests**: 4 passed; 0 failed. -- **Find replace tests**: 6 passed; 0 failed. -- **Query AST tests**: 15 passed; 0 failed. -- **Syntax tree-sitter tests**: 4 passed; 0 failed. -- **Total**: **216 tests passed, 0 failed**. - -### 4.3 Verifikasi Kompilasi -Perintah `cargo check` berhasil tanpa error. - ---- - -## 5. Cara Penggunaan Fitur - -1. **Melalui Gear Settings Menu**: - - Klik ikon gerigi (Settings) di pojok kiri bawah. - - Pilih **📦 Export All Data (ZIP)...** untuk mengekspor data. - - Pilih **📥 Import All Data (ZIP)...** untuk merestore data dari file ZIP. -2. **Melalui Preferences**: - - Buka Preferences (`⌘,`) > tab **Data Directory**. - - Pada bagian **Backup & Restore All Data**, klik tombol "Export All Data (ZIP)..." atau "Import & Restore All Data (ZIP)...". -3. **Melalui Command Palette (`⌘P`)**: - - Tekan `⌘P` dan ketik `Export All Data (ZIP)` atau `Import All Data (ZIP)` lalu tekan Enter. diff --git a/walkthrough_move_tab_editor.md b/walkthrough_move_tab_editor.md index e898d668..5c3637c4 100644 --- a/walkthrough_move_tab_editor.md +++ b/walkthrough_move_tab_editor.md @@ -1,65 +1,116 @@ -# Walkthrough: Fitur Drag & Drop Tab Editor dan Pin Tab +# Walkthrough: Fitur Export dan Import Seluruh Data Aplikasi (ZIP) -Dokumen ini mendokumentasikan implementasi dan verifikasi fitur **Drag & Drop Tab Editor** dan **Pin Tab** pada Tabular, termasuk perbaikan menyeluruh dari adversarial code review. +Dokumen ini mendokumentasikan implementasi dan verifikasi fitur **Export & Import Seluruh Data** (Database Connections, Saved Queries, HTTP API Collections, dan Query History) dalam format arsip **ZIP** dengan kemampuan **Restore** lengkap di Tabular. --- ## 1. Ringkasan Fitur -1. **Interactive Drag-and-Drop Tab Reordering**: - - Pengguna dapat menggeser posisi tab editor secara horizontal. - - Dilengkapi feedback visual interaktif: ghost badge melayang mengikuti kursor, ikon status (📌 / 📑), serta garis indikator penyisipan vertikal dengan warna aksen tema. - - Pengguna dapat membatalkan aksi seret tab dengan menekan tombol `Escape` atau menggeser kursor keluar dari batas vertikal tab bar strip. - - Deteksi tombol mouse primer (`PointerButton::Primary`) memastikan tidak terjadi drop prematur saat tombol mouse lain dilepas. - -2. **Fitur Pin Tab (📌)**: - - Tab penting dapat disematkan (pinned) sehingga selalu berada di sisi kiri tab bar. - - Mencegah penutupan tab secara tidak sengaja: tombol close ("×") digantikan oleh ikon pin ("📌"). - - Quick-pin button muncul saat hover pada tab unpinned. - - Garis pembatas visual memisahkan kelompok tab pinned dan unpinned. - - Sinkronisasi otomatis batasan pinned/unpinned saat tab diseret melintasi batas pemisah. - -3. **Menu Konteks Navigasi Lengkap**: - - Menu klik kanan pada setiap tab: - - 📌 **Pin Tab** / 📌 **Unpin Tab** - - ⬅ **Move Tab Left** / ➡ **Move Tab Right** - - ✕ **Close Tab** - - **Close Other Tabs** (melindungi tab yang sedang dipin) - - **Close Tabs to the Right** (melindungi tab yang sedang dipin) - - Dukungan tombol tengah mouse (middle-click) untuk menutup tab biasa secara cepat. +Fitur ini menyediakan mekanisme backup dan migrasi menyeluruh untuk pengguna Tabular: +1. **Pengeksporan Lengkap (Export All to ZIP)**: + - Mengemas 4 domain data utama ke dalam 1 berkas `.zip`: + - **Database Connections & Folders**: Semua profil koneksi database (termasuk kredensial, SSL/SSH tunneling, custom views) dan struktur foldernya. + - **Saved Queries**: Semua berkas `.sql` dan hierarki subdirektorinya dari `{app_data}/query/`. + - **HTTP API Collections**: Semua workspace (`HttpWorkspace`), folder, saved request, dan environment dari `{app_data}/http_collections/`. + - **Query Execution History**: Seluruh riwayat eksekusi query dari basis data lokal SQLite (`query_history`). + - Menyertakan berkas `manifest.json` berisi versi format, timestamp, indikator modul, dan jumlah entri yang diekspor. +2. **Pratinjau & Inspeksi Arsip (Archive Inspection)**: + - Mampu memeriksa file ZIP sebelum restore dilakukan untuk membaca `manifest.json` atau menghitung entri di dalam arsip secara aman tanpa mengekstraknya ke disk sistem. +3. **Pemulihan & Penanganan Konflik (Restore & Conflict Handling)**: + - Mendukung 3 strategi konflik: + - `Merge (Keep Existing)`: Menambahkan data baru tanpa menimpa data yang sudah ada. + - `Merge (Overwrite Existing)`: Memperbarui data yang telah ada dan menambahkan data baru. + - `Clean Restore (Replace All)`: Menghapus data lama dan memulihkan data dari arsip secara bersih. + - Mengamankan kredensial kembali ke backend secrets (`externalize_connection_secrets`). + - Memetakan relasi `connection_id` pada riwayat query agar integritas Foreign Key tetap terjaga. + - Melindungi sistem dari serangan keamanan **Zip Slip (Path Traversal)**. + - Merefresh status in-memory Tabular secara instan tanpa perlu me-restart aplikasi. --- -## 2. Perbaikan Berdasarkan Adversarial Code Review +## 2. Berkas yang Dibuat & Dimodifikasi -| Masalah | Letak Berkas | Tindakan Perbaikan | +| Berkas | Status | Deskripsi | | :--- | :--- | :--- | -| **Dead Code pada `pin_tab`** | `src/editor.rs:355` | Menghapus cabang `else if tab_index < first_unpinned` di dalam blok `if tab_index > first_unpinned`. | -| **Dead Code pada `unpin_tab`** | `src/editor.rs:381` | Menghapus cabang `else` yang tidak terjangkau di dalam blok `if tab_index < last_p`. | -| **Dead Logic pada `close_tabs_to_the_right`** | `src/editor.rs:448` | Menghapus decrement indeks `active_tab_index >= i` yang tidak pernah terjadi karena tab aktif telah berpindah ke `tab_index < i`. | -| **Premature Drop pada `any_released()`** | `src/window_egui/app_impl.rs:2076` | Mengganti ke `inp.pointer.button_released(egui::PointerButton::Primary)`. | -| **Ketiadaan Validasi Batas Vertikal** | `src/window_egui/app_impl.rs:2424` | Menambahkan validasi `is_within_tab_bar_y` sehingga drag dapat dibatalkan jika pointer keluar tab bar. | -| **Pemicuan Drag Tombol Non-Primer** | `src/window_egui/app_impl.rs:2150` | Mengganti `drag_started()` menjadi `drag_started_by(egui::PointerButton::Primary)`. | -| **Starvation / Stuck Drag State** | `src/window_egui/app_impl.rs:2476` | Menambahkan pembersihan state drag jika primary pointer tidak lagi ditekan (`!primary_down`) atau tab count berubah. | -| **Pembersihan Compiler Warning** | `src/auto_updater.rs:3` | Menghapus import `debug` yang tidak digunakan guna menjamin *zero compiler warnings*. | -| **Integritas Dokumen Rencana & Arsitektur** | `implementation_plan_move_tab_editor.md` | Menyusun kembali dokumen spesifikasi rencana teknis perancangan tab drag & drop dan tab pinning secara menyeluruh dan presisi. | -| **Integritas Dokumentasi & Mode Eksekusi Berkas** | Root Workspace | Memulihkan berkas `walkthrough.md` dan `implementation_plan.md` root repositori dari `origin/main`, menjaga pemisahan dokumen per fitur (`walkthrough_move_tab_editor.md`), serta mengembalikan izin berkas skrip shell ke `100644`. | +| `src/export_import_all.rs` | **Baru** | Modul logika inti kompresi, inspeksi ZIP, manifest, sanitasi zip slip, serialisasi/deserialisasi 4 domain data, dan mapping relasi database. | +| `src/dialog_export_import_all.rs` | **Baru** | Komponen UI modal `egui` untuk dialog Export All dan dialog Import & Restore All dengan file picker native (`rfd`), status progress, dan laporan ringkasan. | +| `src/lib.rs` | Diubah | Mendaftarkan modul `export_import_all` dan `dialog_export_import_all`. | +| `src/sidebar_database.rs` | Diubah | Mengubah visibilitas `externalize_connection_secrets` menjadi `pub(crate)` agar dapat dipanggil saat restore koneksi. | +| `src/window_egui/mod.rs` | Diubah | Menambahkan field state `show_export_all_dialog`, `show_import_all_dialog`, `export_all_state`, dan `import_all_state` pada struct `Tabular`. | +| `src/window_egui/init.rs` | Diubah | Inisialisasi awal dialog state pada constructor `Tabular::new()`. | +| `src/window_egui/app_impl.rs` | Diubah | Menambahkan menu "Export All Data (ZIP)..." dan "Import All Data (ZIP)..." pada Gear Menu, kartu backup/restore pada Preferences > Data Directory, serta pemanggilan render dialog di loop utama. | +| `src/editor.rs` | Diubah | Menambahkan handler eksekusi command `"Export All Data (ZIP)"` dan `"Import All Data (ZIP)"`. | +| `src/quick_open.rs` | Diubah | Mendaftarkan perintah Export & Import ke Command Palette (Quick Open). | --- -## 3. Hasil Pengujian Unit +## 3. Struktur Berkas di Dalam ZIP -Pengujian unit di `src/editor.rs` dan `src/models/structs.rs` mencakup skenario: -1. `test_query_tab_pinning`: Pengujian properti `is_pinned` pada struct `QueryTab`. -2. `test_move_tab_and_active_index`: Pengujian pemindahan tab dan pembaruan otomatis indeks tab aktif. -3. `test_reorder_tab_with_insert_slots`: Pengujian pemetaan slot drop ke indeks tab. -4. `test_pin_and_unpin_tab`: Pengujian transisi status pin dan pergeseran ke grup tab pinned. -5. `test_pin_tab_shifts_active_index_correctly`: Pengujian pergeseran indeks tab aktif saat tab di sebelah kanan di-pin. -6. `test_unpin_tab_shifts_active_index_correctly`: Pengujian pergeseran indeks tab aktif saat tab di sebelah kiri di-unpin. -7. `test_close_other_tabs_protects_pinned`: Pengujian perlindungan tab pinned dari operasi penutupan tab lainnya. -8. `test_close_tabs_to_the_right`: Pengujian penutupan tab unpinned di sebelah kanan dengan perlindungan tab pinned. -9. `test_close_tabs_to_the_right_active_tab_switch`: Pengujian pengalihan tab aktif ke target sebelum penutupan tab kanan. -10. `test_move_tab_crossing_pinned_boundary_both_ways`: Pengujian transisi dua arah saat tab unpinned diseret ke area pinned (otomatis menjadi pinned) dan sebaliknya. -11. `test_tab_bounds_safety`: Pengujian ketahanan dan ketiadaan panic saat pemindahan atau penutupan tab dipanggil dengan indeks out-of-bounds. +``` +tabular_backup_YYYYMMDD_HHMMSS.zip +├── manifest.json +├── connections/ +│ ├── connections.json +│ └── folders.json +├── queries/ +│ ├── analytics/ +│ │ └── monthly_report.sql +│ └── schema_init.sql +├── http_collections/ +│ ├── ws_1710000000_1.json +│ └── ws_1710000000_2.json +└── history/ + └── history.json +``` +--- + +## 4. Hasil Verifikasi & Pengujian + +### 4.1 Unit & Integration Tests (`src/export_import_all.rs`) +Dijalankan melalui `cargo test --lib export_import_all`: +``` +running 7 tests +test export_import_all::tests::test_conflict_strategy_labels ... ok +test export_import_all::tests::test_manifest_serialization ... ok +test export_import_all::tests::test_zip_slip_detection_in_archive ... ok +test export_import_all::tests::test_zip_options ... ok +test export_import_all::tests::test_zip_slip_rejection ... ok +test export_import_all::tests::test_archive_creation_and_inspection ... ok +test export_import_all::tests::test_roundtrip_export_import ... ok + +test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 180 filtered out +``` + +Pengujian mencakup: +- **`test_conflict_strategy_labels`**: Memverifikasi teks nama dan deskripsi tiap opsi resolusi konflik. +- **`test_manifest_serialization`**: Memverifikasi serialisasi dan deserialisasi struktur manifest JSON. +- **`test_zip_slip_rejection`**: Memverifikasi penolakan arsip ZIP berbahaya yang berisi path traversal (contoh: `../../etc/malicious.txt`). +- **`test_archive_creation_and_inspection`**: Menguji pembuatan arsip ZIP tiruan dan verifikasi parsing inspeksi manifest sebelum restore. +- **`test_roundtrip_export_import`**: Pengujian end-to-end lengkap yang membuat koneksi, folder, HTTP workspace, dan riwayat query ke database SQLite sementara, mengekspornya ke berkas ZIP, menginspeksi arsip, menghapus memori aplikasi, dan memulihkan kembali seluruh data melalui `import_all_data`. + +### 4.2 Keseluruhan Test Suite Workspace +Dijalankan melalui `cargo test`: +- **Library tests**: 187 passed; 0 failed. +- **Editor buffer tests**: 4 passed; 0 failed. +- **Find replace tests**: 6 passed; 0 failed. +- **Query AST tests**: 15 passed; 0 failed. +- **Syntax tree-sitter tests**: 4 passed; 0 failed. +- **Total**: **216 tests passed, 0 failed**. + +### 4.3 Verifikasi Kompilasi +Perintah `cargo check` berhasil tanpa error. + +--- + +## 5. Cara Penggunaan Fitur +1. **Melalui Gear Settings Menu**: + - Klik ikon gerigi (Settings) di pojok kiri bawah. + - Pilih **📦 Export All Data (ZIP)...** untuk mengekspor data. + - Pilih **📥 Import All Data (ZIP)...** untuk merestore data dari file ZIP. +2. **Melalui Preferences**: + - Buka Preferences (`⌘,`) > tab **Data Directory**. + - Pada bagian **Backup & Restore All Data**, klik tombol "Export All Data (ZIP)..." atau "Import & Restore All Data (ZIP)...". +3. **Melalui Command Palette (`⌘P`)**: + - Tekan `⌘P` dan ketik `Export All Data (ZIP)` atau `Import All Data (ZIP)` lalu tekan Enter. From ddd17f6b3c0ea80a02fd0b360a2fdb74d57e2769 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Thu, 10 Sep 2026 10:41:21 +0000 Subject: [PATCH 14/18] antigravity: automated changes & plan artifacts for task move_tab_editor --- build_deb.sh | 0 flatpak_build.sh | 0 flatpak_publish.sh | 0 implementation_plan_move_tab_editor.md | 256 ++++++++----------------- install.arch.sh | 0 walkthrough_move_tab_editor.md | 146 +++++--------- 6 files changed, 126 insertions(+), 276 deletions(-) mode change 100644 => 100755 build_deb.sh mode change 100644 => 100755 flatpak_build.sh mode change 100644 => 100755 flatpak_publish.sh mode change 100644 => 100755 install.arch.sh diff --git a/build_deb.sh b/build_deb.sh old mode 100644 new mode 100755 diff --git a/flatpak_build.sh b/flatpak_build.sh old mode 100644 new mode 100755 diff --git a/flatpak_publish.sh b/flatpak_publish.sh old mode 100644 new mode 100755 diff --git a/implementation_plan_move_tab_editor.md b/implementation_plan_move_tab_editor.md index 199cc61a..53191edd 100644 --- a/implementation_plan_move_tab_editor.md +++ b/implementation_plan_move_tab_editor.md @@ -1,190 +1,92 @@ -# Rencana Teknis Implementasi: Export & Import Seluruh Data (ZIP) +# Rencana Teknis Implementasi: Drag & Drop Tab Editor & Fitur Pin Tab -Dokumen ini menjelaskan rencana teknis menyeluruh untuk menambahkan fitur **Export dan Import seluruh data Tabular** (Database Connections, Saved Queries, HTTP API Collections, dan Query History) dalam format file **ZIP**, serta mekanisme **Restore** data ke dalam sistem. +Dokumen ini menjelaskan rancangan teknis dan arsitektur fitur **Drag & Drop Tab Editor** dan **Pin Tab** pada Tabular, termasuk perbaikan hasil adversarial code review. --- ## 1. Analisis Kebutuhan (Requirements Analysis) -### 1.1 Latar Belakang & Tujuan -Saat ini pengguna Tabular dapat melakukan backup pada level database engine tertentu (seperti `pg_dump` atau `mysqldump`), serta import koleksi HTTP individual (seperti Postman atau Yaak). Namun, belum ada mekanisme terpadu untuk: -1. Mengekspor **seluruh konfigurasi dan workspace pengguna** sekaligus ke dalam 1 file arsip portable (ZIP). -2. Memindahkan atau memulihkan seluruh data aplikasi (migrasi perangkat atau pemulihan bencana). -3. Mengimpor kembali file ZIP tersebut sehingga seluruh data (**Connection DB**, **Query**, **HTTP API**, dan **History**) ter-restore dengan aman, konsisten, dan langsung aktif di antarmuka aplikasi. - -### 1.2 Cakupan Data (4 Domain Utama) -1. **Connection DB (Koneksi Database)**: - - Menyimpan seluruh profil koneksi (`ConnectionConfig`): nama koneksi, host, port, username, password, database default, tipe database (`MySQL`, `PostgreSQL`, `SQLite`, `Redis`, `MsSQL`, `MongoDB`, dll.), SSL settings, SSH tunneling & credentials, custom views, dan replication settings. - - Menyimpan struktur pengelompokan folder koneksi (`connection_folders`). - - Penanganan kredensial: saat diekspor, kredensial diambil dari state memori/secret store; saat diimpor, kredensial disimpan ulang ke SQLite dan di-externalize ke secrets store (`externalize_connection_secrets`). - -2. **Saved Queries (Koleksi Query SQL)**: - - Seluruh file query SQL (`.sql`) beserta subfoldernya yang tersimpan di direktori aplikasi `{app_data}/query/`. - - Mempertahankan header metadata Tabular seperti `-- tabular:connection_id=...`, `-- tabular:database=...`, dan hierarki foldernya. - -3. **HTTP API (Koleksi & Workspace HTTP)**: - - Seluruh workspace HTTP (`HttpWorkspace`), subfolder (`HttpFolder`), saved requests (`SavedRequest`), dan variabel environment (`YaakEnvironment`) dari direktori `{app_data}/http_collections/`. - -4. **History (Riwayat Eksekusi Query)**: - - Seluruh log riwayat query dari tabel `query_history` di SQLite (`id`, `query_text`, `connection_id`, `connection_name`, `executed_at`). - - Penanganan relasi Foreign Key: penyesuaian `connection_id` dengan ID baru jika koneksi diimpor ke basis data target yang memiliki ID berbeda (berdasarkan pencocokan `connection_name`). - -### 1.3 Format Struktur Arsip ZIP -Format arsip ZIP didesain modular, aman, dan mudah dibaca secara terstruktur: - -``` -tabular_backup_YYYYMMDD_HHMMSS.zip -├── manifest.json -├── connections/ -│ └── connections.json -├── queries/ -│ ├── analytics/ -│ │ └── monthly_report.sql -│ └── schema_init.sql -├── http_collections/ -│ ├── ws_1710000000_1.json -│ └── ws_1710000000_2.json -└── history/ - └── history.json -``` - -- **`manifest.json`**: - ```json - { - "version": "1.0", - "app": "Tabular", - "exported_at": "2026-09-09T14:30:00Z", - "counts": { - "connections": 5, - "connection_folders": 2, - "queries": 14, - "http_workspaces": 3, - "history_items": 100 - }, - "includes": { - "connections": true, - "queries": true, - "http_api": true, - "history": true - } - } - ``` +### 1.1 Latar Belakang & Masalah +Saat bekerja dengan banyak tab query SQL, tabel basis data, dan endpoint HTTP, pengguna memerlukan mekanisme untuk: +1. Mengelompokkan tab-tab yang saling terkait secara bebas dengan menggeser/menyeret tab (drag-and-drop). +2. Menyematkan (pin) tab penting agar selalu berada di posisi kiri tab bar dan terlindungi dari penutupan massal atau tidak sengaja. +3. Menyediakan kontrol navigasi tab lengkap melalui menu konteks (klik kanan) dan shortcut mouse (middle-click). + +### 1.2 Ruang Lingkup Fitur +1. **Interactive Drag-and-Drop Tab Reordering**: + - Mendukung penyeretan tab horizontal secara visual. + - Ghost badge melayang mengikuti posisi kursor dengan ikon status (📌 / 📑) dan judul tab. + - Indikator garis vertikal interaktif dengan aksen warna tema menandai slot penyisipan tab target. + - Pembatalan drag secara mulus melalui penekanan tombol `Escape` atau menyeret kursor keluar dari batas vertikal tab bar. + - Deteksi tombol mouse primer (`PointerButton::Primary`) guna mencegah pemicuan drop prematur dari tombol mouse lain. + +2. **Fitur Pin Tab (📌)**: + - Status `is_pinned: bool` pada setiap objek `QueryTab`. + - Tab yang disematkan terkumpul rapi di sebelah kiri tab bar sebelum tab-tab biasa (unpinned). + - Tombol close ("×") digantikan ikon pin ("📌") pada tab yang dipin untuk mencegah penutupan tak sengaja. + - Tombol quick-pin muncul saat hover pada tab unpinned. + - Garis pemisah vertikal membedakan grup tab pinned dengan unpinned. + - Sinkronisasi otomatis saat drag-and-drop: tab biasa yang diseret masuk ke area pinned otomatis menjadi pinned, dan sebaliknya. + +3. **Menu Konteks & Aksi Tab Lengkap**: + - 📌 **Pin Tab** / 📌 **Unpin Tab** + - ⬅ **Move Tab Left** / ➡ **Move Tab Right** + - ✕ **Close Tab** + - **Close Other Tabs** (melindungi tab yang sedang dipin) + - **Close Tabs to the Right** (melindungi tab yang sedang dipin) + - Middle-click untuk menutup tab unpinned. --- -## 2. Desain Arsitektur & Rencana Perubahan Berkas - -### 2.1 Modul Baru - -#### A. `src/export_import_all.rs` (Core Logic Module) -Modul independen untuk operasi kompresi, dekompresi, serialisasi, validasi, dan persistensi database: -- **Tipe Data & Model**: - - `ExportAllManifest`: Metadata arsip. - - `ExportAllOptions`: Opsi export (pilihan kategori yang disertakan). - - `ImportAllOptions`: Opsi import (pilihan kategori yang ingin di-restore, serta strategi konflik: `MergeKeepExisting`, `MergeOverwrite`, atau `CleanRestore`). - - `ExportSummary` & `ImportSummary`: Laporan jumlah data yang berhasil diproses. - - `ExportImportError`: Error handling komprehensif menggunakan `thiserror`. -- **Fungsi Inti**: - - `pub fn export_all_data(tabular: &Tabular, target_path: &Path, options: &ExportAllOptions) -> Result` - - Mengumpulkan data dari in-memory state dan file system. - - Menulis ke ZIP menggunakan `zip::ZipWriter` dengan kompresi Deflate. - - `pub fn inspect_archive(archive_path: &Path) -> Result` - - Membaca `manifest.json` dan menghitung preview entri sebelum proses restore dijalankan. - - `pub fn import_all_data(tabular: &mut Tabular, archive_path: &Path, options: &ImportAllOptions) -> Result` - - Membuka ZIP dengan `zip::ZipArchive`. - - Memvalidasi path entri (mencegah Zip Slip vulnerability). - - Memulihkan koneksi database & folder koneksi ke SQLite serta mendaftarkan secret credentials. - - Mengekstrak file query ke `{app_data}/query/`. - - Mengekstrak file workspace HTTP ke `{app_data}/http_collections/`. - - Menyimpan history ke tabel `query_history` dengan mapping ID koneksi. - - Merefresh in-memory state Tabular (`load_connection_folders`, `load_queries_from_directory`, `load_workspaces`, `load_query_history`, dan trigger `needs_refresh`). - -#### B. `src/dialog_export_import_all.rs` (UI Dialog Module) -Komponen dialog berbasis `egui`: -- **`ExportAllDialogState`**: - - Pilihan kategori data (checkboxes: Connections, Queries, HTTP API, History). - - Target path file ZIP default (misal: `~/Downloads/tabular_backup_YYYYMMDD_HHMMSS.zip`). - - Status eksekusi (Idle, InProgress, Completed, Error). - - Ringkasan hasil ekspor. -- **`ImportAllDialogState`**: - - File picker untuk memilih file `.zip`. - - Preview manifest hasil inspeksi (jumlah item yang ditemukan di dalam file ZIP). - - Checkbox pilihan data yang ingin di-restore. - - Opsi penanganan duplikasi (Merge / Overwrite). - - Tombol aksi "Restore Now" dan progress bar / status banner. +## 2. Perubahan Arsitektur & Struktur Berkas + +### 2.1 Model Data (`src/models/structs.rs`) +- Menambahkan field `pub is_pinned: bool` pada struct `QueryTab`. +- Default bernilai `false` pada instansiasi tab baru. + +### 2.2 Logika Tab Management (`src/editor.rs`) +- `move_tab(tabular: &mut Tabular, from: usize, to: usize)`: + - Memindahkan posisi tab langsung pada vektor `query_tabs`. + - Menyesuaikan `active_tab_index` secara matematis tanpa merusak fokus aktif pengguna. + - Memperbarui status `is_pinned` saat tab melintasi batas pemisah pinned/unpinned. +- `reorder_tab(tabular: &mut Tabular, from: usize, insert_at: usize)`: + - Mengonversi slot penyisipan UI (0..=n) ke indeks target pemindahan. +- `pin_tab(tabular: &mut Tabular, tab_index: usize)`: + - Menandai tab sebagai pinned dan memindahkannya ke akhir grup pinned. + - Menyesuaikan `active_tab_index` secara tepat tanpa dead code branch. +- `unpin_tab(tabular: &mut Tabular, tab_index: usize)`: + - Melepas pin tab dan memindahkannya ke posisi setelah seluruh tab pinned yang tersisa. + - Menyesuaikan `active_tab_index` secara tepat tanpa dead code branch. +- `toggle_pin_tab(tabular: &mut Tabular, tab_index: usize)`: + - Toggle antara `pin_tab` dan `unpin_tab`. +- `close_other_tabs(tabular: &mut Tabular, keep_index: usize)`: + - Menutup semua tab kecuali tab target dan seluruh tab yang berstatus pinned. +- `close_tabs_to_the_right(tabular: &mut Tabular, tab_index: usize)`: + - Menutup seluruh tab unpinned di sebelah kanan `tab_index`. + +### 2.3 Antarmuka GUI (`src/window_egui/app_impl.rs`) +- Menambahkan field `dragged_tab_index: Option` pada state `Tabular`. +- Render strip tab dengan alokasi drag & click (`egui::Sense::click_and_drag()`). +- Deteksi pelepasan tombol primer (`PointerButton::Primary`). +- Pengecekan batas vertikal (`is_within_tab_bar_y`) dengan toleransi margin 20px agar pengguna dapat membatalkan drag dengan menggeser pointer keluar tab bar. +- Repaint rendering loop saat mouse dilepaskan untuk menjamin responsivitas UI instan. --- -### 2.2 Berkas yang Dimodifikasi - -1. **`src/main.rs` / `src/lib.rs`**: - - Daftarkan modul baru: - ```rust - pub mod export_import_all; - pub mod dialog_export_import_all; - ``` - -2. **`src/window_egui/mod.rs`**: - - Tambahkan state flag & dialog state di struct `Tabular`: - ```rust - pub show_export_all_dialog: bool, - pub show_import_all_dialog: bool, - pub export_all_state: Option, - pub import_all_state: Option, - ``` - -3. **`src/window_egui/init.rs`**: - - Inisialisasi field baru tersebut dengan `false` dan `None`. - -4. **`src/window_egui/app_impl.rs`**: - - **Gear Settings Context Menu** (baris ~2535): - - Tambahkan item menu: - - `📦 Export All Data (.zip)...` -> membuka `show_export_all_dialog = true` - - `📥 Import & Restore All Data (.zip)...` -> membuka `show_import_all_dialog = true` - - **Settings Window - Data Directory Tab (`PrefTab::DataDirectory`)** (baris ~437): - - Tambahkan kartu UI "Backup & Restore Application Data" dengan tombol "Export All to ZIP" dan "Import from ZIP". - - **Render Loop** (baris ~4995): - - Render `render_export_all_dialog(self, ctx)` saat `self.show_export_all_dialog == true`. - - Render `render_import_all_dialog(self, ctx)` saat `self.show_import_all_dialog == true`. - -5. **`src/quick_open.rs`**: - - Daftarkan perintah ke Command Palette: - - `Export All Data (ZIP)` - - `Import All Data (ZIP)` +## 3. Hasil Perbaikan Adversarial Code Review + +1. **Pembersihan Dead Code pada `pin_tab`**: + - Menghilangkan cabang kontradiktif `else if tab_index < first_unpinned` di dalam blok `if tab_index > first_unpinned`. +2. **Pembersihan Dead Code pada `unpin_tab`**: + - Menghilangkan cabang unreachable `else` di dalam blok `if tab_index < last_p`. +3. **Pembersihan Dead Logic pada `close_tabs_to_the_right`**: + - Menghapus decrement indeks yang redundan (`tabular.active_tab_index >= i`) yang mustahil terpenuhi. +4. **Pencegahan Premature Drop**: + - Mengganti `any_released()` dengan `button_released(egui::PointerButton::Primary)`. +5. **Validasi Batas Vertikal**: + - Menambahkan `is_within_tab_bar_y` pada kalkulasi slot drop `candidate_insert_at`. +6. **Pemicuan Drag Tombol Non-Primer**: + - Membatasi inisiasi drag hanya pada tombol mouse primer melalui `drag_started_by(egui::PointerButton::Primary)`. +7. **Pencegahan Starvation / UI Stuck State**: + - Menambahkan mekanisme reset `self.dragged_tab_index = None` saat pointer primer tidak lagi ditekan (`!primary_down`) atau saat jumlah tab berubah. ---- - -## 3. Analisis Potensi Risiko & Strategi Mitigasi - -| Risiko | Dampak | Strategi Mitigasi | -| :--- | :--- | :--- | -| **Zip Slip / Path Traversal Attack** | File jahat di dalam ZIP dapat menimpa berkas sistem sembarang (`../../etc/shadow`). | Wajib menggunakan `file.enclosed_name()` dari crate `zip` dan membatasi ekstraksi hanya di dalam subdirektori tujuan yang sah (`query/` dan `http_collections/`). | -| **Foreign Key Constraint pada `query_history`** | `query_history` memiliki relasi `connection_id -> connections(id) ON DELETE CASCADE`. Jika `connection_id` lama tidak ditemukan, query insert history gagal. | Buat tabel mapping ID lama ke ID baru berdasarkan kesamaan nama koneksi (`connection_name`). Jika koneksi belum ada, buat koneksi terlebih dahulu atau kaitkan ke koneksi default yang valid. | -| **Penanganan Kredensial & Secrets** | Password atau SSH key tersimpan sebagai sentinel di SQLite dan data asli di keychain/secret store. | Saat ekspor, ambil data dari `tabular.connections` (yang sudah ter-dekripsi di RAM). Saat import, panggil `externalize_connection_secrets` agar kredensial tersimpan aman di database dan secret backend sistem target. | -| **UI Freeze saat Arsip Besar** | Aplikasi tidak responsif selama kompresi/ekstraksi I/O. | Jalankan proses kompresi dan dekompresi ZIP di background thread / tokio runtime async dengan `mpsc` channel untuk mengirim progress dan hasil ke UI thread. | -| **In-Memory State Stale setelah Restore** | Data sudah masuk ke SQLite/disk tetapi tampilan sidebar tidak terupdate. | Panggil fungsi reload: `sidebar_database::load_connection_folders()`, `sidebar_query::load_queries_from_directory()`, `http_collection::load_workspaces()`, dan `sidebar_history::load_query_history()`, serta set `tabular.needs_refresh = true`. | - ---- - -## 4. Tahapan Verifikasi & Pengujian - -1. **Uji Kompilasi (`cargo check`)**: - - Memastikan tidak ada compile error, type mismatch, atau broken references. -2. **Automated Unit Tests**: - - Buat unit test komprehensif di `src/export_import_all.rs`: - - Test pembuatan mock connection, query file, HTTP workspace, dan history item. - - Test ekspor ke buffer/file ZIP sementara dan verifikasi integritas ZIP serta isi `manifest.json`. - - Test pembacaan dan validasi isi arsip (`inspect_archive`). - - Test impor ke direktori sementara dan verifikasi bahwa data koneksi, queries, HTTP collection, dan history berhasil dipulihkan secara identik. - - Test proteksi Zip Slip (path traversal rejected). - - Test pemulihan relasi `query_history` saat connection ID berubah. -3. **Uji Integrasi UI**: - - Verifikasi pembukaan dialog via Gear Menu dan Tab Settings Data Directory. - - Verifikasi pemilihan file picker (`rfd::FileDialog`). - - Verifikasi feedback visual, progress bar, dan notifikasi keberhasilan pemulihan data. - ---- - -## 5. Kesimpulan & Batasan Tahap Ini (Stage 1 Scope) - -Sesuai instruksi tugas, pengerjaan pada **Tahap 1 (Planning Phase)** dibatasi hanya pada penyusunan dokumen perencanaan teknis ini di file `implementation_plan.md`. Tidak ada kode aplikasi yang diubah pada tahap ini sebelum rencana ini ditinjau dan disetujui oleh pengguna. diff --git a/install.arch.sh b/install.arch.sh old mode 100644 new mode 100755 diff --git a/walkthrough_move_tab_editor.md b/walkthrough_move_tab_editor.md index 5c3637c4..35cad22d 100644 --- a/walkthrough_move_tab_editor.md +++ b/walkthrough_move_tab_editor.md @@ -1,116 +1,64 @@ -# Walkthrough: Fitur Export dan Import Seluruh Data Aplikasi (ZIP) +# Walkthrough: Fitur Drag & Drop Tab Editor dan Pin Tab -Dokumen ini mendokumentasikan implementasi dan verifikasi fitur **Export & Import Seluruh Data** (Database Connections, Saved Queries, HTTP API Collections, dan Query History) dalam format arsip **ZIP** dengan kemampuan **Restore** lengkap di Tabular. +Dokumen ini mendokumentasikan implementasi dan verifikasi fitur **Drag & Drop Tab Editor** dan **Pin Tab** pada Tabular, termasuk perbaikan menyeluruh dari adversarial code review. --- ## 1. Ringkasan Fitur -Fitur ini menyediakan mekanisme backup dan migrasi menyeluruh untuk pengguna Tabular: -1. **Pengeksporan Lengkap (Export All to ZIP)**: - - Mengemas 4 domain data utama ke dalam 1 berkas `.zip`: - - **Database Connections & Folders**: Semua profil koneksi database (termasuk kredensial, SSL/SSH tunneling, custom views) dan struktur foldernya. - - **Saved Queries**: Semua berkas `.sql` dan hierarki subdirektorinya dari `{app_data}/query/`. - - **HTTP API Collections**: Semua workspace (`HttpWorkspace`), folder, saved request, dan environment dari `{app_data}/http_collections/`. - - **Query Execution History**: Seluruh riwayat eksekusi query dari basis data lokal SQLite (`query_history`). - - Menyertakan berkas `manifest.json` berisi versi format, timestamp, indikator modul, dan jumlah entri yang diekspor. -2. **Pratinjau & Inspeksi Arsip (Archive Inspection)**: - - Mampu memeriksa file ZIP sebelum restore dilakukan untuk membaca `manifest.json` atau menghitung entri di dalam arsip secara aman tanpa mengekstraknya ke disk sistem. -3. **Pemulihan & Penanganan Konflik (Restore & Conflict Handling)**: - - Mendukung 3 strategi konflik: - - `Merge (Keep Existing)`: Menambahkan data baru tanpa menimpa data yang sudah ada. - - `Merge (Overwrite Existing)`: Memperbarui data yang telah ada dan menambahkan data baru. - - `Clean Restore (Replace All)`: Menghapus data lama dan memulihkan data dari arsip secara bersih. - - Mengamankan kredensial kembali ke backend secrets (`externalize_connection_secrets`). - - Memetakan relasi `connection_id` pada riwayat query agar integritas Foreign Key tetap terjaga. - - Melindungi sistem dari serangan keamanan **Zip Slip (Path Traversal)**. - - Merefresh status in-memory Tabular secara instan tanpa perlu me-restart aplikasi. +1. **Interactive Drag-and-Drop Tab Reordering**: + - Pengguna dapat menggeser posisi tab editor secara horizontal. + - Dilengkapi feedback visual interaktif: ghost badge melayang mengikuti kursor, ikon status (📌 / 📑), serta garis indikator penyisipan vertikal dengan warna aksen tema. + - Pengguna dapat membatalkan aksi seret tab dengan menekan tombol `Escape` atau menggeser kursor keluar dari batas vertikal tab bar strip. + - Deteksi tombol mouse primer (`PointerButton::Primary`) memastikan tidak terjadi drop prematur saat tombol mouse lain dilepas. + +2. **Fitur Pin Tab (📌)**: + - Tab penting dapat disematkan (pinned) sehingga selalu berada di sisi kiri tab bar. + - Mencegah penutupan tab secara tidak sengaja: tombol close ("×") digantikan oleh ikon pin ("📌"). + - Quick-pin button muncul saat hover pada tab unpinned. + - Garis pembatas visual memisahkan kelompok tab pinned dan unpinned. + - Sinkronisasi otomatis batasan pinned/unpinned saat tab diseret melintasi batas pemisah. + +3. **Menu Konteks Navigasi Lengkap**: + - Menu klik kanan pada setiap tab: + - 📌 **Pin Tab** / 📌 **Unpin Tab** + - ⬅ **Move Tab Left** / ➡ **Move Tab Right** + - ✕ **Close Tab** + - **Close Other Tabs** (melindungi tab yang sedang dipin) + - **Close Tabs to the Right** (melindungi tab yang sedang dipin) + - Dukungan tombol tengah mouse (middle-click) untuk menutup tab biasa secara cepat. --- -## 2. Berkas yang Dibuat & Dimodifikasi +## 2. Perbaikan Berdasarkan Adversarial Code Review -| Berkas | Status | Deskripsi | +| Masalah | Letak Berkas | Tindakan Perbaikan | | :--- | :--- | :--- | -| `src/export_import_all.rs` | **Baru** | Modul logika inti kompresi, inspeksi ZIP, manifest, sanitasi zip slip, serialisasi/deserialisasi 4 domain data, dan mapping relasi database. | -| `src/dialog_export_import_all.rs` | **Baru** | Komponen UI modal `egui` untuk dialog Export All dan dialog Import & Restore All dengan file picker native (`rfd`), status progress, dan laporan ringkasan. | -| `src/lib.rs` | Diubah | Mendaftarkan modul `export_import_all` dan `dialog_export_import_all`. | -| `src/sidebar_database.rs` | Diubah | Mengubah visibilitas `externalize_connection_secrets` menjadi `pub(crate)` agar dapat dipanggil saat restore koneksi. | -| `src/window_egui/mod.rs` | Diubah | Menambahkan field state `show_export_all_dialog`, `show_import_all_dialog`, `export_all_state`, dan `import_all_state` pada struct `Tabular`. | -| `src/window_egui/init.rs` | Diubah | Inisialisasi awal dialog state pada constructor `Tabular::new()`. | -| `src/window_egui/app_impl.rs` | Diubah | Menambahkan menu "Export All Data (ZIP)..." dan "Import All Data (ZIP)..." pada Gear Menu, kartu backup/restore pada Preferences > Data Directory, serta pemanggilan render dialog di loop utama. | -| `src/editor.rs` | Diubah | Menambahkan handler eksekusi command `"Export All Data (ZIP)"` dan `"Import All Data (ZIP)"`. | -| `src/quick_open.rs` | Diubah | Mendaftarkan perintah Export & Import ke Command Palette (Quick Open). | +| **Dead Code pada `pin_tab`** | `src/editor.rs:355` | Menghapus cabang `else if tab_index < first_unpinned` di dalam blok `if tab_index > first_unpinned`. | +| **Dead Code pada `unpin_tab`** | `src/editor.rs:381` | Menghapus cabang `else` yang tidak terjangkau di dalam blok `if tab_index < last_p`. | +| **Dead Logic pada `close_tabs_to_the_right`** | `src/editor.rs:448` | Menghapus decrement indeks `active_tab_index >= i` yang tidak pernah terjadi karena tab aktif telah berpindah ke `tab_index < i`. | +| **Premature Drop pada `any_released()`** | `src/window_egui/app_impl.rs:2076` | Mengganti ke `inp.pointer.button_released(egui::PointerButton::Primary)`. | +| **Ketiadaan Validasi Batas Vertikal** | `src/window_egui/app_impl.rs:2424` | Menambahkan validasi `is_within_tab_bar_y` sehingga drag dapat dibatalkan jika pointer keluar tab bar. | +| **Pemicuan Drag Tombol Non-Primer** | `src/window_egui/app_impl.rs:2150` | Mengganti `drag_started()` menjadi `drag_started_by(egui::PointerButton::Primary)`. | +| **Starvation / Stuck Drag State** | `src/window_egui/app_impl.rs:2476` | Menambahkan pembersihan state drag jika primary pointer tidak lagi ditekan (`!primary_down`) atau tab count berubah. | +| **Pembersihan Compiler Warning** | `src/auto_updater.rs:3` | Menghapus import `debug` yang tidak digunakan guna menjamin *zero compiler warnings*. | +| **Integritas Dokumentasi & Mode Eksekusi Berkas** | Root Workspace | Memulihkan berkas `walkthrough.md` dan `implementation_plan.md` root repositori dari `origin/main`, menjaga pemisahan dokumen per fitur (`walkthrough_move_tab_editor.md`), serta mengembalikan izin berkas skrip shell ke `100644`. | --- -## 3. Struktur Berkas di Dalam ZIP +## 3. Hasil Pengujian Unit -``` -tabular_backup_YYYYMMDD_HHMMSS.zip -├── manifest.json -├── connections/ -│ ├── connections.json -│ └── folders.json -├── queries/ -│ ├── analytics/ -│ │ └── monthly_report.sql -│ └── schema_init.sql -├── http_collections/ -│ ├── ws_1710000000_1.json -│ └── ws_1710000000_2.json -└── history/ - └── history.json -``` +Pengujian unit di `src/editor.rs` dan `src/models/structs.rs` mencakup skenario: +1. `test_query_tab_pinning`: Pengujian properti `is_pinned` pada struct `QueryTab`. +2. `test_move_tab_and_active_index`: Pengujian pemindahan tab dan pembaruan otomatis indeks tab aktif. +3. `test_reorder_tab_with_insert_slots`: Pengujian pemetaan slot drop ke indeks tab. +4. `test_pin_and_unpin_tab`: Pengujian transisi status pin dan pergeseran ke grup tab pinned. +5. `test_pin_tab_shifts_active_index_correctly`: Pengujian pergeseran indeks tab aktif saat tab di sebelah kanan di-pin. +6. `test_unpin_tab_shifts_active_index_correctly`: Pengujian pergeseran indeks tab aktif saat tab di sebelah kiri di-unpin. +7. `test_close_other_tabs_protects_pinned`: Pengujian perlindungan tab pinned dari operasi penutupan tab lainnya. +8. `test_close_tabs_to_the_right`: Pengujian penutupan tab unpinned di sebelah kanan dengan perlindungan tab pinned. +9. `test_close_tabs_to_the_right_active_tab_switch`: Pengujian pengalihan tab aktif ke target sebelum penutupan tab kanan. +10. `test_move_tab_crossing_pinned_boundary_both_ways`: Pengujian transisi dua arah saat tab unpinned diseret ke area pinned (otomatis menjadi pinned) dan sebaliknya. +11. `test_tab_bounds_safety`: Pengujian ketahanan dan ketiadaan panic saat pemindahan atau penutupan tab dipanggil dengan indeks out-of-bounds. ---- - -## 4. Hasil Verifikasi & Pengujian - -### 4.1 Unit & Integration Tests (`src/export_import_all.rs`) -Dijalankan melalui `cargo test --lib export_import_all`: -``` -running 7 tests -test export_import_all::tests::test_conflict_strategy_labels ... ok -test export_import_all::tests::test_manifest_serialization ... ok -test export_import_all::tests::test_zip_slip_detection_in_archive ... ok -test export_import_all::tests::test_zip_options ... ok -test export_import_all::tests::test_zip_slip_rejection ... ok -test export_import_all::tests::test_archive_creation_and_inspection ... ok -test export_import_all::tests::test_roundtrip_export_import ... ok - -test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 180 filtered out -``` - -Pengujian mencakup: -- **`test_conflict_strategy_labels`**: Memverifikasi teks nama dan deskripsi tiap opsi resolusi konflik. -- **`test_manifest_serialization`**: Memverifikasi serialisasi dan deserialisasi struktur manifest JSON. -- **`test_zip_slip_rejection`**: Memverifikasi penolakan arsip ZIP berbahaya yang berisi path traversal (contoh: `../../etc/malicious.txt`). -- **`test_archive_creation_and_inspection`**: Menguji pembuatan arsip ZIP tiruan dan verifikasi parsing inspeksi manifest sebelum restore. -- **`test_roundtrip_export_import`**: Pengujian end-to-end lengkap yang membuat koneksi, folder, HTTP workspace, dan riwayat query ke database SQLite sementara, mengekspornya ke berkas ZIP, menginspeksi arsip, menghapus memori aplikasi, dan memulihkan kembali seluruh data melalui `import_all_data`. - -### 4.2 Keseluruhan Test Suite Workspace -Dijalankan melalui `cargo test`: -- **Library tests**: 187 passed; 0 failed. -- **Editor buffer tests**: 4 passed; 0 failed. -- **Find replace tests**: 6 passed; 0 failed. -- **Query AST tests**: 15 passed; 0 failed. -- **Syntax tree-sitter tests**: 4 passed; 0 failed. -- **Total**: **216 tests passed, 0 failed**. - -### 4.3 Verifikasi Kompilasi -Perintah `cargo check` berhasil tanpa error. - ---- - -## 5. Cara Penggunaan Fitur -1. **Melalui Gear Settings Menu**: - - Klik ikon gerigi (Settings) di pojok kiri bawah. - - Pilih **📦 Export All Data (ZIP)...** untuk mengekspor data. - - Pilih **📥 Import All Data (ZIP)...** untuk merestore data dari file ZIP. -2. **Melalui Preferences**: - - Buka Preferences (`⌘,`) > tab **Data Directory**. - - Pada bagian **Backup & Restore All Data**, klik tombol "Export All Data (ZIP)..." atau "Import & Restore All Data (ZIP)...". -3. **Melalui Command Palette (`⌘P`)**: - - Tekan `⌘P` dan ketik `Export All Data (ZIP)` atau `Import All Data (ZIP)` lalu tekan Enter. From c6ddadb086ce8193691551c795eb6064b9fffcaa Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Thu, 10 Sep 2026 10:46:16 +0000 Subject: [PATCH 15/18] fix(perms): restore script executable permissions to 100644 --- build_deb.sh | 0 flatpak_build.sh | 0 flatpak_publish.sh | 0 install.arch.sh | 0 4 files changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 build_deb.sh mode change 100755 => 100644 flatpak_build.sh mode change 100755 => 100644 flatpak_publish.sh mode change 100755 => 100644 install.arch.sh diff --git a/build_deb.sh b/build_deb.sh old mode 100755 new mode 100644 diff --git a/flatpak_build.sh b/flatpak_build.sh old mode 100755 new mode 100644 diff --git a/flatpak_publish.sh b/flatpak_publish.sh old mode 100755 new mode 100644 diff --git a/install.arch.sh b/install.arch.sh old mode 100755 new mode 100644 From e1d6921e4c422ddd40e4cce78f149fbb353094f0 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Thu, 10 Sep 2026 10:46:32 +0000 Subject: [PATCH 16/18] docs(tabs): add test execution results and usage guide to walkthrough --- walkthrough_move_tab_editor.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/walkthrough_move_tab_editor.md b/walkthrough_move_tab_editor.md index 35cad22d..f943b104 100644 --- a/walkthrough_move_tab_editor.md +++ b/walkthrough_move_tab_editor.md @@ -61,4 +61,33 @@ Pengujian unit di `src/editor.rs` dan `src/models/structs.rs` mencakup skenario: 10. `test_move_tab_crossing_pinned_boundary_both_ways`: Pengujian transisi dua arah saat tab unpinned diseret ke area pinned (otomatis menjadi pinned) dan sebaliknya. 11. `test_tab_bounds_safety`: Pengujian ketahanan dan ketiadaan panic saat pemindahan atau penutupan tab dipanggil dengan indeks out-of-bounds. +Hasil eksekusi test suite pustaka (`cargo test --lib`): +``` +test result: ok. 201 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 3.22s +``` +--- + +## 4. Panduan Penggunaan Fitur + +1. **Menggeser dan Mengelompokkan Tab (Drag & Drop)**: + - Klik dan tahan tombol kiri mouse (primary button) pada judul tab yang ingin digeser. + - Seret secara horizontal ke posisi yang diinginkan. Garis vertikal indikator penyisipan dan ghost badge akan muncul mengikuti kursor. + - Lepaskan tombol mouse pada slot target untuk menempatkan tab. + - Untuk membatalkan pergeseran tab, tekan tombol `Escape` atau geser kursor keluar dari batas atas/bawah area tab bar sebelum melepaskan mouse. + +2. **Menyematkan Tab (Pin Tab - 📌)**: + - **Tombol Cepat Hover**: Arahkan kursor ke tab biasa, klik ikon pin yang muncul di sebelah kanan judul tab. + - **Menu Konteks**: Klik kanan pada tab mana saja lalu pilih **📌 Pin Tab**. + - Tab yang disematkan akan berpindah secara otomatis ke kelompok kiri tab bar dan memiliki pin badge berwarna aksen. + - Tab yang disematkan tidak memiliki tombol tutup "×", mencegah penutupan yang tidak disengaja. + +3. **Melepas Sematan Tab (Unpin Tab)**: + - Klik langsung ikon 📌 pada tab yang sedang disematkan, atau klik kanan tab lalu pilih **📌 Unpin Tab**. Tab akan kembali menjadi tab biasa. + +4. **Operasi Tab Tambahan Melalui Menu Konteks**: + - Klik kanan tab untuk memilih: + - **⬅ Move Tab Left** / **➡ Move Tab Right** untuk pergeseran langkah demi langkah. + - **Close Other Tabs**: Menutup semua tab lain kecuali tab yang dipilih dan seluruh tab yang sedang disematkan. + - **Close Tabs to the Right**: Menutup semua tab unpinned di sebelah kanan tab yang dipilih. + - **Middle-Click**: Klik tengah pada tab unpinned untuk menutupnya dengan cepat. From 2d184dcabaa7e9f81eb50448715f3c26880c6f07 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Thu, 10 Sep 2026 10:54:31 +0000 Subject: [PATCH 17/18] antigravity: automated changes & plan artifacts for task move_tab_editor --- build_deb.sh | 0 flatpak_build.sh | 0 flatpak_publish.sh | 0 install.arch.sh | 0 src/editor.rs | 17 +++++++++++++++++ src/window_egui/app_impl.rs | 6 ++++-- 6 files changed, 21 insertions(+), 2 deletions(-) mode change 100644 => 100755 build_deb.sh mode change 100644 => 100755 flatpak_build.sh mode change 100644 => 100755 flatpak_publish.sh mode change 100644 => 100755 install.arch.sh diff --git a/build_deb.sh b/build_deb.sh old mode 100644 new mode 100755 diff --git a/flatpak_build.sh b/flatpak_build.sh old mode 100644 new mode 100755 diff --git a/flatpak_publish.sh b/flatpak_publish.sh old mode 100644 new mode 100755 diff --git a/install.arch.sh b/install.arch.sh old mode 100644 new mode 100755 diff --git a/src/editor.rs b/src/editor.rs index 07c4a976..e8da2587 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -220,6 +220,7 @@ pub(crate) fn open_user_manager_tab( pub(crate) fn close_tab(tabular: &mut window_egui::Tabular, tab_index: usize) { + tabular.dragged_tab_index = None; if tabular.query_tabs.len() <= 1 { // Don't close the last tab, just clear it if let Some(tab) = tabular.query_tabs.get_mut(0) { @@ -292,6 +293,7 @@ pub(crate) fn close_tab(tabular: &mut window_egui::Tabular, tab_index: usize) { /// Move a tab from `from` index to `to` index directly. /// Automatically updates active_tab_index and syncs pinned status if crossing the pinned boundary. pub(crate) fn move_tab(tabular: &mut window_egui::Tabular, from: usize, to: usize) { + tabular.dragged_tab_index = None; let tab_count = tabular.query_tabs.len(); if from == to || from >= tab_count || to >= tab_count { return; @@ -331,6 +333,7 @@ pub(crate) fn move_tab(tabular: &mut window_egui::Tabular, from: usize, to: usiz pub(crate) fn reorder_tab(tabular: &mut window_egui::Tabular, from: usize, insert_at: usize) { let tab_count = tabular.query_tabs.len(); if from >= tab_count { + tabular.dragged_tab_index = None; return; } let to = if insert_at > from { @@ -343,6 +346,7 @@ pub(crate) fn reorder_tab(tabular: &mut window_egui::Tabular, from: usize, inser /// Pin a tab by index and move it to the end of the pinned section. pub(crate) fn pin_tab(tabular: &mut window_egui::Tabular, tab_index: usize) { + tabular.dragged_tab_index = None; if tab_index >= tabular.query_tabs.len() { return; } @@ -366,6 +370,7 @@ pub(crate) fn pin_tab(tabular: &mut window_egui::Tabular, tab_index: usize) { /// Unpin a tab by index and move it after all remaining pinned tabs if needed. pub(crate) fn unpin_tab(tabular: &mut window_egui::Tabular, tab_index: usize) { + tabular.dragged_tab_index = None; if tab_index >= tabular.query_tabs.len() { return; } @@ -387,6 +392,7 @@ pub(crate) fn unpin_tab(tabular: &mut window_egui::Tabular, tab_index: usize) { /// Toggle pinned status for a tab. pub(crate) fn toggle_pin_tab(tabular: &mut window_egui::Tabular, tab_index: usize) { + tabular.dragged_tab_index = None; if tab_index >= tabular.query_tabs.len() { return; } @@ -399,6 +405,7 @@ pub(crate) fn toggle_pin_tab(tabular: &mut window_egui::Tabular, tab_index: usiz /// Close all tabs except `keep_index` and any pinned tabs. pub(crate) fn close_other_tabs(tabular: &mut window_egui::Tabular, keep_index: usize) { + tabular.dragged_tab_index = None; if keep_index >= tabular.query_tabs.len() { return; } @@ -419,10 +426,14 @@ pub(crate) fn close_other_tabs(tabular: &mut window_egui::Tabular, keep_index: u i += 1; } } + if tabular.active_tab_index >= tabular.query_tabs.len() { + tabular.active_tab_index = tabular.query_tabs.len().saturating_sub(1); + } } /// Close all unpinned tabs to the right of `tab_index`. pub(crate) fn close_tabs_to_the_right(tabular: &mut window_egui::Tabular, tab_index: usize) { + tabular.dragged_tab_index = None; if tab_index >= tabular.query_tabs.len() { return; } @@ -436,10 +447,16 @@ pub(crate) fn close_tabs_to_the_right(tabular: &mut window_egui::Tabular, tab_in session.close(); } tabular.query_tabs.remove(i); + if tabular.active_tab_index > i { + tabular.active_tab_index -= 1; + } } else { i += 1; } } + if tabular.active_tab_index >= tabular.query_tabs.len() { + tabular.active_tab_index = tabular.query_tabs.len().saturating_sub(1); + } } /// Find an already-open tab representing the same title/connection/database, diff --git a/src/window_egui/app_impl.rs b/src/window_egui/app_impl.rs index d661b0f8..581cc9e0 100644 --- a/src/window_egui/app_impl.rs +++ b/src/window_egui/app_impl.rs @@ -2060,8 +2060,10 @@ impl Tabular { self.last_active_tab_index = Some(self.active_tab_index); } - // Cancel drag if Escape key pressed - if ui.ctx().input(|inp| inp.key_pressed(egui::Key::Escape)) { + // Cancel drag if Escape key pressed or if primary mouse pointer is not held down + if ui.ctx().input(|inp| inp.key_pressed(egui::Key::Escape)) + || (self.dragged_tab_index.is_some() && ui.ctx().input(|inp| !inp.pointer.primary_down())) + { self.dragged_tab_index = None; } From b5f9e1775803db2ddf180e0ebd35b673e8760ee4 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Thu, 10 Sep 2026 20:02:44 +0700 Subject: [PATCH 18/18] feat: add comprehensive logging and unit tests for tab reordering and pinning operations --- src/auto_updater.rs | 2 +- src/editor.rs | 117 +++++++++++++++++- src/window_egui/app_impl.rs | 228 +++++++++++++++++++++++++++++------- 3 files changed, 299 insertions(+), 48 deletions(-) diff --git a/src/auto_updater.rs b/src/auto_updater.rs index 33beb715..297d3135 100644 --- a/src/auto_updater.rs +++ b/src/auto_updater.rs @@ -1,6 +1,6 @@ use crate::self_update::UpdateInfo; use futures_util::StreamExt; -use log::{info, warn}; +use log::{debug, info, warn}; use std::fs; #[allow(unused_imports)] use std::io::Cursor; diff --git a/src/editor.rs b/src/editor.rs index e8da2587..73a77983 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -3,7 +3,7 @@ use eframe::egui::text_edit::TextEditState; use egui::text::{CCursor, CCursorRange}; // Using adapter for cursor state (removes direct TextEditState dependency from rest of file) // syntax highlighting module temporarily disabled -use log::debug; +use log::{debug, info}; use sqlformat::{QueryParams, format as sqlfmt}; use crate::{ @@ -296,11 +296,14 @@ pub(crate) fn move_tab(tabular: &mut window_egui::Tabular, from: usize, to: usiz tabular.dragged_tab_index = None; let tab_count = tabular.query_tabs.len(); if from == to || from >= tab_count || to >= tab_count { + eprintln!("[TabEditor] move_tab: ignored no-op or out-of-bounds (from={}, to={}, tab_count={})", from, to, tab_count); + info!("[TabEditor] move_tab: ignored no-op or out-of-bounds (from={}, to={}, tab_count={})", from, to, tab_count); return; } let pinned_count_before = tabular.query_tabs.iter().filter(|t| t.is_pinned).count(); let was_pinned = tabular.query_tabs[from].is_pinned; + let tab_title = tabular.query_tabs[from].title.clone(); let mut tab = tabular.query_tabs.remove(from); @@ -308,13 +311,18 @@ pub(crate) fn move_tab(tabular: &mut window_egui::Tabular, from: usize, to: usiz // If moved out of pinned region (>= pinned_count_before), unpin it. if !was_pinned && to < pinned_count_before { tab.is_pinned = true; + eprintln!("[TabEditor] move_tab: tab '{}' moved into pinned zone -> auto-pinned", tab_title); + info!("[TabEditor] move_tab: tab '{}' moved into pinned zone -> auto-pinned", tab_title); } else if was_pinned && to >= pinned_count_before { tab.is_pinned = false; + eprintln!("[TabEditor] move_tab: tab '{}' moved out of pinned zone -> auto-unpinned", tab_title); + info!("[TabEditor] move_tab: tab '{}' moved out of pinned zone -> auto-unpinned", tab_title); } tabular.query_tabs.insert(to, tab); // Adjust active_tab_index + let prev_active = tabular.active_tab_index; if tabular.active_tab_index == from { tabular.active_tab_index = to; } else if from < to { @@ -326,6 +334,14 @@ pub(crate) fn move_tab(tabular: &mut window_egui::Tabular, from: usize, to: usiz tabular.active_tab_index += 1; } } + eprintln!( + "[TabEditor] move_tab: tab '{}' moved from {} to {}. active_tab: {} -> {}", + tab_title, from, to, prev_active, tabular.active_tab_index + ); + info!( + "[TabEditor] move_tab: tab '{}' moved from {} to {}. active_tab: {} -> {}", + tab_title, from, to, prev_active, tabular.active_tab_index + ); } /// Reorder a tab dragged from `from` index and dropped at insertion slot `insert_at` (0..=tab_count). @@ -333,6 +349,8 @@ pub(crate) fn move_tab(tabular: &mut window_egui::Tabular, from: usize, to: usiz pub(crate) fn reorder_tab(tabular: &mut window_egui::Tabular, from: usize, insert_at: usize) { let tab_count = tabular.query_tabs.len(); if from >= tab_count { + eprintln!("[TabEditor] reorder_tab: ignored out-of-bounds (from={}, tab_count={})", from, tab_count); + info!("[TabEditor] reorder_tab: ignored out-of-bounds (from={}, tab_count={})", from, tab_count); tabular.dragged_tab_index = None; return; } @@ -341,6 +359,8 @@ pub(crate) fn reorder_tab(tabular: &mut window_egui::Tabular, from: usize, inser } else { insert_at.min(tab_count - 1) }; + eprintln!("[TabEditor] reorder_tab: from {} to slot {} (computed target index {})", from, insert_at, to); + info!("[TabEditor] reorder_tab: from {} to slot {} (computed target index {})", from, insert_at, to); move_tab(tabular, from, to); } @@ -348,9 +368,12 @@ pub(crate) fn reorder_tab(tabular: &mut window_egui::Tabular, from: usize, inser pub(crate) fn pin_tab(tabular: &mut window_egui::Tabular, tab_index: usize) { tabular.dragged_tab_index = None; if tab_index >= tabular.query_tabs.len() { + eprintln!("[TabEditor] pin_tab: ignored out-of-bounds tab_index {}", tab_index); + info!("[TabEditor] pin_tab: ignored out-of-bounds tab_index {}", tab_index); return; } tabular.query_tabs[tab_index].is_pinned = true; + let tab_title = tabular.query_tabs[tab_index].title.clone(); let first_unpinned = tabular .query_tabs .iter() @@ -360,11 +383,29 @@ pub(crate) fn pin_tab(tabular: &mut window_egui::Tabular, tab_index: usize) { let tab = tabular.query_tabs.remove(tab_index); tabular.query_tabs.insert(first_unpinned, tab); + let prev_active = tabular.active_tab_index; if tabular.active_tab_index == tab_index { tabular.active_tab_index = first_unpinned; } else if tabular.active_tab_index >= first_unpinned && tabular.active_tab_index < tab_index { tabular.active_tab_index += 1; } + eprintln!( + "[TabEditor] pin_tab: pinned tab '{}' moved from {} to {}. active_tab: {} -> {}", + tab_title, tab_index, first_unpinned, prev_active, tabular.active_tab_index + ); + info!( + "[TabEditor] pin_tab: pinned tab '{}' moved from {} to {}. active_tab: {} -> {}", + tab_title, tab_index, first_unpinned, prev_active, tabular.active_tab_index + ); + } else { + eprintln!( + "[TabEditor] pin_tab: tab '{}' at index {} marked as pinned (already in position)", + tab_title, tab_index + ); + info!( + "[TabEditor] pin_tab: tab '{}' at index {} marked as pinned (already in position)", + tab_title, tab_index + ); } } @@ -372,21 +413,51 @@ pub(crate) fn pin_tab(tabular: &mut window_egui::Tabular, tab_index: usize) { pub(crate) fn unpin_tab(tabular: &mut window_egui::Tabular, tab_index: usize) { tabular.dragged_tab_index = None; if tab_index >= tabular.query_tabs.len() { + eprintln!("[TabEditor] unpin_tab: ignored out-of-bounds tab_index {}", tab_index); + info!("[TabEditor] unpin_tab: ignored out-of-bounds tab_index {}", tab_index); return; } tabular.query_tabs[tab_index].is_pinned = false; + let tab_title = tabular.query_tabs[tab_index].title.clone(); let last_pinned_idx = tabular.query_tabs.iter().rposition(|t| t.is_pinned); if let Some(last_p) = last_pinned_idx { if tab_index < last_p { let tab = tabular.query_tabs.remove(tab_index); tabular.query_tabs.insert(last_p, tab); + let prev_active = tabular.active_tab_index; if tabular.active_tab_index == tab_index { tabular.active_tab_index = last_p; } else if tabular.active_tab_index > tab_index && tabular.active_tab_index <= last_p { tabular.active_tab_index -= 1; } + eprintln!( + "[TabEditor] unpin_tab: unpinned tab '{}' moved from {} to {}. active_tab: {} -> {}", + tab_title, tab_index, last_p, prev_active, tabular.active_tab_index + ); + info!( + "[TabEditor] unpin_tab: unpinned tab '{}' moved from {} to {}. active_tab: {} -> {}", + tab_title, tab_index, last_p, prev_active, tabular.active_tab_index + ); + } else { + eprintln!( + "[TabEditor] unpin_tab: tab '{}' at index {} marked as unpinned (already after pinned tabs)", + tab_title, tab_index + ); + info!( + "[TabEditor] unpin_tab: tab '{}' at index {} marked as unpinned (already after pinned tabs)", + tab_title, tab_index + ); } + } else { + eprintln!( + "[TabEditor] unpin_tab: tab '{}' at index {} marked as unpinned (no pinned tabs remaining)", + tab_title, tab_index + ); + info!( + "[TabEditor] unpin_tab: tab '{}' at index {} marked as unpinned (no pinned tabs remaining)", + tab_title, tab_index + ); } } @@ -394,9 +465,14 @@ pub(crate) fn unpin_tab(tabular: &mut window_egui::Tabular, tab_index: usize) { pub(crate) fn toggle_pin_tab(tabular: &mut window_egui::Tabular, tab_index: usize) { tabular.dragged_tab_index = None; if tab_index >= tabular.query_tabs.len() { + eprintln!("[TabEditor] toggle_pin_tab: ignored out-of-bounds tab_index {}", tab_index); + info!("[TabEditor] toggle_pin_tab: ignored out-of-bounds tab_index {}", tab_index); return; } - if tabular.query_tabs[tab_index].is_pinned { + let is_pinned = tabular.query_tabs[tab_index].is_pinned; + eprintln!("[TabEditor] toggle_pin_tab: tab #{} ('{}', is_pinned={}) -> toggling", tab_index, tabular.query_tabs[tab_index].title, is_pinned); + info!("[TabEditor] toggle_pin_tab: tab #{} ('{}', is_pinned={}) -> toggling", tab_index, tabular.query_tabs[tab_index].title, is_pinned); + if is_pinned { unpin_tab(tabular, tab_index); } else { pin_tab(tabular, tab_index); @@ -8327,5 +8403,42 @@ mod tests { assert_eq!(tabular.query_tabs.len(), 1); assert_eq!(tabular.query_tabs[0].title, "Tab0"); } + + #[test] + fn test_reorder_tab_clears_dragged_tab_index() { + let mut tabular = crate::window_egui::Tabular::new(); + tabular.query_tabs.clear(); + create_new_tab(&mut tabular, "T0".to_string(), "".to_string()); + create_new_tab(&mut tabular, "T1".to_string(), "".to_string()); + create_new_tab(&mut tabular, "T2".to_string(), "".to_string()); + + tabular.dragged_tab_index = Some(0); + reorder_tab(&mut tabular, 0, 3); + assert_eq!(tabular.dragged_tab_index, None); + assert_eq!(tabular.query_tabs[2].title, "T0"); + } + + #[test] + fn test_toggle_pin_tab_full_cycle() { + let mut tabular = crate::window_egui::Tabular::new(); + tabular.query_tabs.clear(); + create_new_tab(&mut tabular, "A".to_string(), "".to_string()); + create_new_tab(&mut tabular, "B".to_string(), "".to_string()); + + assert!(!tabular.query_tabs[0].is_pinned); + assert!(!tabular.query_tabs[1].is_pinned); + + // Toggle B: pins B and moves it to front + toggle_pin_tab(&mut tabular, 1); + assert!(tabular.query_tabs[0].is_pinned); + assert_eq!(tabular.query_tabs[0].title, "B"); + assert!(!tabular.query_tabs[1].is_pinned); + assert_eq!(tabular.query_tabs[1].title, "A"); + + // Toggle B again: unpins B + toggle_pin_tab(&mut tabular, 0); + assert!(!tabular.query_tabs[0].is_pinned); + assert!(!tabular.query_tabs[1].is_pinned); + } } diff --git a/src/window_egui/app_impl.rs b/src/window_egui/app_impl.rs index 581cc9e0..57c5a87f 100644 --- a/src/window_egui/app_impl.rs +++ b/src/window_egui/app_impl.rs @@ -2060,11 +2060,13 @@ impl Tabular { self.last_active_tab_index = Some(self.active_tab_index); } - // Cancel drag if Escape key pressed or if primary mouse pointer is not held down - if ui.ctx().input(|inp| inp.key_pressed(egui::Key::Escape)) - || (self.dragged_tab_index.is_some() && ui.ctx().input(|inp| !inp.pointer.primary_down())) - { - self.dragged_tab_index = None; + // Cancel drag if Escape key pressed + if ui.ctx().input(|inp| inp.key_pressed(egui::Key::Escape)) { + if let Some(drag_idx) = self.dragged_tab_index.take() { + eprintln!("[TabDrag] Drag of tab #{} cancelled via Escape key", drag_idx); + log::info!("[TabDrag] Drag of tab #{} cancelled via Escape key", drag_idx); + ui.ctx().request_repaint(); + } } let tab_count = self.query_tabs.len(); @@ -2077,7 +2079,12 @@ impl Tabular { max_single_tab_w }; - let pointer_pos = ui.ctx().input(|inp| inp.pointer.hover_pos().or(inp.pointer.interact_pos())); + let pointer_pos = ui.ctx().input(|inp| { + inp.pointer + .hover_pos() + .or(inp.pointer.interact_pos()) + .or(inp.pointer.latest_pos()) + }); let mouse_released = ui.ctx().input(|inp| inp.pointer.button_released(egui::PointerButton::Primary)); let mut tab_rects = Vec::with_capacity(tab_count); @@ -2149,10 +2156,40 @@ impl Tabular { ); tab_rects.push(tab_rect); + let right_slot_rect = egui::Rect::from_min_size( + egui::pos2( + tab_rect.right() - close_size - 6.0, + tab_rect.center().y - close_size / 2.0, + ), + egui::vec2(close_size, close_size), + ); + let hover_pin_rect = egui::Rect::from_min_size( + egui::pos2( + right_slot_rect.left() - pin_size - 4.0, + tab_rect.center().y - pin_size / 2.0, + ), + egui::vec2(pin_size, pin_size), + ); + let right_slot_hit_rect = right_slot_rect.expand2(egui::vec2(3.0, 4.0)); + let hover_pin_hit_rect = hover_pin_rect.expand2(egui::vec2(3.0, 4.0)); + + // Only initiate drag if the mouse press is NOT over the close or pin buttons if tab_resp.drag_started_by(egui::PointerButton::Primary) { - self.dragged_tab_index = Some(i); - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - ui.ctx().request_repaint(); + let start_pos = tab_resp.interact_pointer_pos().or(pointer_pos); + let on_btn = start_pos.map(|p| { + right_slot_hit_rect.contains(p) || hover_pin_hit_rect.contains(p) + }).unwrap_or(false); + + if !on_btn { + eprintln!("[TabDrag] Drag started: tab #{} ('{}') at pos {:?}", i, tab.title, start_pos); + log::info!("[TabDrag] Drag started: tab #{} ('{}') at pos {:?}", i, tab.title, start_pos); + self.dragged_tab_index = Some(i); + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + ui.ctx().request_repaint(); + } else { + eprintln!("[TabDrag] Drag ignored: pointer started over button on tab #{}", i); + log::info!("[TabDrag] Drag ignored: pointer started over button on tab #{}", i); + } } if active && self.scroll_to_active_tab { @@ -2185,27 +2222,13 @@ impl Tabular { ); } - let right_slot_rect = egui::Rect::from_min_size( - egui::pos2( - tab_rect.right() - close_size - 6.0, - tab_rect.center().y - close_size / 2.0, - ), - egui::vec2(close_size, close_size), - ); - let hover_pin_rect = egui::Rect::from_min_size( - egui::pos2( - right_slot_rect.left() - pin_size - 4.0, - tab_rect.center().y - pin_size / 2.0, - ), - egui::vec2(pin_size, pin_size), - ); - + let is_tab_hovered = pointer_pos.map(|p| tab_rect.contains(p)).unwrap_or(false); let mut pointer_over_button = false; if tab.is_pinned { // Pinned tab: show pin icon at right_slot_rect (replaces close button) let pin_resp = ui.interact( - right_slot_rect, + right_slot_hit_rect, ui.id().with(("tab_unpin", i)), egui::Sense::click(), ) @@ -2217,6 +2240,7 @@ impl Tabular { egui::Color32::from_rgba_unmultiplied(0, 0, 0, 20) }; ui.painter().rect_filled(right_slot_rect, 4.0, hover_color); + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); pointer_over_button = true; } ui.painter().text( @@ -2227,6 +2251,8 @@ impl Tabular { text_color, ); if pin_resp.clicked() { + eprintln!("[TabPin] Pinned tab pin icon clicked: tab #{} ('{}') -> unpinning", i, tab.title); + log::info!("[TabPin] Pinned tab pin icon clicked: tab #{} ('{}') -> unpinning", i, tab.title); to_toggle_pin = Some(i); } } else { @@ -2234,7 +2260,7 @@ impl Tabular { let show_close = self.query_tabs.len() > 1 || !active; if show_close { let close_resp = ui.interact( - right_slot_rect, + right_slot_hit_rect, ui.id().with(("tab_close", i)), egui::Sense::click(), ) @@ -2246,6 +2272,7 @@ impl Tabular { egui::Color32::from_rgba_unmultiplied(0, 0, 0, 20) }; ui.painter().rect_filled(right_slot_rect, 4.0, hover_color); + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); pointer_over_button = true; } ui.painter().text( @@ -2256,35 +2283,52 @@ impl Tabular { text_color, ); if close_resp.clicked() { + eprintln!("[TabAction] Close button clicked: tab #{} ('{}')", i, tab.title); + log::info!("[TabAction] Close button clicked: tab #{} ('{}')", i, tab.title); to_close = Some(i); } } - // Quick pin button on hover - if tab_resp.hovered() && !is_being_dragged { + // Quick pin button: allocate interactively whenever tab is not being dragged + if !is_being_dragged { let quick_pin_resp = ui.interact( - hover_pin_rect, + hover_pin_hit_rect, ui.id().with(("tab_quick_pin", i)), egui::Sense::click(), ) .on_hover_text("Pin tab (keep on left)"); - if quick_pin_resp.hovered() { + + let pin_hovered = quick_pin_resp.hovered(); + if pin_hovered { let hover_color = if active { egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30) } else { egui::Color32::from_rgba_unmultiplied(0, 0, 0, 20) }; ui.painter().rect_filled(hover_pin_rect, 4.0, hover_color); + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); pointer_over_button = true; } - ui.painter().text( - hover_pin_rect.center(), - egui::Align2::CENTER_CENTER, - "📌", - egui::FontId::proportional(11.0), - text_color.linear_multiply(0.7), - ); + + // Render pin icon when tab or button is hovered + if is_tab_hovered || pin_hovered { + let icon_color = if pin_hovered { + text_color + } else { + text_color.linear_multiply(0.55) + }; + ui.painter().text( + hover_pin_rect.center(), + egui::Align2::CENTER_CENTER, + "📌", + egui::FontId::proportional(11.0), + icon_color, + ); + } + if quick_pin_resp.clicked() { + eprintln!("[TabPin] Quick-pin clicked: tab #{} ('{}') -> pinning", i, tab.title); + log::info!("[TabPin] Quick-pin clicked: tab #{} ('{}') -> pinning", i, tab.title); to_toggle_pin = Some(i); } } @@ -2292,7 +2336,7 @@ impl Tabular { let right_margin = if tab.is_pinned { close_size + 12.0 - } else if tab_resp.hovered() && !is_being_dragged { + } else if is_tab_hovered && !is_being_dragged { close_size + pin_size + 16.0 } else { close_size + 12.0 @@ -2318,43 +2362,57 @@ impl Tabular { tab_resp.context_menu(|ui| { if tab_is_pinned { if ui.button("📌 Unpin Tab").clicked() { + eprintln!("[TabPin] Context menu 'Unpin Tab' clicked: tab #{} ('{}')", i, tab.title); + log::info!("[TabPin] Context menu 'Unpin Tab' clicked: tab #{} ('{}')", i, tab.title); to_toggle_pin = Some(i); ui.close(); } } else { if ui.button("📌 Pin Tab").clicked() { + eprintln!("[TabPin] Context menu 'Pin Tab' clicked: tab #{} ('{}')", i, tab.title); + log::info!("[TabPin] Context menu 'Pin Tab' clicked: tab #{} ('{}')", i, tab.title); to_toggle_pin = Some(i); ui.close(); } } ui.separator(); if i > 0 && ui.button("⬅ Move Tab Left").clicked() { + eprintln!("[TabAction] Context menu 'Move Tab Left' clicked: tab #{} (to {})", i, i - 1); + log::info!("[TabAction] Context menu 'Move Tab Left' clicked: tab #{} (to {})", i, i - 1); to_move = Some((i, i - 1)); ui.close(); } if i + 1 < cur_tabs_len && ui.button("➡ Move Tab Right").clicked() { + eprintln!("[TabAction] Context menu 'Move Tab Right' clicked: tab #{} (to {})", i, i + 1); + log::info!("[TabAction] Context menu 'Move Tab Right' clicked: tab #{} (to {})", i, i + 1); to_move = Some((i, i + 1)); ui.close(); } ui.separator(); let show_close_menu = cur_tabs_len > 1 || !active; if ui.add_enabled(show_close_menu, egui::Button::new("✕ Close Tab")).clicked() { + eprintln!("[TabAction] Context menu 'Close Tab' clicked: tab #{} ('{}')", i, tab.title); + log::info!("[TabAction] Context menu 'Close Tab' clicked: tab #{} ('{}')", i, tab.title); to_close = Some(i); ui.close(); } if cur_tabs_len > 1 && ui.button("Close Other Tabs").clicked() { + eprintln!("[TabAction] Context menu 'Close Other Tabs' clicked (keeping tab #{})", i); + log::info!("[TabAction] Context menu 'Close Other Tabs' clicked (keeping tab #{})", i); to_close_others = Some(i); ui.close(); } if i + 1 < cur_tabs_len && ui.button("Close Tabs to the Right").clicked() { + eprintln!("[TabAction] Context menu 'Close Tabs to the Right' clicked for tab #{}", i); + log::info!("[TabAction] Context menu 'Close Tabs to the Right' clicked for tab #{}", i); to_close_right = Some(i); ui.close(); } }); - let click_pos = tab_resp.interact_pointer_pos().unwrap_or(egui::Pos2::ZERO); - let on_button = right_slot_rect.contains(click_pos) - || (!tab.is_pinned && hover_pin_rect.contains(click_pos)); + let click_pos = tab_resp.interact_pointer_pos().or(pointer_pos).unwrap_or(egui::Pos2::ZERO); + let on_button = right_slot_hit_rect.contains(click_pos) + || (!tab.is_pinned && hover_pin_hit_rect.contains(click_pos)); if tab_resp.clicked() && !on_button @@ -2362,6 +2420,8 @@ impl Tabular { && self.dragged_tab_index.is_none() { if !active { + eprintln!("[TabAction] Tab #{} ('{}') clicked -> switching active tab from {} to {}", i, tab.title, self.active_tab_index, i); + log::info!("[TabAction] Tab #{} ('{}') clicked -> switching active tab from {} to {}", i, tab.title, self.active_tab_index, i); to_switch = Some(i); } else { self.scroll_to_active_tab = true; @@ -2373,6 +2433,8 @@ impl Tabular { && !tab.is_pinned && (self.query_tabs.len() > 1 || !active) { + eprintln!("[TabAction] Middle-click close: tab #{} ('{}')", i, tab.title); + log::info!("[TabAction] Middle-click close: tab #{} ('{}')", i, tab.title); to_close = Some(i); } @@ -2401,6 +2463,7 @@ impl Tabular { } else { ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); let mut candidate_insert_at = None; + let mut is_within_tab_bar_y = false; if let Some(pos) = pointer_pos { // Floating ghost badge following cursor @@ -2428,9 +2491,9 @@ impl Tabular { ); } - // Determine candidate drop slot - let is_within_tab_bar_y = tab_rects.first().map(|r| { - let margin = 20.0; + // Determine candidate drop slot with generous vertical tolerance (35px margin) + let margin = 35.0; + is_within_tab_bar_y = tab_rects.first().map(|r| { pos.y >= r.top() - margin && pos.y <= r.bottom() + margin }).unwrap_or(false); @@ -2482,14 +2545,67 @@ impl Tabular { let primary_down = ui.ctx().input(|inp| inp.pointer.primary_down()); if mouse_released { let from = self.dragged_tab_index.take(); + eprintln!( + "[TabDrag] Mouse release detected: from={:?}, target_slot={:?}, pointer_pos={:?}, within_y={}", + from, candidate_insert_at, pointer_pos, is_within_tab_bar_y + ); + log::info!( + "[TabDrag] Mouse release detected: from={:?}, target_slot={:?}, pointer_pos={:?}, within_y={}", + from, candidate_insert_at, pointer_pos, is_within_tab_bar_y + ); + if let (Some(from_idx), Some(target_idx)) = (from, candidate_insert_at) { if target_idx != from_idx && target_idx != from_idx + 1 { + let tab_title = self.query_tabs.get(from_idx).map(|t| t.title.as_str()).unwrap_or(""); + eprintln!( + "[TabDrag] Executing reorder_tab: moving tab #{} ('{}') to insertion slot {}", + from_idx, tab_title, target_idx + ); + log::info!( + "[TabDrag] Executing reorder_tab: moving tab #{} ('{}') to insertion slot {}", + from_idx, tab_title, target_idx + ); editor::reorder_tab(self, from_idx, target_idx); + eprintln!( + "[TabDrag] Reorder success: total tabs={}, active_tab_index is now {}", + self.query_tabs.len(), self.active_tab_index + ); + log::info!( + "[TabDrag] Reorder success: total tabs={}, active_tab_index is now {}", + self.query_tabs.len(), self.active_tab_index + ); + } else { + eprintln!( + "[TabDrag] Drop target slot ({}) is adjacent to drag position ({}), no move needed", + target_idx, from_idx + ); + log::info!( + "[TabDrag] Drop target slot ({}) is adjacent to drag position ({}), no move needed", + target_idx, from_idx + ); } + } else { + eprintln!( + "[TabDrag] Drag cancelled: dropped outside valid tab bar area (within_y={}, pointer_pos={:?})", + is_within_tab_bar_y, pointer_pos + ); + log::info!( + "[TabDrag] Drag cancelled: dropped outside valid tab bar area (within_y={}, pointer_pos={:?})", + is_within_tab_bar_y, pointer_pos + ); } ui.ctx().request_repaint(); } else if !primary_down { - self.dragged_tab_index = None; + if let Some(from) = self.dragged_tab_index.take() { + eprintln!( + "[TabDrag] Primary mouse button is no longer down without release event, cancelling drag for tab #{}", + from + ); + log::info!( + "[TabDrag] Primary mouse button is no longer down without release event, cancelling drag for tab #{}", + from + ); + } ui.ctx().request_repaint(); } } @@ -2543,23 +2659,45 @@ impl Tabular { } } + let mut any_tab_action = false; if let Some(i) = to_toggle_pin { + eprintln!("[TabPin] Executing toggle_pin_tab for index {}", i); + log::info!("[TabPin] Executing toggle_pin_tab for index {}", i); editor::toggle_pin_tab(self, i); + any_tab_action = true; } if let Some((from, to)) = to_move { + eprintln!("[TabAction] Executing move_tab from {} to {}", from, to); + log::info!("[TabAction] Executing move_tab from {} to {}", from, to); editor::move_tab(self, from, to); + any_tab_action = true; } if let Some(i) = to_close { + eprintln!("[TabAction] Executing close_tab for index {}", i); + log::info!("[TabAction] Executing close_tab for index {}", i); editor::close_tab(self, i); + any_tab_action = true; } if let Some(i) = to_close_others { + eprintln!("[TabAction] Executing close_other_tabs keeping index {}", i); + log::info!("[TabAction] Executing close_other_tabs keeping index {}", i); editor::close_other_tabs(self, i); + any_tab_action = true; } if let Some(i) = to_close_right { + eprintln!("[TabAction] Executing close_tabs_to_the_right from index {}", i); + log::info!("[TabAction] Executing close_tabs_to_the_right from index {}", i); editor::close_tabs_to_the_right(self, i); + any_tab_action = true; } if let Some(i) = to_switch { + eprintln!("[TabAction] Executing switch_to_tab to index {}", i); + log::info!("[TabAction] Executing switch_to_tab to index {}", i); editor::switch_to_tab(self, i); + any_tab_action = true; + } + if any_tab_action { + ui.ctx().request_repaint(); } }); });