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}
+
+
+
+
+
+ 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