diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 6f02de35..4346fe8b 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "enables the default permissions", - "windows": ["main", "visual-explain", "er-diagram", "task-manager", "json-viewer-*"], + "windows": ["main", "visual-explain", "er-diagram", "task-manager", "json-viewer-*", "results-window-*"], "permissions": [ "core:default", "core:window:allow-set-title", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ff2cc441..03cd2540 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -38,6 +38,7 @@ pub mod heartbeat; pub mod heartbeat_tests; pub mod json_viewer; pub mod keychain_utils; +pub mod results_window; pub mod k8s_tunnel; pub mod log_commands; pub mod logger; @@ -183,6 +184,7 @@ pub fn run() { )) .manage(explain_import::PendingExplainFile::default()) .manage(json_viewer::JsonViewerStore::default()) + .manage(results_window::ResultsWindowStore::default()) .manage(query_history::QueryHistoryState::default()) .setup(move |app| { // Allow the SSH tunnel code (which runs without a Tauri context) @@ -482,6 +484,8 @@ pub fn run() { json_viewer::open_json_viewer_window, json_viewer::get_json_viewer_session, json_viewer::complete_json_viewer_session, + results_window::open_results_window, + results_window::close_results_window, // Task Manager task_manager::get_process_list, task_manager::get_system_stats, diff --git a/src-tauri/src/results_window.rs b/src-tauri/src/results_window.rs new file mode 100644 index 00000000..e60eb231 --- /dev/null +++ b/src-tauri/src/results_window.rs @@ -0,0 +1,172 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent}; +use urlencoding::encode; + +/// Persisted geometry so a re-opened detached results window restores where the +/// user last left it. Mirrors the pattern used by `json_viewer.rs`. +#[derive(Debug, Clone, Copy)] +pub struct WindowBounds { + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, +} + +/// Geometry is remembered per tab so that with several windows detached at once +/// each one re-opens where its own window last was, instead of all stacking on +/// top of the most-recently-closed one. +#[derive(Default)] +pub struct ResultsWindowStore { + pub bounds: Mutex>, +} + +/// One detached results window per editor tab. The frontend keeps each window's +/// data in sync over Tauri events keyed by `tab_id`. +fn window_label(tab_id: &str) -> String { + format!("results-window-{}", tab_id) +} + +/// Open (or focus) the detached results window for a given tab. Result data is +/// streamed from the main window via the `results-window:sync` event (keyed by +/// `tabId`), so no payload is passed here. +#[tauri::command] +pub async fn open_results_window( + app: AppHandle, + store: tauri::State<'_, ResultsWindowStore>, + tab_id: String, + title: Option, +) -> Result<(), String> { + let label = window_label(&tab_id); + + // If it already exists, just bring it to the front. + if let Some(window) = app.get_webview_window(&label) { + let _ = window.unminimize(); + let _ = window.set_focus(); + return Ok(()); + } + + let window_title = title.unwrap_or_else(|| "Query Results".to_string()); + + let remembered = store + .bounds + .lock() + .map_err(|e| format!("Failed to acquire bounds lock: {}", e))? + .get(&tab_id) + .copied(); + + let url = format!("/results-window?tab={}", encode(&tab_id)); + let mut builder = + WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(url.into())) + .title(&window_title) + .min_inner_size(500.0, 300.0) + .background_color(tauri::webview::Color(2, 6, 23, 255)); + + builder = match remembered { + Some(b) => builder + .inner_size(b.width as f64, b.height as f64) + .position(b.x as f64, b.y as f64), + None => builder.inner_size(900.0, 600.0).center(), + }; + + match builder.build() { + Err(e) => Err(format!("Failed to create results window: {}", e)), + Ok(window) => { + let app_handle = app.clone(); + let captured_label = label.clone(); + let captured_tab_id = tab_id.clone(); + window.on_window_event(move |event| { + if let WindowEvent::CloseRequested { .. } = event { + if let Some(win) = app_handle.get_webview_window(&captured_label) { + // Save the inner size: it is restored via `.inner_size(...)` + // below, so saving `outer_size()` would grow the window by + // the decoration height on each detach→close→reopen cycle. + if let (Ok(pos), Ok(size)) = (win.outer_position(), win.inner_size()) { + let store = app_handle.state::(); + if let Ok(mut bounds) = store.bounds.lock() { + bounds.insert( + captured_tab_id.clone(), + WindowBounds { + x: pos.x, + y: pos.y, + width: size.width, + height: size.height, + }, + ); + }; + } + } + // Let the main window re-attach this tab's results panel. + let _ = app_handle.emit( + "results-window:closed", + serde_json::json!({ "tabId": captured_tab_id }), + ); + } + }); + Ok(()) + } + } +} + +/// Programmatically close a tab's detached results window (used by the +/// "Re-attach" button and when the bound tab is closed). +#[tauri::command] +pub async fn close_results_window(app: AppHandle, tab_id: String) -> Result<(), String> { + if let Some(window) = app.get_webview_window(&window_label(&tab_id)) { + window + .close() + .map_err(|e| format!("Failed to close results window: {}", e))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn window_label_is_tab_scoped() { + assert_eq!(window_label("abc"), "results-window-abc"); + assert_eq!(window_label("tab-123"), "results-window-tab-123"); + // Distinct tabs must map to distinct labels (each gets its own window). + assert_ne!(window_label("a"), window_label("b")); + } + + #[test] + fn bounds_are_remembered_per_tab() { + let store = ResultsWindowStore::default(); + { + let mut bounds = store.bounds.lock().unwrap(); + bounds.insert( + "tab-a".to_string(), + WindowBounds { + x: 100, + y: 200, + width: 800, + height: 600, + }, + ); + bounds.insert( + "tab-b".to_string(), + WindowBounds { + x: 10, + y: 20, + width: 400, + height: 300, + }, + ); + } + let bounds = store.bounds.lock().unwrap(); + let a = bounds.get("tab-a").unwrap(); + assert_eq!((a.x, a.y, a.width, a.height), (100, 200, 800, 600)); + let b = bounds.get("tab-b").unwrap(); + assert_eq!((b.x, b.y, b.width, b.height), (10, 20, 400, 300)); + } + + #[test] + fn bounds_default_is_empty() { + let store = ResultsWindowStore::default(); + assert!(store.bounds.lock().unwrap().is_empty()); + } +} diff --git a/src/App.tsx b/src/App.tsx index 96c59b47..0d8cb4b5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import { SchemaDiagramPage } from "./pages/SchemaDiagramPage"; import { TaskManagerPage } from "./pages/TaskManagerPage"; import { VisualExplainPage } from "./pages/VisualExplainPage"; import { JsonViewerPage } from "./pages/JsonViewerPage"; +import { ResultsWindowPage } from "./pages/ResultsWindowPage"; import { ConnectionHealthMonitor } from "./components/ConnectionHealthMonitor"; import { EditorErrorBoundary } from "./components/ui/EditorErrorBoundary"; import { UpdateNotificationModal } from "./components/modals/UpdateNotificationModal"; @@ -139,6 +140,10 @@ export function App() { } /> } /> } /> + } + /> diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 5f455424..b7b1349b 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -845,6 +845,15 @@ "jumpToPage": "Klicken, um zur Seite zu springen", "loadRowCount": "Zeilenanzahl laden", "executePrompt": "Führe eine Abfrage aus, um Ergebnisse zu sehen", + "results": { + "minimize": "Minimieren", + "maximize": "Maximieren (Editor ausblenden)", + "restore": "Editor wiederherstellen", + "close": "Schließen", + "detach": "In separatem Fenster ablösen", + "detached": "Ergebnisse in separatem Fenster abgelöst", + "reattach": "Wieder andocken" + }, "tableRunPrompt": "Drücke Ausführen (Ctrl/Command+F5), um Tabellendaten zu laden", "closeTab": "Tab schließen", "closeOthers": "Andere Tabs schließen", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index da398750..51b34d1f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -862,6 +862,15 @@ "loadRowCount": "Load row count", "executePrompt": "Execute a query to see results", "tableRunPrompt": "Press Run (Ctrl/Command+F5) to load table data", + "results": { + "minimize": "Minimize", + "maximize": "Maximize (hide editor)", + "restore": "Restore editor", + "close": "Close", + "detach": "Detach to a separate window", + "detached": "Results detached to a separate window", + "reattach": "Re-attach" + }, "closeTab": "Close Tab", "closeOthers": "Close Other Tabs", "closeRight": "Close Tabs to Right", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 84730f8e..56963cb8 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -844,6 +844,15 @@ "jumpToPage": "Clic para ir a la página", "loadRowCount": "Cargar conteo de filas", "executePrompt": "Ejecuta una consulta para ver resultados", + "results": { + "minimize": "Minimizar", + "maximize": "Maximizar (ocultar editor)", + "restore": "Restaurar editor", + "close": "Cerrar", + "detach": "Separar en una ventana aparte", + "detached": "Resultados separados en una ventana aparte", + "reattach": "Volver a acoplar" + }, "closeTab": "Cerrar Pestaña", "closeOthers": "Cerrar Otras Pestañas", "closeRight": "Cerrar Pestañas a la Derecha", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index a3a49cec..27fe0bfc 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -845,6 +845,15 @@ "jumpToPage": "Cliquer pour aller à la page", "loadRowCount": "Charger le nombre de lignes", "executePrompt": "Exécutez une requête pour voir les résultats", + "results": { + "minimize": "Réduire", + "maximize": "Agrandir (masquer l’éditeur)", + "restore": "Restaurer l’éditeur", + "close": "Fermer", + "detach": "Détacher dans une fenêtre séparée", + "detached": "Résultats détachés dans une fenêtre séparée", + "reattach": "Rattacher" + }, "tableRunPrompt": "Appuyez sur Exécuter (Ctrl/Commande+F5) pour charger les données de la table", "closeTab": "Fermer l’onglet", "closeOthers": "Fermer les autres onglets", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index b9becea6..f7172253 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -829,6 +829,15 @@ "loadRowCount": "Carica conteggio righe", "executePrompt": "Esegui una query per vedere i risultati", "tableRunPrompt": "Premi Esegui (Ctrl/Command+F5) per caricare i dati della tabella", + "results": { + "minimize": "Riduci a icona", + "maximize": "Massimizza (nascondi editor)", + "restore": "Ripristina editor", + "close": "Chiudi", + "detach": "Sgancia in una finestra separata", + "detached": "Risultati sganciati in una finestra separata", + "reattach": "Riaggancia" + }, "closeTab": "Chiudi scheda", "closeOthers": "Chiudi altre schede", "closeRight": "Chiudi schede a destra", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 5bc7a91b..9e957336 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -858,6 +858,15 @@ "jumpToPage": "クリックでページ移動", "loadRowCount": "行数を読み込む", "executePrompt": "クエリを実行すると結果が表示されます", + "results": { + "minimize": "最小化", + "maximize": "最大化(エディターを隠す)", + "restore": "エディターを復元", + "close": "閉じる", + "detach": "別ウィンドウに切り離す", + "detached": "結果を別ウィンドウに切り離しました", + "reattach": "元に戻す" + }, "tableRunPrompt": "Run (Ctrl/Command+F5) を押してテーブルデータを読み込んでください", "closeTab": "タブを閉じる", "closeOthers": "他のタブを閉じる", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 4e2f9eec..8085d229 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -840,6 +840,15 @@ "jumpToPage": "Нажмите для перехода на страницу", "loadRowCount": "Загрузить количество строк", "executePrompt": "Выполните запрос, чтобы увидеть результаты", + "results": { + "minimize": "Свернуть", + "maximize": "Развернуть (скрыть редактор)", + "restore": "Восстановить редактор", + "close": "Закрыть", + "detach": "Открепить в отдельное окно", + "detached": "Результаты откреплены в отдельное окно", + "reattach": "Прикрепить обратно" + }, "tableRunPrompt": "Нажмите «Запустить» (Ctrl/Command+F5) для загрузки данных таблицы", "closeTab": "Закрыть вкладку", "closeOthers": "Закрыть остальные вкладки", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 543df1a4..fad96f68 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -813,6 +813,15 @@ "jumpToPage": "点击跳转到页面", "loadRowCount": "加载行数", "executePrompt": "执行查询以查看结果", + "results": { + "minimize": "最小化", + "maximize": "最大化(隐藏编辑器)", + "restore": "恢复编辑器", + "close": "关闭", + "detach": "分离到独立窗口", + "detached": "结果已分离到独立窗口", + "reattach": "重新附加" + }, "tableRunPrompt": "按运行(Ctrl/Command+F5)加载表数据", "closeTab": "关闭标签", "closeOthers": "关闭其他标签", diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index aaa990a1..4ab71b6b 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -45,10 +45,13 @@ import { Copy, FileText, FileJson, + Maximize2, + Minimize2, + ExternalLink, CheckCircle2, } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; +import { listen, emit } from "@tauri-apps/api/event"; import { TableToolbar } from "../components/ui/TableToolbar"; import { DataGrid } from "../components/ui/DataGrid"; import { MultiResultPanel } from "../components/ui/MultiResultPanel"; @@ -80,6 +83,18 @@ import { interpolateQueryParams, } from "../utils/queryParameters"; import { formatDuration } from "../utils/formatTime"; +import { + buildSyncPayload, + applyAction, + RESULTS_SYNC_EVENT, + RESULTS_ACTION_EVENT, + RESULTS_READY_EVENT, + RESULTS_CLOSED_EVENT, + type ResultsWindowActionHandlers, + type ResultsReadyPayload, + type ResultsActionEnvelope, + type ResultsClosedPayload, +} from "../utils/resultsWindowSync"; import { SqlEditorWrapper } from "../components/ui/SqlEditorWrapper"; import { NotebookView } from "../components/notebook/NotebookView"; import { useSqlAutocompleteRegistration } from "../hooks/useSqlAutocompleteRegistration"; @@ -303,6 +318,15 @@ export const Editor = () => { const [editorHeight, setEditorHeight] = useState(300); const editorHeightRef = useRef(300); const [isResultsCollapsed, setIsResultsCollapsed] = useState(false); + // Ids of tabs whose results are detached into their own separate windows (one + // window per tab). Each window keeps showing its tab even when the user + // switches tabs in the main window. + const [detachedTabIds, setDetachedTabIds] = useState>( + () => new Set(), + ); + // Mirror of detachedTabIds for use inside callbacks/refs without re-creating + // them or reading stale closures. Kept in sync alongside tabsRef below. + const detachedTabIdsRef = useRef(detachedTabIds); const isDragging = useRef(false); const rafRef = useRef(null); const editorsRef = useRef[0]>>({}); @@ -526,7 +550,8 @@ export const Editor = () => { useEffect(() => { tabsRef.current = tabs; activeTabIdRef.current = activeTabId; - }, [tabs, activeTabId]); + detachedTabIdsRef.current = detachedTabIds; + }, [tabs, activeTabId, detachedTabIds]); useEffect(() => { updateScrollArrows(); @@ -624,6 +649,11 @@ export const Editor = () => { const targetTab = tabsRef.current.find((t) => t.id === targetTabId); if (!targetTab) return; + // When the target tab's results live in a detached window, this run was + // triggered from that window: don't touch main-window-only UI state + // (results panel, params modal) — it belongs to whatever tab is active here. + const isDetached = detachedTabIdsRef.current.has(targetTabId); + let textToRun = sql?.trim() || targetTab?.query; // For Table Tabs, reconstruct query if filter/sort are present if (targetTab?.type === "table" && targetTab.activeTable) { @@ -656,14 +686,18 @@ export const Editor = () => { // If we have missing params if (missingParams.length > 0) { - setQueryParamsModal({ - isOpen: true, - sql: textToRun, - parameters: params, - pendingPageNum: pageNum, - pendingTabId: targetTabId, - mode: "run", - }); + // The params modal lives in the main window; don't pop it for a run + // triggered from a detached window (it would hijack the active tab). + if (!isDetached) { + setQueryParamsModal({ + isOpen: true, + sql: textToRun, + parameters: params, + pendingPageNum: pageNum, + pendingTabId: targetTabId, + mode: "run", + }); + } return; } @@ -671,8 +705,11 @@ export const Editor = () => { textToRun = interpolateQueryParams(textToRun, storedParams); } - // Automatically open results panel when running a query - setIsResultsCollapsed(false); + // Automatically open the results panel when running a query — but only + // for the main window; a detached run must not re-expand the main panel. + if (!isDetached) { + setIsResultsCollapsed(false); + } // Preserve total_rows across page changes so the count doesn't disappear const previousTotalRows = @@ -987,8 +1024,8 @@ export const Editor = () => { ); const runResultEntryPage = useCallback( - async (entryId: string, pageNum: number) => { - const targetTabId = activeTabIdRef.current; + async (entryId: string, pageNum: number, tabIdArg?: string) => { + const targetTabId = tabIdArg ?? activeTabIdRef.current; if (!activeConnectionId || !targetTabId) return; const currentTab = tabsRef.current.find((t) => t.id === targetTabId); @@ -1063,25 +1100,248 @@ export const Editor = () => { [activeConnectionId, updateTab, settings.resultPageSize, activeSchema, t], ); - const loadCount = useCallback(async () => { - if (!activeTab?.result?.pagination || !activeConnectionId) return; - setIsCountLoading(true); + const loadCount = useCallback( + async (tabIdArg?: string) => { + const tab = tabIdArg + ? tabsRef.current.find((t) => t.id === tabIdArg) + : activeTab; + if (!tab?.result?.pagination || !activeConnectionId) return; + // setIsCountLoading drives the spinner in the main window only; skip it for + // a count triggered from a detached window (its own window owns its spinner). + const isDetached = detachedTabIdsRef.current.has(tab.id); + if (!isDetached) setIsCountLoading(true); + try { + const total = await invoke("count_query", { + connectionId: activeConnectionId, + query: tab.query, + schema: tab.schema ?? activeSchema, + }); + const latest = tabsRef.current.find((t) => t.id === tab.id) ?? tab; + if (!latest.result?.pagination) return; + updateTab(tab.id, { + result: { + ...latest.result, + pagination: { ...latest.result.pagination, total_rows: total }, + }, + }); + } finally { + if (!isDetached) setIsCountLoading(false); + } + }, + [activeTab, activeConnectionId, activeSchema, updateTab], + ); + + // --- Detached results windows (one per detached tab) --- + const handleDetachResults = useCallback(async () => { + if (!activeTab) return; + const tabId = activeTab.id; try { - const total = await invoke("count_query", { - connectionId: activeConnectionId, - query: activeTab.query, - schema: activeTab.schema ?? activeSchema, + await invoke("open_results_window", { + tabId, + title: `${activeTab.title} — Query Results`, }); - updateTab(activeTab.id, { - result: { - ...activeTab.result, - pagination: { ...activeTab.result.pagination, total_rows: total }, - }, - }); - } finally { - setIsCountLoading(false); + setDetachedTabIds((prev) => new Set(prev).add(tabId)); + } catch (e) { + console.error("Failed to detach results", e); + } + }, [activeTab]); + + const handleReattachResults = useCallback(async (tabId: string) => { + try { + await invoke("close_results_window", { tabId }); + } catch (e) { + console.error("Failed to close results window", e); + } + setDetachedTabIds((prev) => { + const next = new Set(prev); + next.delete(tabId); + return next; + }); + }, []); + + // Push each detached tab's result state to its window whenever the tabs + // change (every detached tab is re-synced; its window filters by tabId). + useEffect(() => { + if (detachedTabIds.size === 0) return; + for (const id of detachedTabIds) { + const tab = tabs.find((t) => t.id === id); + if (tab) { + emit( + RESULTS_SYNC_EVENT, + buildSyncPayload(tab, { + connectionId: activeConnectionId, + copyFormat, + csvDelimiter, + csvIncludeHeaders, + }), + ); + } } - }, [activeTab, activeConnectionId, activeSchema, updateTab]); + }, [ + tabs, + detachedTabIds, + activeConnectionId, + copyFormat, + csvDelimiter, + csvIncludeHeaders, + ]); + + // If a detached tab is closed in the main window, close its orphaned window. + // Closing the window emits RESULTS_CLOSED_EVENT, whose listener owns pruning + // detachedTabIds — so this effect stays side-effect-only (no setState here). + useEffect(() => { + for (const id of detachedTabIds) { + if (!tabs.some((t) => t.id === id)) { + invoke("close_results_window", { tabId: id }).catch(() => {}); + } + } + }, [tabs, detachedTabIds]); + + // Respond to the detached windows' handshakes and forwarded actions. The main + // window owns all query/DB logic, so actions map onto the existing handlers + // targeting the tab named in each event (not necessarily the active one). + // + // Registered unconditionally (no detachedTabIds.size gate): a freshly opened + // window emits its ready handshake as soon as it boots, and listen() registers + // asynchronously — gating behind the first detach races that emit and can leave + // the window stuck on "Loading…". Each handler self-guards (action via + // detachedTabIdsRef, ready via the tabsRef lookup, closed via prev.has). + useEffect(() => { + const emitSyncFor = (tabId: string) => { + const tab = tabsRef.current.find((t) => t.id === tabId); + if (tab) { + emit( + RESULTS_SYNC_EVENT, + buildSyncPayload(tab, { + connectionId: activeConnectionId, + copyFormat, + csvDelimiter, + csvIncludeHeaders, + }), + ); + } + }; + + const makeHandlers = (tabId: string): ResultsWindowActionHandlers => { + const tabResults = () => { + const tab = tabsRef.current.find((t) => t.id === tabId); + return tab && tab.results ? tab : null; + }; + return { + onRunQueryPage: (query, page) => runQuery(query, page, tabId), + onPageChange: (entryId, page) => runResultEntryPage(entryId, page, tabId), + onRerunEntry: (entryId) => runResultEntryPage(entryId, 1, tabId), + onLoadCount: () => loadCount(tabId), + onSelectResult: (entryId) => + updateTab(tabId, { activeResultId: entryId }), + onCloseEntry: (entryId) => { + const tab = tabResults(); + if (!tab) return; + const { results: newResults, nextActiveId } = removeResultEntry( + tab.results!, + entryId, + tab.activeResultId, + ); + if (newResults.length === 0) { + updateTab(tab.id, { results: undefined, activeResultId: undefined }); + } else { + updateTab(tab.id, { + results: newResults, + activeResultId: nextActiveId, + }); + } + }, + onCloseOtherEntries: (entryId) => { + const tab = tabResults(); + if (!tab) return; + const { results: newResults, nextActiveId } = removeOtherEntries( + tab.results!, + entryId, + ); + updateTab(tab.id, { + results: newResults, + activeResultId: nextActiveId, + }); + }, + onCloseEntriesToRight: (entryId) => { + const tab = tabResults(); + if (!tab) return; + const { results: newResults, nextActiveId } = removeEntriesToRight( + tab.results!, + entryId, + tab.activeResultId, + ); + updateTab(tab.id, { + results: newResults, + activeResultId: nextActiveId, + }); + }, + onCloseEntriesToLeft: (entryId) => { + const tab = tabResults(); + if (!tab) return; + const { results: newResults, nextActiveId } = removeEntriesToLeft( + tab.results!, + entryId, + tab.activeResultId, + ); + updateTab(tab.id, { + results: newResults, + activeResultId: nextActiveId, + }); + }, + onCloseAllEntries: () => + updateTab(tabId, { results: undefined, activeResultId: undefined }), + onRenameEntry: (entryId, label) => { + const tab = tabResults(); + if (!tab) return; + updateTab(tab.id, { + results: updateResultEntry(tab.results!, entryId, { label }), + }); + }, + }; + }; + + const readyP = listen(RESULTS_READY_EVENT, (event) => + emitSyncFor(event.payload.tabId), + ); + const actionP = listen( + RESULTS_ACTION_EVENT, + (event) => { + // Only honor actions for tabs we actually have detached — defense in + // depth against events arriving for a reattached/unknown tab. + const { tabId, action } = event.payload; + if (!detachedTabIdsRef.current.has(tabId)) return; + applyAction(action, makeHandlers(tabId)); + }, + ); + const closedP = listen( + RESULTS_CLOSED_EVENT, + (event) => { + const closedId = event.payload.tabId; + setDetachedTabIds((prev) => { + if (!prev.has(closedId)) return prev; + const next = new Set(prev); + next.delete(closedId); + return next; + }); + }, + ); + + return () => { + readyP.then((u) => u()); + actionP.then((u) => u()); + closedP.then((u) => u()); + }; + }, [ + activeConnectionId, + copyFormat, + csvDelimiter, + csvIncludeHeaders, + runQuery, + runResultEntryPage, + loadCount, + updateTab, + ]); const handleRunButton = useCallback(() => { if (!activeTab) return; @@ -2984,51 +3244,86 @@ export const Editor = () => {
-
+
e.stopPropagation()} + > + {/* Detach results into a separate window */} + {/* Minimize (collapse the results panel) */} + + {/* Maximize results (hide editor) / restore */} + -
- - {isEditorOpen && ( + {/* Close (collapse the results panel, keeps the data) */} - )} +
)} {/* Results Panel */}
- {activeTab.results && activeTab.results.length > 0 ? ( + {detachedTabIds.has(activeTab.id) ? ( +
+ +

{t("editor.results.detached")}

+ +
+ ) : activeTab.results && activeTab.results.length > 0 ? ( { {activeTab.result.pagination.total_rows === null && (