From 9e1b0c478e8b10a730fddcfa28954437935674f7 Mon Sep 17 00:00:00 2001 From: Abeautifulsnow Date: Mon, 13 Jul 2026 16:15:09 +0800 Subject: [PATCH] feat(editor): prefill new queries from focused tables --- apps/desktop/src/App.vue | 16 +- .../editor/EditorSettingsDialog.vue | 13 ++ apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + .../__tests__/app/openTabsPersistence.spec.ts | 53 +++++ .../lib/__tests__/sql/newQueryContext.spec.ts | 215 ++++++++++++++++++ .../src/lib/app/openTabsPersistence.ts | 9 + .../src/lib/settings/editorSettingsDraft.ts | 1 + apps/desktop/src/lib/sql/newQueryContext.ts | 96 +++++++- apps/desktop/src/stores/queryStore.ts | 6 +- apps/desktop/src/stores/settingsStore.ts | 4 + 16 files changed, 421 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/sql/newQueryContext.spec.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index a4e010f7ad..aaa0779108 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -38,7 +38,7 @@ import * as api from "@/lib/backend/api"; import { connectionRedactedNameLabel } from "@/lib/connection/connectionPresentation"; import { quickConnectionOpenTarget } from "@/lib/connection/connectionOpenTarget"; import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase"; -import { findTreeNodeById, resolveNewQueryTarget } from "@/lib/sql/newQueryContext"; +import { findTreeNodeById, resolveNewQueryTarget, resolveNewQueryInitialSql } from "@/lib/sql/newQueryContext"; import { buildExecutableObjectSourceStatements, executeObjectSourceSave } from "@/lib/table/objectSourceEditor"; import { resolveExecutableSql, resolveExecutableSqlWithBackend, type SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget"; import { uuid } from "@/lib/common/utils"; @@ -1088,7 +1088,19 @@ async function newQuery() { const conn = connectionStore.getConfig(target.connectionId); if (!conn) return; connectionStore.activeConnectionId = target.connectionId; - const tabId = queryStore.createTab(conn.id, target.database, undefined, "query", target.schema); + // Prefill the editor with `SELECT * FROM ` when enabled and a + // table context (active data/structure tab or selected table node) is available. + // Built before createTab so the tab opens with the content directly (no flash). + const initialSql = resolveNewQueryInitialSql({ + activeTab: activeTab.value, + selectedTreeNode: findTreeNodeById(connectionStore.treeNodes, connectionStore.selectedTreeNodeId), + preferredSource: newQueryContextSource.value, + prefillEnabled: settingsStore.editorSettings.prefillNewQueryWithSelect, + targetConnectionId: target.connectionId, + targetDatabase: target.database, + databaseType: effectiveDatabaseTypeForConnection(conn), + }); + const tabId = queryStore.createTab(conn.id, target.database, undefined, "query", target.schema, initialSql); try { await connectionStore.ensureConnected(target.connectionId); if (target.shouldRefreshDefaultDatabase) { diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index ef0005bcd8..f1562fc78a 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -330,6 +330,7 @@ const editAutoSelectActiveSidebarNode = ref(settingsStore.editorSettings.autoSel const editOpenTabsRestoreMode = ref(settingsStore.editorSettings.openTabsRestoreMode); const editDisconnectTabHandlingMode = ref(settingsStore.editorSettings.disconnectTabHandlingMode); const editReuseDataTab = ref(settingsStore.editorSettings.reuseDataTab); +const editPrefillNewQueryWithSelect = ref(settingsStore.editorSettings.prefillNewQueryWithSelect); const editUpdateNotificationsEnabled = ref(settingsStore.editorSettings.updateNotificationsEnabled); const editSidebarHiddenTablePrefixes = ref(settingsStore.editorSettings.sidebarHiddenTablePrefixes.join("\n")); const editSidebarHideTableComments = ref(settingsStore.editorSettings.sidebarHideTableComments); @@ -415,6 +416,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft { openTabsRestoreMode: editOpenTabsRestoreMode.value, disconnectTabHandlingMode: editDisconnectTabHandlingMode.value, reuseDataTab: editReuseDataTab.value, + prefillNewQueryWithSelect: editPrefillNewQueryWithSelect.value, updateNotificationsEnabled: editUpdateNotificationsEnabled.value, sidebarHideTableComments: editSidebarHideTableComments.value, sidebarAllowHorizontalScroll: editSidebarAllowHorizontalScroll.value, @@ -690,6 +692,7 @@ function syncEditorSettingsDraftFromStore() { editOpenTabsRestoreMode.value = settingsStore.editorSettings.openTabsRestoreMode; editDisconnectTabHandlingMode.value = settingsStore.editorSettings.disconnectTabHandlingMode; editReuseDataTab.value = settingsStore.editorSettings.reuseDataTab; + editPrefillNewQueryWithSelect.value = settingsStore.editorSettings.prefillNewQueryWithSelect; editUpdateNotificationsEnabled.value = settingsStore.editorSettings.updateNotificationsEnabled; editSidebarHiddenTablePrefixes.value = settingsStore.editorSettings.sidebarHiddenTablePrefixes.join("\n"); editSidebarHideTableComments.value = settingsStore.editorSettings.sidebarHideTableComments; @@ -882,6 +885,7 @@ function resetDefaultsForTab(tab: SettingsCategory) { editOpenTabsRestoreMode.value = DEFAULT_EDITOR_SETTINGS.openTabsRestoreMode; editDisconnectTabHandlingMode.value = DEFAULT_EDITOR_SETTINGS.disconnectTabHandlingMode; editReuseDataTab.value = DEFAULT_EDITOR_SETTINGS.reuseDataTab; + editPrefillNewQueryWithSelect.value = DEFAULT_EDITOR_SETTINGS.prefillNewQueryWithSelect; editUpdateNotificationsEnabled.value = DEFAULT_EDITOR_SETTINGS.updateNotificationsEnabled; editSidebarHideTableComments.value = DEFAULT_EDITOR_SETTINGS.sidebarHideTableComments; editSidebarAllowHorizontalScroll.value = DEFAULT_EDITOR_SETTINGS.sidebarAllowHorizontalScroll; @@ -960,6 +964,7 @@ function resetAllDefaults() { editOpenTabsRestoreMode.value = DEFAULT_EDITOR_SETTINGS.openTabsRestoreMode; editDisconnectTabHandlingMode.value = DEFAULT_EDITOR_SETTINGS.disconnectTabHandlingMode; editReuseDataTab.value = DEFAULT_EDITOR_SETTINGS.reuseDataTab; + editPrefillNewQueryWithSelect.value = DEFAULT_EDITOR_SETTINGS.prefillNewQueryWithSelect; editUpdateNotificationsEnabled.value = DEFAULT_EDITOR_SETTINGS.updateNotificationsEnabled; editSidebarHideTableComments.value = DEFAULT_EDITOR_SETTINGS.sidebarHideTableComments; editSidebarAllowHorizontalScroll.value = DEFAULT_EDITOR_SETTINGS.sidebarAllowHorizontalScroll; @@ -2799,6 +2804,14 @@ onUnmounted(cleanupPreviewEditor); + +
+
+ +

{{ t("settings.prefillNewQueryWithSelectDescription") }}

+
+ +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 71fdf20bb2..61e7854a6e 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -3351,6 +3351,8 @@ export default { confirmDangerousSqlExecutionDescription: "When disabled, ALTER, DROP, DELETE, TRUNCATE, and other dangerous SQL run without the warning dialog.", confirmUnsavedSqlClose: "Confirm before closing unsaved SQL", confirmUnsavedSqlCloseDescription: "When disabled, SQL tabs with unsaved edits close or quit without the save confirmation dialog.", + prefillNewQueryWithSelect: "Prefill new query with SELECT *", + prefillNewQueryWithSelectDescription: "When creating a new query, prefill the editor with SELECT * FROM based on the active table tab or the table selected in the sidebar.", sqlVariableSyntax: "SQL variable & placeholder substitution", sqlVariableSyntaxDescription: "Choose which variable and placeholder syntaxes DBX substitutes before running SQL, per database type. All are enabled by default.", sqlVariableSyntax_positional: "Positional placeholder", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index e0eaad353c..8c081ff88f 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -3187,6 +3187,8 @@ export default withEnglishFallback({ confirmDangerousSqlExecutionDescription: "Cuando se desactiva, ALTER, DROP, DELETE, TRUNCATE y otras sentencias peligrosas se ejecutan sin el diálogo de advertencia.", confirmUnsavedSqlClose: "Confirmar antes de cerrar SQL sin guardar", confirmUnsavedSqlCloseDescription: "Cuando se desactiva, las pestañas SQL con ediciones sin guardar se cierran o salen sin el diálogo de confirmación de guardado.", + prefillNewQueryWithSelect: "Rellenar nueva consulta con SELECT *", + prefillNewQueryWithSelectDescription: "Al crear una nueva consulta, rellena el editor con SELECT * FROM según la pestaña de tabla activa o la tabla seleccionada en la barra lateral.", sqlVariableSyntax: "Sustitución de variables y marcadores SQL", sqlVariableSyntaxDescription: "Elige qué sintaxis de variables y marcadores sustituye DBX antes de ejecutar SQL, por tipo de base de datos. Todas están habilitadas de forma predeterminada.", sqlVariableSyntax_positional: "Marcador posicional", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 4b1d62ebf4..72fd78bad3 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -3185,6 +3185,8 @@ export default withEnglishFallback({ confirmDangerousSqlExecutionDescription: "Se disattivato, ALTER, DROP, DELETE, TRUNCATE e altri SQL pericolosi verranno eseguiti senza la finestra di avviso.", confirmUnsavedSqlClose: "Conferma prima di chiudere SQL non salvato", confirmUnsavedSqlCloseDescription: "Se disattivato, le schede SQL con modifiche non salvate verranno chiuse o usciranno senza la finestra di conferma di salvataggio.", + prefillNewQueryWithSelect: "Precompila nuova query con SELECT *", + prefillNewQueryWithSelectDescription: "Alla creazione di una nuova query, precompila l'editor con SELECT * FROM in base alla scheda tabella attiva o alla tabella selezionata nella barra laterale.", sqlVariableSyntax: "Sostituzione di variabili e segnaposto SQL", sqlVariableSyntaxDescription: "Scegli quali sintassi di variabili e segnaposto DBX sostituisce prima di eseguire SQL, per tipo di database. Tutte abilitate per impostazione predefinita.", sqlVariableSyntax_positional: "Segnaposto posizionale", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 0131e57e3c..5f264c1be6 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -3175,6 +3175,8 @@ export default withEnglishFallback({ confirmDangerousSqlExecutionDescription: "無効時、ALTER、DROP、DELETE、TRUNCATEなどの危険なSQLが警告ダイアログなしで実行されます。", confirmUnsavedSqlClose: "未保存のSQLを閉じる前に確認", confirmUnsavedSqlCloseDescription: "無効時、未保存の編集があるSQLタブは保存確認ダイアログなしで閉じるか終了します。", + prefillNewQueryWithSelect: "新規クエリに SELECT を自動入力", + prefillNewQueryWithSelectDescription: "新規クエリ作成時、アクティブなテーブルタブまたはサイドバーで選択したテーブルに基づき、エディタに SELECT * FROM <テーブル名> を自動入力します。", sqlVariableSyntax: "SQL 変数・プレースホルダー置換", sqlVariableSyntaxDescription: "SQL 実行前に置換する変数・プレースホルダー構文をデータベース種別ごとに選択します。既定ではすべて有効です。", sqlVariableSyntax_positional: "位置プレースホルダー", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 6a32db45f2..579f62cff0 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -3187,6 +3187,8 @@ export default withEnglishFallback({ confirmDangerousSqlExecutionDescription: "Quando desativado, ALTER, DROP, DELETE, TRUNCATE e outros SQL perigosos são executados sem a caixa de diálogo de aviso.", confirmUnsavedSqlClose: "Confirmar antes de fechar SQL não salvo", confirmUnsavedSqlCloseDescription: "Quando desativado, abas SQL com edições não salvas são fechadas ou o app é encerrado sem a caixa de diálogo de confirmação de salvamento.", + prefillNewQueryWithSelect: "Preencher nova consulta com SELECT *", + prefillNewQueryWithSelectDescription: "Ao criar uma nova consulta, preenche o editor com SELECT * FROM com base na aba de tabela ativa ou na tabela selecionada na barra lateral.", sqlVariableSyntax: "Substituição de variáveis e espaços reservados SQL", sqlVariableSyntaxDescription: "Escolha quais sintaxes de variáveis e espaços reservados o DBX substitui antes de executar SQL, por tipo de banco de dados. Todas ativadas por padrão.", sqlVariableSyntax_positional: "Espaço reservado posicional", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index f3670b7813..b0f008175d 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -3350,6 +3350,8 @@ export default withEnglishFallback({ confirmDangerousSqlExecutionDescription: "关闭后,ALTER、DROP、DELETE、TRUNCATE 等危险 SQL 将直接执行。", confirmUnsavedSqlClose: "关闭未保存 SQL 前弹出确认", confirmUnsavedSqlCloseDescription: "关闭后,有未保存内容的 SQL 标签页会直接关闭或退出,不再弹出保存确认。", + prefillNewQueryWithSelect: "新建查询时预填充 SELECT 语句", + prefillNewQueryWithSelectDescription: "新建查询时,根据当前激活的数据表标签页或侧边栏选中的表,自动在编辑器中填充 SELECT * FROM <表名>。", sqlVariableSyntax: "SQL 变量与占位符替换", sqlVariableSyntaxDescription: "按数据库类型选择执行 SQL 前替换哪些变量与占位符语法,默认全部开启。", sqlVariableSyntax_positional: "位置占位符", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 775d3f04bd..68a7560d9c 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -3036,6 +3036,8 @@ export default withEnglishFallback({ confirmDangerousSqlExecutionDescription: "關閉後,ALTER、DROP、DELETE、TRUNCATE 等危險 SQL 會直接執行。", confirmUnsavedSqlClose: "關閉未儲存 SQL 前彈出確認", confirmUnsavedSqlCloseDescription: "關閉後,有未儲存內容的 SQL 分頁會直接關閉或結束,不再彈出儲存確認。", + prefillNewQueryWithSelect: "新建查詢時預填 SELECT 語句", + prefillNewQueryWithSelectDescription: "新建查詢時,根據目前啟用的資料表分頁或側邊欄選取的資料表,自動在編輯器中填入 SELECT * FROM <資料表名稱>。", sqlVariableSyntax: "SQL 變數與佔位符替換", sqlVariableSyntaxDescription: "依資料庫類型選擇執行 SQL 前替換哪些變數與佔位符語法,預設全部開啟。", sqlVariableSyntax_positional: "位置佔位符", diff --git a/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts b/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts new file mode 100644 index 0000000000..bb6534d225 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { serializeOpenTabs, restoreOpenTabsPayload } from "@/lib/app/openTabsPersistence"; +import type { QueryTab } from "@/types/database"; + +function queryTab(overrides: Partial): QueryTab { + return { + id: "t1", + title: "query_1", + connectionId: "c1", + database: "db", + mode: "query", + sql: "", + isExecuting: false, + ...overrides, + } as QueryTab; +} + +function roundTrip(tabs: QueryTab[]) { + const saved = serializeOpenTabs(tabs); + return restoreOpenTabsPayload({ tabs: saved, activeTabId: tabs[0]?.id ?? null }).tabs; +} + +describe("openTabsPersistence originalSql round-trip", () => { + it("restores a clean prefilled query tab as clean (sql === originalSql)", () => { + const sql = 'SELECT * FROM "public"."users"'; + const [restored] = roundTrip([queryTab({ sql, originalSql: sql })]); + expect(restored.sql).toBe(sql); + expect(restored.originalSql).toBe(sql); + expect(restored.sql === restored.originalSql).toBe(true); + }); + + it("restores a user-edited scratch query tab as dirty (originalSql stays empty)", () => { + const [restored] = roundTrip([queryTab({ sql: "SELECT 1", originalSql: "" })]); + expect(restored.sql).toBe("SELECT 1"); + expect(restored.originalSql).toBe(""); + expect(restored.sql === restored.originalSql).toBe(false); + }); + + it("restores an empty new query tab as clean", () => { + const [restored] = roundTrip([queryTab({ sql: "", originalSql: "" })]); + expect(restored.sql).toBe(""); + expect(restored.originalSql).toBe(""); + }); + + it("falls back to empty originalSql for old saved state without the field (backward compat)", () => { + const [restored] = restoreOpenTabsPayload({ + tabs: [{ id: "t1", title: "query_1", connectionId: "c1", database: "db", mode: "query", sql: "SELECT 1" }], + activeTabId: "t1", + }).tabs; + expect(restored.sql).toBe("SELECT 1"); + expect(restored.originalSql).toBe(""); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/sql/newQueryContext.spec.ts b/apps/desktop/src/lib/__tests__/sql/newQueryContext.spec.ts new file mode 100644 index 0000000000..062c9b1764 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/newQueryContext.spec.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from "vitest"; +import { buildSelectAllSql, isNewQueryPrefillSupported, resolveNewQueryInitialSql, resolveNewQueryTable, type ResolveNewQueryTableInput } from "@/lib/sql/newQueryContext"; +import type { QueryTab, TreeNode } from "@/types/database"; + +function dataTab(overrides: Partial> = {}): ResolveNewQueryTableInput["activeTab"] { + return { + mode: "data", + connectionId: "conn-1", + database: "app_db", + schema: "public", + title: "users", + tableMeta: { schema: "public", tableName: "users", columns: [], primaryKeys: [] }, + ...overrides, + }; +} + +function tableNode(overrides: Partial> = {}): ResolveNewQueryTableInput["selectedTreeNode"] { + return { type: "table", connectionId: "conn-1", database: "app_db", schema: "public", tableName: "orders", label: "orders", ...overrides }; +} + +describe("resolveNewQueryTable", () => { + it("resolves the table from an active data tab", () => { + const table = resolveNewQueryTable({ activeTab: dataTab(), preferredSource: "tab" }); + expect(table).toEqual({ connectionId: "conn-1", database: "app_db", schema: "public", catalog: undefined, tableName: "users" }); + }); + + it("returns null when a data tab has no loaded tableMeta (still loading or errored)", () => { + // A data tab's title is schema/catalog-qualified (e.g. "public.events"), so it must + // not be used as a bare table name - require the loaded tableMeta instead. + const table = resolveNewQueryTable({ + activeTab: { mode: "data", connectionId: "conn-1", database: "app_db", schema: "public", title: "public.events" }, + preferredSource: "tab", + }); + expect(table).toBeNull(); + }); + + it("resolves the table from an active structure tab", () => { + const table = resolveNewQueryTable({ + activeTab: { mode: "structure", connectionId: "conn-1", database: "app_db", schema: "public", structureTableName: "users" }, + preferredSource: "tab", + }); + expect(table).toEqual({ connectionId: "conn-1", database: "app_db", schema: "public", catalog: undefined, tableName: "users" }); + }); + + it("returns null for a query tab with no table context", () => { + const table = resolveNewQueryTable({ + activeTab: { mode: "query", connectionId: "conn-1", database: "app_db", schema: "public", title: "query_1" }, + preferredSource: "tab", + }); + expect(table).toBeNull(); + }); + + it("resolves the table from a selected sidebar table/view/materialized_view node", () => { + expect(resolveNewQueryTable({ selectedTreeNode: tableNode(), preferredSource: "sidebar" })?.tableName).toBe("orders"); + expect(resolveNewQueryTable({ selectedTreeNode: tableNode({ type: "view" }), preferredSource: "sidebar" })?.tableName).toBe("orders"); + expect(resolveNewQueryTable({ selectedTreeNode: tableNode({ type: "materialized_view" }), preferredSource: "sidebar" })?.tableName).toBe("orders"); + }); + + it("uses the node label when tableName is absent", () => { + const table = resolveNewQueryTable({ + selectedTreeNode: { type: "table", connectionId: "conn-1", database: "app_db", schema: "public", label: "by_label" }, + preferredSource: "sidebar", + }); + expect(table?.tableName).toBe("by_label"); + }); + + it("ignores sidebar nodes that are not tables", () => { + const table = resolveNewQueryTable({ + selectedTreeNode: { type: "schema", connectionId: "conn-1", label: "public" }, + preferredSource: "sidebar", + }); + expect(table).toBeNull(); + }); + + it("prefers the active tab when preferredSource is 'tab'", () => { + const table = resolveNewQueryTable({ activeTab: dataTab(), selectedTreeNode: tableNode(), preferredSource: "tab" }); + expect(table?.tableName).toBe("users"); + }); + + it("prefers the sidebar node when preferredSource is 'sidebar'", () => { + const table = resolveNewQueryTable({ activeTab: dataTab(), selectedTreeNode: tableNode(), preferredSource: "sidebar" }); + expect(table?.tableName).toBe("orders"); + }); + + it("falls back to the secondary context when the primary has no table", () => { + const table = resolveNewQueryTable({ + activeTab: { mode: "query", connectionId: "conn-1", database: "app_db", title: "query_1" }, + selectedTreeNode: tableNode(), + preferredSource: "tab", + }); + expect(table?.tableName).toBe("orders"); + }); + + it("returns null when no context is available", () => { + expect(resolveNewQueryTable({})).toBeNull(); + expect(resolveNewQueryTable({ activeTab: null, selectedTreeNode: null })).toBeNull(); + }); +}); + +describe("buildSelectAllSql", () => { + it("quotes a MySQL table with backticks", () => { + expect(buildSelectAllSql("mysql", { tableName: "users" })).toBe("SELECT * FROM `users`"); + }); + + it("ignores the schema for non-schema-aware databases like MySQL", () => { + expect(buildSelectAllSql("mysql", { schema: "mydb", tableName: "users" })).toBe("SELECT * FROM `users`"); + }); + + it("qualifies and quotes a PostgreSQL table with its schema", () => { + expect(buildSelectAllSql("postgres", { schema: "public", tableName: "users" })).toBe('SELECT * FROM "public"."users"'); + }); + + it("bracket-quotes a SQL Server table", () => { + expect(buildSelectAllSql("sqlserver", { schema: "dbo", tableName: "users" })).toBe("SELECT * FROM [dbo].[users]"); + expect(buildSelectAllSql("sqlserver", { tableName: "users" })).toBe("SELECT * FROM [users]"); + }); + + it("escapes embedded quote characters", () => { + expect(buildSelectAllSql("mysql", { tableName: "a`b" })).toBe("SELECT * FROM `a``b`"); + expect(buildSelectAllSql("postgres", { tableName: 'a"b' })).toBe('SELECT * FROM "a""b"'); + }); +}); + +describe("isNewQueryPrefillSupported", () => { + it("disables the prefill for Neo4j (Cypher, not SQL)", () => { + expect(isNewQueryPrefillSupported("neo4j")).toBe(false); + }); + + it("enables the prefill for standard SQL databases", () => { + expect(isNewQueryPrefillSupported("mysql")).toBe(true); + expect(isNewQueryPrefillSupported("postgres")).toBe(true); + expect(isNewQueryPrefillSupported("sqlserver")).toBe(true); + expect(isNewQueryPrefillSupported("sqlite")).toBe(true); + expect(isNewQueryPrefillSupported("clickhouse")).toBe(true); + }); + + it("enables the prefill when the database type is unknown", () => { + expect(isNewQueryPrefillSupported(undefined)).toBe(true); + }); +}); + +describe("resolveNewQueryInitialSql", () => { + it("prefills SQL from the active table when enabled", () => { + expect( + resolveNewQueryInitialSql({ + activeTab: dataTab(), + prefillEnabled: true, + targetConnectionId: "conn-1", + targetDatabase: "app_db", + databaseType: "postgres", + }), + ).toBe('SELECT * FROM "public"."users"'); + }); + + it("leaves new queries empty when the setting is disabled", () => { + expect( + resolveNewQueryInitialSql({ + activeTab: dataTab(), + prefillEnabled: false, + targetConnectionId: "conn-1", + targetDatabase: "app_db", + databaseType: "postgres", + }), + ).toBeUndefined(); + }); + + it("does not prefill a table from another connection", () => { + expect( + resolveNewQueryInitialSql({ + activeTab: dataTab({ connectionId: "conn-2" }), + prefillEnabled: true, + targetConnectionId: "conn-1", + targetDatabase: "app_db", + databaseType: "postgres", + }), + ).toBeUndefined(); + }); + + it("does not prefill a table from another database on the same connection", () => { + expect( + resolveNewQueryInitialSql({ + activeTab: { mode: "query", connectionId: "conn-1", database: "db_a", title: "query_1" }, + selectedTreeNode: tableNode({ database: "db_b" }), + preferredSource: "tab", + prefillEnabled: true, + targetConnectionId: "conn-1", + targetDatabase: "db_a", + databaseType: "mysql", + }), + ).toBeUndefined(); + }); + + it("leaves new queries empty without a table context", () => { + expect( + resolveNewQueryInitialSql({ + prefillEnabled: true, + targetConnectionId: "conn-1", + targetDatabase: "app_db", + databaseType: "postgres", + }), + ).toBeUndefined(); + }); + + it("does not prefill for unsupported database types (e.g. Neo4j)", () => { + expect( + resolveNewQueryInitialSql({ + activeTab: dataTab(), + prefillEnabled: true, + targetConnectionId: "conn-1", + targetDatabase: "app_db", + databaseType: "neo4j", + }), + ).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/lib/app/openTabsPersistence.ts b/apps/desktop/src/lib/app/openTabsPersistence.ts index ae089d4516..24cdf72020 100644 --- a/apps/desktop/src/lib/app/openTabsPersistence.ts +++ b/apps/desktop/src/lib/app/openTabsPersistence.ts @@ -22,6 +22,7 @@ export interface SavedOpenTab { database: string; schema?: string; sql: string; + originalSql?: string; savedSqlId?: string; externalSqlPath?: string; lastExecutedSql?: string; @@ -69,6 +70,10 @@ function restoredOriginalSql(tab: SavedOpenTab, mode: QueryTab["mode"], sql: str if (mode !== "query") return undefined; if (tab.externalSqlPath) return sql; if (tab.savedSqlId) return sql ? "" : undefined; + // Prefer the persisted originalSql so a clean prefilled query tab (sql === originalSql) + // restores clean instead of being marked dirty. Older saved state without this field + // falls through to "" (preserving prior behavior for user-edited scratch tabs). + if (tab.originalSql !== undefined) return tab.originalSql; return ""; } @@ -81,6 +86,10 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] { database: tab.database, schema: tab.schema, sql: shouldPersistTabSql(tab) ? tab.sql : "", + // Only round-trip originalSql for plain query tabs (no savedSqlId / externalSqlPath): + // saved-SQL and external-file tabs re-derive it on restore, and persisting it here + // would duplicate their (potentially large) SQL text in the open-tabs state. + ...(tab.originalSql !== undefined && !tab.savedSqlId && !tab.externalSqlPath ? { originalSql: tab.originalSql } : {}), savedSqlId: tab.savedSqlId, externalSqlPath: tab.externalSqlPath, ...(tab.lastExecutedSql !== undefined ? { lastExecutedSql: tab.lastExecutedSql } : {}), diff --git a/apps/desktop/src/lib/settings/editorSettingsDraft.ts b/apps/desktop/src/lib/settings/editorSettingsDraft.ts index 3799094929..75ec3c2cbe 100644 --- a/apps/desktop/src/lib/settings/editorSettingsDraft.ts +++ b/apps/desktop/src/lib/settings/editorSettingsDraft.ts @@ -38,6 +38,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [ "openTabsRestoreMode", "disconnectTabHandlingMode", "reuseDataTab", + "prefillNewQueryWithSelect", "updateNotificationsEnabled", "sidebarHideTableComments", "sidebarAllowHorizontalScroll", diff --git a/apps/desktop/src/lib/sql/newQueryContext.ts b/apps/desktop/src/lib/sql/newQueryContext.ts index b52182100c..449f3cd1fd 100644 --- a/apps/desktop/src/lib/sql/newQueryContext.ts +++ b/apps/desktop/src/lib/sql/newQueryContext.ts @@ -1,5 +1,6 @@ import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase"; -import type { ConnectionConfig, QueryTab, TreeNode } from "@/types/database"; +import { qualifiedTableName } from "@/lib/table/tableSelectSql"; +import type { ConnectionConfig, DatabaseType, QueryTab, TreeNode } from "@/types/database"; export interface NewQueryTarget { connectionId: string; @@ -59,3 +60,96 @@ function targetFromContext(context: Pick | null; + selectedTreeNode?: Pick | null; + preferredSource?: NewQueryContextSource; +} + +export interface ResolveNewQueryInitialSqlInput extends ResolveNewQueryTableInput { + prefillEnabled: boolean; + targetConnectionId: string; + targetDatabase: string; + databaseType?: DatabaseType; +} + +// Database types whose "table" view does not use standard SQL `SELECT * FROM
` +// (e.g. Neo4j uses Cypher). The new-query prefill is skipped for these. +const NEW_QUERY_PREFILL_DISABLED_TYPES: ReadonlySet = new Set(["neo4j"]); + +export function isNewQueryPrefillSupported(databaseType: DatabaseType | undefined): boolean { + return !NEW_QUERY_PREFILL_DISABLED_TYPES.has(databaseType); +} + +function tableFromTab(tab: ResolveNewQueryTableInput["activeTab"]): NewQueryTable | null { + if (!tab?.connectionId) return null; + if (tab.mode === "data") { + // Require the loaded tableMeta: a data tab's title is schema/catalog-qualified + // (e.g. "public.users"), so using it as a bare table name while tableMeta is + // still loading or errored would yield an invalid double-qualified reference. + const meta = tab.tableMeta; + const tableName = meta?.tableName?.trim(); + if (!tableName) return null; + return { connectionId: tab.connectionId, database: tab.database, schema: meta?.schema ?? tab.schema, catalog: meta?.catalog, tableName }; + } + if (tab.mode === "structure") { + const tableName = (tab.structureTableName || "").trim(); + if (!tableName) return null; + return { connectionId: tab.connectionId, database: tab.database, schema: tab.schema, tableName }; + } + return null; +} + +function tableFromNode(node: ResolveNewQueryTableInput["selectedTreeNode"]): NewQueryTable | null { + if (!node?.connectionId) return null; + if (node.type !== "table" && node.type !== "view" && node.type !== "materialized_view") return null; + const tableName = (node.tableName || node.label || "").trim(); + if (!tableName) return null; + return { connectionId: node.connectionId, database: node.database || "", schema: node.schema, catalog: node.catalog, tableName }; +} + +/** + * Resolves the "focused table" for a new query, mirroring the primary/secondary + * selection of {@link resolveNewQueryTarget}: when `preferredSource` is `"sidebar"` + * the selected tree node wins, otherwise the active tab wins; the other is the + * fallback. Returns null when no table context is available. + */ +export function resolveNewQueryTable(input: ResolveNewQueryTableInput): NewQueryTable | null { + const tabFirst = input.preferredSource !== "sidebar"; + const primary = tabFirst ? tableFromTab(input.activeTab) : tableFromNode(input.selectedTreeNode); + if (primary) return primary; + return tabFirst ? tableFromNode(input.selectedTreeNode) : tableFromTab(input.activeTab); +} + +/** + * Builds a `SELECT * FROM
` statement for the new-query prefill, reusing + * the same per-dialect identifier quoting and schema/catalog qualification used + * by the table-data view. + */ +export function buildSelectAllSql(databaseType: DatabaseType | undefined, table: Pick): string { + const ref = qualifiedTableName({ databaseType, schema: table.schema, catalog: table.catalog, tableName: table.tableName }); + return `SELECT * FROM ${ref}`; +} + +/** + * Resolves the optional initial SQL for a new query tab. A table from another + * connection or database is intentionally ignored because it cannot safely run + * in the execution context selected for the new tab. + */ +export function resolveNewQueryInitialSql(input: ResolveNewQueryInitialSqlInput): string | undefined { + if (!input.prefillEnabled || !isNewQueryPrefillSupported(input.databaseType)) return undefined; + + const table = resolveNewQueryTable(input); + if (!table || table.connectionId !== input.targetConnectionId || table.database !== input.targetDatabase) return undefined; + + return buildSelectAllSql(input.databaseType, table); +} diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 741886a3bd..7acc17e3b9 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -866,7 +866,7 @@ export const useQueryStore = defineStore("query", () => { return tabs.value.find((tab) => tab.connectionId === connectionId && tab.database === database && tab.title === title && tab.mode === mode && (tab.schema || "") === (schema || "")); } - function createTab(connectionId: string, database: string, title?: string, mode: QueryTab["mode"] = "query", schema?: string) { + function createTab(connectionId: string, database: string, title?: string, mode: QueryTab["mode"] = "query", schema?: string, initialSql?: string) { if (title) { const existing = findTabByIdentity(connectionId, database, title, mode, schema); if (existing) { @@ -883,13 +883,13 @@ export const useQueryStore = defineStore("query", () => { connectionId, database, schema, - sql: "", + sql: initialSql ?? "", isExecuting: false, isCancelling: false, isExplaining: false, mode, }; - if (mode === "query") tab.originalSql = ""; + if (mode === "query") tab.originalSql = initialSql ?? ""; tabs.value.push(tab); activeTabId.value = id; return id; diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 6c1fc85718..1a1cb91b11 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -413,6 +413,7 @@ export interface EditorSettings { openTabsRestoreMode: OpenTabsRestoreMode; disconnectTabHandlingMode: DisconnectTabHandlingMode; reuseDataTab: boolean; + prefillNewQueryWithSelect: boolean; updateNotificationsEnabled: boolean; sidebarHiddenTablePrefixes: string[]; sidebarHideTableComments: boolean; @@ -548,6 +549,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { openTabsRestoreMode: "all", disconnectTabHandlingMode: "close-tabs", reuseDataTab: false, + prefillNewQueryWithSelect: true, updateNotificationsEnabled: true, sidebarHiddenTablePrefixes: [], sidebarHideTableComments: false, @@ -783,6 +785,7 @@ export function normalizeEditorSettings(settings: Partial, exist openTabsRestoreMode: normalizeOpenTabsRestoreMode((settings as Partial).openTabsRestoreMode, (settings as Partial & { restoreOpenTabsOnLaunch?: boolean }).restoreOpenTabsOnLaunch), disconnectTabHandlingMode: normalizeDisconnectTabHandlingMode((settings as Partial).disconnectTabHandlingMode, (settings as Partial & { closeQueryTabsOnDisconnect?: boolean }).closeQueryTabsOnDisconnect), reuseDataTab: settings.reuseDataTab ?? DEFAULT_EDITOR_SETTINGS.reuseDataTab, + prefillNewQueryWithSelect: typeof settings.prefillNewQueryWithSelect === "boolean" ? settings.prefillNewQueryWithSelect : DEFAULT_EDITOR_SETTINGS.prefillNewQueryWithSelect, updateNotificationsEnabled: settings.updateNotificationsEnabled ?? DEFAULT_EDITOR_SETTINGS.updateNotificationsEnabled, sidebarHiddenTablePrefixes: normalizeSidebarHiddenTablePrefixes(settings.sidebarHiddenTablePrefixes), sidebarHideTableComments: settings.sidebarHideTableComments ?? DEFAULT_EDITOR_SETTINGS.sidebarHideTableComments, @@ -1030,6 +1033,7 @@ export const useSettingsStore = defineStore("settings", () => { if (partial.openTabsRestoreMode !== undefined) editorSettings.value.openTabsRestoreMode = normalizeOpenTabsRestoreMode(partial.openTabsRestoreMode); if (partial.disconnectTabHandlingMode !== undefined) editorSettings.value.disconnectTabHandlingMode = normalizeDisconnectTabHandlingMode(partial.disconnectTabHandlingMode); if (partial.reuseDataTab !== undefined) editorSettings.value.reuseDataTab = partial.reuseDataTab; + if (partial.prefillNewQueryWithSelect !== undefined) editorSettings.value.prefillNewQueryWithSelect = partial.prefillNewQueryWithSelect; if (partial.updateNotificationsEnabled !== undefined) editorSettings.value.updateNotificationsEnabled = partial.updateNotificationsEnabled; if (partial.sidebarHiddenTablePrefixes !== undefined) editorSettings.value.sidebarHiddenTablePrefixes = normalizeSidebarHiddenTablePrefixes(partial.sidebarHiddenTablePrefixes); if (partial.sidebarHideTableComments !== undefined) editorSettings.value.sidebarHideTableComments = partial.sidebarHideTableComments;