diff --git a/README.md b/README.md index 9741d6e2..59217cdf 100644 --- a/README.md +++ b/README.md @@ -31,15 +31,30 @@ 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 - **Smart Sidebar Tree Search**: Case-insensitive instant filtering across Connections, Queries, History, and HTTP Collections. When searching for a folder name, the folder and all of its contents (connections, queries, history entries, subfolders) remain fully displayed and automatically expanded. - **AI Assistant (`Cmd+Shift+A`)**: Schema-aware SQL completion with OpenAI, Anthropic Claude, Groq, GitHub Copilot, or custom endpoints +- **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. + ### Smart Sidebar Tree Search & Folder Content Preservation Instant in-memory filtering for all sidebar navigation panels (Connections, Saved Queries, History, and HTTP Collections). - **Folder Search Preservation**: When typing a search query matching a folder name (such as custom connection folders, query categories, date folders, or HTTP collection folders), the matching folder is displayed and automatically expanded with all of its child contents intact. - **Hierarchical Context**: When searching for specific tables, queries, or history items, the sidebar displays matching items while maintaining their parent directory structure for clear visual context. - ### Visual Query Profiler (New in v0.13) Analyze and optimize slow queries with interactive visual graphs instead of deciphering raw JSON outputs. - Supports PostgreSQL (`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)`), MySQL (`EXPLAIN FORMAT=JSON`), and Microsoft SQL Server ShowPlan. diff --git a/implementation_plan_move_tab_editor.md b/implementation_plan_move_tab_editor.md new file mode 100644 index 00000000..53191edd --- /dev/null +++ b/implementation_plan_move_tab_editor.md @@ -0,0 +1,92 @@ +# Rencana Teknis Implementasi: Drag & Drop Tab Editor & Fitur Pin Tab + +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 & 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. 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. + +--- + +## 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. + diff --git a/src/editor.rs b/src/editor.rs index 059d63e6..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::{ @@ -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); @@ -219,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) { @@ -227,6 +229,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 +290,251 @@ 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 { + 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); + + // 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; + 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 { + 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; + } + } + 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). +/// 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 { + 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; + } + let to = if insert_at > from { + (insert_at - 1).min(tab_count - 1) + } 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); +} + +/// 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() { + 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() + .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); + + 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 + ); + } +} + +/// 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() { + 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 + ); + } +} + +/// 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() { + 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; + } + 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); + } +} + +/// 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; + } + 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; + } + } + 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; + } + 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 -= 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, /// so callers can activate it instead of opening a duplicate tab. pub(crate) fn find_tab_for_target( @@ -7903,5 +8151,294 @@ 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"); + } + + #[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); + } + + #[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"); + } + + #[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/models/structs.rs b/src/models/structs.rs index 03dce336..17dc7b3c 100644 --- a/src/models/structs.rs +++ b/src/models/structs.rs @@ -755,6 +755,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)] @@ -1777,4 +1778,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 f9915ed2..60c2cb25 100644 --- a/src/sidebar_query.rs +++ b/src/sidebar_query.rs @@ -563,6 +563,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 66dd1412..adb4eb78 100644 --- a/src/window_egui/app_impl.rs +++ b/src/window_egui/app_impl.rs @@ -2050,12 +2050,25 @@ 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)) { + 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(); let max_single_tab_w = 240.0; let min_single_tab_w = 110.0; @@ -2066,14 +2079,32 @@ impl Tabular { max_single_tab_w }; + 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); + 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 +2113,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 +2146,51 @@ 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); + + 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) { + 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 { tab_resp.scroll_to_me(Some(egui::Align::Center)); @@ -2144,14 +2221,127 @@ impl Tabular { super::style::theme_accent(ui.ctx()), ); } - let close_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 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_hit_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); + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + 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() { + 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 { + // Unpinned tab: show close button + let show_close = self.query_tabs.len() > 1 || !active; + if show_close { + let close_resp = ui.interact( + right_slot_hit_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); + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + 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() { + 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: allocate interactively whenever tab is not being dragged + if !is_being_dragged { + let quick_pin_resp = ui.interact( + hover_pin_hit_rect, + ui.id().with(("tab_quick_pin", i)), + egui::Sense::click(), + ) + .on_hover_text("Pin tab (keep on left)"); + + 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; + } + + // 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); + } + } + } + + let right_margin = if tab.is_pinned { + close_size + 12.0 + } else if is_tab_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,45 +2356,261 @@ 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() { + 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.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() { + 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().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() - && !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 { + 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; } } + + // Middle-click to close unpinned tabs + if tab_resp.middle_clicked() + && !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); + } + + // 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 { + 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; + let mut is_within_tab_bar_y = false; + + 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 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); + + 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()); + } + } + + // 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 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(); + 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 { + 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(); + } + } + } + self.scroll_to_active_tab = false; let is_http_active = self.selected_menu == "APIs" @@ -2253,11 +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(); } }); }); diff --git a/src/window_egui/init.rs b/src/window_egui/init.rs index dfbb544c..36ac5b9d 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_move_tab_editor.md b/walkthrough_move_tab_editor.md new file mode 100644 index 00000000..f943b104 --- /dev/null +++ b/walkthrough_move_tab_editor.md @@ -0,0 +1,93 @@ +# Walkthrough: Fitur Drag & Drop Tab Editor dan Pin Tab + +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 + +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. Perbaikan Berdasarkan Adversarial Code Review + +| Masalah | Letak Berkas | Tindakan Perbaikan | +| :--- | :--- | :--- | +| **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. 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. +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.