From e8bd3b4845a7f2a776336ebf9538390b9e569beb Mon Sep 17 00:00:00 2001 From: Mike Nikles <788827+mikenikles@users.noreply.github.com> Date: Thu, 8 Jan 2026 20:57:06 -0800 Subject: [PATCH] Refactor: improve code quality, maintainability, and accessibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Phase 1: Error Handling Framework - Create centralized error handling in src/lib/errors/ - Add Result type, AppError interface, and ErrorCode enum - Add handleError function with severity-based routing ## Phase 2: Accessibility Improvements - Add aria-labels to pagination buttons in query-editor - Add aria-labels and keyboard handlers to query-history - Add aria-labels to sidebar-left star/delete buttons - Add translations for accessibility labels in all languages ## Phase 3: Type Organization - Reorganize types into src/lib/types/ directory - Split into database.ts, schema.ts, query.ts, explain.ts, erd.ts, persisted.ts - Add JSDoc documentation to exported interfaces - Create barrel export in index.ts ## Phase 4: State Management Migration (Map → Record) - Migrate all connection-keyed state from Map to Record - Update all 11 manager files to use spread syntax - Create record-utils.ts with helper functions - Delete map-utils.ts ## Phase 5-6: Component Extraction - Split query-editor.svelte (703 → 400 lines) into subcomponents: - query-toolbar.svelte - query-result-tabs.svelte - query-export-menu.svelte - query-pagination.svelte - query-error-display.svelte - Extract ssh-tunnel-config.svelte from connection-dialog.svelte Co-Authored-By: Claude Opus 4.5 --- messages/ar.json | 8 + messages/de.json | 8 + messages/en.json | 8 + messages/es.json | 8 + messages/fr.json | 8 + messages/ko.json | 8 + src/lib/components/command-palette.svelte | 2 +- src/lib/components/connection-dialog.svelte | 137 +--- src/lib/components/connection-dialog/index.ts | 1 + .../ssh-tunnel-config.svelte | 175 +++++ src/lib/components/query-editor.svelte | 610 +++++------------- src/lib/components/query-editor/index.ts | 5 + .../query-editor/query-error-display.svelte | 54 ++ .../query-editor/query-export-menu.svelte | 70 ++ .../query-editor/query-pagination.svelte | 103 +++ .../query-editor/query-result-tabs.svelte | 54 ++ .../query-editor/query-toolbar.svelte | 143 ++++ src/lib/components/query-history.svelte | 18 +- src/lib/components/sidebar-left.svelte | 2 + src/lib/errors/handler.ts | 99 +++ src/lib/errors/index.ts | 11 + src/lib/errors/types.ts | 110 ++++ .../database/connection-manager.svelte.ts | 17 +- src/lib/hooks/database/erd-tabs.svelte.ts | 155 +++-- src/lib/hooks/database/explain-tabs.svelte.ts | 342 +++++----- src/lib/hooks/database/map-utils.ts | 58 -- .../database/persistence-manager.svelte.ts | 24 +- .../hooks/database/query-execution.svelte.ts | 31 +- .../hooks/database/query-history.svelte.ts | 53 +- src/lib/hooks/database/query-tabs.svelte.ts | 419 ++++++------ src/lib/hooks/database/record-utils.ts | 84 +++ .../hooks/database/saved-queries.svelte.ts | 86 +-- src/lib/hooks/database/schema-tabs.svelte.ts | 471 +++++++------- .../database/state-restoration.svelte.ts | 315 +++++---- src/lib/hooks/database/state.svelte.ts | 285 ++++---- src/lib/hooks/database/tab-ordering.svelte.ts | 289 ++++----- src/lib/types.ts | 254 +------- src/lib/types/database.ts | 81 +++ src/lib/types/erd.ts | 14 + src/lib/types/explain.ts | 84 +++ src/lib/types/index.ts | 46 ++ src/lib/types/persisted.ts | 133 ++++ src/lib/types/query.ts | 138 ++++ src/lib/types/schema.ts | 79 +++ 44 files changed, 3023 insertions(+), 2077 deletions(-) create mode 100644 src/lib/components/connection-dialog/index.ts create mode 100644 src/lib/components/connection-dialog/ssh-tunnel-config.svelte create mode 100644 src/lib/components/query-editor/index.ts create mode 100644 src/lib/components/query-editor/query-error-display.svelte create mode 100644 src/lib/components/query-editor/query-export-menu.svelte create mode 100644 src/lib/components/query-editor/query-pagination.svelte create mode 100644 src/lib/components/query-editor/query-result-tabs.svelte create mode 100644 src/lib/components/query-editor/query-toolbar.svelte create mode 100644 src/lib/errors/handler.ts create mode 100644 src/lib/errors/index.ts create mode 100644 src/lib/errors/types.ts delete mode 100644 src/lib/hooks/database/map-utils.ts create mode 100644 src/lib/hooks/database/record-utils.ts create mode 100644 src/lib/types/database.ts create mode 100644 src/lib/types/erd.ts create mode 100644 src/lib/types/explain.ts create mode 100644 src/lib/types/index.ts create mode 100644 src/lib/types/persisted.ts create mode 100644 src/lib/types/query.ts create mode 100644 src/lib/types/schema.ts diff --git a/messages/ar.json b/messages/ar.json index c6ea1cc..3e57ca6 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -233,6 +233,11 @@ "query_row_copied": "تم نسخ الصف كـ JSON", "query_column_copied": "تم نسخ قيم العمود", "query_error_copied": "تم نسخ الخطأ إلى الحافظة", + "query_copy_error": "نسخ الخطأ إلى الحافظة", + "query_first_page": "الانتقال إلى الصفحة الأولى", + "query_previous_page": "الانتقال إلى الصفحة السابقة", + "query_next_page": "الانتقال إلى الصفحة التالية", + "query_last_page": "الانتقال إلى الصفحة الأخيرة", "query_copy_cell": "نسخ قيمة الخلية", "query_copy_row": "نسخ الصف كـ JSON", "query_copy_column": "نسخ قيم العمود", @@ -254,6 +259,9 @@ "history_no_history": "لم يتم العثور على سجل استعلامات", "history_no_saved": "لم يتم العثور على استعلامات محفوظة", "history_save_hint": "احفظ الاستعلامات للوصول إليها لاحقاً", + "history_add_favorite": "إضافة إلى المفضلة", + "history_remove_favorite": "إزالة من المفضلة", + "history_delete_saved": "حذف الاستعلام المحفوظ", "row_actions_delete": "حذف الصف", "save_query_title": "حفظ الاستعلام", "save_query_description": "أعطِ استعلامك اسماً لحفظه للاستخدام لاحقاً", diff --git a/messages/de.json b/messages/de.json index 4c5043b..a90bd20 100644 --- a/messages/de.json +++ b/messages/de.json @@ -233,6 +233,11 @@ "query_row_copied": "Zeile als JSON kopiert", "query_column_copied": "Spaltenwerte kopiert", "query_error_copied": "Fehler in Zwischenablage kopiert", + "query_copy_error": "Fehler in Zwischenablage kopieren", + "query_first_page": "Zur ersten Seite gehen", + "query_previous_page": "Zur vorherigen Seite gehen", + "query_next_page": "Zur nächsten Seite gehen", + "query_last_page": "Zur letzten Seite gehen", "query_copy_cell": "Zellwert kopieren", "query_copy_row": "Zeile als JSON kopieren", "query_copy_column": "Spaltenwerte kopieren", @@ -254,6 +259,9 @@ "history_no_history": "Kein Abfrageverlauf gefunden", "history_no_saved": "Keine gespeicherten Abfragen gefunden", "history_save_hint": "Speichern Sie Abfragen, um später darauf zuzugreifen", + "history_add_favorite": "Zu Favoriten hinzufügen", + "history_remove_favorite": "Aus Favoriten entfernen", + "history_delete_saved": "Gespeicherte Abfrage löschen", "row_actions_delete": "Zeile löschen", "save_query_title": "Abfrage speichern", "save_query_description": "Geben Sie Ihrer Abfrage einen Namen, um sie für später zu speichern", diff --git a/messages/en.json b/messages/en.json index 6740017..b9a263d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -233,6 +233,11 @@ "query_row_copied": "Row copied as JSON", "query_column_copied": "Column values copied", "query_error_copied": "Error copied to clipboard", + "query_copy_error": "Copy error to clipboard", + "query_first_page": "Go to first page", + "query_previous_page": "Go to previous page", + "query_next_page": "Go to next page", + "query_last_page": "Go to last page", "query_copy_cell": "Copy Cell Value", "query_copy_row": "Copy Row as JSON", "query_copy_column": "Copy Column Values", @@ -254,6 +259,9 @@ "history_no_history": "No query history found", "history_no_saved": "No saved queries found", "history_save_hint": "Save queries to access them later", + "history_add_favorite": "Add to favorites", + "history_remove_favorite": "Remove from favorites", + "history_delete_saved": "Delete saved query", "row_actions_delete": "Delete Row", "save_query_title": "Save Query", "save_query_description": "Give your query a name to save it for later use", diff --git a/messages/es.json b/messages/es.json index 7f44319..2a04b14 100644 --- a/messages/es.json +++ b/messages/es.json @@ -233,6 +233,11 @@ "query_row_copied": "Fila copiada como JSON", "query_column_copied": "Valores de columna copiados", "query_error_copied": "Error copiado al portapapeles", + "query_copy_error": "Copiar error al portapapeles", + "query_first_page": "Ir a la primera página", + "query_previous_page": "Ir a la página anterior", + "query_next_page": "Ir a la página siguiente", + "query_last_page": "Ir a la última página", "query_copy_cell": "Copiar valor de celda", "query_copy_row": "Copiar fila como JSON", "query_copy_column": "Copiar valores de columna", @@ -254,6 +259,9 @@ "history_no_history": "No se encontró historial de consultas", "history_no_saved": "No se encontraron consultas guardadas", "history_save_hint": "Guarda consultas para acceder a ellas más tarde", + "history_add_favorite": "Agregar a favoritos", + "history_remove_favorite": "Quitar de favoritos", + "history_delete_saved": "Eliminar consulta guardada", "row_actions_delete": "Eliminar fila", "save_query_title": "Guardar consulta", "save_query_description": "Dale un nombre a tu consulta para guardarla para uso posterior", diff --git a/messages/fr.json b/messages/fr.json index 06fe9fe..d1817a2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -233,6 +233,11 @@ "query_row_copied": "Ligne copiée en JSON", "query_column_copied": "Valeurs de la colonne copiées", "query_error_copied": "Erreur copiée dans le presse-papiers", + "query_copy_error": "Copier l'erreur dans le presse-papiers", + "query_first_page": "Aller à la première page", + "query_previous_page": "Aller à la page précédente", + "query_next_page": "Aller à la page suivante", + "query_last_page": "Aller à la dernière page", "query_copy_cell": "Copier la valeur de la cellule", "query_copy_row": "Copier la ligne en JSON", "query_copy_column": "Copier les valeurs de la colonne", @@ -254,6 +259,9 @@ "history_no_history": "Aucun historique de requêtes trouvé", "history_no_saved": "Aucune requête enregistrée trouvée", "history_save_hint": "Enregistrez des requêtes pour y accéder plus tard", + "history_add_favorite": "Ajouter aux favoris", + "history_remove_favorite": "Retirer des favoris", + "history_delete_saved": "Supprimer la requête enregistrée", "row_actions_delete": "Supprimer la ligne", "save_query_title": "Enregistrer la requête", "save_query_description": "Donnez un nom à votre requête pour l'enregistrer pour une utilisation ultérieure", diff --git a/messages/ko.json b/messages/ko.json index 4d19732..63120b9 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -233,6 +233,11 @@ "query_row_copied": "행이 JSON으로 복사되었습니다", "query_column_copied": "열 값이 복사되었습니다", "query_error_copied": "오류가 클립보드에 복사되었습니다", + "query_copy_error": "오류를 클립보드에 복사", + "query_first_page": "첫 페이지로 이동", + "query_previous_page": "이전 페이지로 이동", + "query_next_page": "다음 페이지로 이동", + "query_last_page": "마지막 페이지로 이동", "query_copy_cell": "셀 값 복사", "query_copy_row": "행을 JSON으로 복사", "query_copy_column": "열 값 복사", @@ -254,6 +259,9 @@ "history_no_history": "쿼리 기록이 없습니다", "history_no_saved": "저장된 쿼리가 없습니다", "history_save_hint": "나중에 접근하려면 쿼리를 저장하세요", + "history_add_favorite": "즐겨찾기에 추가", + "history_remove_favorite": "즐겨찾기에서 제거", + "history_delete_saved": "저장된 쿼리 삭제", "row_actions_delete": "행 삭제", "save_query_title": "쿼리 저장", "save_query_description": "나중에 사용할 수 있도록 쿼리에 이름을 지정하세요", diff --git a/src/lib/components/command-palette.svelte b/src/lib/components/command-palette.svelte index f8c6a81..465318a 100644 --- a/src/lib/components/command-palette.svelte +++ b/src/lib/components/command-palette.svelte @@ -27,7 +27,7 @@ // Derived state for dynamic commands const tables = $derived( - db.state.activeConnectionId ? db.state.schemas.get(db.state.activeConnectionId) || [] : [] + db.state.activeConnectionId ? db.state.schemas[db.state.activeConnectionId] ?? [] : [] ); const connections = $derived(db.state.connections); const savedQueries = $derived(db.state.activeConnectionSavedQueries); diff --git a/src/lib/components/connection-dialog.svelte b/src/lib/components/connection-dialog.svelte index 54265fb..c3f3210 100644 --- a/src/lib/components/connection-dialog.svelte +++ b/src/lib/components/connection-dialog.svelte @@ -27,8 +27,8 @@ } from "$lib/components/ui/tabs"; import type { DatabaseType, SSHAuthMethod } from "$lib/types"; import { toast } from "svelte-sonner"; - import { open as openFileDialog } from "@tauri-apps/plugin-dialog"; import CopyIcon from "@lucide/svelte/icons/copy"; + import { SshTunnelConfig } from "$lib/components/connection-dialog/index.js"; let { open = $bindable(false), prefill = undefined }: Props = $props(); const db = useDatabase(); @@ -400,20 +400,6 @@ } }; - const selectSshKeyFile = async () => { - try { - const selected = await openFileDialog({ - multiple: false, - title: "Select SSH Key File", - }); - if (selected && typeof selected === "string") { - formData.sshKeyPath = selected; - } - } catch (error) { - console.error("Failed to select file:", error); - } - }; - type Props = { open?: boolean; prefill?: { @@ -641,108 +627,25 @@ {/if} -
-
- - -
- - {#if formData.sshEnabled} -
-
-
- - -
-
- - -
-
- -
- - -
- -
- - -
- - {#if formData.sshAuthMethod === "password"} -
- - -
- {:else} -
- -
- - -
-
-
- - -
- {/if} - - {#if isReconnecting && formData.sshEnabled} -

- ⚠ {m.connection_dialog_warning_ssh()} -

- {/if} -
- {/if} -
+ formData.sshEnabled = v} + onHostChange={(v) => formData.sshHost = v} + onPortChange={(v) => formData.sshPort = v} + onUsernameChange={(v) => formData.sshUsername = v} + onAuthMethodChange={(v) => formData.sshAuthMethod = v} + onPasswordChange={(v) => formData.sshPassword = v} + onKeyPathChange={(v) => formData.sshKeyPath = v} + onKeyPassphraseChange={(v) => formData.sshKeyPassphrase = v} + /> {/if} diff --git a/src/lib/components/connection-dialog/index.ts b/src/lib/components/connection-dialog/index.ts new file mode 100644 index 0000000..3da63f4 --- /dev/null +++ b/src/lib/components/connection-dialog/index.ts @@ -0,0 +1 @@ +export { default as SshTunnelConfig } from './ssh-tunnel-config.svelte'; diff --git a/src/lib/components/connection-dialog/ssh-tunnel-config.svelte b/src/lib/components/connection-dialog/ssh-tunnel-config.svelte new file mode 100644 index 0000000..72e25ab --- /dev/null +++ b/src/lib/components/connection-dialog/ssh-tunnel-config.svelte @@ -0,0 +1,175 @@ + + +
+
+ onEnabledChange(e.currentTarget.checked)} + class="h-4 w-4 rounded border-gray-300" + /> + +
+ + {#if enabled} +
+
+
+ + onHostChange(e.currentTarget.value)} + placeholder={m.connection_dialog_placeholder_ssh_host()} + /> +
+
+ + onPortChange(parseInt(e.currentTarget.value) || 22)} + /> +
+
+ +
+ + onUsernameChange(e.currentTarget.value)} + placeholder={m.connection_dialog_placeholder_ssh_username()} + /> +
+ +
+ + +
+ + {#if authMethod === "password"} +
+ + onPasswordChange(e.currentTarget.value)} + placeholder={m.connection_dialog_placeholder_ssh_password()} + /> +
+ {:else} +
+ +
+ onKeyPathChange(e.currentTarget.value)} + placeholder={m.connection_dialog_placeholder_ssh_key_path()} + class="flex-1" + /> + +
+
+
+ + onKeyPassphraseChange(e.currentTarget.value)} + placeholder={m.connection_dialog_placeholder_optional()} + /> +
+ {/if} + + {#if isReconnecting && enabled} +

+ ⚠ {m.connection_dialog_warning_ssh()} +

+ {/if} +
+ {/if} +
diff --git a/src/lib/components/query-editor.svelte b/src/lib/components/query-editor.svelte index 4a7e154..f86aa24 100644 --- a/src/lib/components/query-editor.svelte +++ b/src/lib/components/query-editor.svelte @@ -1,14 +1,11 @@
- {#if db.state.activeQueryTab} - -
-
- - {#if liveStatementCount > 1} - - {m.query_statements_count({ count: liveStatementCount })} - - {/if} - - {#if activeResult} - {#if activeResult.isError} - - - {m.query_error()} - - {:else if activeResult.queryType && ['insert', 'update', 'delete'].includes(activeResult.queryType)} - - {activeResult.affectedRows ?? 0} - {m.query_rows_affected()} - {#if activeResult.lastInsertId} - ID: {activeResult.lastInsertId} - {/if} - - {/if} - {/if} -
-
- -
- - - - - - - - - {m.query_execute()} - {#if findShortcut('executeQuery')} - - {/if} - - - handleExplain(false)}> - - {m.query_explain()} - - handleExplain(true)}> - - {m.query_explain_analyze()} - - - -
- - -
-
- - - - -
- {#key db.state.activeQueryTabId} - sidebar.toggle()} - onChange={(newValue) => { - currentQuery = newValue; - if (db.state.activeQueryTabId) { - db.queryTabs.updateContent(db.state.activeQueryTabId, newValue); - } - }} - /> - {/key} -
-
- - - - - -
- {#if allResults.length > 0} - -
- {#each allResults as result, i} - - {/each} -
- - - {#if activeResult && !activeResult.isError} -
- - - - {m.query_export()} - - - - - {m.query_download()} - handleExport("csv")} - > - - CSV - - handleExport("json")} - > - - JSON - - handleExport("sql")} - > - - SQL INSERT - - - handleExport("markdown")} - > - - Markdown - - - - - {m.query_copy_to_clipboard()} - handleCopy("csv")} - > - - CSV - - handleCopy("json")} - > - - JSON - - handleCopy("sql")} - > - - SQL INSERT - - - handleCopy("markdown")} - > - - Markdown - - - - -
- {/if} - - - {#if activeResult?.isError} -
-
- -
-
-
-

{m.query_statement_failed({ n: activeResultIndex + 1 })}

-
{activeResult.error}
-
- -
-
- {m.query_show_sql()} -
{activeResult.statementSql}
-
-
-
-
- {:else if activeResult} - - - - {#if activeResult.totalPages > 1} - {@const start = (activeResult.page - 1) * activeResult.pageSize + 1} - {@const end = Math.min(activeResult.page * activeResult.pageSize, activeResult.totalRows)} -
-
- {m.query_showing_rows({ start, end, total: activeResult.totalRows.toLocaleString() })} -
-
- - - {activeResult.pageSize} rows - - - - {#each [25, 50, 100, 250, 500, 1000] as size} - db.queries.setPageSize(db.state.activeQueryTabId!, size)} - class={activeResult.pageSize === size ? "bg-accent" : ""} - > - {m.query_rows_count({ count: size })} - - {/each} - - - -
- - - - {m.query_page_of({ page: activeResult.page, total: activeResult.totalPages })} - - - -
-
-
- {/if} - {/if} - {:else} -
-
-
- -

{m.query_no_results()}

-

- {m.query_run_hint({ shortcut: "⌘+Enter" })} -

-
- - {#if activeSampleQueries.length > 0} -
-

- {m.empty_query_sample_title()} -

- {#each activeSampleQueries as sampleQuery} - - {/each} -
- {/if} -
-
- {/if} -
-
-
- {:else} -
-
-
- -

{m.query_create_tab()}

-
- -
-
- {/if} + {#if db.state.activeQueryTab} + + + + + +
+ {#key db.state.activeQueryTabId} + sidebar.toggle()} + onChange={(newValue) => { + currentQuery = newValue; + if (db.state.activeQueryTabId) { + db.queryTabs.updateContent(db.state.activeQueryTabId, newValue); + } + }} + /> + {/key} +
+
+ + + + + +
+ {#if allResults.length > 0} + db.queries.setActiveResult(db.state.activeQueryTabId!, i)} + /> + + {#if activeResult && !activeResult.isError} + + {/if} + + {#if activeResult?.isError} + + {:else if activeResult} + + + {#if activeResult.totalPages > 1} + db.queries.goToPage(db.state.activeQueryTabId!, page)} + onSetPageSize={(size) => db.queries.setPageSize(db.state.activeQueryTabId!, size)} + /> + {/if} + {/if} + {:else} +
+
+
+ +

{m.query_no_results()}

+

+ {m.query_run_hint({ shortcut: "⌘+Enter" })} +

+
+ + {#if activeSampleQueries.length > 0} +
+

+ {m.empty_query_sample_title()} +

+ {#each activeSampleQueries as sampleQuery} + + {/each} +
+ {/if} +
+
+ {/if} +
+
+
+ {:else} +
+
+
+ +

{m.query_create_tab()}

+
+ +
+
+ {/if}
{#if db.state.activeQueryTab} - + {/if} - - - {m.query_delete_row_title()} - - {m.query_delete_row_description()} - - - - - - - + + + {m.query_delete_row_title()} + + {m.query_delete_row_description()} + + + + + + + diff --git a/src/lib/components/query-editor/index.ts b/src/lib/components/query-editor/index.ts new file mode 100644 index 0000000..66351d0 --- /dev/null +++ b/src/lib/components/query-editor/index.ts @@ -0,0 +1,5 @@ +export { default as QueryToolbar } from './query-toolbar.svelte'; +export { default as QueryResultTabs } from './query-result-tabs.svelte'; +export { default as QueryExportMenu } from './query-export-menu.svelte'; +export { default as QueryPagination } from './query-pagination.svelte'; +export { default as QueryErrorDisplay } from './query-error-display.svelte'; diff --git a/src/lib/components/query-editor/query-error-display.svelte b/src/lib/components/query-editor/query-error-display.svelte new file mode 100644 index 0000000..d75dfb5 --- /dev/null +++ b/src/lib/components/query-editor/query-error-display.svelte @@ -0,0 +1,54 @@ + + +
+
+ +
+
+
+

+ {m.query_statement_failed({ n: statementIndex + 1 })} +

+
{error}
+
+ +
+
+ {m.query_show_sql()} +
{statementSql}
+
+
+
+
diff --git a/src/lib/components/query-editor/query-export-menu.svelte b/src/lib/components/query-editor/query-export-menu.svelte new file mode 100644 index 0000000..66d4bf7 --- /dev/null +++ b/src/lib/components/query-editor/query-export-menu.svelte @@ -0,0 +1,70 @@ + + +
+ + + + {m.query_export()} + + + + + {m.query_download()} + onExport("csv")}> + + CSV + + onExport("json")}> + + JSON + + onExport("sql")}> + + SQL INSERT + + onExport("markdown")}> + + Markdown + + + + + {m.query_copy_to_clipboard()} + onCopy("csv")}> + + CSV + + onCopy("json")}> + + JSON + + onCopy("sql")}> + + SQL INSERT + + onCopy("markdown")}> + + Markdown + + + + +
diff --git a/src/lib/components/query-editor/query-pagination.svelte b/src/lib/components/query-editor/query-pagination.svelte new file mode 100644 index 0000000..56dd789 --- /dev/null +++ b/src/lib/components/query-editor/query-pagination.svelte @@ -0,0 +1,103 @@ + + +
+
+ {m.query_showing_rows({ start, end, total: totalRows.toLocaleString() })} +
+
+ + + {pageSize} rows + + + + {#each [25, 50, 100, 250, 500, 1000] as size} + onSetPageSize(size)} + class={pageSize === size ? "bg-accent" : ""} + > + {m.query_rows_count({ count: size })} + + {/each} + + + +
+ + + + {m.query_page_of({ page, total: totalPages })} + + + +
+
+
diff --git a/src/lib/components/query-editor/query-result-tabs.svelte b/src/lib/components/query-editor/query-result-tabs.svelte new file mode 100644 index 0000000..5a39c43 --- /dev/null +++ b/src/lib/components/query-editor/query-result-tabs.svelte @@ -0,0 +1,54 @@ + + +
+ {#each results as result, i} + + {/each} +
diff --git a/src/lib/components/query-editor/query-toolbar.svelte b/src/lib/components/query-editor/query-toolbar.svelte new file mode 100644 index 0000000..bafbe78 --- /dev/null +++ b/src/lib/components/query-editor/query-toolbar.svelte @@ -0,0 +1,143 @@ + + +
+
+ {#if liveStatementCount > 1} + + {m.query_statements_count({ count: liveStatementCount })} + + {/if} + {#if activeResult} + {#if activeResult.isError} + + + {m.query_error()} + + {:else if activeResult.queryType && ['insert', 'update', 'delete'].includes(activeResult.queryType)} + + {activeResult.affectedRows ?? 0} + {m.query_rows_affected()} + {#if activeResult.lastInsertId} + ID: {activeResult.lastInsertId} + {/if} + + {/if} + {/if} +
+
+
+ + + + + + + + + {m.query_execute()} + {#if findShortcut('executeQuery')} + + {/if} + + + onExplain(false)}> + + {m.query_explain()} + + onExplain(true)}> + + {m.query_explain_analyze()} + + + +
+ + +
+
diff --git a/src/lib/components/query-history.svelte b/src/lib/components/query-history.svelte index e24380c..9709d2d 100644 --- a/src/lib/components/query-history.svelte +++ b/src/lib/components/query-history.svelte @@ -47,7 +47,13 @@
{#each filteredHistory as item (item.id)} -
db.queryTabs.loadFromHistory(item.id)}> +
db.queryTabs.loadFromHistory(item.id)} + onkeydown={(e) => e.key === 'Enter' && db.queryTabs.loadFromHistory(item.id)} + >
@@ -59,6 +65,7 @@ size="icon" variant="ghost" class={["size-6 shrink-0 [&_svg:not([class*='size-'])]:size-3", item.favorite && "text-yellow-500"]} + aria-label={item.favorite ? m.history_remove_favorite() : m.history_add_favorite()} onclick={(e) => { e.stopPropagation(); db.history.toggleQueryFavorite(item.id); @@ -87,7 +94,13 @@
{#each filteredSavedQueries as item (item.id)} -
db.queryTabs.loadSaved(item.id)}> +
db.queryTabs.loadSaved(item.id)} + onkeydown={(e) => e.key === 'Enter' && db.queryTabs.loadSaved(item.id)} + >
@@ -97,6 +110,7 @@ size="icon" variant="ghost" class="size-6 shrink-0 [&_svg:not([class*='size-'])]:size-3 opacity-0 group-hover:opacity-100 transition-opacity hover:text-destructive" + aria-label={m.history_delete_saved()} onclick={(e) => { e.stopPropagation(); db.savedQueries.deleteSavedQuery(item.id); diff --git a/src/lib/components/sidebar-left.svelte b/src/lib/components/sidebar-left.svelte index 052122f..bc2eb25 100644 --- a/src/lib/components/sidebar-left.svelte +++ b/src/lib/components/sidebar-left.svelte @@ -201,6 +201,7 @@ "size-5 shrink-0 [&_svg:not([class*='size-'])]:size-3", item.favorite && "text-yellow-500", ]} + aria-label={item.favorite ? m.history_remove_favorite() : m.history_add_favorite()} onclick={(e) => { e.stopPropagation(); db.history.toggleQueryFavorite(item.id); @@ -255,6 +256,7 @@ size="icon" variant="ghost" class="size-5 shrink-0 [&_svg:not([class*='size-'])]:size-3 opacity-0 group-hover:opacity-100 transition-opacity hover:text-destructive" + aria-label={m.history_delete_saved()} onclick={(e) => { e.stopPropagation(); db.savedQueries.deleteSavedQuery(item.id); diff --git a/src/lib/errors/handler.ts b/src/lib/errors/handler.ts new file mode 100644 index 0000000..fd56714 --- /dev/null +++ b/src/lib/errors/handler.ts @@ -0,0 +1,99 @@ +/** + * Centralized error handling with severity-based routing. + * Provides consistent user feedback and logging across the application. + */ + +import { toast } from 'svelte-sonner'; +import type { AppError, ErrorCode } from './types'; + +type ErrorSeverity = 'error' | 'warning' | 'info'; + +/** + * Maps error codes to their display severity. + * Determines whether to show error toast, warning, or info. + */ +const errorSeverity: Record = { + CONNECTION_FAILED: 'error', + QUERY_FAILED: 'error', + PERSISTENCE_FAILED: 'warning', // Don't block user for persistence issues + SSH_TUNNEL_FAILED: 'error', + SCHEMA_LOAD_FAILED: 'error', + VALIDATION_FAILED: 'warning', + EXPORT_FAILED: 'error', + IMPORT_FAILED: 'error', + UNKNOWN: 'error' +}; + +export interface HandleErrorOptions { + /** If true, don't show a toast notification */ + silent?: boolean; + /** Override the default severity */ + severity?: ErrorSeverity; +} + +/** + * Handles an AppError by logging and optionally showing a toast notification. + * + * @example + * // Show error toast + * handleError(appError); + * + * // Silent error (logging only) + * handleError(appError, { silent: true }); + */ +export function handleError(error: AppError, options?: HandleErrorOptions): void { + // Always log for debugging + console.error(`[${error.code}] ${error.message}`, error.context, error.cause); + + if (options?.silent) { + return; + } + + const severity = options?.severity ?? errorSeverity[error.code]; + + switch (severity) { + case 'error': + toast.error(error.userMessage); + break; + case 'warning': + toast.warning(error.userMessage); + break; + case 'info': + toast.info(error.userMessage); + break; + } +} + +/** + * Wraps an async operation with error handling. + * Returns a Result type for explicit error handling. + * + * @example + * const result = await withErrorHandling( + * () => fetchData(), + * 'QUERY_FAILED', + * 'Failed to fetch data' + * ); + */ +export async function withErrorHandling( + operation: () => Promise, + errorCode: ErrorCode, + userMessage: string, + options?: HandleErrorOptions & { context?: Record } +): Promise<{ ok: true; value: T } | { ok: false; error: AppError }> { + try { + const value = await operation(); + return { ok: true, value }; + } catch (e) { + const error: AppError = { + code: errorCode, + message: e instanceof Error ? e.message : String(e), + userMessage, + context: options?.context, + cause: e instanceof Error ? e : undefined + }; + + handleError(error, options); + return { ok: false, error }; + } +} diff --git a/src/lib/errors/index.ts b/src/lib/errors/index.ts new file mode 100644 index 0000000..ca569d8 --- /dev/null +++ b/src/lib/errors/index.ts @@ -0,0 +1,11 @@ +export { + type AppError, + type ErrorCode, + type Result, + createError, + extractErrorMessage, + ok, + err +} from './types'; + +export { handleError, withErrorHandling, type HandleErrorOptions } from './handler'; diff --git a/src/lib/errors/types.ts b/src/lib/errors/types.ts new file mode 100644 index 0000000..5bb4280 --- /dev/null +++ b/src/lib/errors/types.ts @@ -0,0 +1,110 @@ +/** + * Centralized error handling types for Seaquel. + * Provides consistent error representation across the application. + */ + +/** + * Error codes for categorizing application errors. + * Used to determine severity and handling behavior. + */ +export type ErrorCode = + | 'CONNECTION_FAILED' + | 'QUERY_FAILED' + | 'PERSISTENCE_FAILED' + | 'SSH_TUNNEL_FAILED' + | 'SCHEMA_LOAD_FAILED' + | 'VALIDATION_FAILED' + | 'EXPORT_FAILED' + | 'IMPORT_FAILED' + | 'UNKNOWN'; + +/** + * Structured application error with user-friendly messaging. + */ +export interface AppError { + /** Error category for routing and severity determination */ + code: ErrorCode; + /** Technical error message for debugging */ + message: string; + /** User-friendly message suitable for display */ + userMessage: string; + /** Additional context for debugging */ + context?: Record; + /** Original error if wrapping another error */ + cause?: Error; +} + +/** + * Result type for operations that can fail. + * Encourages explicit error handling over try/catch. + * + * @example + * const result = await connectToDatabase(config); + * if (result.ok) { + * console.log('Connected:', result.value); + * } else { + * handleError(result.error); + * } + */ +export type Result = { ok: true; value: T } | { ok: false; error: E }; + +/** + * Creates a structured AppError with consistent formatting. + */ +export function createError( + code: ErrorCode, + message: string, + userMessage?: string, + context?: Record, + cause?: Error +): AppError { + return { + code, + message, + userMessage: userMessage ?? message, + context, + cause + }; +} + +/** + * Creates a successful Result. + */ +export function ok(value: T): Result { + return { ok: true, value }; +} + +/** + * Creates a failed Result. + */ +export function err(error: E): Result { + return { ok: false, error }; +} + +/** + * Extracts a human-readable error message from various error formats. + * Handles Tauri errors, database errors, and standard Error objects. + */ +export function extractErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === 'string') { + // Handle common database error formats + const dbErrorMatch = error.match(/error returned from database: (.+)/); + if (dbErrorMatch) { + return dbErrorMatch[1]; + } + return error; + } + + if (typeof error === 'object' && error !== null) { + const errObj = error as Record; + if (typeof errObj.message === 'string') { + return errObj.code ? `${errObj.code}: ${errObj.message}` : errObj.message; + } + } + + return 'An unknown error occurred'; +} diff --git a/src/lib/hooks/database/connection-manager.svelte.ts b/src/lib/hooks/database/connection-manager.svelte.ts index db701c6..c115aa8 100644 --- a/src/lib/hooks/database/connection-manager.svelte.ts +++ b/src/lib/hooks/database/connection-manager.svelte.ts @@ -8,7 +8,6 @@ import type { TabOrderingManager } from "./tab-ordering.svelte.js"; import { getAdapter, type DatabaseAdapter } from "$lib/db"; import { createSshTunnel, closeSshTunnel } from "$lib/services/ssh-tunnel"; import { mssqlConnect, mssqlDisconnect, mssqlQuery } from "$lib/services/mssql"; -import { setMapValue } from "./map-utils.js"; type ConnectionInput = Omit & { sshPassword?: string; @@ -209,9 +208,10 @@ export class ConnectionManager { this.state.activeConnectionId = newConnection.id; // Store tables immediately (without column metadata) so UI is responsive - const newSchemas = new Map(this.state.schemas); - newSchemas.set(newConnection.id, schemasWithTables); - this.state.schemas = newSchemas; + this.state.schemas = { + ...this.state.schemas, + [newConnection.id]: schemasWithTables, + }; // Load column metadata asynchronously in the background this.onSchemaLoaded(newConnection.id, schemasWithTables, adapter, newConnection.database, newConnection.mssqlConnectionId); @@ -321,9 +321,10 @@ export class ConnectionManager { } // Store tables immediately (without column metadata) so UI is responsive - const newSchemas = new Map(this.state.schemas); - newSchemas.set(connectionId, schemasWithTables); - this.state.schemas = newSchemas; + this.state.schemas = { + ...this.state.schemas, + [connectionId]: schemasWithTables, + }; // Load column metadata asynchronously in the background this.onSchemaLoaded(connectionId, schemasWithTables, adapter, database, mssqlConnectionId); @@ -336,7 +337,7 @@ export class ConnectionManager { // Create initial query tab if no tabs were restored if (!hasRestoredTabs) { - const tabs = this.state.queryTabsByConnection.get(connectionId) || []; + const tabs = this.state.queryTabsByConnection[connectionId] ?? []; if (tabs.length === 0) { this.onCreateInitialTab(); } diff --git a/src/lib/hooks/database/erd-tabs.svelte.ts b/src/lib/hooks/database/erd-tabs.svelte.ts index 80c3c52..be3b030 100644 --- a/src/lib/hooks/database/erd-tabs.svelte.ts +++ b/src/lib/hooks/database/erd-tabs.svelte.ts @@ -1,98 +1,93 @@ -import type { ErdTab } from "$lib/types"; -import type { DatabaseState } from "./state.svelte.js"; -import type { TabOrderingManager } from "./tab-ordering.svelte.js"; -import { setMapValue } from "./map-utils.js"; +import type { ErdTab } from '$lib/types'; +import type { DatabaseState } from './state.svelte.js'; +import type { TabOrderingManager } from './tab-ordering.svelte.js'; /** * Manages ERD (Entity Relationship Diagram) tabs. */ export class ErdTabManager { - constructor( - private state: DatabaseState, - private tabOrdering: TabOrderingManager, - private schedulePersistence: (connectionId: string | null) => void, - private setActiveView: (view: "query" | "schema" | "explain" | "erd") => void - ) {} + constructor( + private state: DatabaseState, + private tabOrdering: TabOrderingManager, + private schedulePersistence: (connectionId: string | null) => void, + private setActiveView: (view: 'query' | 'schema' | 'explain' | 'erd') => void + ) {} - /** - * Add an ERD tab for the current connection. - * Returns the tab ID or null if no active connection. - */ - add(): string | null { - if (!this.state.activeConnectionId || !this.state.activeConnection) return null; + /** + * Add an ERD tab for the current connection. + * Returns the tab ID or null if no active connection. + */ + add(): string | null { + if (!this.state.activeConnectionId || !this.state.activeConnection) return null; - const tabs = this.state.erdTabsByConnection.get(this.state.activeConnectionId) || []; + const connectionId = this.state.activeConnectionId; + const tabs = this.state.erdTabsByConnection[connectionId] ?? []; - // Check if an ERD tab already exists for this connection - const existingTab = tabs.find((t) => t.name === `ERD: ${this.state.activeConnection!.name}`); - if (existingTab) { - // Just switch to the existing tab - setMapValue( - () => this.state.activeErdTabIdByConnection, - (m) => (this.state.activeErdTabIdByConnection = m), - this.state.activeConnectionId, - existingTab.id - ); - this.setActiveView("erd"); - return existingTab.id; - } + // Check if an ERD tab already exists for this connection + const existingTab = tabs.find((t) => t.name === `ERD: ${this.state.activeConnection!.name}`); + if (existingTab) { + // Just switch to the existing tab + this.state.activeErdTabIdByConnection = { + ...this.state.activeErdTabIdByConnection, + [connectionId]: existingTab.id + }; + this.setActiveView('erd'); + return existingTab.id; + } - const erdTabId = `erd-${Date.now()}`; - const newErdTab: ErdTab = { - id: erdTabId, - name: `ERD: ${this.state.activeConnection.name}`, - }; + const erdTabId = `erd-${Date.now()}`; + const newErdTab: ErdTab = { + id: erdTabId, + name: `ERD: ${this.state.activeConnection.name}` + }; - const newErdTabs = new Map(this.state.erdTabsByConnection); - newErdTabs.set(this.state.activeConnectionId, [...tabs, newErdTab]); - this.state.erdTabsByConnection = newErdTabs; + this.state.erdTabsByConnection = { + ...this.state.erdTabsByConnection, + [connectionId]: [...tabs, newErdTab] + }; - this.tabOrdering.add(erdTabId); + this.tabOrdering.add(erdTabId); - setMapValue( - () => this.state.activeErdTabIdByConnection, - (m) => (this.state.activeErdTabIdByConnection = m), - this.state.activeConnectionId, - erdTabId - ); + this.state.activeErdTabIdByConnection = { + ...this.state.activeErdTabIdByConnection, + [connectionId]: erdTabId + }; - this.setActiveView("erd"); - this.schedulePersistence(this.state.activeConnectionId); + this.setActiveView('erd'); + this.schedulePersistence(connectionId); - return erdTabId; - } + return erdTabId; + } - /** - * Remove an ERD tab by ID. - */ - remove(id: string): void { - this.tabOrdering.removeTabGeneric( - () => this.state.erdTabsByConnection, - (m) => (this.state.erdTabsByConnection = m), - () => this.state.activeErdTabIdByConnection, - (m) => (this.state.activeErdTabIdByConnection = m), - id - ); - this.schedulePersistence(this.state.activeConnectionId); + /** + * Remove an ERD tab by ID. + */ + remove(id: string): void { + this.tabOrdering.removeTabGeneric( + () => this.state.erdTabsByConnection, + (r) => (this.state.erdTabsByConnection = r), + () => this.state.activeErdTabIdByConnection, + (r) => (this.state.activeErdTabIdByConnection = r), + id + ); + this.schedulePersistence(this.state.activeConnectionId); - // If no more ERD tabs, switch back to query view - const remainingTabs = this.state.erdTabsByConnection.get(this.state.activeConnectionId!) || []; - if (remainingTabs.length === 0) { - this.setActiveView("query"); - } - } + // If no more ERD tabs, switch back to query view + const remainingTabs = this.state.erdTabsByConnection[this.state.activeConnectionId!] ?? []; + if (remainingTabs.length === 0) { + this.setActiveView('query'); + } + } - /** - * Set the active ERD tab by ID. - */ - setActive(id: string): void { - if (!this.state.activeConnectionId) return; - setMapValue( - () => this.state.activeErdTabIdByConnection, - (m) => (this.state.activeErdTabIdByConnection = m), - this.state.activeConnectionId, - id - ); - this.schedulePersistence(this.state.activeConnectionId); - } + /** + * Set the active ERD tab by ID. + */ + setActive(id: string): void { + if (!this.state.activeConnectionId) return; + this.state.activeErdTabIdByConnection = { + ...this.state.activeErdTabIdByConnection, + [this.state.activeConnectionId]: id + }; + this.schedulePersistence(this.state.activeConnectionId); + } } diff --git a/src/lib/hooks/database/explain-tabs.svelte.ts b/src/lib/hooks/database/explain-tabs.svelte.ts index 98573d4..98a415a 100644 --- a/src/lib/hooks/database/explain-tabs.svelte.ts +++ b/src/lib/hooks/database/explain-tabs.svelte.ts @@ -1,176 +1,178 @@ -import { toast } from "svelte-sonner"; -import type { ExplainTab, ExplainResult, ExplainPlanNode } from "$lib/types"; -import type { DatabaseState } from "./state.svelte.js"; -import type { TabOrderingManager } from "./tab-ordering.svelte.js"; -import { getAdapter, type ExplainNode } from "$lib/db"; -import { setMapValue } from "./map-utils.js"; -import { getStatementAtOffset } from "$lib/db/sql-parser"; +import { toast } from 'svelte-sonner'; +import type { ExplainTab, ExplainResult, ExplainPlanNode } from '$lib/types'; +import type { DatabaseState } from './state.svelte.js'; +import type { TabOrderingManager } from './tab-ordering.svelte.js'; +import { getAdapter, type ExplainNode } from '$lib/db'; +import { getStatementAtOffset } from '$lib/db/sql-parser'; /** * Manages EXPLAIN/ANALYZE tabs: execute, remove, set active. */ export class ExplainTabManager { - constructor( - private state: DatabaseState, - private tabOrdering: TabOrderingManager, - private schedulePersistence: (connectionId: string | null) => void, - private setActiveView: (view: "query" | "schema" | "explain" | "erd") => void - ) {} - - /** - * Convert ExplainNode from adapter to ExplainResult for rendering. - */ - private convertExplainNodeToResult(node: ExplainNode, isAnalyze: boolean): ExplainResult { - let nodeCounter = 0; - - const convertNode = (n: ExplainNode): ExplainPlanNode => { - const id = `node-${nodeCounter++}`; - - return { - id, - nodeType: n.type, - relationName: undefined, - alias: undefined, - startupCost: 0, - totalCost: n.cost || 0, - planRows: n.rows || 0, - planWidth: 0, - actualStartupTime: undefined, - actualTotalTime: n.actualTime, - actualRows: n.actualRows, - actualLoops: undefined, - filter: n.label !== n.type ? n.label : undefined, - indexName: undefined, - indexCond: undefined, - joinType: undefined, - hashCond: undefined, - sortKey: undefined, - children: (n.children || []).map((child) => convertNode(child)), - }; - }; - - return { - plan: convertNode(node), - planningTime: 0, - executionTime: undefined, - isAnalyze, - }; - } - - /** - * Execute EXPLAIN or EXPLAIN ANALYZE on a query tab. - * If cursorOffset is provided, explains only the statement at that cursor position. - */ - async execute(tabId: string, analyze: boolean = false, cursorOffset?: number): Promise { - if (!this.state.activeConnectionId || !this.state.activeConnection) return; - - const tabs = this.state.queryTabsByConnection.get(this.state.activeConnectionId) || []; - const tab = tabs.find((t) => t.id === tabId); - if (!tab || !tab.query.trim()) return; - - // Get the statement to explain based on cursor position - const dbType = this.state.activeConnection.type; - let queryToExplain = tab.query; - - if (cursorOffset !== undefined) { - const statement = getStatementAtOffset(tab.query, cursorOffset, dbType); - if (statement) { - queryToExplain = statement.sql; - } - } - - if (!queryToExplain.trim()) return; - - // Create a new explain tab - const explainTabs = this.state.explainTabsByConnection.get(this.state.activeConnectionId) || []; - const explainTabId = `explain-${Date.now()}`; - const queryPreview = queryToExplain.substring(0, 30).replace(/\s+/g, " ").trim(); - const newExplainTab: ExplainTab = $state({ - id: explainTabId, - name: analyze ? `Analyze: ${queryPreview}...` : `Explain: ${queryPreview}...`, - sourceQuery: queryToExplain, - result: undefined, - isExecuting: true, - }); - - const newExplainTabs = new Map(this.state.explainTabsByConnection); - newExplainTabs.set(this.state.activeConnectionId, [...explainTabs, newExplainTab]); - this.state.explainTabsByConnection = newExplainTabs; - - this.tabOrdering.add(explainTabId); - - // Set as active and switch view - const newActiveExplainIds = new Map(this.state.activeExplainTabIdByConnection); - newActiveExplainIds.set(this.state.activeConnectionId, explainTabId); - this.state.activeExplainTabIdByConnection = newActiveExplainIds; - this.setActiveView("explain"); - this.schedulePersistence(this.state.activeConnectionId); - - try { - const adapter = getAdapter(this.state.activeConnection.type); - const explainQuery = adapter.getExplainQuery(queryToExplain, analyze); - const result = (await this.state.activeConnection.database!.select(explainQuery)) as unknown[]; - - // Use adapter to parse the results into common format - const parsedNode = adapter.parseExplainResult(result, analyze); - - // Convert to ExplainResult format for rendering - const explainResult: ExplainResult = this.convertExplainNodeToResult(parsedNode, analyze); - - // Update the explain tab with results - newExplainTab.result = explainResult; - newExplainTab.isExecuting = false; - - // Trigger reactivity - const updatedExplainTabs = new Map(this.state.explainTabsByConnection); - updatedExplainTabs.set( - this.state.activeConnectionId!, - [...(this.state.explainTabsByConnection.get(this.state.activeConnectionId!) || [])] - ); - this.state.explainTabsByConnection = updatedExplainTabs; - } catch (error) { - // Remove failed explain tab - const updatedExplainTabs = new Map(this.state.explainTabsByConnection); - const currentTabs = updatedExplainTabs.get(this.state.activeConnectionId!) || []; - updatedExplainTabs.set( - this.state.activeConnectionId!, - currentTabs.filter((t) => t.id !== explainTabId) - ); - this.state.explainTabsByConnection = updatedExplainTabs; - - // Switch back to query view - this.setActiveView("query"); - toast.error(`Explain failed: ${error}`); - } - } - - /** - * Remove an explain tab by ID. - */ - remove(id: string): void { - this.tabOrdering.removeTabGeneric( - () => this.state.explainTabsByConnection, - (m) => (this.state.explainTabsByConnection = m), - () => this.state.activeExplainTabIdByConnection, - (m) => (this.state.activeExplainTabIdByConnection = m), - id - ); - this.schedulePersistence(this.state.activeConnectionId); - // Switch to query view if no explain tabs left - if (this.state.activeConnectionId && this.state.explainTabs.length === 0) { - this.setActiveView("query"); - } - } - - /** - * Set the active explain tab by ID. - */ - setActive(id: string): void { - if (!this.state.activeConnectionId) return; - - const newActiveExplainIds = new Map(this.state.activeExplainTabIdByConnection); - newActiveExplainIds.set(this.state.activeConnectionId, id); - this.state.activeExplainTabIdByConnection = newActiveExplainIds; - this.schedulePersistence(this.state.activeConnectionId); - } + constructor( + private state: DatabaseState, + private tabOrdering: TabOrderingManager, + private schedulePersistence: (connectionId: string | null) => void, + private setActiveView: (view: 'query' | 'schema' | 'explain' | 'erd') => void + ) {} + + /** + * Convert ExplainNode from adapter to ExplainResult for rendering. + */ + private convertExplainNodeToResult(node: ExplainNode, isAnalyze: boolean): ExplainResult { + let nodeCounter = 0; + + const convertNode = (n: ExplainNode): ExplainPlanNode => { + const id = `node-${nodeCounter++}`; + + return { + id, + nodeType: n.type, + relationName: undefined, + alias: undefined, + startupCost: 0, + totalCost: n.cost || 0, + planRows: n.rows || 0, + planWidth: 0, + actualStartupTime: undefined, + actualTotalTime: n.actualTime, + actualRows: n.actualRows, + actualLoops: undefined, + filter: n.label !== n.type ? n.label : undefined, + indexName: undefined, + indexCond: undefined, + joinType: undefined, + hashCond: undefined, + sortKey: undefined, + children: (n.children || []).map((child) => convertNode(child)) + }; + }; + + return { + plan: convertNode(node), + planningTime: 0, + executionTime: undefined, + isAnalyze + }; + } + + /** + * Execute EXPLAIN or EXPLAIN ANALYZE on a query tab. + * If cursorOffset is provided, explains only the statement at that cursor position. + */ + async execute(tabId: string, analyze: boolean = false, cursorOffset?: number): Promise { + if (!this.state.activeConnectionId || !this.state.activeConnection) return; + + const connectionId = this.state.activeConnectionId; + const tabs = this.state.queryTabsByConnection[connectionId] ?? []; + const tab = tabs.find((t) => t.id === tabId); + if (!tab || !tab.query.trim()) return; + + // Get the statement to explain based on cursor position + const dbType = this.state.activeConnection.type; + let queryToExplain = tab.query; + + if (cursorOffset !== undefined) { + const statement = getStatementAtOffset(tab.query, cursorOffset, dbType); + if (statement) { + queryToExplain = statement.sql; + } + } + + if (!queryToExplain.trim()) return; + + // Create a new explain tab + const explainTabs = this.state.explainTabsByConnection[connectionId] ?? []; + const explainTabId = `explain-${Date.now()}`; + const queryPreview = queryToExplain.substring(0, 30).replace(/\s+/g, ' ').trim(); + const newExplainTab: ExplainTab = $state({ + id: explainTabId, + name: analyze ? `Analyze: ${queryPreview}...` : `Explain: ${queryPreview}...`, + sourceQuery: queryToExplain, + result: undefined, + isExecuting: true + }); + + this.state.explainTabsByConnection = { + ...this.state.explainTabsByConnection, + [connectionId]: [...explainTabs, newExplainTab] + }; + + this.tabOrdering.add(explainTabId); + + // Set as active and switch view + this.state.activeExplainTabIdByConnection = { + ...this.state.activeExplainTabIdByConnection, + [connectionId]: explainTabId + }; + this.setActiveView('explain'); + this.schedulePersistence(connectionId); + + try { + const adapter = getAdapter(this.state.activeConnection.type); + const explainQuery = adapter.getExplainQuery(queryToExplain, analyze); + const result = (await this.state.activeConnection.database!.select( + explainQuery + )) as unknown[]; + + // Use adapter to parse the results into common format + const parsedNode = adapter.parseExplainResult(result, analyze); + + // Convert to ExplainResult format for rendering + const explainResult: ExplainResult = this.convertExplainNodeToResult(parsedNode, analyze); + + // Update the explain tab with results + newExplainTab.result = explainResult; + newExplainTab.isExecuting = false; + + // Trigger reactivity by creating new array + const currentTabs = this.state.explainTabsByConnection[connectionId] ?? []; + this.state.explainTabsByConnection = { + ...this.state.explainTabsByConnection, + [connectionId]: [...currentTabs] + }; + } catch (error) { + // Remove failed explain tab + const currentTabs = this.state.explainTabsByConnection[connectionId] ?? []; + this.state.explainTabsByConnection = { + ...this.state.explainTabsByConnection, + [connectionId]: currentTabs.filter((t) => t.id !== explainTabId) + }; + + // Switch back to query view + this.setActiveView('query'); + toast.error(`Explain failed: ${error}`); + } + } + + /** + * Remove an explain tab by ID. + */ + remove(id: string): void { + this.tabOrdering.removeTabGeneric( + () => this.state.explainTabsByConnection, + (r) => (this.state.explainTabsByConnection = r), + () => this.state.activeExplainTabIdByConnection, + (r) => (this.state.activeExplainTabIdByConnection = r), + id + ); + this.schedulePersistence(this.state.activeConnectionId); + // Switch to query view if no explain tabs left + if (this.state.activeConnectionId && this.state.explainTabs.length === 0) { + this.setActiveView('query'); + } + } + + /** + * Set the active explain tab by ID. + */ + setActive(id: string): void { + if (!this.state.activeConnectionId) return; + + this.state.activeExplainTabIdByConnection = { + ...this.state.activeExplainTabIdByConnection, + [this.state.activeConnectionId]: id + }; + this.schedulePersistence(this.state.activeConnectionId); + } } diff --git a/src/lib/hooks/database/map-utils.ts b/src/lib/hooks/database/map-utils.ts deleted file mode 100644 index 1495a28..0000000 --- a/src/lib/hooks/database/map-utils.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Helper functions for updating Map state with Svelte 5 reactivity. - * Maps need to be replaced with new instances to trigger reactivity. - */ - -/** - * Set a value in a Map state while maintaining reactivity. - * Creates a new Map instance and calls the setter. - */ -export function setMapValue( - getter: () => Map, - setter: (m: Map) => void, - key: K, - value: V -): void { - const newMap = new Map(getter()); - newMap.set(key, value); - setter(newMap); -} - -/** - * Delete a key from a Map state while maintaining reactivity. - * Creates a new Map instance and calls the setter. - */ -export function deleteMapKey( - getter: () => Map, - setter: (m: Map) => void, - key: K -): void { - const newMap = new Map(getter()); - newMap.delete(key); - setter(newMap); -} - -/** - * Update a specific item in an array stored in a Map while maintaining reactivity. - * Creates new Map and array instances, and replaces the item with updated properties. - */ -export function updateMapArrayItem( - getter: () => Map, - setter: (m: Map) => void, - mapKey: K, - itemId: string, - updates: Partial -): void { - const currentMap = getter(); - const currentArray = currentMap.get(mapKey) || []; - - // Create new array with updated item - const newArray = currentArray.map(item => - item.id === itemId ? { ...item, ...updates } : item - ); - - // Create new Map with new array - const newMap = new Map(currentMap); - newMap.set(mapKey, newArray); - setter(newMap); -} diff --git a/src/lib/hooks/database/persistence-manager.svelte.ts b/src/lib/hooks/database/persistence-manager.svelte.ts index 19d3a25..c4c0f36 100644 --- a/src/lib/hooks/database/persistence-manager.svelte.ts +++ b/src/lib/hooks/database/persistence-manager.svelte.ts @@ -48,7 +48,7 @@ export class PersistenceManager { this.persistenceTimer = null; } // Persist all connections that have data - for (const connectionId of this.state.queryTabsByConnection.keys()) { + for (const connectionId of Object.keys(this.state.queryTabsByConnection)) { this.persistConnectionState(connectionId); } } @@ -63,7 +63,7 @@ export class PersistenceManager { // Serialization methods serializeQueryTabs(connectionId: string): PersistedQueryTab[] { - const tabs = this.state.queryTabsByConnection.get(connectionId) || []; + const tabs = this.state.queryTabsByConnection[connectionId] ?? []; return tabs.map((tab) => ({ id: tab.id, name: tab.name, @@ -73,7 +73,7 @@ export class PersistenceManager { } serializeSchemaTabs(connectionId: string): PersistedSchemaTab[] { - const tabs = this.state.schemaTabsByConnection.get(connectionId) || []; + const tabs = this.state.schemaTabsByConnection[connectionId] ?? []; return tabs.map((tab) => ({ id: tab.id, tableName: tab.table.name, @@ -82,7 +82,7 @@ export class PersistenceManager { } serializeExplainTabs(connectionId: string): PersistedExplainTab[] { - const tabs = this.state.explainTabsByConnection.get(connectionId) || []; + const tabs = this.state.explainTabsByConnection[connectionId] ?? []; return tabs.map((tab) => ({ id: tab.id, name: tab.name, @@ -91,7 +91,7 @@ export class PersistenceManager { } serializeErdTabs(connectionId: string): PersistedErdTab[] { - const tabs = this.state.erdTabsByConnection.get(connectionId) || []; + const tabs = this.state.erdTabsByConnection[connectionId] ?? []; return tabs.map((tab) => ({ id: tab.id, name: tab.name, @@ -99,7 +99,7 @@ export class PersistenceManager { } serializeSavedQueries(connectionId: string): PersistedSavedQuery[] { - const queries = this.state.savedQueriesByConnection.get(connectionId) || []; + const queries = this.state.savedQueriesByConnection[connectionId] ?? []; return queries.map((q) => ({ id: q.id, name: q.name, @@ -111,7 +111,7 @@ export class PersistenceManager { } serializeQueryHistory(connectionId: string): PersistedQueryHistoryItem[] { - const history = this.state.queryHistoryByConnection.get(connectionId) || []; + const history = this.state.queryHistoryByConnection[connectionId] ?? []; return history.slice(0, this.MAX_HISTORY_ITEMS).map((h) => ({ id: h.id, query: h.query, @@ -138,11 +138,11 @@ export class PersistenceManager { schemaTabs: this.serializeSchemaTabs(connectionId), explainTabs: this.serializeExplainTabs(connectionId), erdTabs: this.serializeErdTabs(connectionId), - tabOrder: this.state.tabOrderByConnection.get(connectionId) || [], - activeQueryTabId: this.state.activeQueryTabIdByConnection.get(connectionId) || null, - activeSchemaTabId: this.state.activeSchemaTabIdByConnection.get(connectionId) || null, - activeExplainTabId: this.state.activeExplainTabIdByConnection.get(connectionId) || null, - activeErdTabId: this.state.activeErdTabIdByConnection.get(connectionId) || null, + tabOrder: this.state.tabOrderByConnection[connectionId] ?? [], + activeQueryTabId: this.state.activeQueryTabIdByConnection[connectionId] ?? null, + activeSchemaTabId: this.state.activeSchemaTabIdByConnection[connectionId] ?? null, + activeExplainTabId: this.state.activeExplainTabIdByConnection[connectionId] ?? null, + activeErdTabId: this.state.activeErdTabIdByConnection[connectionId] ?? null, activeView: this.state.activeView, savedQueries: this.serializeSavedQueries(connectionId), queryHistory: this.serializeQueryHistory(connectionId), diff --git a/src/lib/hooks/database/query-execution.svelte.ts b/src/lib/hooks/database/query-execution.svelte.ts index 539d5ab..b1de5d4 100644 --- a/src/lib/hooks/database/query-execution.svelte.ts +++ b/src/lib/hooks/database/query-execution.svelte.ts @@ -4,7 +4,6 @@ import type { DatabaseState } from "./state.svelte.js"; import type { QueryHistoryManager } from "./query-history.svelte.js"; import { detectQueryType, isWriteQuery, extractTableFromSelect } from "$lib/db/query-utils"; import { splitSqlStatements } from "$lib/db/sql-parser"; -import { updateMapArrayItem } from "./map-utils.js"; import { m } from "$lib/paraglide/messages.js"; import { mssqlQuery, mssqlExecute } from "$lib/services/mssql"; @@ -37,13 +36,17 @@ export class QueryExecutionManager { updates: Partial<{ results: StatementResult[]; activeResultIndex: number; isExecuting: boolean }> ): void { if (!this.state.activeConnectionId) return; - updateMapArrayItem( - () => this.state.queryTabsByConnection, - (m) => (this.state.queryTabsByConnection = m), - this.state.activeConnectionId, - tabId, - updates + + const connectionId = this.state.activeConnectionId; + const tabs = this.state.queryTabsByConnection[connectionId] ?? []; + const updatedTabs = tabs.map((tab) => + tab.id === tabId ? { ...tab, ...updates } : tab ); + + this.state.queryTabsByConnection = { + ...this.state.queryTabsByConnection, + [connectionId]: updatedTabs, + }; } /** @@ -51,7 +54,7 @@ export class QueryExecutionManager { */ getPrimaryKeysForTable(schema: string, tableName: string): string[] { if (!this.state.activeConnectionId) return []; - const tables = this.state.schemas.get(this.state.activeConnectionId) || []; + const tables = this.state.schemas[this.state.activeConnectionId] ?? []; const table = tables.find((t) => t.name === tableName && t.schema === schema); if (!table) return []; return table.columns.filter((c) => c.isPrimaryKey).map((c) => c.name); @@ -213,7 +216,7 @@ export class QueryExecutionManager { return; } - const tabs = this.state.queryTabsByConnection.get(this.state.activeConnectionId) || []; + const tabs = this.state.queryTabsByConnection[this.state.activeConnectionId] ?? []; const tab = tabs.find((t) => t.id === tabId); if (!tab) return; @@ -291,7 +294,7 @@ export class QueryExecutionManager { setActiveResult(tabId: string, resultIndex: number): void { if (!this.state.activeConnectionId) return; - const tabs = this.state.queryTabsByConnection.get(this.state.activeConnectionId) || []; + const tabs = this.state.queryTabsByConnection[this.state.activeConnectionId] ?? []; const tab = tabs.find((t) => t.id === tabId); if (!tab?.results || resultIndex < 0 || resultIndex >= tab.results.length) return; @@ -302,7 +305,7 @@ export class QueryExecutionManager { * Navigate to a specific page for a specific result. */ async goToPage(tabId: string, page: number, resultIndex?: number): Promise { - const tabs = this.state.queryTabsByConnection.get(this.state.activeConnectionId!) || []; + const tabs = this.state.queryTabsByConnection[this.state.activeConnectionId!] ?? []; const tab = tabs.find((t) => t.id === tabId); if (!tab?.results) return; @@ -331,7 +334,7 @@ export class QueryExecutionManager { const isConnected = connection?.database || connection?.mssqlConnectionId; if (!connection || !isConnected) return; - const tabs = this.state.queryTabsByConnection.get(this.state.activeConnectionId) || []; + const tabs = this.state.queryTabsByConnection[this.state.activeConnectionId] ?? []; const tab = tabs.find((t) => t.id === tabId); if (!tab?.results || resultIndex >= tab.results.length) return; @@ -366,7 +369,7 @@ export class QueryExecutionManager { * Set page size and re-execute query. */ async setPageSize(tabId: string, pageSize: number, resultIndex?: number): Promise { - const tabs = this.state.queryTabsByConnection.get(this.state.activeConnectionId!) || []; + const tabs = this.state.queryTabsByConnection[this.state.activeConnectionId!] ?? []; const tab = tabs.find((t) => t.id === tabId); if (!tab?.results) return; @@ -385,7 +388,7 @@ export class QueryExecutionManager { newValue: unknown, sourceTable: { schema: string; name: string; primaryKeys: string[] } ): Promise<{ success: boolean; error?: string }> { - const tabs = this.state.queryTabsByConnection.get(this.state.activeConnectionId!) || []; + const tabs = this.state.queryTabsByConnection[this.state.activeConnectionId!] ?? []; const tab = tabs.find((t) => t.id === tabId); if (!tab?.results || resultIndex >= tab.results.length) { return { success: false, error: "No results" }; diff --git a/src/lib/hooks/database/query-history.svelte.ts b/src/lib/hooks/database/query-history.svelte.ts index 01302da..c4d1aa3 100644 --- a/src/lib/hooks/database/query-history.svelte.ts +++ b/src/lib/hooks/database/query-history.svelte.ts @@ -17,22 +17,25 @@ export class QueryHistoryManager { addToHistory(query: string, results: QueryResult) { if (!this.state.activeConnectionId) return; - const queryHistory = this.state.queryHistoryByConnection.get(this.state.activeConnectionId) || []; - const newQueryHistory = new Map(this.state.queryHistoryByConnection); - newQueryHistory.set(this.state.activeConnectionId, [ - { - id: `hist-${Date.now()}`, - query, - timestamp: new Date(), - executionTime: results.executionTime, - rowCount: results.affectedRows ?? results.totalRows, - connectionId: this.state.activeConnectionId, - favorite: false, - }, - ...queryHistory, - ]); - this.state.queryHistoryByConnection = newQueryHistory; - this.schedulePersistence(this.state.activeConnectionId); + const connectionId = this.state.activeConnectionId; + const queryHistory = this.state.queryHistoryByConnection[connectionId] ?? []; + + this.state.queryHistoryByConnection = { + ...this.state.queryHistoryByConnection, + [connectionId]: [ + { + id: `hist-${Date.now()}`, + query, + timestamp: new Date(), + executionTime: results.executionTime, + rowCount: results.affectedRows ?? results.totalRows, + connectionId, + favorite: false, + }, + ...queryHistory, + ], + }; + this.schedulePersistence(connectionId); } /** @@ -41,15 +44,19 @@ export class QueryHistoryManager { toggleQueryFavorite(id: string) { if (!this.state.activeConnectionId) return; - const queryHistory = - this.state.queryHistoryByConnection.get(this.state.activeConnectionId) || []; + const connectionId = this.state.activeConnectionId; + const queryHistory = this.state.queryHistoryByConnection[connectionId] ?? []; const item = queryHistory.find((h: QueryHistoryItem) => h.id === id); + if (item) { - item.favorite = !item.favorite; - const newQueryHistory = new Map(this.state.queryHistoryByConnection); - newQueryHistory.set(this.state.activeConnectionId, [...queryHistory]); - this.state.queryHistoryByConnection = newQueryHistory; - this.schedulePersistence(this.state.activeConnectionId); + const updatedHistory = queryHistory.map((h) => + h.id === id ? { ...h, favorite: !h.favorite } : h + ); + this.state.queryHistoryByConnection = { + ...this.state.queryHistoryByConnection, + [connectionId]: updatedHistory, + }; + this.schedulePersistence(connectionId); } } } diff --git a/src/lib/hooks/database/query-tabs.svelte.ts b/src/lib/hooks/database/query-tabs.svelte.ts index 12c1d26..59eebfa 100644 --- a/src/lib/hooks/database/query-tabs.svelte.ts +++ b/src/lib/hooks/database/query-tabs.svelte.ts @@ -1,211 +1,220 @@ -import type { QueryTab } from "$lib/types"; -import type { DatabaseState } from "./state.svelte.js"; -import type { TabOrderingManager } from "./tab-ordering.svelte.js"; -import { setMapValue } from "./map-utils.js"; +import type { QueryTab } from '$lib/types'; +import type { DatabaseState } from './state.svelte.js'; +import type { TabOrderingManager } from './tab-ordering.svelte.js'; /** * Manages query tabs: add, remove, rename, update content. */ export class QueryTabManager { - constructor( - private state: DatabaseState, - private tabOrdering: TabOrderingManager, - private schedulePersistence: (connectionId: string | null) => void - ) {} - - /** - * Add a new query tab. - */ - add(name?: string, query?: string, savedQueryId?: string): string | null { - if (!this.state.activeConnectionId) return null; - - const tabs = this.state.queryTabsByConnection.get(this.state.activeConnectionId) || []; - const newTab: QueryTab = $state({ - id: `tab-${Date.now()}`, - name: name || `Query ${tabs.length + 1}`, - query: query || "", - isExecuting: false, - savedQueryId, - }); - - // Create new Map to trigger reactivity - const newQueryTabs = new Map(this.state.queryTabsByConnection); - newQueryTabs.set(this.state.activeConnectionId, [...tabs, newTab]); - this.state.queryTabsByConnection = newQueryTabs; - - const newActiveQueryIds = new Map(this.state.activeQueryTabIdByConnection); - newActiveQueryIds.set(this.state.activeConnectionId, newTab.id); - this.state.activeQueryTabIdByConnection = newActiveQueryIds; - - this.tabOrdering.add(newTab.id); - this.schedulePersistence(this.state.activeConnectionId); - return newTab.id; - } - - /** - * Remove a query tab by ID. - */ - remove(id: string): void { - this.tabOrdering.removeTabGeneric( - () => this.state.queryTabsByConnection, - (m) => (this.state.queryTabsByConnection = m), - () => this.state.activeQueryTabIdByConnection, - (m) => (this.state.activeQueryTabIdByConnection = m), - id - ); - this.schedulePersistence(this.state.activeConnectionId); - } - - /** - * Rename a query tab. - */ - rename(id: string, newName: string): void { - if (!this.state.activeConnectionId) return; - - const tabs = this.state.queryTabsByConnection.get(this.state.activeConnectionId) || []; - const tab = tabs.find((t) => t.id === id); - if (tab) { - // Create new array with updated tab object for proper reactivity - const updatedTabs = tabs.map((t) => (t.id === id ? { ...t, name: newName } : t)); - const newQueryTabs = new Map(this.state.queryTabsByConnection); - newQueryTabs.set(this.state.activeConnectionId, updatedTabs); - this.state.queryTabsByConnection = newQueryTabs; - - // Also update linked saved query name if exists - if (tab.savedQueryId) { - const savedQueries = - this.state.savedQueriesByConnection.get(this.state.activeConnectionId) || []; - const updatedSavedQueries = savedQueries.map((q) => - q.id === tab.savedQueryId ? { ...q, name: newName, updatedAt: new Date() } : q - ); - const newSavedQueries = new Map(this.state.savedQueriesByConnection); - newSavedQueries.set(this.state.activeConnectionId, updatedSavedQueries); - this.state.savedQueriesByConnection = newSavedQueries; - } - - this.schedulePersistence(this.state.activeConnectionId); - } - } - - /** - * Set the active query tab by ID. - */ - setActive(id: string): void { - if (!this.state.activeConnectionId) return; - - const newActiveQueryIds = new Map(this.state.activeQueryTabIdByConnection); - newActiveQueryIds.set(this.state.activeConnectionId, id); - this.state.activeQueryTabIdByConnection = newActiveQueryIds; - this.schedulePersistence(this.state.activeConnectionId); - } - - /** - * Check if a tab has unsaved changes. - */ - hasUnsavedChanges(tabId: string): boolean { - const tab = this.state.queryTabs.find((t) => t.id === tabId); - if (!tab) return false; - - // Empty tabs are not considered "unsaved" - if (!tab.query.trim()) return false; - - // Tab not linked to a saved query = unsaved - if (!tab.savedQueryId) return true; - - // Tab linked to saved query - compare content - const savedQuery = this.state.activeConnectionSavedQueries.find((q) => q.id === tab.savedQueryId); - if (!savedQuery) return true; - - return tab.query !== savedQuery.query; - } - - /** - * Update the query content in a tab. - */ - updateContent(id: string, query: string): void { - if (!this.state.activeConnectionId) return; - - const tabs = this.state.queryTabsByConnection.get(this.state.activeConnectionId) || []; - const tab = tabs.find((t) => t.id === id); - if (tab && tab.query !== query) { - // Create new objects for proper reactivity - const updatedTabs = tabs.map((t) => (t.id === id ? { ...t, query } : t)); - const newQueryTabs = new Map(this.state.queryTabsByConnection); - newQueryTabs.set(this.state.activeConnectionId, updatedTabs); - this.state.queryTabsByConnection = newQueryTabs; - this.schedulePersistence(this.state.activeConnectionId); - } - } - - /** - * Find a query tab by its query content and focus it, or create a new one if not found. - * Returns the tab ID. - */ - focusOrCreate(query: string, name?: string, setActiveView?: () => void): string | null { - if (!this.state.activeConnectionId) return null; - - const tabs = this.state.queryTabsByConnection.get(this.state.activeConnectionId) || []; - const existingTab = tabs.find((t) => t.query.trim() === query.trim()); - - if (existingTab) { - this.setActive(existingTab.id); - setActiveView?.(); - return existingTab.id; - } - - // Create new tab if not found - const newTabId = this.add(name, query); - setActiveView?.(); - return newTabId; - } - - /** - * Load a saved query into a tab (or switch to existing tab). - */ - loadSaved(savedQueryId: string, setActiveView?: () => void): void { - if (!this.state.activeConnectionId) return; - - const savedQueries = this.state.savedQueriesByConnection.get(this.state.activeConnectionId) || []; - const savedQuery = savedQueries.find((q) => q.id === savedQueryId); - if (!savedQuery) return; - - // Check if a tab with this saved query is already open - const tabs = this.state.queryTabsByConnection.get(this.state.activeConnectionId) || []; - const existingTab = tabs.find((t) => t.savedQueryId === savedQueryId); - - if (existingTab) { - // Switch to existing tab - this.setActive(existingTab.id); - setActiveView?.(); - } else { - // Create new tab - this.add(savedQuery.name, savedQuery.query, savedQueryId); - setActiveView?.(); - } - } - - /** - * Load a query from history into a tab (or switch to existing tab). - */ - loadFromHistory(historyId: string, setActiveView?: () => void): void { - if (!this.state.activeConnectionId) return; - - const queryHistory = this.state.queryHistoryByConnection.get(this.state.activeConnectionId) || []; - const item = queryHistory.find((h) => h.id === historyId); - if (!item) return; - - // Check if a tab with the exact same query is already open - const tabs = this.state.queryTabsByConnection.get(this.state.activeConnectionId) || []; - const existingTab = tabs.find((t) => t.query.trim() === item.query.trim()); - - if (existingTab) { - // Switch to existing tab - this.setActive(existingTab.id); - setActiveView?.(); - } else { - // Create new tab - this.add(`History: ${item.query.substring(0, 20)}...`, item.query); - setActiveView?.(); - } - } + constructor( + private state: DatabaseState, + private tabOrdering: TabOrderingManager, + private schedulePersistence: (connectionId: string | null) => void + ) {} + + /** + * Add a new query tab. + */ + add(name?: string, query?: string, savedQueryId?: string): string | null { + if (!this.state.activeConnectionId) return null; + + const connectionId = this.state.activeConnectionId; + const tabs = this.state.queryTabsByConnection[connectionId] ?? []; + const newTab: QueryTab = $state({ + id: `tab-${Date.now()}`, + name: name || `Query ${tabs.length + 1}`, + query: query || '', + isExecuting: false, + savedQueryId + }); + + // Update tabs using spread syntax + this.state.queryTabsByConnection = { + ...this.state.queryTabsByConnection, + [connectionId]: [...tabs, newTab] + }; + + this.state.activeQueryTabIdByConnection = { + ...this.state.activeQueryTabIdByConnection, + [connectionId]: newTab.id + }; + + this.tabOrdering.add(newTab.id); + this.schedulePersistence(connectionId); + return newTab.id; + } + + /** + * Remove a query tab by ID. + */ + remove(id: string): void { + this.tabOrdering.removeTabGeneric( + () => this.state.queryTabsByConnection, + (r) => (this.state.queryTabsByConnection = r), + () => this.state.activeQueryTabIdByConnection, + (r) => (this.state.activeQueryTabIdByConnection = r), + id + ); + this.schedulePersistence(this.state.activeConnectionId); + } + + /** + * Rename a query tab. + */ + rename(id: string, newName: string): void { + if (!this.state.activeConnectionId) return; + + const connectionId = this.state.activeConnectionId; + const tabs = this.state.queryTabsByConnection[connectionId] ?? []; + const tab = tabs.find((t) => t.id === id); + if (tab) { + // Create new array with updated tab object for proper reactivity + const updatedTabs = tabs.map((t) => (t.id === id ? { ...t, name: newName } : t)); + this.state.queryTabsByConnection = { + ...this.state.queryTabsByConnection, + [connectionId]: updatedTabs + }; + + // Also update linked saved query name if exists + if (tab.savedQueryId) { + const savedQueries = this.state.savedQueriesByConnection[connectionId] ?? []; + const updatedSavedQueries = savedQueries.map((q) => + q.id === tab.savedQueryId ? { ...q, name: newName, updatedAt: new Date() } : q + ); + this.state.savedQueriesByConnection = { + ...this.state.savedQueriesByConnection, + [connectionId]: updatedSavedQueries + }; + } + + this.schedulePersistence(connectionId); + } + } + + /** + * Set the active query tab by ID. + */ + setActive(id: string): void { + if (!this.state.activeConnectionId) return; + + this.state.activeQueryTabIdByConnection = { + ...this.state.activeQueryTabIdByConnection, + [this.state.activeConnectionId]: id + }; + this.schedulePersistence(this.state.activeConnectionId); + } + + /** + * Check if a tab has unsaved changes. + */ + hasUnsavedChanges(tabId: string): boolean { + const tab = this.state.queryTabs.find((t) => t.id === tabId); + if (!tab) return false; + + // Empty tabs are not considered "unsaved" + if (!tab.query.trim()) return false; + + // Tab not linked to a saved query = unsaved + if (!tab.savedQueryId) return true; + + // Tab linked to saved query - compare content + const savedQuery = this.state.activeConnectionSavedQueries.find( + (q) => q.id === tab.savedQueryId + ); + if (!savedQuery) return true; + + return tab.query !== savedQuery.query; + } + + /** + * Update the query content in a tab. + */ + updateContent(id: string, query: string): void { + if (!this.state.activeConnectionId) return; + + const connectionId = this.state.activeConnectionId; + const tabs = this.state.queryTabsByConnection[connectionId] ?? []; + const tab = tabs.find((t) => t.id === id); + if (tab && tab.query !== query) { + // Create new objects for proper reactivity + const updatedTabs = tabs.map((t) => (t.id === id ? { ...t, query } : t)); + this.state.queryTabsByConnection = { + ...this.state.queryTabsByConnection, + [connectionId]: updatedTabs + }; + this.schedulePersistence(connectionId); + } + } + + /** + * Find a query tab by its query content and focus it, or create a new one if not found. + * Returns the tab ID. + */ + focusOrCreate(query: string, name?: string, setActiveView?: () => void): string | null { + if (!this.state.activeConnectionId) return null; + + const tabs = this.state.queryTabsByConnection[this.state.activeConnectionId] ?? []; + const existingTab = tabs.find((t) => t.query.trim() === query.trim()); + + if (existingTab) { + this.setActive(existingTab.id); + setActiveView?.(); + return existingTab.id; + } + + // Create new tab if not found + const newTabId = this.add(name, query); + setActiveView?.(); + return newTabId; + } + + /** + * Load a saved query into a tab (or switch to existing tab). + */ + loadSaved(savedQueryId: string, setActiveView?: () => void): void { + if (!this.state.activeConnectionId) return; + + const savedQueries = this.state.savedQueriesByConnection[this.state.activeConnectionId] ?? []; + const savedQuery = savedQueries.find((q) => q.id === savedQueryId); + if (!savedQuery) return; + + // Check if a tab with this saved query is already open + const tabs = this.state.queryTabsByConnection[this.state.activeConnectionId] ?? []; + const existingTab = tabs.find((t) => t.savedQueryId === savedQueryId); + + if (existingTab) { + // Switch to existing tab + this.setActive(existingTab.id); + setActiveView?.(); + } else { + // Create new tab + this.add(savedQuery.name, savedQuery.query, savedQueryId); + setActiveView?.(); + } + } + + /** + * Load a query from history into a tab (or switch to existing tab). + */ + loadFromHistory(historyId: string, setActiveView?: () => void): void { + if (!this.state.activeConnectionId) return; + + const queryHistory = this.state.queryHistoryByConnection[this.state.activeConnectionId] ?? []; + const item = queryHistory.find((h) => h.id === historyId); + if (!item) return; + + // Check if a tab with the exact same query is already open + const tabs = this.state.queryTabsByConnection[this.state.activeConnectionId] ?? []; + const existingTab = tabs.find((t) => t.query.trim() === item.query.trim()); + + if (existingTab) { + // Switch to existing tab + this.setActive(existingTab.id); + setActiveView?.(); + } else { + // Create new tab + this.add(`History: ${item.query.substring(0, 20)}...`, item.query); + setActiveView?.(); + } + } } diff --git a/src/lib/hooks/database/record-utils.ts b/src/lib/hooks/database/record-utils.ts new file mode 100644 index 0000000..1757890 --- /dev/null +++ b/src/lib/hooks/database/record-utils.ts @@ -0,0 +1,84 @@ +/** + * Helper functions for updating Record-based state with Svelte 5 reactivity. + * Records work with simple spread syntax, making updates more ergonomic than Maps. + * + * Note: Most updates can be done directly with spread syntax: + * state.data = { ...state.data, [key]: value } + * + * These helpers are provided for common patterns that benefit from abstraction. + */ + +/** + * Update an item in an array stored in a Record. + * Creates new objects for proper reactivity. + * + * @example + * state.tabsByConnection = updateRecordArrayItem( + * state.tabsByConnection, + * connectionId, + * tabId, + * { name: 'New Name' } + * ); + */ +export function updateRecordArrayItem( + record: Record, + key: string, + itemId: string, + updates: Partial +): Record { + const currentArray = record[key] ?? []; + const newArray = currentArray.map((item) => + item.id === itemId ? { ...item, ...updates } : item + ); + return { ...record, [key]: newArray }; +} + +/** + * Remove an item from an array stored in a Record. + * + * @example + * state.tabsByConnection = removeRecordArrayItem( + * state.tabsByConnection, + * connectionId, + * tabId + * ); + */ +export function removeRecordArrayItem( + record: Record, + key: string, + itemId: string +): Record { + const currentArray = record[key] ?? []; + const newArray = currentArray.filter((item) => item.id !== itemId); + return { ...record, [key]: newArray }; +} + +/** + * Add an item to an array stored in a Record. + * + * @example + * state.tabsByConnection = addRecordArrayItem( + * state.tabsByConnection, + * connectionId, + * newTab + * ); + */ +export function addRecordArrayItem( + record: Record, + key: string, + item: T +): Record { + const currentArray = record[key] ?? []; + return { ...record, [key]: [...currentArray, item] }; +} + +/** + * Delete a key from a Record. + * + * @example + * state.tabsByConnection = deleteRecordKey(state.tabsByConnection, connectionId); + */ +export function deleteRecordKey(record: Record, key: string): Record { + const { [key]: _, ...rest } = record; + return rest; +} diff --git a/src/lib/hooks/database/saved-queries.svelte.ts b/src/lib/hooks/database/saved-queries.svelte.ts index 857c629..f22fa63 100644 --- a/src/lib/hooks/database/saved-queries.svelte.ts +++ b/src/lib/hooks/database/saved-queries.svelte.ts @@ -14,19 +14,19 @@ export class SavedQueryManager { saveQuery(name: string, query: string, tabId?: string): string | null { if (!this.state.activeConnectionId) return null; + const connectionId = this.state.activeConnectionId; + // Check if this tab is already linked to a saved query let savedQueryId: string | undefined; if (tabId) { - const tabs = - this.state.queryTabsByConnection.get(this.state.activeConnectionId) || []; + const tabs = this.state.queryTabsByConnection[connectionId] ?? []; const tab = tabs.find((t) => t.id === tabId); savedQueryId = tab?.savedQueryId; } if (savedQueryId) { // Update existing saved query with new object for proper reactivity - const savedQueries = - this.state.savedQueriesByConnection.get(this.state.activeConnectionId) || []; + const savedQueries = this.state.savedQueriesByConnection[connectionId] ?? []; const savedQuery = savedQueries.find((q) => q.id === savedQueryId); if (savedQuery) { const updatedSavedQueries = savedQueries.map((q) => @@ -34,26 +34,27 @@ export class SavedQueryManager { ? { ...q, name, query, updatedAt: new Date() } : q ); - const newSavedQueries = new Map(this.state.savedQueriesByConnection); - newSavedQueries.set(this.state.activeConnectionId, updatedSavedQueries); - this.state.savedQueriesByConnection = newSavedQueries; + this.state.savedQueriesByConnection = { + ...this.state.savedQueriesByConnection, + [connectionId]: updatedSavedQueries, + }; // Also update tab name if it differs if (tabId) { - const tabs = - this.state.queryTabsByConnection.get(this.state.activeConnectionId) || []; + const tabs = this.state.queryTabsByConnection[connectionId] ?? []; const tab = tabs.find((t) => t.id === tabId); if (tab && tab.name !== name) { const updatedTabs = tabs.map((t) => t.id === tabId ? { ...t, name } : t ); - const newQueryTabs = new Map(this.state.queryTabsByConnection); - newQueryTabs.set(this.state.activeConnectionId, updatedTabs); - this.state.queryTabsByConnection = newQueryTabs; + this.state.queryTabsByConnection = { + ...this.state.queryTabsByConnection, + [connectionId]: updatedTabs, + }; } } - this.schedulePersistence(this.state.activeConnectionId); + this.schedulePersistence(connectionId); return savedQueryId; } } @@ -63,58 +64,57 @@ export class SavedQueryManager { id: `saved-${Date.now()}`, name, query, - connectionId: this.state.activeConnectionId, + connectionId, createdAt: new Date(), updatedAt: new Date(), }; - const savedQueries = - this.state.savedQueriesByConnection.get(this.state.activeConnectionId) || []; - const newSavedQueries = new Map(this.state.savedQueriesByConnection); - newSavedQueries.set(this.state.activeConnectionId, [ - ...savedQueries, - newSavedQuery, - ]); - this.state.savedQueriesByConnection = newSavedQueries; + const savedQueries = this.state.savedQueriesByConnection[connectionId] ?? []; + this.state.savedQueriesByConnection = { + ...this.state.savedQueriesByConnection, + [connectionId]: [...savedQueries, newSavedQuery], + }; // Link tab to saved query if tabId provided if (tabId) { - const tabs = - this.state.queryTabsByConnection.get(this.state.activeConnectionId) || []; + const tabs = this.state.queryTabsByConnection[connectionId] ?? []; const updatedTabs = tabs.map((t) => t.id === tabId ? { ...t, savedQueryId: newSavedQuery.id, name } : t ); - const newQueryTabs = new Map(this.state.queryTabsByConnection); - newQueryTabs.set(this.state.activeConnectionId, updatedTabs); - this.state.queryTabsByConnection = newQueryTabs; + this.state.queryTabsByConnection = { + ...this.state.queryTabsByConnection, + [connectionId]: updatedTabs, + }; } - this.schedulePersistence(this.state.activeConnectionId); + this.schedulePersistence(connectionId); return newSavedQuery.id; } deleteSavedQuery(id: string) { if (!this.state.activeConnectionId) return; - const savedQueries = - this.state.savedQueriesByConnection.get(this.state.activeConnectionId) || []; + const connectionId = this.state.activeConnectionId; + const savedQueries = this.state.savedQueriesByConnection[connectionId] ?? []; const filtered = savedQueries.filter((q) => q.id !== id); - const newSavedQueries = new Map(this.state.savedQueriesByConnection); - newSavedQueries.set(this.state.activeConnectionId, filtered); - this.state.savedQueriesByConnection = newSavedQueries; + + this.state.savedQueriesByConnection = { + ...this.state.savedQueriesByConnection, + [connectionId]: filtered, + }; // Remove savedQueryId from any tabs using this query - const tabs = this.state.queryTabsByConnection.get(this.state.activeConnectionId) || []; - tabs.forEach((tab) => { - if (tab.savedQueryId === id) { - tab.savedQueryId = undefined; - } - }); - const newQueryTabs = new Map(this.state.queryTabsByConnection); - newQueryTabs.set(this.state.activeConnectionId, [...tabs]); - this.state.queryTabsByConnection = newQueryTabs; - this.schedulePersistence(this.state.activeConnectionId); + const tabs = this.state.queryTabsByConnection[connectionId] ?? []; + const updatedTabs = tabs.map((tab) => + tab.savedQueryId === id ? { ...tab, savedQueryId: undefined } : tab + ); + + this.state.queryTabsByConnection = { + ...this.state.queryTabsByConnection, + [connectionId]: updatedTabs, + }; + this.schedulePersistence(connectionId); } } diff --git a/src/lib/hooks/database/schema-tabs.svelte.ts b/src/lib/hooks/database/schema-tabs.svelte.ts index 3701551..ccebc3c 100644 --- a/src/lib/hooks/database/schema-tabs.svelte.ts +++ b/src/lib/hooks/database/schema-tabs.svelte.ts @@ -1,238 +1,247 @@ -import type Database from "@tauri-apps/plugin-sql"; -import type { SchemaTable, SchemaTab } from "$lib/types"; -import type { DatabaseState } from "./state.svelte.js"; -import type { TabOrderingManager } from "./tab-ordering.svelte.js"; -import { getAdapter, type DatabaseAdapter } from "$lib/db"; -import { setMapValue } from "./map-utils.js"; -import { mssqlQuery } from "$lib/services/mssql"; +import type Database from '@tauri-apps/plugin-sql'; +import type { SchemaTable, SchemaTab } from '$lib/types'; +import type { DatabaseState } from './state.svelte.js'; +import type { TabOrderingManager } from './tab-ordering.svelte.js'; +import { getAdapter, type DatabaseAdapter } from '$lib/db'; +import { mssqlQuery } from '$lib/services/mssql'; /** * Manages schema tabs: add, remove, set active. * Handles table metadata loading. */ export class SchemaTabManager { - constructor( - private state: DatabaseState, - private tabOrdering: TabOrderingManager, - private schedulePersistence: (connectionId: string | null) => void - ) {} - - /** - * Add a schema tab for the specified table. - * Fetches table metadata (columns, indexes, foreign keys). - */ - async add(table: SchemaTable): Promise { - if (!this.state.activeConnectionId || !this.state.activeConnection) return null; - - const tabs = this.state.schemaTabsByConnection.get(this.state.activeConnectionId) || []; - const adapter = getAdapter(this.state.activeConnection.type); - const isMssql = this.state.activeConnection.type === "mssql" && this.state.activeConnection.mssqlConnectionId; - - // Fetch table metadata - query columns and indexes - let columnsResult: unknown[]; - let indexesResult: unknown[]; - let foreignKeysResult: unknown[] | undefined; - - if (isMssql) { - const columnsQueryResult = await mssqlQuery( - this.state.activeConnection.mssqlConnectionId!, - adapter.getColumnsQuery(table.name, table.schema) - ); - columnsResult = columnsQueryResult.rows; - - const indexesQueryResult = await mssqlQuery( - this.state.activeConnection.mssqlConnectionId!, - adapter.getIndexesQuery(table.name, table.schema) - ); - indexesResult = indexesQueryResult.rows; - - if (adapter.getForeignKeysQuery) { - const fkQueryResult = await mssqlQuery( - this.state.activeConnection.mssqlConnectionId!, - adapter.getForeignKeysQuery(table.name, table.schema) - ); - foreignKeysResult = fkQueryResult.rows; - } - } else { - columnsResult = (await this.state.activeConnection.database!.select( - adapter.getColumnsQuery(table.name, table.schema) - )) as unknown[]; - - indexesResult = (await this.state.activeConnection.database!.select( - adapter.getIndexesQuery(table.name, table.schema) - )) as unknown[]; - - // Fetch foreign keys if adapter supports it (needed for SQLite) - if (adapter.getForeignKeysQuery) { - foreignKeysResult = (await this.state.activeConnection.database!.select( - adapter.getForeignKeysQuery(table.name, table.schema) - )) as unknown[]; - } - } - - // Update table with fetched columns and indexes - const updatedTable: SchemaTable = { - ...table, - columns: adapter.parseColumnsResult(columnsResult || [], foreignKeysResult), - indexes: adapter.parseIndexesResult(indexesResult || []), - }; - - // Update this.state.schemas with the refreshed table metadata - const connectionSchemas = [...(this.state.schemas.get(this.state.activeConnectionId) || [])]; - const tableIndex = connectionSchemas.findIndex( - (t) => t.name === table.name && t.schema === table.schema - ); - if (tableIndex >= 0) { - connectionSchemas[tableIndex] = updatedTable; - } - const newSchemas = new Map(this.state.schemas); - newSchemas.set(this.state.activeConnectionId, connectionSchemas); - this.state.schemas = newSchemas; - - // Check if table is already open - const existingTab = tabs.find( - (t) => t.table.name === table.name && t.table.schema === table.schema - ); - if (existingTab) { - // Update existing tab with new metadata - const updatedTabs = tabs.map((t) => - t.id === existingTab.id ? { ...t, table: updatedTable } : t - ); - const newSchemaTabs = new Map(this.state.schemaTabsByConnection); - newSchemaTabs.set(this.state.activeConnectionId, updatedTabs); - this.state.schemaTabsByConnection = newSchemaTabs; - - const newActiveSchemaIds = new Map(this.state.activeSchemaTabIdByConnection); - newActiveSchemaIds.set(this.state.activeConnectionId, existingTab.id); - this.state.activeSchemaTabIdByConnection = newActiveSchemaIds; - this.schedulePersistence(this.state.activeConnectionId); - return existingTab.id; - } - - const newTab: SchemaTab = { - id: `schema-tab-${Date.now()}`, - table: updatedTable, - }; - - // Create new Map to trigger reactivity - const newSchemaTabs = new Map(this.state.schemaTabsByConnection); - newSchemaTabs.set(this.state.activeConnectionId, [...tabs, newTab]); - this.state.schemaTabsByConnection = newSchemaTabs; - - this.tabOrdering.add(newTab.id); - - const newActiveSchemaIds = new Map(this.state.activeSchemaTabIdByConnection); - newActiveSchemaIds.set(this.state.activeConnectionId, newTab.id); - this.state.activeSchemaTabIdByConnection = newActiveSchemaIds; - - this.schedulePersistence(this.state.activeConnectionId); - return newTab.id; - } - - /** - * Remove a schema tab by ID. - */ - remove(id: string): void { - this.tabOrdering.removeTabGeneric( - () => this.state.schemaTabsByConnection, - (m) => (this.state.schemaTabsByConnection = m), - () => this.state.activeSchemaTabIdByConnection, - (m) => (this.state.activeSchemaTabIdByConnection = m), - id - ); - this.schedulePersistence(this.state.activeConnectionId); - } - - /** - * Set the active schema tab by ID. - */ - setActive(id: string): void { - if (!this.state.activeConnectionId) return; - - const newActiveSchemaIds = new Map(this.state.activeSchemaTabIdByConnection); - newActiveSchemaIds.set(this.state.activeConnectionId, id); - this.state.activeSchemaTabIdByConnection = newActiveSchemaIds; - this.schedulePersistence(this.state.activeConnectionId); - } - - /** - * Load column and index metadata for all tables in the background. - * Updates the schema state progressively as each table's metadata is loaded. - */ - async loadTableMetadataInBackground( - connectionId: string, - tables: SchemaTable[], - adapter: DatabaseAdapter, - database: Database | undefined, - mssqlConnectionId?: string - ): Promise { - // Process tables in parallel but update state as each completes - const promises = tables.map(async (table, index) => { - try { - let columnsResult: unknown[]; - let indexesResult: unknown[]; - let foreignKeysResult: unknown[] | undefined; - - if (mssqlConnectionId) { - const columnsQueryResult = await mssqlQuery( - mssqlConnectionId, - adapter.getColumnsQuery(table.name, table.schema) - ); - columnsResult = columnsQueryResult.rows; - - const indexesQueryResult = await mssqlQuery( - mssqlConnectionId, - adapter.getIndexesQuery(table.name, table.schema) - ); - indexesResult = indexesQueryResult.rows; - - if (adapter.getForeignKeysQuery) { - const fkQueryResult = await mssqlQuery( - mssqlConnectionId, - adapter.getForeignKeysQuery(table.name, table.schema) - ); - foreignKeysResult = fkQueryResult.rows; - } - } else if (database) { - columnsResult = (await database.select( - adapter.getColumnsQuery(table.name, table.schema) - )) as unknown[]; - - indexesResult = (await database.select( - adapter.getIndexesQuery(table.name, table.schema) - )) as unknown[]; - - // Fetch foreign keys if adapter supports it (needed for SQLite) - if (adapter.getForeignKeysQuery) { - foreignKeysResult = (await database.select( - adapter.getForeignKeysQuery(table.name, table.schema) - )) as unknown[]; - } - } else { - // No valid connection, skip - return; - } - - const updatedTable: SchemaTable = { - ...table, - columns: adapter.parseColumnsResult(columnsResult || [], foreignKeysResult), - indexes: adapter.parseIndexesResult(indexesResult || []), - }; - - // Update the schema state with the new table metadata - const currentSchemas = this.state.schemas.get(connectionId); - if (currentSchemas) { - const updatedSchemas = [...currentSchemas]; - updatedSchemas[index] = updatedTable; - const newSchemas = new Map(this.state.schemas); - newSchemas.set(connectionId, updatedSchemas); - this.state.schemas = newSchemas; - } - } catch (error) { - console.error(`Failed to load metadata for table ${table.schema}.${table.name}:`, error); - } - }); - - await Promise.allSettled(promises); - } + constructor( + private state: DatabaseState, + private tabOrdering: TabOrderingManager, + private schedulePersistence: (connectionId: string | null) => void + ) {} + + /** + * Add a schema tab for the specified table. + * Fetches table metadata (columns, indexes, foreign keys). + */ + async add(table: SchemaTable): Promise { + if (!this.state.activeConnectionId || !this.state.activeConnection) return null; + + const connectionId = this.state.activeConnectionId; + const tabs = this.state.schemaTabsByConnection[connectionId] ?? []; + const adapter = getAdapter(this.state.activeConnection.type); + const isMssql = + this.state.activeConnection.type === 'mssql' && + this.state.activeConnection.mssqlConnectionId; + + // Fetch table metadata - query columns and indexes + let columnsResult: unknown[]; + let indexesResult: unknown[]; + let foreignKeysResult: unknown[] | undefined; + + if (isMssql) { + const columnsQueryResult = await mssqlQuery( + this.state.activeConnection.mssqlConnectionId!, + adapter.getColumnsQuery(table.name, table.schema) + ); + columnsResult = columnsQueryResult.rows; + + const indexesQueryResult = await mssqlQuery( + this.state.activeConnection.mssqlConnectionId!, + adapter.getIndexesQuery(table.name, table.schema) + ); + indexesResult = indexesQueryResult.rows; + + if (adapter.getForeignKeysQuery) { + const fkQueryResult = await mssqlQuery( + this.state.activeConnection.mssqlConnectionId!, + adapter.getForeignKeysQuery(table.name, table.schema) + ); + foreignKeysResult = fkQueryResult.rows; + } + } else { + columnsResult = (await this.state.activeConnection.database!.select( + adapter.getColumnsQuery(table.name, table.schema) + )) as unknown[]; + + indexesResult = (await this.state.activeConnection.database!.select( + adapter.getIndexesQuery(table.name, table.schema) + )) as unknown[]; + + // Fetch foreign keys if adapter supports it (needed for SQLite) + if (adapter.getForeignKeysQuery) { + foreignKeysResult = (await this.state.activeConnection.database!.select( + adapter.getForeignKeysQuery(table.name, table.schema) + )) as unknown[]; + } + } + + // Update table with fetched columns and indexes + const updatedTable: SchemaTable = { + ...table, + columns: adapter.parseColumnsResult(columnsResult || [], foreignKeysResult), + indexes: adapter.parseIndexesResult(indexesResult || []) + }; + + // Update this.state.schemas with the refreshed table metadata + const connectionSchemas = [...(this.state.schemas[connectionId] ?? [])]; + const tableIndex = connectionSchemas.findIndex( + (t) => t.name === table.name && t.schema === table.schema + ); + if (tableIndex >= 0) { + connectionSchemas[tableIndex] = updatedTable; + } + this.state.schemas = { + ...this.state.schemas, + [connectionId]: connectionSchemas + }; + + // Check if table is already open + const existingTab = tabs.find( + (t) => t.table.name === table.name && t.table.schema === table.schema + ); + if (existingTab) { + // Update existing tab with new metadata + const updatedTabs = tabs.map((t) => + t.id === existingTab.id ? { ...t, table: updatedTable } : t + ); + this.state.schemaTabsByConnection = { + ...this.state.schemaTabsByConnection, + [connectionId]: updatedTabs + }; + + this.state.activeSchemaTabIdByConnection = { + ...this.state.activeSchemaTabIdByConnection, + [connectionId]: existingTab.id + }; + this.schedulePersistence(connectionId); + return existingTab.id; + } + + const newTab: SchemaTab = { + id: `schema-tab-${Date.now()}`, + table: updatedTable + }; + + // Update state using spread syntax + this.state.schemaTabsByConnection = { + ...this.state.schemaTabsByConnection, + [connectionId]: [...tabs, newTab] + }; + + this.tabOrdering.add(newTab.id); + + this.state.activeSchemaTabIdByConnection = { + ...this.state.activeSchemaTabIdByConnection, + [connectionId]: newTab.id + }; + + this.schedulePersistence(connectionId); + return newTab.id; + } + + /** + * Remove a schema tab by ID. + */ + remove(id: string): void { + this.tabOrdering.removeTabGeneric( + () => this.state.schemaTabsByConnection, + (r) => (this.state.schemaTabsByConnection = r), + () => this.state.activeSchemaTabIdByConnection, + (r) => (this.state.activeSchemaTabIdByConnection = r), + id + ); + this.schedulePersistence(this.state.activeConnectionId); + } + + /** + * Set the active schema tab by ID. + */ + setActive(id: string): void { + if (!this.state.activeConnectionId) return; + + this.state.activeSchemaTabIdByConnection = { + ...this.state.activeSchemaTabIdByConnection, + [this.state.activeConnectionId]: id + }; + this.schedulePersistence(this.state.activeConnectionId); + } + + /** + * Load column and index metadata for all tables in the background. + * Updates the schema state progressively as each table's metadata is loaded. + */ + async loadTableMetadataInBackground( + connectionId: string, + tables: SchemaTable[], + adapter: DatabaseAdapter, + database: Database | undefined, + mssqlConnectionId?: string + ): Promise { + // Process tables in parallel but update state as each completes + const promises = tables.map(async (table, index) => { + try { + let columnsResult: unknown[]; + let indexesResult: unknown[]; + let foreignKeysResult: unknown[] | undefined; + + if (mssqlConnectionId) { + const columnsQueryResult = await mssqlQuery( + mssqlConnectionId, + adapter.getColumnsQuery(table.name, table.schema) + ); + columnsResult = columnsQueryResult.rows; + + const indexesQueryResult = await mssqlQuery( + mssqlConnectionId, + adapter.getIndexesQuery(table.name, table.schema) + ); + indexesResult = indexesQueryResult.rows; + + if (adapter.getForeignKeysQuery) { + const fkQueryResult = await mssqlQuery( + mssqlConnectionId, + adapter.getForeignKeysQuery(table.name, table.schema) + ); + foreignKeysResult = fkQueryResult.rows; + } + } else if (database) { + columnsResult = (await database.select( + adapter.getColumnsQuery(table.name, table.schema) + )) as unknown[]; + + indexesResult = (await database.select( + adapter.getIndexesQuery(table.name, table.schema) + )) as unknown[]; + + // Fetch foreign keys if adapter supports it (needed for SQLite) + if (adapter.getForeignKeysQuery) { + foreignKeysResult = (await database.select( + adapter.getForeignKeysQuery(table.name, table.schema) + )) as unknown[]; + } + } else { + // No valid connection, skip + return; + } + + const updatedTable: SchemaTable = { + ...table, + columns: adapter.parseColumnsResult(columnsResult || [], foreignKeysResult), + indexes: adapter.parseIndexesResult(indexesResult || []) + }; + + // Update the schema state with the new table metadata + const currentSchemas = this.state.schemas[connectionId]; + if (currentSchemas) { + const updatedSchemas = [...currentSchemas]; + updatedSchemas[index] = updatedTable; + this.state.schemas = { + ...this.state.schemas, + [connectionId]: updatedSchemas + }; + } + } catch (error) { + console.error(`Failed to load metadata for table ${table.schema}.${table.name}:`, error); + } + }); + + await Promise.allSettled(promises); + } } diff --git a/src/lib/hooks/database/state-restoration.svelte.ts b/src/lib/hooks/database/state-restoration.svelte.ts index b1fcf8c..fc5d7fc 100644 --- a/src/lib/hooks/database/state-restoration.svelte.ts +++ b/src/lib/hooks/database/state-restoration.svelte.ts @@ -11,7 +11,6 @@ import type { } from "$lib/types"; import type { DatabaseState } from "./state.svelte.js"; import type { PersistenceManager } from "./persistence-manager.svelte.js"; -import { setMapValue, deleteMapKey } from "./map-utils.js"; /** * Manages restoration of persisted state when reconnecting to a database. @@ -24,77 +23,169 @@ export class StateRestorationManager { ) {} /** - * Initialize all per-connection maps for a new connection. + * Initialize all per-connection records for a new connection. */ initializeConnectionMaps(connectionId: string): void { - setMapValue(() => this.state.queryTabsByConnection, (m) => (this.state.queryTabsByConnection = m), connectionId, []); - setMapValue(() => this.state.schemaTabsByConnection, (m) => (this.state.schemaTabsByConnection = m), connectionId, []); - setMapValue(() => this.state.activeQueryTabIdByConnection, (m) => (this.state.activeQueryTabIdByConnection = m), connectionId, null); - setMapValue(() => this.state.activeSchemaTabIdByConnection, (m) => (this.state.activeSchemaTabIdByConnection = m), connectionId, null); - setMapValue(() => this.state.queryHistoryByConnection, (m) => (this.state.queryHistoryByConnection = m), connectionId, []); - setMapValue(() => this.state.savedQueriesByConnection, (m) => (this.state.savedQueriesByConnection = m), connectionId, []); - setMapValue(() => this.state.explainTabsByConnection, (m) => (this.state.explainTabsByConnection = m), connectionId, []); - setMapValue(() => this.state.activeExplainTabIdByConnection, (m) => (this.state.activeExplainTabIdByConnection = m), connectionId, null); - setMapValue(() => this.state.erdTabsByConnection, (m) => (this.state.erdTabsByConnection = m), connectionId, []); - setMapValue(() => this.state.activeErdTabIdByConnection, (m) => (this.state.activeErdTabIdByConnection = m), connectionId, null); - setMapValue(() => this.state.tabOrderByConnection, (m) => (this.state.tabOrderByConnection = m), connectionId, []); - setMapValue(() => this.state.schemas, (m) => (this.state.schemas = m), connectionId, []); + this.state.queryTabsByConnection = { + ...this.state.queryTabsByConnection, + [connectionId]: [], + }; + this.state.schemaTabsByConnection = { + ...this.state.schemaTabsByConnection, + [connectionId]: [], + }; + this.state.activeQueryTabIdByConnection = { + ...this.state.activeQueryTabIdByConnection, + [connectionId]: null, + }; + this.state.activeSchemaTabIdByConnection = { + ...this.state.activeSchemaTabIdByConnection, + [connectionId]: null, + }; + this.state.queryHistoryByConnection = { + ...this.state.queryHistoryByConnection, + [connectionId]: [], + }; + this.state.savedQueriesByConnection = { + ...this.state.savedQueriesByConnection, + [connectionId]: [], + }; + this.state.explainTabsByConnection = { + ...this.state.explainTabsByConnection, + [connectionId]: [], + }; + this.state.activeExplainTabIdByConnection = { + ...this.state.activeExplainTabIdByConnection, + [connectionId]: null, + }; + this.state.erdTabsByConnection = { + ...this.state.erdTabsByConnection, + [connectionId]: [], + }; + this.state.activeErdTabIdByConnection = { + ...this.state.activeErdTabIdByConnection, + [connectionId]: null, + }; + this.state.tabOrderByConnection = { + ...this.state.tabOrderByConnection, + [connectionId]: [], + }; + this.state.schemas = { + ...this.state.schemas, + [connectionId]: [], + }; } /** - * Clean up all per-connection maps when removing a connection. + * Clean up all per-connection records when removing a connection. */ cleanupConnectionMaps(connectionId: string): void { - deleteMapKey(() => this.state.queryTabsByConnection, (m) => (this.state.queryTabsByConnection = m), connectionId); - deleteMapKey(() => this.state.schemaTabsByConnection, (m) => (this.state.schemaTabsByConnection = m), connectionId); - deleteMapKey(() => this.state.activeQueryTabIdByConnection, (m) => (this.state.activeQueryTabIdByConnection = m), connectionId); - deleteMapKey(() => this.state.activeSchemaTabIdByConnection, (m) => (this.state.activeSchemaTabIdByConnection = m), connectionId); - deleteMapKey(() => this.state.queryHistoryByConnection, (m) => (this.state.queryHistoryByConnection = m), connectionId); - deleteMapKey(() => this.state.savedQueriesByConnection, (m) => (this.state.savedQueriesByConnection = m), connectionId); - deleteMapKey(() => this.state.explainTabsByConnection, (m) => (this.state.explainTabsByConnection = m), connectionId); - deleteMapKey(() => this.state.activeExplainTabIdByConnection, (m) => (this.state.activeExplainTabIdByConnection = m), connectionId); - deleteMapKey(() => this.state.erdTabsByConnection, (m) => (this.state.erdTabsByConnection = m), connectionId); - deleteMapKey(() => this.state.activeErdTabIdByConnection, (m) => (this.state.activeErdTabIdByConnection = m), connectionId); - deleteMapKey(() => this.state.tabOrderByConnection, (m) => (this.state.tabOrderByConnection = m), connectionId); - deleteMapKey(() => this.state.schemas, (m) => (this.state.schemas = m), connectionId); + const { [connectionId]: _, ...restQueryTabs } = this.state.queryTabsByConnection; + this.state.queryTabsByConnection = restQueryTabs; + + const { [connectionId]: _2, ...restSchemaTabs } = this.state.schemaTabsByConnection; + this.state.schemaTabsByConnection = restSchemaTabs; + + const { [connectionId]: _3, ...restActiveQueryTab } = this.state.activeQueryTabIdByConnection; + this.state.activeQueryTabIdByConnection = restActiveQueryTab; + + const { [connectionId]: _4, ...restActiveSchemaTab } = this.state.activeSchemaTabIdByConnection; + this.state.activeSchemaTabIdByConnection = restActiveSchemaTab; + + const { [connectionId]: _5, ...restQueryHistory } = this.state.queryHistoryByConnection; + this.state.queryHistoryByConnection = restQueryHistory; + + const { [connectionId]: _6, ...restSavedQueries } = this.state.savedQueriesByConnection; + this.state.savedQueriesByConnection = restSavedQueries; + + const { [connectionId]: _7, ...restExplainTabs } = this.state.explainTabsByConnection; + this.state.explainTabsByConnection = restExplainTabs; + + const { [connectionId]: _8, ...restActiveExplainTab } = this.state.activeExplainTabIdByConnection; + this.state.activeExplainTabIdByConnection = restActiveExplainTab; + + const { [connectionId]: _9, ...restErdTabs } = this.state.erdTabsByConnection; + this.state.erdTabsByConnection = restErdTabs; + + const { [connectionId]: _10, ...restActiveErdTab } = this.state.activeErdTabIdByConnection; + this.state.activeErdTabIdByConnection = restActiveErdTab; + + const { [connectionId]: _11, ...restTabOrder } = this.state.tabOrderByConnection; + this.state.tabOrderByConnection = restTabOrder; + + const { [connectionId]: _12, ...restSchemas } = this.state.schemas; + this.state.schemas = restSchemas; } /** - * Ensure maps exist for a connection (used during reconnect to preserve existing state). + * Ensure records exist for a connection (used during reconnect to preserve existing state). */ ensureConnectionMapsExist(connectionId: string): void { - if (!this.state.queryTabsByConnection.has(connectionId)) { - setMapValue(() => this.state.queryTabsByConnection, (m) => (this.state.queryTabsByConnection = m), connectionId, []); + if (!(connectionId in this.state.queryTabsByConnection)) { + this.state.queryTabsByConnection = { + ...this.state.queryTabsByConnection, + [connectionId]: [], + }; } - if (!this.state.schemaTabsByConnection.has(connectionId)) { - setMapValue(() => this.state.schemaTabsByConnection, (m) => (this.state.schemaTabsByConnection = m), connectionId, []); + if (!(connectionId in this.state.schemaTabsByConnection)) { + this.state.schemaTabsByConnection = { + ...this.state.schemaTabsByConnection, + [connectionId]: [], + }; } - if (!this.state.activeQueryTabIdByConnection.has(connectionId)) { - setMapValue(() => this.state.activeQueryTabIdByConnection, (m) => (this.state.activeQueryTabIdByConnection = m), connectionId, null); + if (!(connectionId in this.state.activeQueryTabIdByConnection)) { + this.state.activeQueryTabIdByConnection = { + ...this.state.activeQueryTabIdByConnection, + [connectionId]: null, + }; } - if (!this.state.activeSchemaTabIdByConnection.has(connectionId)) { - setMapValue(() => this.state.activeSchemaTabIdByConnection, (m) => (this.state.activeSchemaTabIdByConnection = m), connectionId, null); + if (!(connectionId in this.state.activeSchemaTabIdByConnection)) { + this.state.activeSchemaTabIdByConnection = { + ...this.state.activeSchemaTabIdByConnection, + [connectionId]: null, + }; } - if (!this.state.queryHistoryByConnection.has(connectionId)) { - setMapValue(() => this.state.queryHistoryByConnection, (m) => (this.state.queryHistoryByConnection = m), connectionId, []); + if (!(connectionId in this.state.queryHistoryByConnection)) { + this.state.queryHistoryByConnection = { + ...this.state.queryHistoryByConnection, + [connectionId]: [], + }; } - if (!this.state.savedQueriesByConnection.has(connectionId)) { - setMapValue(() => this.state.savedQueriesByConnection, (m) => (this.state.savedQueriesByConnection = m), connectionId, []); + if (!(connectionId in this.state.savedQueriesByConnection)) { + this.state.savedQueriesByConnection = { + ...this.state.savedQueriesByConnection, + [connectionId]: [], + }; } - if (!this.state.explainTabsByConnection.has(connectionId)) { - setMapValue(() => this.state.explainTabsByConnection, (m) => (this.state.explainTabsByConnection = m), connectionId, []); + if (!(connectionId in this.state.explainTabsByConnection)) { + this.state.explainTabsByConnection = { + ...this.state.explainTabsByConnection, + [connectionId]: [], + }; } - if (!this.state.activeExplainTabIdByConnection.has(connectionId)) { - setMapValue(() => this.state.activeExplainTabIdByConnection, (m) => (this.state.activeExplainTabIdByConnection = m), connectionId, null); + if (!(connectionId in this.state.activeExplainTabIdByConnection)) { + this.state.activeExplainTabIdByConnection = { + ...this.state.activeExplainTabIdByConnection, + [connectionId]: null, + }; } - if (!this.state.erdTabsByConnection.has(connectionId)) { - setMapValue(() => this.state.erdTabsByConnection, (m) => (this.state.erdTabsByConnection = m), connectionId, []); + if (!(connectionId in this.state.erdTabsByConnection)) { + this.state.erdTabsByConnection = { + ...this.state.erdTabsByConnection, + [connectionId]: [], + }; } - if (!this.state.activeErdTabIdByConnection.has(connectionId)) { - setMapValue(() => this.state.activeErdTabIdByConnection, (m) => (this.state.activeErdTabIdByConnection = m), connectionId, null); + if (!(connectionId in this.state.activeErdTabIdByConnection)) { + this.state.activeErdTabIdByConnection = { + ...this.state.activeErdTabIdByConnection, + [connectionId]: null, + }; } - if (!this.state.tabOrderByConnection.has(connectionId)) { - setMapValue(() => this.state.tabOrderByConnection, (m) => (this.state.tabOrderByConnection = m), connectionId, []); + if (!(connectionId in this.state.tabOrderByConnection)) { + this.state.tabOrderByConnection = { + ...this.state.tabOrderByConnection, + [connectionId]: [], + }; } } @@ -110,12 +201,10 @@ export class StateRestorationManager { createdAt: new Date(q.createdAt), updatedAt: new Date(q.updatedAt), })); - setMapValue( - () => this.state.savedQueriesByConnection, - (m) => (this.state.savedQueriesByConnection = m), - connectionId, - savedQueries - ); + this.state.savedQueriesByConnection = { + ...this.state.savedQueriesByConnection, + [connectionId]: savedQueries, + }; } /** @@ -131,19 +220,17 @@ export class StateRestorationManager { connectionId: h.connectionId, favorite: h.favorite, })); - setMapValue( - () => this.state.queryHistoryByConnection, - (m) => (this.state.queryHistoryByConnection = m), - connectionId, - history - ); + this.state.queryHistoryByConnection = { + ...this.state.queryHistoryByConnection, + [connectionId]: history, + }; } /** * Restore schema tabs matching current schema. */ private restoreSchemaTabs(connectionId: string, persistedTabs: PersistedSchemaTab[]): void { - const schemas = this.state.schemas.get(connectionId) || []; + const schemas = this.state.schemas[connectionId] ?? []; const schemaTabs: SchemaTab[] = []; for (const pt of persistedTabs) { @@ -159,12 +246,10 @@ export class StateRestorationManager { // If table no longer exists, skip restoring this tab } - setMapValue( - () => this.state.schemaTabsByConnection, - (m) => (this.state.schemaTabsByConnection = m), - connectionId, - schemaTabs - ); + this.state.schemaTabsByConnection = { + ...this.state.schemaTabsByConnection, + [connectionId]: schemaTabs, + }; } /** @@ -184,12 +269,10 @@ export class StateRestorationManager { isExecuting: false, results: undefined, })); - setMapValue( - () => this.state.queryTabsByConnection, - (m) => (this.state.queryTabsByConnection = m), - connectionId, - queryTabs - ); + this.state.queryTabsByConnection = { + ...this.state.queryTabsByConnection, + [connectionId]: queryTabs, + }; // Restore explain tabs (without results - they'll need to be re-executed) const explainTabs: ExplainTab[] = persistedState.explainTabs.map((pt) => ({ @@ -199,74 +282,58 @@ export class StateRestorationManager { isExecuting: false, result: undefined, })); - setMapValue( - () => this.state.explainTabsByConnection, - (m) => (this.state.explainTabsByConnection = m), - connectionId, - explainTabs - ); + this.state.explainTabsByConnection = { + ...this.state.explainTabsByConnection, + [connectionId]: explainTabs, + }; // Restore schema tabs - need to match against current schema this.restoreSchemaTabs(connectionId, persistedState.schemaTabs); // Restore active tab IDs (only if tabs exist) if (persistedState.activeQueryTabId && queryTabs.some((t) => t.id === persistedState.activeQueryTabId)) { - setMapValue( - () => this.state.activeQueryTabIdByConnection, - (m) => (this.state.activeQueryTabIdByConnection = m), - connectionId, - persistedState.activeQueryTabId - ); + this.state.activeQueryTabIdByConnection = { + ...this.state.activeQueryTabIdByConnection, + [connectionId]: persistedState.activeQueryTabId, + }; } else if (queryTabs.length > 0) { - setMapValue( - () => this.state.activeQueryTabIdByConnection, - (m) => (this.state.activeQueryTabIdByConnection = m), - connectionId, - queryTabs[0].id - ); + this.state.activeQueryTabIdByConnection = { + ...this.state.activeQueryTabIdByConnection, + [connectionId]: queryTabs[0].id, + }; } - const schemaTabs = this.state.schemaTabsByConnection.get(connectionId) || []; + const schemaTabs = this.state.schemaTabsByConnection[connectionId] ?? []; if (persistedState.activeSchemaTabId && schemaTabs.some((t) => t.id === persistedState.activeSchemaTabId)) { - setMapValue( - () => this.state.activeSchemaTabIdByConnection, - (m) => (this.state.activeSchemaTabIdByConnection = m), - connectionId, - persistedState.activeSchemaTabId - ); + this.state.activeSchemaTabIdByConnection = { + ...this.state.activeSchemaTabIdByConnection, + [connectionId]: persistedState.activeSchemaTabId, + }; } else if (schemaTabs.length > 0) { - setMapValue( - () => this.state.activeSchemaTabIdByConnection, - (m) => (this.state.activeSchemaTabIdByConnection = m), - connectionId, - schemaTabs[0].id - ); + this.state.activeSchemaTabIdByConnection = { + ...this.state.activeSchemaTabIdByConnection, + [connectionId]: schemaTabs[0].id, + }; } if (persistedState.activeExplainTabId && explainTabs.some((t) => t.id === persistedState.activeExplainTabId)) { - setMapValue( - () => this.state.activeExplainTabIdByConnection, - (m) => (this.state.activeExplainTabIdByConnection = m), - connectionId, - persistedState.activeExplainTabId - ); + this.state.activeExplainTabIdByConnection = { + ...this.state.activeExplainTabIdByConnection, + [connectionId]: persistedState.activeExplainTabId, + }; } else if (explainTabs.length > 0) { - setMapValue( - () => this.state.activeExplainTabIdByConnection, - (m) => (this.state.activeExplainTabIdByConnection = m), - connectionId, - explainTabs[0].id - ); + this.state.activeExplainTabIdByConnection = { + ...this.state.activeExplainTabIdByConnection, + [connectionId]: explainTabs[0].id, + }; } // Restore tab order (if available, otherwise will use timestamp ordering) if (persistedState.tabOrder && persistedState.tabOrder.length > 0) { - setMapValue( - () => this.state.tabOrderByConnection, - (m) => (this.state.tabOrderByConnection = m), - connectionId, - persistedState.tabOrder - ); + this.state.tabOrderByConnection = { + ...this.state.tabOrderByConnection, + [connectionId]: persistedState.tabOrder, + }; } // Restore active view diff --git a/src/lib/hooks/database/state.svelte.ts b/src/lib/hooks/database/state.svelte.ts index 570e9ef..d469c98 100644 --- a/src/lib/hooks/database/state.svelte.ts +++ b/src/lib/hooks/database/state.svelte.ts @@ -1,160 +1,143 @@ import type { - DatabaseConnection, - SchemaTable, - QueryTab, - QueryHistoryItem, - AIMessage, - SchemaTab, - SavedQuery, - ExplainTab, - ErdTab, -} from "$lib/types"; + DatabaseConnection, + SchemaTable, + QueryTab, + QueryHistoryItem, + AIMessage, + SchemaTab, + SavedQuery, + ExplainTab, + ErdTab +} from '$lib/types'; /** * Central state container for the database module. * All reactive state and derived values are declared here. * Modules receive this instance and read/write state through it. + * + * State is organized using Records (objects) instead of Maps for simpler + * reactivity updates using spread syntax. */ export class DatabaseState { - // Core state - connections = $state([]); - activeConnectionId = $state(null); - schemas = $state>(new Map()); - - // Query tabs state - queryTabsByConnection = $state>(new Map()); - activeQueryTabIdByConnection = $state>(new Map()); - - // Schema tabs state - schemaTabsByConnection = $state>(new Map()); - activeSchemaTabIdByConnection = $state>(new Map()); - - // Query history and saved queries - queryHistoryByConnection = $state>(new Map()); - savedQueriesByConnection = $state>(new Map()); - - // Explain tabs state - explainTabsByConnection = $state>(new Map()); - activeExplainTabIdByConnection = $state>(new Map()); - - // ERD tabs state - erdTabsByConnection = $state>(new Map()); - activeErdTabIdByConnection = $state>(new Map()); - - // Tab ordering state (stores ordered array of all tab IDs per connection) - tabOrderByConnection = $state>(new Map()); - - // AI state - aiMessages = $state([]); - isAIOpen = $state(false); - - // View state - activeView = $state<"query" | "schema" | "explain" | "erd">("query"); - - // Derived: active connection object - activeConnection = $derived( - this.connections.find((c) => c.id === this.activeConnectionId) || null, - ); - - // Derived: query tabs for active connection - queryTabs = $derived( - this.activeConnectionId - ? this.queryTabsByConnection.get(this.activeConnectionId) || [] - : [], - ); - - // Derived: active query tab ID for active connection - activeQueryTabId = $derived( - this.activeConnectionId - ? this.activeQueryTabIdByConnection.get(this.activeConnectionId) || null - : null, - ); - - // Derived: active query tab object - activeQueryTab = $derived( - this.queryTabs.find((t) => t.id === this.activeQueryTabId) || null, - ); - - // Derived: active query result (for multi-statement support) - activeQueryResult = $derived( - this.activeQueryTab?.results?.[this.activeQueryTab.activeResultIndex ?? 0] || null, - ); - - // Derived: schema tabs for active connection - schemaTabs = $derived( - this.activeConnectionId - ? this.schemaTabsByConnection.get(this.activeConnectionId) || [] - : [], - ); - - // Derived: active schema tab ID for active connection - activeSchemaTabId = $derived( - this.activeConnectionId - ? this.activeSchemaTabIdByConnection.get(this.activeConnectionId) || null - : null, - ); - - // Derived: active schema tab object - activeSchemaTab = $derived( - this.schemaTabs.find((t) => t.id === this.activeSchemaTabId) || null, - ); - - // Derived: schema for active connection - activeSchema = $derived( - this.activeConnectionId - ? this.schemas.get(this.activeConnectionId) || [] - : [], - ); - - // Derived: query history for active connection - activeConnectionQueryHistory = $derived( - this.activeConnectionId - ? this.queryHistoryByConnection.get(this.activeConnectionId) || [] - : [], - ); - - // Derived: saved queries for active connection - activeConnectionSavedQueries = $derived( - this.activeConnectionId - ? this.savedQueriesByConnection.get(this.activeConnectionId) || [] - : [], - ); - - // Derived: explain tabs for active connection - explainTabs = $derived( - this.activeConnectionId - ? this.explainTabsByConnection.get(this.activeConnectionId) || [] - : [], - ); - - // Derived: active explain tab ID for active connection - activeExplainTabId = $derived( - this.activeConnectionId - ? this.activeExplainTabIdByConnection.get(this.activeConnectionId) || null - : null, - ); - - // Derived: active explain tab object - activeExplainTab = $derived( - this.explainTabs.find((t) => t.id === this.activeExplainTabId) || null, - ); - - // Derived: ERD tabs for active connection - erdTabs = $derived( - this.activeConnectionId - ? this.erdTabsByConnection.get(this.activeConnectionId) || [] - : [], - ); - - // Derived: active ERD tab ID for active connection - activeErdTabId = $derived( - this.activeConnectionId - ? this.activeErdTabIdByConnection.get(this.activeConnectionId) || null - : null, - ); - - // Derived: active ERD tab object - activeErdTab = $derived( - this.erdTabs.find((t) => t.id === this.activeErdTabId) || null, - ); + // Core state + connections = $state([]); + activeConnectionId = $state(null); + schemas = $state>({}); + + // Query tabs state + queryTabsByConnection = $state>({}); + activeQueryTabIdByConnection = $state>({}); + + // Schema tabs state + schemaTabsByConnection = $state>({}); + activeSchemaTabIdByConnection = $state>({}); + + // Query history and saved queries + queryHistoryByConnection = $state>({}); + savedQueriesByConnection = $state>({}); + + // Explain tabs state + explainTabsByConnection = $state>({}); + activeExplainTabIdByConnection = $state>({}); + + // ERD tabs state + erdTabsByConnection = $state>({}); + activeErdTabIdByConnection = $state>({}); + + // Tab ordering state (stores ordered array of all tab IDs per connection) + tabOrderByConnection = $state>({}); + + // AI state + aiMessages = $state([]); + isAIOpen = $state(false); + + // View state + activeView = $state<'query' | 'schema' | 'explain' | 'erd'>('query'); + + // Derived: active connection object + activeConnection = $derived( + this.connections.find((c) => c.id === this.activeConnectionId) || null + ); + + // Derived: query tabs for active connection + queryTabs = $derived( + this.activeConnectionId ? (this.queryTabsByConnection[this.activeConnectionId] ?? []) : [] + ); + + // Derived: active query tab ID for active connection + activeQueryTabId = $derived( + this.activeConnectionId + ? (this.activeQueryTabIdByConnection[this.activeConnectionId] ?? null) + : null + ); + + // Derived: active query tab object + activeQueryTab = $derived(this.queryTabs.find((t) => t.id === this.activeQueryTabId) || null); + + // Derived: active query result (for multi-statement support) + activeQueryResult = $derived( + this.activeQueryTab?.results?.[this.activeQueryTab.activeResultIndex ?? 0] || null + ); + + // Derived: schema tabs for active connection + schemaTabs = $derived( + this.activeConnectionId ? (this.schemaTabsByConnection[this.activeConnectionId] ?? []) : [] + ); + + // Derived: active schema tab ID for active connection + activeSchemaTabId = $derived( + this.activeConnectionId + ? (this.activeSchemaTabIdByConnection[this.activeConnectionId] ?? null) + : null + ); + + // Derived: active schema tab object + activeSchemaTab = $derived(this.schemaTabs.find((t) => t.id === this.activeSchemaTabId) || null); + + // Derived: schema for active connection + activeSchema = $derived( + this.activeConnectionId ? (this.schemas[this.activeConnectionId] ?? []) : [] + ); + + // Derived: query history for active connection + activeConnectionQueryHistory = $derived( + this.activeConnectionId ? (this.queryHistoryByConnection[this.activeConnectionId] ?? []) : [] + ); + + // Derived: saved queries for active connection + activeConnectionSavedQueries = $derived( + this.activeConnectionId ? (this.savedQueriesByConnection[this.activeConnectionId] ?? []) : [] + ); + + // Derived: explain tabs for active connection + explainTabs = $derived( + this.activeConnectionId ? (this.explainTabsByConnection[this.activeConnectionId] ?? []) : [] + ); + + // Derived: active explain tab ID for active connection + activeExplainTabId = $derived( + this.activeConnectionId + ? (this.activeExplainTabIdByConnection[this.activeConnectionId] ?? null) + : null + ); + + // Derived: active explain tab object + activeExplainTab = $derived( + this.explainTabs.find((t) => t.id === this.activeExplainTabId) || null + ); + + // Derived: ERD tabs for active connection + erdTabs = $derived( + this.activeConnectionId ? (this.erdTabsByConnection[this.activeConnectionId] ?? []) : [] + ); + + // Derived: active ERD tab ID for active connection + activeErdTabId = $derived( + this.activeConnectionId + ? (this.activeErdTabIdByConnection[this.activeConnectionId] ?? null) + : null + ); + + // Derived: active ERD tab object + activeErdTab = $derived(this.erdTabs.find((t) => t.id === this.activeErdTabId) || null); } diff --git a/src/lib/hooks/database/tab-ordering.svelte.ts b/src/lib/hooks/database/tab-ordering.svelte.ts index 27fd4c3..0d29685 100644 --- a/src/lib/hooks/database/tab-ordering.svelte.ts +++ b/src/lib/hooks/database/tab-ordering.svelte.ts @@ -1,153 +1,150 @@ -import type { QueryTab, SchemaTab, ExplainTab, ErdTab } from "$lib/types"; -import type { DatabaseState } from "./state.svelte.js"; -import { setMapValue } from "./map-utils.js"; +import type { QueryTab, SchemaTab, ExplainTab, ErdTab } from '$lib/types'; +import type { DatabaseState } from './state.svelte.js'; /** * Manages tab ordering across all tab types (query, schema, explain, ERD). * Provides generic tab removal logic and ordered tab computation. */ export class TabOrderingManager { - constructor( - private state: DatabaseState, - private schedulePersistence: (connectionId: string | null) => void - ) {} - - /** - * Generic tab removal helper used by all tab managers. - * Handles removing from tab list and updating active tab selection. - */ - removeTabGeneric( - tabsGetter: () => Map, - tabsSetter: (m: Map) => void, - activeIdGetter: () => Map, - activeIdSetter: (m: Map) => void, - tabId: string - ): void { - if (!this.state.activeConnectionId) return; - - const tabs = tabsGetter().get(this.state.activeConnectionId) || []; - const index = tabs.findIndex((t) => t.id === tabId); - const newTabs = tabs.filter((t) => t.id !== tabId); - - setMapValue(tabsGetter, tabsSetter, this.state.activeConnectionId, newTabs); - - // Remove from tab order - this.removeFromTabOrder(tabId); - - const currentActiveId = activeIdGetter().get(this.state.activeConnectionId); - if (currentActiveId === tabId) { - let newActiveId: string | null = null; - if (newTabs.length > 0) { - const newIndex = Math.min(index, newTabs.length - 1); - newActiveId = newTabs[newIndex]?.id || null; - } - setMapValue(activeIdGetter, activeIdSetter, this.state.activeConnectionId, newActiveId); - } - } - - /** - * Add a tab ID to the ordering array. - */ - add(tabId: string): void { - if (!this.state.activeConnectionId) return; - const order = this.state.tabOrderByConnection.get(this.state.activeConnectionId) || []; - if (!order.includes(tabId)) { - setMapValue( - () => this.state.tabOrderByConnection, - (m) => (this.state.tabOrderByConnection = m), - this.state.activeConnectionId, - [...order, tabId] - ); - } - } - - /** - * Remove a tab ID from the ordering array. - */ - removeFromTabOrder(tabId: string): void { - if (!this.state.activeConnectionId) return; - const order = this.state.tabOrderByConnection.get(this.state.activeConnectionId) || []; - setMapValue( - () => this.state.tabOrderByConnection, - (m) => (this.state.tabOrderByConnection = m), - this.state.activeConnectionId, - order.filter((id) => id !== tabId) - ); - } - - /** - * Reorder tabs to match the provided order array. - */ - reorder(newOrder: string[]): void { - if (!this.state.activeConnectionId) return; - setMapValue( - () => this.state.tabOrderByConnection, - (m) => (this.state.tabOrderByConnection = m), - this.state.activeConnectionId, - newOrder - ); - this.schedulePersistence(this.state.activeConnectionId); - } - - /** - * Extract timestamp from tab ID for default ordering. - */ - private getTabTimestamp(id: string): number { - const match = id.match(/\d+$/); - return match ? parseInt(match[0], 10) : 0; - } - - /** - * Get all tabs ordered by user preference or creation time. - */ - get ordered(): Array<{ - id: string; - type: "query" | "schema" | "explain" | "erd"; - tab: QueryTab | SchemaTab | ExplainTab | ErdTab; - }> { - if (!this.state.activeConnectionId) return []; - - // Ensure we have arrays (defensive against undefined) - const queryTabs = this.state.queryTabs || []; - const schemaTabs = this.state.schemaTabs || []; - const explainTabs = this.state.explainTabs || []; - const erdTabs = this.state.erdTabs || []; - - const allTabsUnordered: Array<{ - id: string; - type: "query" | "schema" | "explain" | "erd"; - tab: QueryTab | SchemaTab | ExplainTab | ErdTab; - }> = []; - - for (const t of queryTabs) { - allTabsUnordered.push({ id: t.id, type: "query", tab: t }); - } - for (const t of schemaTabs) { - allTabsUnordered.push({ id: t.id, type: "schema", tab: t }); - } - for (const t of explainTabs) { - allTabsUnordered.push({ id: t.id, type: "explain", tab: t }); - } - for (const t of erdTabs) { - allTabsUnordered.push({ id: t.id, type: "erd", tab: t }); - } - - const order = this.state.tabOrderByConnection.get(this.state.activeConnectionId) || []; - - // Sort by order array, falling back to timestamp for new tabs - return allTabsUnordered.sort((a, b) => { - const aIndex = order.indexOf(a.id); - const bIndex = order.indexOf(b.id); - - // Both in order array: use order - if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex; - - // Only one in order: ordered comes first - if (aIndex !== -1) return -1; - if (bIndex !== -1) return 1; - - // Neither in order: fall back to timestamp - return this.getTabTimestamp(a.id) - this.getTabTimestamp(b.id); - }); - } + constructor( + private state: DatabaseState, + private schedulePersistence: (connectionId: string | null) => void + ) {} + + /** + * Generic tab removal helper used by all tab managers. + * Handles removing from tab list and updating active tab selection. + */ + removeTabGeneric( + tabsGetter: () => Record, + tabsSetter: (r: Record) => void, + activeIdGetter: () => Record, + activeIdSetter: (r: Record) => void, + tabId: string + ): void { + if (!this.state.activeConnectionId) return; + + const connectionId = this.state.activeConnectionId; + const tabs = tabsGetter()[connectionId] ?? []; + const index = tabs.findIndex((t) => t.id === tabId); + const newTabs = tabs.filter((t) => t.id !== tabId); + + // Update tabs using spread syntax + tabsSetter({ ...tabsGetter(), [connectionId]: newTabs }); + + // Remove from tab order + this.removeFromTabOrder(tabId); + + const currentActiveId = activeIdGetter()[connectionId]; + if (currentActiveId === tabId) { + let newActiveId: string | null = null; + if (newTabs.length > 0) { + const newIndex = Math.min(index, newTabs.length - 1); + newActiveId = newTabs[newIndex]?.id || null; + } + activeIdSetter({ ...activeIdGetter(), [connectionId]: newActiveId }); + } + } + + /** + * Add a tab ID to the ordering array. + */ + add(tabId: string): void { + if (!this.state.activeConnectionId) return; + const connectionId = this.state.activeConnectionId; + const order = this.state.tabOrderByConnection[connectionId] ?? []; + if (!order.includes(tabId)) { + this.state.tabOrderByConnection = { + ...this.state.tabOrderByConnection, + [connectionId]: [...order, tabId] + }; + } + } + + /** + * Remove a tab ID from the ordering array. + */ + removeFromTabOrder(tabId: string): void { + if (!this.state.activeConnectionId) return; + const connectionId = this.state.activeConnectionId; + const order = this.state.tabOrderByConnection[connectionId] ?? []; + this.state.tabOrderByConnection = { + ...this.state.tabOrderByConnection, + [connectionId]: order.filter((id) => id !== tabId) + }; + } + + /** + * Reorder tabs to match the provided order array. + */ + reorder(newOrder: string[]): void { + if (!this.state.activeConnectionId) return; + this.state.tabOrderByConnection = { + ...this.state.tabOrderByConnection, + [this.state.activeConnectionId]: newOrder + }; + this.schedulePersistence(this.state.activeConnectionId); + } + + /** + * Extract timestamp from tab ID for default ordering. + */ + private getTabTimestamp(id: string): number { + const match = id.match(/\d+$/); + return match ? parseInt(match[0], 10) : 0; + } + + /** + * Get all tabs ordered by user preference or creation time. + */ + get ordered(): Array<{ + id: string; + type: 'query' | 'schema' | 'explain' | 'erd'; + tab: QueryTab | SchemaTab | ExplainTab | ErdTab; + }> { + if (!this.state.activeConnectionId) return []; + + // Ensure we have arrays (defensive against undefined) + const queryTabs = this.state.queryTabs || []; + const schemaTabs = this.state.schemaTabs || []; + const explainTabs = this.state.explainTabs || []; + const erdTabs = this.state.erdTabs || []; + + const allTabsUnordered: Array<{ + id: string; + type: 'query' | 'schema' | 'explain' | 'erd'; + tab: QueryTab | SchemaTab | ExplainTab | ErdTab; + }> = []; + + for (const t of queryTabs) { + allTabsUnordered.push({ id: t.id, type: 'query', tab: t }); + } + for (const t of schemaTabs) { + allTabsUnordered.push({ id: t.id, type: 'schema', tab: t }); + } + for (const t of explainTabs) { + allTabsUnordered.push({ id: t.id, type: 'explain', tab: t }); + } + for (const t of erdTabs) { + allTabsUnordered.push({ id: t.id, type: 'erd', tab: t }); + } + + const order = this.state.tabOrderByConnection[this.state.activeConnectionId] ?? []; + + // Sort by order array, falling back to timestamp for new tabs + return allTabsUnordered.sort((a, b) => { + const aIndex = order.indexOf(a.id); + const bIndex = order.indexOf(b.id); + + // Both in order array: use order + if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex; + + // Only one in order: ordered comes first + if (aIndex !== -1) return -1; + if (bIndex !== -1) return 1; + + // Neither in order: fall back to timestamp + return this.getTabTimestamp(a.id) - this.getTabTimestamp(b.id); + }); + } } diff --git a/src/lib/types.ts b/src/lib/types.ts index 9afe1d2..1e3105b 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,238 +1,16 @@ -import type Database from "@tauri-apps/plugin-sql"; -import type { QueryType } from './db/query-utils'; - -export type DatabaseType = "postgres" | "mysql" | "sqlite" | "mongodb" | "mariadb" | "mssql"; - -export type SSHAuthMethod = "password" | "key"; - -export interface SSHTunnelConfig { - enabled: boolean; - host: string; - port: number; - username: string; - authMethod: SSHAuthMethod; -} - -export interface DatabaseConnection { - id: string; - name: string; - type: DatabaseType; - host: string; - port: number; - databaseName: string; - username: string; - password: string; - sslMode?: string; - connectionString?: string; - lastConnected?: Date; - database?: Database; - mssqlConnectionId?: string; // For MSSQL connections (uses custom Rust backend instead of tauri-plugin-sql) - sshTunnel?: SSHTunnelConfig; - tunnelLocalPort?: number; -} - -export interface SchemaTable { - name: string; - schema: string; - type: "table" | "view"; - rowCount?: number; - columns: SchemaColumn[]; - indexes: SchemaIndex[]; -} - -export interface ForeignKeyRef { - referencedSchema: string; - referencedTable: string; - referencedColumn: string; -} - -export interface SchemaColumn { - name: string; - type: string; - nullable: boolean; - defaultValue?: string; - isPrimaryKey: boolean; - isForeignKey: boolean; - foreignKeyRef?: ForeignKeyRef; -} - -export interface SchemaIndex { - name: string; - columns: string[]; - unique: boolean; - type: string; -} - -export interface QueryTab { - id: string; - name: string; - query: string; - results?: StatementResult[]; - activeResultIndex?: number; - isExecuting: boolean; - savedQueryId?: string; -} - -export interface SchemaTab { - id: string; - table: SchemaTable; -} - -export interface QueryResult { - columns: string[]; - rows: any[]; - rowCount: number; - totalRows: number; - executionTime: number; - affectedRows?: number; - lastInsertId?: number; - queryType?: QueryType; - sourceTable?: { - schema: string; - name: string; - primaryKeys: string[]; - }; - page: number; - pageSize: number; - totalPages: number; -} - -export interface StatementResult extends QueryResult { - statementIndex: number; - statementSql: string; - error?: string; - isError: boolean; -} - -export interface QueryHistoryItem { - id: string; - query: string; - timestamp: Date; - executionTime: number; - rowCount: number; - connectionId: string; - favorite: boolean; -} - -export interface SavedQuery { - id: string; - name: string; - query: string; - connectionId: string; - createdAt: Date; - updatedAt: Date; -} - -export interface AIMessage { - id: string; - role: "user" | "assistant"; - content: string; - timestamp: Date; - query?: string; -} - -// EXPLAIN/ANALYZE types -export interface ExplainPlanNode { - id: string; - nodeType: string; - relationName?: string; - alias?: string; - startupCost: number; - totalCost: number; - planRows: number; - planWidth: number; - // ANALYZE fields - actualStartupTime?: number; - actualTotalTime?: number; - actualRows?: number; - actualLoops?: number; - // Conditions - filter?: string; - indexName?: string; - indexCond?: string; - joinType?: string; - hashCond?: string; - sortKey?: string[]; - children: ExplainPlanNode[]; -} - -export interface ExplainResult { - plan: ExplainPlanNode; - planningTime: number; - executionTime?: number; - isAnalyze: boolean; -} - -export interface ExplainTab { - id: string; - name: string; - sourceQuery: string; - result?: ExplainResult; - isExecuting: boolean; -} - -// ERD types -export interface ErdTab { - id: string; - name: string; -} - -// Persisted state types (for storing across app restarts) -export interface PersistedQueryTab { - id: string; - name: string; - query: string; - savedQueryId?: string; -} - -export interface PersistedSchemaTab { - id: string; - tableName: string; - schemaName: string; -} - -export interface PersistedExplainTab { - id: string; - name: string; - sourceQuery: string; -} - -export interface PersistedErdTab { - id: string; - name: string; -} - -export interface PersistedSavedQuery { - id: string; - name: string; - query: string; - connectionId: string; - createdAt: string; // ISO string - updatedAt: string; // ISO string -} - -export interface PersistedQueryHistoryItem { - id: string; - query: string; - timestamp: string; // ISO string - executionTime: number; - rowCount: number; - connectionId: string; - favorite: boolean; -} - -export interface PersistedConnectionState { - connectionId: string; - queryTabs: PersistedQueryTab[]; - schemaTabs: PersistedSchemaTab[]; - explainTabs: PersistedExplainTab[]; - erdTabs: PersistedErdTab[]; - tabOrder: string[]; - activeQueryTabId: string | null; - activeSchemaTabId: string | null; - activeExplainTabId: string | null; - activeErdTabId: string | null; - activeView: "query" | "schema" | "explain" | "erd"; - savedQueries: PersistedSavedQuery[]; - queryHistory: PersistedQueryHistoryItem[]; -} \ No newline at end of file +/** + * Re-exports all types from the types module. + * This file maintains backward compatibility with existing imports. + * + * Types are now organized in src/lib/types/: + * - database.ts: DatabaseConnection, DatabaseType, SSHTunnelConfig + * - schema.ts: SchemaTable, SchemaColumn, SchemaIndex + * - query.ts: QueryTab, QueryResult, QueryHistoryItem, SavedQuery + * - explain.ts: ExplainTab, ExplainPlanNode, ExplainResult + * - erd.ts: ErdTab + * - persisted.ts: All PersistedXxx types + * + * @module types + */ + +export * from './types/index'; diff --git a/src/lib/types/database.ts b/src/lib/types/database.ts new file mode 100644 index 0000000..ae8aba8 --- /dev/null +++ b/src/lib/types/database.ts @@ -0,0 +1,81 @@ +/** + * Database connection types and configuration. + * @module types/database + */ + +import type Database from '@tauri-apps/plugin-sql'; + +/** + * Supported database engine types. + */ +export type DatabaseType = 'postgres' | 'mysql' | 'sqlite' | 'mongodb' | 'mariadb' | 'mssql'; + +/** + * SSH tunnel authentication methods. + */ +export type SSHAuthMethod = 'password' | 'key'; + +/** + * Configuration for SSH tunnel connections. + * Used to connect to databases through an SSH jump host. + */ +export interface SSHTunnelConfig { + /** Whether SSH tunneling is enabled */ + enabled: boolean; + /** SSH server hostname */ + host: string; + /** SSH server port (typically 22) */ + port: number; + /** SSH username for authentication */ + username: string; + /** Authentication method: password or SSH key */ + authMethod: SSHAuthMethod; +} + +/** + * Represents a database connection configuration and runtime state. + * + * @example + * const connection: DatabaseConnection = { + * id: 'conn-localhost-5432', + * name: 'Local Development', + * type: 'postgres', + * host: 'localhost', + * port: 5432, + * databaseName: 'myapp_dev', + * username: 'postgres', + * password: '' + * }; + */ +export interface DatabaseConnection { + /** Unique identifier for the connection */ + id: string; + /** User-friendly display name */ + name: string; + /** Database engine type */ + type: DatabaseType; + /** Database server hostname or IP address */ + host: string; + /** Database server port */ + port: number; + /** Name of the database to connect to */ + databaseName: string; + /** Username for authentication */ + username: string; + /** Password for authentication (not persisted) */ + password: string; + /** SSL/TLS mode for the connection */ + sslMode?: string; + /** Original connection string if parsed from one */ + connectionString?: string; + /** Timestamp of last successful connection */ + lastConnected?: Date; + /** Active database connection handle (tauri-plugin-sql) */ + database?: Database; + /** Connection ID for MSSQL (uses custom Rust backend) */ + mssqlConnectionId?: string; + /** SSH tunnel configuration */ + sshTunnel?: SSHTunnelConfig; + /** Local port for SSH tunnel forwarding */ + tunnelLocalPort?: number; +} diff --git a/src/lib/types/erd.ts b/src/lib/types/erd.ts new file mode 100644 index 0000000..8277783 --- /dev/null +++ b/src/lib/types/erd.ts @@ -0,0 +1,14 @@ +/** + * Entity Relationship Diagram (ERD) types. + * @module types/erd + */ + +/** + * Represents an open ERD viewer tab. + */ +export interface ErdTab { + /** Unique tab identifier */ + id: string; + /** Tab display name */ + name: string; +} diff --git a/src/lib/types/explain.ts b/src/lib/types/explain.ts new file mode 100644 index 0000000..37f4b76 --- /dev/null +++ b/src/lib/types/explain.ts @@ -0,0 +1,84 @@ +/** + * EXPLAIN/ANALYZE query plan types. + * @module types/explain + */ + +/** + * A node in the query execution plan tree. + * Represents a single operation in the database's query plan. + */ +export interface ExplainPlanNode { + /** Unique identifier for this node */ + id: string; + /** Type of operation (e.g., 'Seq Scan', 'Index Scan', 'Hash Join') */ + nodeType: string; + /** Table or relation name being accessed */ + relationName?: string; + /** Alias for the relation in the query */ + alias?: string; + /** Estimated cost to start returning rows */ + startupCost: number; + /** Estimated total cost to complete the operation */ + totalCost: number; + /** Estimated number of rows to be returned */ + planRows: number; + /** Estimated average width of rows in bytes */ + planWidth: number; + + // ANALYZE fields (actual execution statistics) + /** Actual time to start returning rows (ms) */ + actualStartupTime?: number; + /** Actual total execution time (ms) */ + actualTotalTime?: number; + /** Actual number of rows returned */ + actualRows?: number; + /** Number of times this node was executed */ + actualLoops?: number; + + // Conditions and additional info + /** Filter condition applied to rows */ + filter?: string; + /** Name of the index being used */ + indexName?: string; + /** Index condition for index scans */ + indexCond?: string; + /** Type of join (for join nodes) */ + joinType?: string; + /** Hash condition for hash joins */ + hashCond?: string; + /** Sort keys for sort operations */ + sortKey?: string[]; + + /** Child nodes in the plan tree */ + children: ExplainPlanNode[]; +} + +/** + * Complete result of an EXPLAIN or EXPLAIN ANALYZE query. + */ +export interface ExplainResult { + /** Root node of the execution plan tree */ + plan: ExplainPlanNode; + /** Time spent planning the query (ms) */ + planningTime: number; + /** Time spent executing the query (ms) - only for ANALYZE */ + executionTime?: number; + /** Whether this was an EXPLAIN ANALYZE (vs plain EXPLAIN) */ + isAnalyze: boolean; +} + +/** + * Represents an open EXPLAIN plan viewer tab. + */ +export interface ExplainTab { + /** Unique tab identifier */ + id: string; + /** Tab display name */ + name: string; + /** The original query that was explained */ + sourceQuery: string; + /** The explain result, if available */ + result?: ExplainResult; + /** Whether the explain is currently running */ + isExecuting: boolean; +} diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts new file mode 100644 index 0000000..7492070 --- /dev/null +++ b/src/lib/types/index.ts @@ -0,0 +1,46 @@ +/** + * Central type exports for Seaquel. + * All application types are organized into domain-specific modules. + * @module types + */ + +// Database connection types +export type { DatabaseType, SSHAuthMethod, SSHTunnelConfig, DatabaseConnection } from './database'; + +// Schema types +export type { + ForeignKeyRef, + SchemaColumn, + SchemaIndex, + SchemaTable, + SchemaTab +} from './schema'; + +// Query execution types +export type { + SourceTableInfo, + QueryResult, + StatementResult, + QueryTab, + QueryHistoryItem, + SavedQuery, + AIMessage +} from './query'; + +// EXPLAIN types +export type { ExplainPlanNode, ExplainResult, ExplainTab } from './explain'; + +// ERD types +export type { ErdTab } from './erd'; + +// Persisted state types +export type { + PersistedQueryTab, + PersistedSchemaTab, + PersistedExplainTab, + PersistedErdTab, + PersistedSavedQuery, + PersistedQueryHistoryItem, + PersistedConnectionState, + ActiveViewType +} from './persisted'; diff --git a/src/lib/types/persisted.ts b/src/lib/types/persisted.ts new file mode 100644 index 0000000..6a6deb5 --- /dev/null +++ b/src/lib/types/persisted.ts @@ -0,0 +1,133 @@ +/** + * Persisted state types for storing data across app restarts. + * These types use ISO string dates instead of Date objects for JSON serialization. + * @module types/persisted + */ + +/** + * Persisted query tab state. + * Stores query content but not execution results. + */ +export interface PersistedQueryTab { + /** Tab identifier */ + id: string; + /** Tab display name */ + name: string; + /** SQL query text */ + query: string; + /** ID of the saved query this tab was loaded from */ + savedQueryId?: string; +} + +/** + * Persisted schema tab state. + * Stores reference to the viewed table. + */ +export interface PersistedSchemaTab { + /** Tab identifier */ + id: string; + /** Name of the table being viewed */ + tableName: string; + /** Schema name of the table */ + schemaName: string; +} + +/** + * Persisted EXPLAIN tab state. + */ +export interface PersistedExplainTab { + /** Tab identifier */ + id: string; + /** Tab display name */ + name: string; + /** The original query that was explained */ + sourceQuery: string; +} + +/** + * Persisted ERD tab state. + */ +export interface PersistedErdTab { + /** Tab identifier */ + id: string; + /** Tab display name */ + name: string; +} + +/** + * Persisted saved query. + * Uses ISO strings for dates. + */ +export interface PersistedSavedQuery { + /** Query identifier */ + id: string; + /** User-defined name */ + name: string; + /** SQL query text */ + query: string; + /** Connection this query belongs to */ + connectionId: string; + /** When first saved (ISO 8601 string) */ + createdAt: string; + /** When last modified (ISO 8601 string) */ + updatedAt: string; +} + +/** + * Persisted query history entry. + * Uses ISO strings for dates. + */ +export interface PersistedQueryHistoryItem { + /** Entry identifier */ + id: string; + /** The executed SQL query */ + query: string; + /** When executed (ISO 8601 string) */ + timestamp: string; + /** Execution time in milliseconds */ + executionTime: number; + /** Number of rows returned or affected */ + rowCount: number; + /** Connection this query was run on */ + connectionId: string; + /** Whether marked as favorite */ + favorite: boolean; +} + +/** + * View type options for the main workspace. + */ +export type ActiveViewType = 'query' | 'schema' | 'explain' | 'erd'; + +/** + * Complete persisted state for a single connection. + * Stores all tabs, history, and UI state. + */ +export interface PersistedConnectionState { + /** Connection identifier */ + connectionId: string; + /** Query editor tabs */ + queryTabs: PersistedQueryTab[]; + /** Schema browser tabs */ + schemaTabs: PersistedSchemaTab[]; + /** EXPLAIN viewer tabs */ + explainTabs: PersistedExplainTab[]; + /** ERD viewer tabs */ + erdTabs: PersistedErdTab[]; + /** Ordered list of all tab IDs for drag-drop ordering */ + tabOrder: string[]; + /** Currently active query tab */ + activeQueryTabId: string | null; + /** Currently active schema tab */ + activeSchemaTabId: string | null; + /** Currently active explain tab */ + activeExplainTabId: string | null; + /** Currently active ERD tab */ + activeErdTabId: string | null; + /** Which view type is currently active */ + activeView: ActiveViewType; + /** Saved queries for this connection */ + savedQueries: PersistedSavedQuery[]; + /** Query execution history */ + queryHistory: PersistedQueryHistoryItem[]; +} diff --git a/src/lib/types/query.ts b/src/lib/types/query.ts new file mode 100644 index 0000000..3effb06 --- /dev/null +++ b/src/lib/types/query.ts @@ -0,0 +1,138 @@ +/** + * Query execution and results types. + * @module types/query + */ + +import type { QueryType } from '../db/query-utils'; + +/** + * Source table information for editable query results. + * Used to identify which table a result set came from for UPDATE/DELETE operations. + */ +export interface SourceTableInfo { + /** Schema name of the source table */ + schema: string; + /** Table name */ + name: string; + /** Primary key column names for row identification */ + primaryKeys: string[]; +} + +/** + * Result of a query execution with pagination support. + */ +export interface QueryResult { + /** Column names in the result set */ + columns: string[]; + /** Row data as key-value objects */ + rows: Record[]; + /** Number of rows in the current page */ + rowCount: number; + /** Total number of rows matching the query */ + totalRows: number; + /** Query execution time in milliseconds */ + executionTime: number; + /** Number of rows affected (for INSERT/UPDATE/DELETE) */ + affectedRows?: number; + /** Last inserted ID (for INSERT with auto-increment) */ + lastInsertId?: number; + /** Type of query that was executed */ + queryType?: QueryType; + /** Source table info for editable results */ + sourceTable?: SourceTableInfo; + /** Current page number (1-indexed) */ + page: number; + /** Number of rows per page */ + pageSize: number; + /** Total number of pages */ + totalPages: number; +} + +/** + * Result of a single SQL statement within a multi-statement query. + * Extends QueryResult with statement-specific metadata. + */ +export interface StatementResult extends QueryResult { + /** Index of this statement in the batch (0-indexed) */ + statementIndex: number; + /** The SQL text of this specific statement */ + statementSql: string; + /** Error message if this statement failed */ + error?: string; + /** Whether this statement resulted in an error */ + isError: boolean; +} + +/** + * Represents an open query editor tab. + */ +export interface QueryTab { + /** Unique tab identifier */ + id: string; + /** Tab display name */ + name: string; + /** SQL query text in the editor */ + query: string; + /** Results from executing the query (one per statement) */ + results?: StatementResult[]; + /** Index of the currently displayed result (for multi-statement queries) */ + activeResultIndex?: number; + /** Whether a query is currently executing */ + isExecuting: boolean; + /** ID of the saved query this tab was loaded from, if any */ + savedQueryId?: string; +} + +/** + * An entry in the query execution history. + */ +export interface QueryHistoryItem { + /** Unique identifier */ + id: string; + /** The executed SQL query */ + query: string; + /** When the query was executed */ + timestamp: Date; + /** Execution time in milliseconds */ + executionTime: number; + /** Number of rows returned or affected */ + rowCount: number; + /** ID of the connection this query was run on */ + connectionId: string; + /** Whether this query is marked as a favorite */ + favorite: boolean; +} + +/** + * A saved query for quick access. + */ +export interface SavedQuery { + /** Unique identifier */ + id: string; + /** User-defined name for the query */ + name: string; + /** The SQL query text */ + query: string; + /** ID of the connection this query belongs to */ + connectionId: string; + /** When the query was first saved */ + createdAt: Date; + /** When the query was last modified */ + updatedAt: Date; +} + +/** + * A message in the AI assistant conversation. + */ +export interface AIMessage { + /** Unique identifier */ + id: string; + /** Who sent the message */ + role: 'user' | 'assistant'; + /** Message content */ + content: string; + /** When the message was sent */ + timestamp: Date; + /** SQL query suggested or discussed, if any */ + query?: string; +} diff --git a/src/lib/types/schema.ts b/src/lib/types/schema.ts new file mode 100644 index 0000000..6e6d0cd --- /dev/null +++ b/src/lib/types/schema.ts @@ -0,0 +1,79 @@ +/** + * Database schema types for tables, columns, and indexes. + * @module types/schema + */ + +/** + * Reference to a foreign key target column. + * Describes which column in another table this foreign key points to. + */ +export interface ForeignKeyRef { + /** Schema name of the referenced table */ + referencedSchema: string; + /** Name of the referenced table */ + referencedTable: string; + /** Name of the referenced column */ + referencedColumn: string; +} + +/** + * Represents a column in a database table. + */ +export interface SchemaColumn { + /** Column name */ + name: string; + /** Data type (e.g., 'varchar(255)', 'integer', 'timestamp') */ + type: string; + /** Whether the column allows NULL values */ + nullable: boolean; + /** Default value expression, if any */ + defaultValue?: string; + /** Whether this column is part of the primary key */ + isPrimaryKey: boolean; + /** Whether this column is a foreign key */ + isForeignKey: boolean; + /** Foreign key reference details, if this is a foreign key */ + foreignKeyRef?: ForeignKeyRef; +} + +/** + * Represents an index on a database table. + */ +export interface SchemaIndex { + /** Index name */ + name: string; + /** Columns included in the index */ + columns: string[]; + /** Whether this is a unique index */ + unique: boolean; + /** Index type (e.g., 'btree', 'hash', 'gin') */ + type: string; +} + +/** + * Represents a table or view in the database schema. + */ +export interface SchemaTable { + /** Table or view name */ + name: string; + /** Schema name (e.g., 'public' in PostgreSQL) */ + schema: string; + /** Whether this is a table or view */ + type: 'table' | 'view'; + /** Approximate row count, if available */ + rowCount?: number; + /** Column definitions */ + columns: SchemaColumn[]; + /** Index definitions */ + indexes: SchemaIndex[]; +} + +/** + * Represents an open schema browser tab. + */ +export interface SchemaTab { + /** Unique tab identifier */ + id: string; + /** The table being viewed */ + table: SchemaTable; +}