From d94335dc6257005a6e2748375bec93c8ec16e85d Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 1 Jul 2026 09:44:22 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(query-editor):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E8=87=AA=E5=AE=9A=E4=B9=89=E6=96=B0=E5=BB=BA=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E9=BB=98=E8=AE=A4=20SQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 appearance 持久化设置中新增 newQuerySqlTemplate,支持默认回退与空白模板 - 新建空白查询标签页按用户模板初始化,不影响外部 SQL、保存查询和只读标签 - 在工具中心补充模板编辑与恢复默认入口,并完善 store、编辑器和 i18n 回归测试 --- frontend/src/App.tool-center.test.ts | 9 ++++ frontend/src/App.tsx | 29 ++++++++++++ .../QueryEditor.external-sql-save.test.tsx | 26 ++++++++++- frontend/src/components/QueryEditor.tsx | 8 +++- .../queryEditor/QueryEditorHelpers.ts | 18 +++++++- frontend/src/i18n/catalog.test.ts | 4 ++ frontend/src/store.test.ts | 45 +++++++++++++++++++ frontend/src/store.ts | 17 +++++++ shared/i18n/de-DE.json | 4 ++ shared/i18n/en-US.json | 4 ++ shared/i18n/ja-JP.json | 4 ++ shared/i18n/ru-RU.json | 4 ++ shared/i18n/zh-CN.json | 4 ++ shared/i18n/zh-TW.json | 4 ++ 14 files changed, 175 insertions(+), 5 deletions(-) diff --git a/frontend/src/App.tool-center.test.ts b/frontend/src/App.tool-center.test.ts index 88748d89..108e76d9 100644 --- a/frontend/src/App.tool-center.test.ts +++ b/frontend/src/App.tool-center.test.ts @@ -451,8 +451,17 @@ describe('global appearance tokens', () => { expect(linuxCJKFontBannerSource).not.toContain('Font Settings'); expect(appSource).toContain("t('app.theme.font_family.linux_cjk_install_prefix')"); expect(appSource).toContain("t('app.theme.font_family.linux_cjk_install_suffix')"); + expect(appSource).toContain("t('app.theme.query_template.title')"); + expect(appSource).toContain("t('app.theme.query_template.description')"); + expect(appSource).toContain("t('app.theme.query_template.hint')"); + expect(appSource).toContain("t('app.theme.query_template.reset_default')"); + expect(appSource).toContain("const newQuerySqlTemplate = appearance.newQuerySqlTemplate ?? DEFAULT_QUERY_TEMPLATE;"); + expect(appSource).toContain("onChange={(event) => setAppearance({ newQuerySqlTemplate: event.target.value })}"); + expect(appSource).toContain("onClick={() => setAppearance({ newQuerySqlTemplate: null })}"); expect(appSource).not.toContain('Ubuntu/Linux 未检测到中文 CJK 字体'); expect(appSource).not.toContain(',然后重启 GoNavi。'); + expect(appSource).not.toContain('新建查询默认 SQL'); + expect(appSource).not.toContain('清空后新建查询将保持空白'); expect(appSource).toContain('setIsLinuxCJKFontBannerDismissed(true)'); expect(appSource).toContain('matchFontFamilyOption'); expect(appSource).toContain('showSearch'); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6052111f..eacf1779 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -71,6 +71,7 @@ import { hasLegacyMigratableSensitiveItems, stripLegacyPersistedConnectionById, } from './utils/legacyConnectionStorage'; +import { DEFAULT_QUERY_TEMPLATE } from './components/queryEditor/QueryEditorHelpers'; import { getSecurityUpdateStatusMeta, resolveSecurityUpdateEntryVisibility, @@ -279,6 +280,7 @@ function App() { : (sanitizeSidebarTreeFontSize(appearance.sidebarTreeFontSize) ?? effectiveFontSize); const effectiveSidebarRailScale = sanitizeV2SidebarRailScale(appearance.v2SidebarRailScale); const tableDoubleClickAction = appearance.tableDoubleClickAction === 'open-design' ? 'open-design' : 'open-data'; + const newQuerySqlTemplate = appearance.newQuerySqlTemplate ?? DEFAULT_QUERY_TEMPLATE; const tabDisplaySettings = useMemo( () => sanitizeTabDisplaySettings(appearance.tabDisplay), [appearance.tabDisplay], @@ -4818,6 +4820,33 @@ function App() { +
+
{t('app.theme.query_template.title')}
+
+
+ {t('app.theme.query_template.description')} +
+ setAppearance({ newQuerySqlTemplate: event.target.value })} + style={{ fontFamily: 'var(--gn-font-mono)' }} + /> +
+
+ {t('app.theme.query_template.hint')} +
+ +
+
+
diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 80dd8027..dd0f7399 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -49,7 +49,10 @@ const storeState = vi.hoisted(() => ({ saveQuery: vi.fn(), theme: 'light', languagePreference: 'zh-CN' as 'zh-CN' | 'en-US', - appearance: { uiVersion: 'legacy' as 'legacy' | 'v2' }, + appearance: { + uiVersion: 'legacy' as 'legacy' | 'v2', + newQuerySqlTemplate: null as string | null, + }, sqlFormatOptions: { keywordCase: 'upper' as const }, setSqlFormatOptions: vi.fn(), queryOptions: { @@ -705,6 +708,7 @@ describe('QueryEditor external SQL save', () => { storeState.aiPanelVisible = false; storeState.setAIPanelVisible.mockReset(); storeState.appearance.uiVersion = 'legacy'; + storeState.appearance.newQuerySqlTemplate = null; storeState.queryOptions = { maxRows: 5000, showColumnComment: true, @@ -833,6 +837,26 @@ describe('QueryEditor external SQL save', () => { expect(editorState.value).toBe('SELECT * FROM '); }); + it('uses the customized new query template for a fresh blank query tab', async () => { + storeState.appearance.newQuerySqlTemplate = 'SELECT id,\n name\nFROM users;'; + + await act(async () => { + create(); + }); + + expect(editorState.value).toBe('SELECT id,\n name\nFROM users;'); + }); + + it('allows a blank new query template when the default content is cleared', async () => { + storeState.appearance.newQuerySqlTemplate = ''; + + await act(async () => { + create(); + }); + + expect(editorState.value).toBe(''); + }); + it('keeps the query results panel hidden by default on first entry', async () => { storeState.appearance.uiVersion = 'v2'; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 222fb8bc..6a1e2ccc 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -124,6 +124,7 @@ import { queryCompletionMetadataRowsBySpecs, readSidebarSqlDropText, matchLeadingSelectTableReference, + resolveNewQueryDefaultTemplate, resolveEventTargetNode, resolveNextResultSetIndex, resolveOracleExactCaseTableReference, @@ -759,7 +760,11 @@ const resetSharedQueryEditorMetadata = () => { const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => { - const [query, setQuery] = useState(getInitialEditorQuery(tab)); + const appearance = useStore(state => state.appearance); + const [query, setQuery] = useState(() => getInitialEditorQuery( + tab, + resolveNewQueryDefaultTemplate(appearance.newQuerySqlTemplate), + )); const isExternalSQLFileTab = Boolean(String(tab.filePath || '').trim()); const isObjectEditQueryTab = tab.type === 'query' && tab.queryMode === 'object-edit'; @@ -860,7 +865,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const theme = useStore(state => state.theme); const languagePreference = useStore((state) => state.languagePreference); void languagePreference; - const appearance = useStore(state => state.appearance); const darkMode = theme === 'dark'; const isV2Ui = appearance.uiVersion === 'v2'; const sqlFormatOptions = useStore(state => state.sqlFormatOptions); diff --git a/frontend/src/components/queryEditor/QueryEditorHelpers.ts b/frontend/src/components/queryEditor/QueryEditorHelpers.ts index 0c12d703..ef4309f7 100644 --- a/frontend/src/components/queryEditor/QueryEditorHelpers.ts +++ b/frontend/src/components/queryEditor/QueryEditorHelpers.ts @@ -682,11 +682,25 @@ export const splitCompletionSchemaAndTable = (qualified: string): { schema: stri export const DEFAULT_QUERY_TEMPLATE = 'SELECT * FROM '; +export const resolveNewQueryDefaultTemplate = ( + template: string | null | undefined, +): string => { + if (template === null || template === undefined) { + return DEFAULT_QUERY_TEMPLATE; + } + return String(template) + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n'); +}; + export const getTabQueryValue = (tab: TabData): string => ( typeof tab.query === 'string' ? tab.query : '' ); -export const getInitialEditorQuery = (tab: TabData): string => { +export const getInitialEditorQuery = ( + tab: TabData, + defaultQueryTemplate: string | null | undefined = DEFAULT_QUERY_TEMPLATE, +): string => { if (hasQueryTabDraft(tab.id)) { return getQueryTabDraft(tab.id); } @@ -694,7 +708,7 @@ export const getInitialEditorQuery = (tab: TabData): string => { if (tabQuery || tab.filePath || tab.savedQueryId || tab.readOnly) { return tabQuery; } - return DEFAULT_QUERY_TEMPLATE; + return resolveNewQueryDefaultTemplate(defaultQueryTemplate); }; export const resolveNextResultSetIndex = (sets: Array<{ key?: string }>): number => { diff --git a/frontend/src/i18n/catalog.test.ts b/frontend/src/i18n/catalog.test.ts index 004e29e4..e4cee93e 100644 --- a/frontend/src/i18n/catalog.test.ts +++ b/frontend/src/i18n/catalog.test.ts @@ -200,6 +200,10 @@ describe("i18n catalog", () => { "app.theme.nav.theme.description", "app.theme.nav.theme.title", "app.theme.navigation_title", + "app.theme.query_template.description", + "app.theme.query_template.hint", + "app.theme.query_template.reset_default", + "app.theme.query_template.title", "app.theme.theme_settings_description", "app.theme.theme_settings_title", "app.theme.ui_version.beta_warning", diff --git a/frontend/src/store.test.ts b/frontend/src/store.test.ts index c36e1c94..e5fb1d66 100644 --- a/frontend/src/store.test.ts +++ b/frontend/src/store.test.ts @@ -82,6 +82,7 @@ describe('store appearance persistence', () => { expect(appearance.sidebarTreeFontSizeFollowGlobal).toBe(true); expect(appearance.customUIFontFamily).toBeNull(); expect(appearance.customMonoFontFamily).toBeNull(); + expect(appearance.newQuerySqlTemplate).toBeNull(); expect(appearance.tabDisplay).toEqual({ layout: 'single', primaryElements: ['connection', 'kind', 'object'], @@ -260,6 +261,50 @@ describe('store appearance persistence', () => { expect(appearance.customMonoFontFamily).toBeNull(); }); + it('persists the new query SQL template while preserving blank and trailing-space overrides', async () => { + const { useStore } = await importStore(); + + useStore.getState().setAppearance({ + newQuerySqlTemplate: 'SELECT * FROM ', + }); + expect(useStore.getState().appearance.newQuerySqlTemplate).toBe('SELECT * FROM '); + + useStore.getState().setAppearance({ + newQuerySqlTemplate: 'SELECT id,\r\n name\nFROM users;\r', + }); + + let persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}'); + expect(persisted.state.appearance.newQuerySqlTemplate).toBe('SELECT id,\n name\nFROM users;\n'); + + vi.resetModules(); + let reloaded = await importStore(); + expect(reloaded.useStore.getState().appearance.newQuerySqlTemplate).toBe('SELECT id,\n name\nFROM users;\n'); + + reloaded.useStore.getState().setAppearance({ + newQuerySqlTemplate: '', + }); + + persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}'); + expect(persisted.state.appearance.newQuerySqlTemplate).toBe(''); + + vi.resetModules(); + reloaded = await importStore(); + expect(reloaded.useStore.getState().appearance.newQuerySqlTemplate).toBe(''); + + storage.setItem('lite-db-storage', JSON.stringify({ + state: { + appearance: { + newQuerySqlTemplate: 123, + }, + }, + version: 13, + })); + + vi.resetModules(); + reloaded = await importStore(); + expect(reloaded.useStore.getState().appearance.newQuerySqlTemplate).toBeNull(); + }); + it('persists v2 sidebar search preferences and sanitizes filter text', async () => { const { useStore } = await importStore(); diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 1f2244d6..2a7abb45 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -105,6 +105,7 @@ export interface AppearanceSettings extends DataGridDisplaySettings { v2SidebarRailScale: number; customUIFontFamily: string | null; customMonoFontFamily: string | null; + newQuerySqlTemplate: string | null; tabDisplay: TabDisplaySettings; redisDbAliases: RedisDbAliasMap; } @@ -126,6 +127,7 @@ export const DEFAULT_APPEARANCE: AppearanceSettings = { v2SidebarRailScale: DEFAULT_V2_SIDEBAR_RAIL_SCALE, customUIFontFamily: null, customMonoFontFamily: null, + newQuerySqlTemplate: null, tabDisplay: DEFAULT_TAB_DISPLAY_SETTINGS, redisDbAliases: DEFAULT_REDIS_DB_ALIASES, ...DEFAULT_DATA_GRID_DISPLAY_SETTINGS, @@ -140,6 +142,7 @@ const DEFAULT_STARTUP_FULLSCREEN = false; const LEGACY_DEFAULT_OPACITY = 0.95; const OPACITY_EPSILON = 1e-6; const MAX_SIDEBAR_PERSISTED_FILTER_LENGTH = 120; +const MAX_NEW_QUERY_SQL_TEMPLATE_LENGTH = 32 * 1024; const sanitizeV2SidebarSearchMode = ( value: unknown, @@ -160,6 +163,19 @@ const sanitizeV2SidebarPersistedFilter = (value: unknown): string => { return value.trim().slice(0, MAX_SIDEBAR_PERSISTED_FILTER_LENGTH); }; +const sanitizeNewQuerySqlTemplate = (value: unknown): string | null => { + if (value === null || value === undefined) { + return DEFAULT_APPEARANCE.newQuerySqlTemplate; + } + if (typeof value !== "string") { + return DEFAULT_APPEARANCE.newQuerySqlTemplate; + } + return value + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .slice(0, MAX_NEW_QUERY_SQL_TEMPLATE_LENGTH); +}; + export const sanitizeV2SidebarRailScale = (value: unknown): number => { return normalizeFloatInRange( value, @@ -2214,6 +2230,7 @@ const sanitizeAppearance = ( ), customUIFontFamily: sanitizeFontFamilyInput(appearance.customUIFontFamily), customMonoFontFamily: sanitizeFontFamilyInput(appearance.customMonoFontFamily), + newQuerySqlTemplate: sanitizeNewQuerySqlTemplate(appearance.newQuerySqlTemplate), tabDisplay: sanitizeTabDisplaySettings(appearance.tabDisplay), redisDbAliases: sanitizeRedisDbAliases(appearance.redisDbAliases), showDataTableVerticalBorders: diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 7cadcfe1..b944cee3 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -2715,6 +2715,10 @@ "app.theme.font_family.mono_title": "Monospace-Schriftfamilie", "app.theme.font_family.title": "Schriftfamilie", "app.theme.font_family.ui_title": "UI-Schriftfamilie", + "app.theme.query_template.description": "Legt fest, welcher SQL-Inhalt beim Öffnen eines neuen Abfrage-Tabs vorbefüllt wird.", + "app.theme.query_template.hint": "Leeren Sie den Text, damit neue Abfrage-Tabs leer starten. Mit Standard wiederherstellen kehren Sie zu SELECT * FROM zurück.", + "app.theme.query_template.reset_default": "Standard wiederherstellen", + "app.theme.query_template.title": "Standard-SQL für neue Abfragen", "app.theme.mac_window.restart_hint": "* Benutzerdefinierte Schaltflächen oben rechts werden ausgeblendet. Falls der Systemfensterstil nicht sofort aktualisiert wird, starten Sie die App neu.", "app.theme.mac_window.title": "macOS-Fenstersteuerung", "app.theme.mac_window.use_native_controls": "Native macOS-Fenstersteuerung verwenden", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 98600194..e645ae34 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -2715,6 +2715,10 @@ "app.theme.font_family.mono_title": "Mono Font Family", "app.theme.font_family.title": "Font Family", "app.theme.font_family.ui_title": "UI Font Family", + "app.theme.query_template.description": "Configure the SQL that is prefilled when you open a fresh query tab.", + "app.theme.query_template.hint": "Clear the text to keep new query tabs blank. Use Restore default to go back to SELECT * FROM .", + "app.theme.query_template.reset_default": "Restore default", + "app.theme.query_template.title": "New Query Default SQL", "app.theme.mac_window.restart_hint": "* Custom top-right buttons are hidden; restart the app if the system window style does not refresh immediately", "app.theme.mac_window.title": "macOS Window Controls", "app.theme.mac_window.use_native_controls": "Use macOS Native Window Controls", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index 09902d0c..680fab9e 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -2715,6 +2715,10 @@ "app.theme.font_family.mono_title": "等幅フォントファミリー", "app.theme.font_family.title": "フォントファミリー", "app.theme.font_family.ui_title": "UI フォントファミリー", + "app.theme.query_template.description": "新しいクエリタブを開いたときに自動入力する SQL を設定します。", + "app.theme.query_template.hint": "空にすると新しいクエリタブは空白のまま開きます。既定に戻すと SELECT * FROM に戻ります。", + "app.theme.query_template.reset_default": "既定に戻す", + "app.theme.query_template.title": "新規クエリの既定 SQL", "app.theme.mac_window.restart_hint": "* 右上のカスタムボタンは非表示に同期されています。システムウィンドウのスタイルがすぐに更新されない場合は、アプリを再起動して確認してください", "app.theme.mac_window.title": "macOS ウィンドウ制御", "app.theme.mac_window.use_native_controls": "macOS ネイティブウィンドウ制御を使用", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index 84e69e3a..1608b3b2 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -2715,6 +2715,10 @@ "app.theme.font_family.mono_title": "Моноширинное семейство шрифтов", "app.theme.font_family.title": "Семейство шрифтов", "app.theme.font_family.ui_title": "Семейство шрифтов UI", + "app.theme.query_template.description": "Настройте SQL, который будет автоматически подставляться при открытии новой вкладки запроса.", + "app.theme.query_template.hint": "Очистите текст, чтобы новые вкладки запроса открывались пустыми. Кнопка восстановления вернет SELECT * FROM .", + "app.theme.query_template.reset_default": "Восстановить по умолчанию", + "app.theme.query_template.title": "SQL по умолчанию для нового запроса", "app.theme.mac_window.restart_hint": "* Пользовательские кнопки справа вверху скрыты. Если системный стиль окна не обновился сразу, перезапустите приложение.", "app.theme.mac_window.title": "Управление окном macOS", "app.theme.mac_window.use_native_controls": "Использовать нативные элементы управления окном macOS", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index 7ecef403..27b41f01 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -2715,6 +2715,10 @@ "app.theme.font_family.mono_title": "代码字体 (Mono Font Family)", "app.theme.font_family.title": "字体族", "app.theme.font_family.ui_title": "界面字体 (UI Font Family)", + "app.theme.query_template.description": "配置新建查询标签页时默认预填的 SQL 内容。", + "app.theme.query_template.hint": "清空后新建查询将保持空白;点击“恢复默认”可回到 SELECT * FROM 。", + "app.theme.query_template.reset_default": "恢复默认", + "app.theme.query_template.title": "新建查询默认 SQL", "app.theme.mac_window.restart_hint": "* 已同步隐藏右上角自定义按钮;如系统窗口样式未立即刷新,可重启应用后再确认", "app.theme.mac_window.title": "macOS 窗口控制", "app.theme.mac_window.use_native_controls": "使用 macOS 原生窗口控制", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index 424b85b7..f35e5b97 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -2715,6 +2715,10 @@ "app.theme.font_family.mono_title": "程式碼字型 (Mono Font Family)", "app.theme.font_family.title": "字型族", "app.theme.font_family.ui_title": "介面字型 (UI Font Family)", + "app.theme.query_template.description": "設定新建查詢分頁時預先填入的 SQL 內容。", + "app.theme.query_template.hint": "清空後新建查詢會保持空白;點擊「恢復預設」可回到 SELECT * FROM 。", + "app.theme.query_template.reset_default": "恢復預設", + "app.theme.query_template.title": "新建查詢預設 SQL", "app.theme.mac_window.restart_hint": "* 已同步隐藏右上角自定义按钮;如系统視窗样式未立即重新整理,可重启套用后再确认", "app.theme.mac_window.title": "macOS 視窗控制", "app.theme.mac_window.use_native_controls": "使用 macOS 原生視窗控制",