From e0cc787f0a838775c5c4beed1ba54b343d6fba86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Thu, 10 Sep 2026 15:57:27 +0800 Subject: [PATCH] feat(query): auto-keep results by default and fix restore race --- .../editor/EditorSettingsDialog.vue | 13 +++ apps/desktop/src/i18n/locales/az.ts | 2 + 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/ko.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/tr.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + .../src/lib/app/openTabsPersistence.ts | 4 +- .../src/lib/settings/editorSettingsDraft.ts | 1 + .../src/lib/settings/settingsSearch.ts | 1 + .../src/lib/settings/settingsTransfer.ts | 1 + .../queryStore.multiStatementError.spec.ts | 103 +++++++++++++++++- apps/desktop/src/stores/queryStore.ts | 100 ++++++++++++++--- apps/desktop/src/stores/settingsStore.ts | 4 + apps/desktop/src/types/database.ts | 1 + 19 files changed, 231 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 576663c5fd..6288f6f6fa 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -532,6 +532,7 @@ const editDataGridFilterEditorView = ref(settingsStore const editDataGridKeepFilterEditorExpanded = ref(settingsStore.editorSettings.dataGridKeepFilterEditorExpanded); const dataGridFilterViewPreviewExpanded = ref(true); const editDataGridTextFilterPanelHeight = ref(settingsStore.editorSettings.dataGridTextFilterPanelHeight); +const editDefaultAutoKeepResults = ref(settingsStore.editorSettings.defaultAutoKeepResults); const editMultiStatementDefaultView = ref(settingsStore.editorSettings.multiStatementDefaultView); const editDataGridAutoTransposeSingleRow = ref(settingsStore.editorSettings.dataGridAutoTransposeSingleRow); const editDataGridCellDetailButtonVisible = ref(settingsStore.editorSettings.dataGridCellDetailButtonVisible); @@ -757,6 +758,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft { dataGridFilterEditorView: editDataGridFilterEditorView.value, dataGridKeepFilterEditorExpanded: editDataGridKeepFilterEditorExpanded.value, dataGridTextFilterPanelHeight: editDataGridTextFilterPanelHeight.value, + defaultAutoKeepResults: editDefaultAutoKeepResults.value, multiStatementDefaultView: editMultiStatementDefaultView.value, dataGridAutoTransposeSingleRow: editDataGridAutoTransposeSingleRow.value, dataGridCellDetailButtonVisible: editDataGridCellDetailButtonVisible.value, @@ -1254,6 +1256,7 @@ function syncEditorSettingsDraftFromStore() { editDataGridFilterEditorView.value = settingsStore.editorSettings.dataGridFilterEditorView; editDataGridKeepFilterEditorExpanded.value = settingsStore.editorSettings.dataGridKeepFilterEditorExpanded; editDataGridTextFilterPanelHeight.value = settingsStore.editorSettings.dataGridTextFilterPanelHeight; + editDefaultAutoKeepResults.value = settingsStore.editorSettings.defaultAutoKeepResults; editMultiStatementDefaultView.value = settingsStore.editorSettings.multiStatementDefaultView; editDataGridAutoTransposeSingleRow.value = settingsStore.editorSettings.dataGridAutoTransposeSingleRow; editDataGridCellDetailButtonVisible.value = settingsStore.editorSettings.dataGridCellDetailButtonVisible; @@ -1370,6 +1373,7 @@ const editorSettingsDraftRefs: EditorSettingsDraftRefMap = { dataGridFilterEditorView: editDataGridFilterEditorView, dataGridKeepFilterEditorExpanded: editDataGridKeepFilterEditorExpanded, dataGridTextFilterPanelHeight: editDataGridTextFilterPanelHeight, + defaultAutoKeepResults: editDefaultAutoKeepResults, multiStatementDefaultView: editMultiStatementDefaultView, dataGridAutoTransposeSingleRow: editDataGridAutoTransposeSingleRow, dataGridCellDetailButtonVisible: editDataGridCellDetailButtonVisible, @@ -1739,6 +1743,7 @@ function resetDefaultsForTab(tab: SettingsCategory) { editDataGridFilterEditorView.value = DEFAULT_EDITOR_SETTINGS.dataGridFilterEditorView; editDataGridKeepFilterEditorExpanded.value = DEFAULT_EDITOR_SETTINGS.dataGridKeepFilterEditorExpanded; editDataGridTextFilterPanelHeight.value = DEFAULT_EDITOR_SETTINGS.dataGridTextFilterPanelHeight; + editDefaultAutoKeepResults.value = DEFAULT_EDITOR_SETTINGS.defaultAutoKeepResults; editMultiStatementDefaultView.value = DEFAULT_EDITOR_SETTINGS.multiStatementDefaultView; editDataGridAutoTransposeSingleRow.value = DEFAULT_EDITOR_SETTINGS.dataGridAutoTransposeSingleRow; editDataGridCellDetailButtonVisible.value = DEFAULT_EDITOR_SETTINGS.dataGridCellDetailButtonVisible; @@ -1826,6 +1831,7 @@ function resetAllDefaults() { editDataGridFilterEditorView.value = DEFAULT_EDITOR_SETTINGS.dataGridFilterEditorView; editDataGridKeepFilterEditorExpanded.value = DEFAULT_EDITOR_SETTINGS.dataGridKeepFilterEditorExpanded; editDataGridTextFilterPanelHeight.value = DEFAULT_EDITOR_SETTINGS.dataGridTextFilterPanelHeight; + editDefaultAutoKeepResults.value = DEFAULT_EDITOR_SETTINGS.defaultAutoKeepResults; editMultiStatementDefaultView.value = DEFAULT_EDITOR_SETTINGS.multiStatementDefaultView; editDataGridAutoTransposeSingleRow.value = DEFAULT_EDITOR_SETTINGS.dataGridAutoTransposeSingleRow; editDataGridCellDetailButtonVisible.value = DEFAULT_EDITOR_SETTINGS.dataGridCellDetailButtonVisible; @@ -6814,6 +6820,13 @@ onUnmounted(() => { @update:model-value="updatePageSizeDraft" /> +
+
+ +

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

+
+ +
diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 87a26d0862..cd57bac214 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -6795,6 +6795,8 @@ export default withEnglishFallback({ tableOpenPageSizeDescription: "Yeni cədvəl açıldıqda hər səhifədə yüklənən sətirlər. Cədvəldə səhifə başına sətir sayını dəyişmək gələcək cədvəl vərəqləri üçün bu standartı yeniləyir.", queryPageSize: "Sorğuda səhifə başına standart sətir sayı", queryPageSizeDescription: "Sorğu nəticəsinin hər səhifəsində yüklənən sətirlər. Dəstəklənən maksimum səhifə ölçüsü {max} təşkil edir.", + defaultAutoKeepResults: "Sorğu nəticələrini standart olaraq avtomatik saxla", + defaultAutoKeepResultsDescription: "Tətbiq edildikdə açıq sorğu vərəqləri yenilənir və yeni vərəqlər üçün standart təyin olunur. Hər vərəqdə ayrıca dəyişə bilərsiniz.", multiStatementDefaultView: "Bir neçə əmr üçün standart görünüş", multiStatementDefaultViewDescription: "Bir neçə SQL əmrindən ibarət toplunun nəticə cədvəlində, yoxsa icra xülasəsində açılacağını seçin.", queryResultMaxRows: "Sorğu nəticəsində maksimum sətir sayı", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index cb03cd4d47..fdbf4392db 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -6792,6 +6792,8 @@ export default { tableOpenPageSizeDescription: "Rows loaded per page when opening a new table. Changing rows per page in a table updates this default for future table tabs.", queryPageSize: "Default query rows per page", queryPageSizeDescription: "Rows loaded per query result page. The maximum supported page size is {max}.", + defaultAutoKeepResults: "Auto-keep query results by default", + defaultAutoKeepResultsDescription: "Applying this setting updates open query tabs and sets the default for new ones. You can still toggle it per tab.", multiStatementDefaultView: "Default view for multiple statements", multiStatementDefaultViewDescription: "Choose whether batches with multiple SQL statements open in the result table or execution summary.", queryResultMaxRows: "Maximum query result rows", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 0d9107f969..30ff402c6b 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -6364,6 +6364,8 @@ export default withEnglishFallback({ tableOpenPageSizeDescription: "Filas cargadas por página al abrir una tabla nueva. Cambiar las filas por página actualiza este valor para futuras pestañas de tablas.", queryPageSize: "Filas predeterminadas por página de consulta", queryPageSizeDescription: "Filas cargadas por página de resultados. Se admiten hasta {max} filas.", + defaultAutoKeepResults: "Conservar resultados automáticamente por defecto", + defaultAutoKeepResultsDescription: "Al aplicar este ajuste se actualizan las pestañas de consulta abiertas y se establece el valor predeterminado para las nuevas. Puedes cambiarlo en cada pestaña.", multiStatementDefaultView: "Vista predeterminada para varias sentencias", multiStatementDefaultViewDescription: "Elige si los lotes con varias sentencias SQL se abren en la tabla de resultados o en el resumen de ejecución.", queryResultMaxRows: "Máximo de filas del resultado", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 0428f584b3..862a8c209b 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -6364,6 +6364,8 @@ export default withEnglishFallback({ tableOpenPageSizeDescription: "Righe caricate per pagina quando si apre una nuova tabella. La modifica delle righe per pagina aggiorna questo valore per le future schede tabella.", queryPageSize: "Righe predefinite per pagina di query", queryPageSizeDescription: "Righe caricate per pagina dei risultati. Sono supportate fino a {max} righe.", + defaultAutoKeepResults: "Mantieni automaticamente i risultati per impostazione predefinita", + defaultAutoKeepResultsDescription: "Applicando questa impostazione si aggiornano le schede di query aperte e si imposta il valore predefinito per le nuove. Puoi modificarlo per ogni scheda.", multiStatementDefaultView: "Vista predefinita per istruzioni multiple", multiStatementDefaultViewDescription: "Scegli se i batch con più istruzioni SQL si aprono nella tabella dei risultati o nel riepilogo dell'esecuzione.", queryResultMaxRows: "Numero massimo di righe del risultato", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 08a0b8e352..6eff0224bb 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -6398,6 +6398,8 @@ export default withEnglishFallback({ tableOpenPageSizeDescription: "新しいテーブルを開くときに 1 ページあたり読み込む行数です。テーブルで行数を変更すると、今後のテーブルタブのデフォルトも更新されます。", queryPageSize: "クエリ結果のデフォルト行数", queryPageSizeDescription: "クエリ結果の 1 ページあたりの行数です。最大 {max} 行まで指定できます。", + defaultAutoKeepResults: "クエリ結果を既定で自動保持", + defaultAutoKeepResultsDescription: "適用すると開いているクエリタブを更新し、新しいタブの既定値にも設定します。タブごとに切り替えられます。", multiStatementDefaultView: "複数ステートメントの既定ビュー", multiStatementDefaultViewDescription: "複数の SQL ステートメントを含むバッチを、結果テーブルまたは実行サマリーのどちらで開くか選択します。", queryResultMaxRows: "クエリ結果の最大行数", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index f6543397b5..d7bdbc5625 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -6105,6 +6105,8 @@ export default withEnglishFallback({ tableOpenPageSizeDescription: "새 테이블을 열 때 페이지당 로드되는 행 수입니다. 테이블에서 페이지당 행을 변경하면 향후 테이블 탭의 기본값이 업데이트됩니다.", queryPageSize: "쿼리 결과 기본 페이지당 행 수", queryPageSizeDescription: "쿼리 결과 페이지당 로드할 행 수입니다. 최대 {max}행을 지원합니다.", + defaultAutoKeepResults: "기본적으로 쿼리 결과 자동 보관", + defaultAutoKeepResultsDescription: "적용하면 열린 쿼리 탭을 업데이트하고 새 탭의 기본값으로 설정합니다. 탭별로 전환할 수 있습니다.", multiStatementDefaultView: "여러 구문 기본 보기", multiStatementDefaultViewDescription: "여러 SQL 구문이 있는 배치를 결과 테이블 또는 실행 요약 중 어디에서 열지 선택합니다.", queryResultMaxRows: "쿼리 결과 최대 행 수", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index bedd3630a0..1e8d992e43 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -6366,6 +6366,8 @@ export default withEnglishFallback({ tableOpenPageSizeDescription: "Linhas carregadas por página ao abrir uma nova tabela. Alterar as linhas por página atualiza esse padrão para futuras abas de tabelas.", queryPageSize: "Linhas padrão por página de consulta", queryPageSizeDescription: "Linhas carregadas por página de resultados. São suportadas até {max} linhas.", + defaultAutoKeepResults: "Manter resultados automaticamente por padrão", + defaultAutoKeepResultsDescription: "Ao aplicar esta configuração, as abas de consulta abertas são atualizadas e o padrão para novas abas é definido. Você pode alternar em cada aba.", multiStatementDefaultView: "Visualização padrão para várias instruções", multiStatementDefaultViewDescription: "Escolha se lotes com várias instruções SQL abrem na tabela de resultados ou no resumo da execução.", queryResultMaxRows: "Máximo de linhas do resultado", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index ef7ee49b77..35c807732f 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -6677,6 +6677,8 @@ export default withEnglishFallback({ tableOpenPageSizeDescription: "Yeni bir tablo açıldığında sayfa başına yüklenen satır sayısı. Bir tabloda sayfa başına satırı değiştirmek, sonraki tablo sekmeleri için bu varsayılanı günceller.", queryPageSize: "Varsayılan sorgu sayfa başına satır", queryPageSizeDescription: "Sorgu sonucu sayfası başına yüklenen satır sayısı. Desteklenen en büyük sayfa boyutu {max}.", + defaultAutoKeepResults: "Sorgu sonuçlarını varsayılan olarak otomatik sakla", + defaultAutoKeepResultsDescription: "Bu ayar uygulandığında açık sorgu sekmeleri güncellenir ve yeni sekmeler için varsayılan belirlenir. Her sekmede ayrı ayrı değiştirebilirsiniz.", multiStatementDefaultView: "Birden çok ifade için varsayılan görünüm", multiStatementDefaultViewDescription: "Birden çok SQL ifadesi içeren toplu işlerin sonuç tablosunda mı yoksa yürütme özetinde mi açılacağını seçin.", queryResultMaxRows: "En fazla sorgu sonucu satırı", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index eb9fc63fca..1db74c43e0 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -6775,6 +6775,8 @@ export default withEnglishFallback({ tableOpenPageSizeDescription: "新打开数据表时每页加载的行数。在表数据页修改每页行数后,也会更新后续新建表标签页的默认值。", queryPageSize: "查询结果默认每页行数", queryPageSizeDescription: "每个查询结果分页加载的行数,最大支持 {max} 行。", + defaultAutoKeepResults: "默认自动保留查询结果", + defaultAutoKeepResultsDescription: "应用后同步更新已打开的查询标签,新建查询标签也使用此设置。仍可在标签内单独切换。", multiStatementDefaultView: "多语句执行默认视图", multiStatementDefaultViewDescription: "选择多条 SQL 语句批量执行后默认打开数据表或执行摘要。", queryResultMaxRows: "查询结果最大行数", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 1f2a5257db..9ac16c51ff 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -5679,6 +5679,8 @@ export default withEnglishFallback({ tableOpenPageSizeDescription: "新開啟資料表時每頁載入的列數。在資料表頁面調整每頁列數後,也會更新後續新建資料表分頁的預設值。", queryPageSize: "查詢結果預設每頁列數", queryPageSizeDescription: "每個查詢結果分頁載入的列數,最多支援 {max} 列。", + defaultAutoKeepResults: "預設自動保留查詢結果", + defaultAutoKeepResultsDescription: "套用後同步更新已開啟的查詢分頁,新建查詢分頁也使用此設定。仍可在分頁內個別切換。", multiStatementDefaultView: "多語句執行預設檢視", multiStatementDefaultViewDescription: "選擇多條 SQL 語句批次執行後預設開啟資料表或執行摘要。", queryResultMaxRows: "查詢結果最大列數", diff --git a/apps/desktop/src/lib/app/openTabsPersistence.ts b/apps/desktop/src/lib/app/openTabsPersistence.ts index e945bb8da5..33a8d1ae5b 100644 --- a/apps/desktop/src/lib/app/openTabsPersistence.ts +++ b/apps/desktop/src/lib/app/openTabsPersistence.ts @@ -194,7 +194,7 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] { } : {}), ...(tab.mode === "query" && tab.activeResultRunId !== undefined ? { activeResultRunId: tab.activeResultRunId } : {}), - ...(tab.mode === "query" && tab.resultAutoSave ? { resultAutoSave: true } : {}), + ...(tab.mode === "query" && typeof tab.resultAutoSave === "boolean" ? { resultAutoSave: tab.resultAutoSave } : {}), })); } @@ -245,7 +245,7 @@ function restoreOpenTabsArray(parsed: unknown, rawActiveTabId: string | null, op resultCacheState: mode !== "data" && tab.resultCacheKey ? "disk" : undefined, resultRuns, activeResultRunId: resultRuns?.some((run) => run.id === tab.activeResultRunId) ? tab.activeResultRunId : resultRuns?.[0]?.id, - resultAutoSave: mode === "query" && tab.resultAutoSave ? true : undefined, + resultAutoSave: mode === "query" && typeof tab.resultAutoSave === "boolean" ? tab.resultAutoSave : undefined, }; }); const activeTabId = rawActiveTabId || null; diff --git a/apps/desktop/src/lib/settings/editorSettingsDraft.ts b/apps/desktop/src/lib/settings/editorSettingsDraft.ts index c6966cda37..81d0ff08de 100644 --- a/apps/desktop/src/lib/settings/editorSettingsDraft.ts +++ b/apps/desktop/src/lib/settings/editorSettingsDraft.ts @@ -52,6 +52,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [ "dataGridFilterEditorView", "dataGridKeepFilterEditorExpanded", "dataGridTextFilterPanelHeight", + "defaultAutoKeepResults", "multiStatementDefaultView", "dataGridAutoTransposeSingleRow", "dataGridCellDetailButtonVisible", diff --git a/apps/desktop/src/lib/settings/settingsSearch.ts b/apps/desktop/src/lib/settings/settingsSearch.ts index d35423ab98..019b0f2292 100644 --- a/apps/desktop/src/lib/settings/settingsSearch.ts +++ b/apps/desktop/src/lib/settings/settingsSearch.ts @@ -194,6 +194,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] = { id: "navigation-disconnect-tabs", category: "navigation", titleKey: "settings.disconnectTabHandlingMode", descriptionKey: "settings.disconnectTabHandlingModeDescription", targetId: "navigation" }, { id: "query-page-size", category: "data", titleKey: "settings.queryPageSize", descriptionKey: "settings.queryPageSizeDescription", targetId: "data" }, { id: "data-page-size", category: "data", titleKey: "settings.tableOpenPageSize", descriptionKey: "settings.tableOpenPageSizeDescription", targetId: "data" }, + { id: "default-auto-keep-results", category: "data", titleKey: "settings.defaultAutoKeepResults", descriptionKey: "settings.defaultAutoKeepResultsDescription", targetId: "default-auto-keep-results" }, { id: "multi-statement-default-view", category: "data", titleKey: "settings.multiStatementDefaultView", descriptionKey: "settings.multiStatementDefaultViewDescription", targetId: "multi-statement-default-view" }, { id: "query-result-max-rows", category: "data", titleKey: "settings.queryResultMaxRows", descriptionKey: "settings.queryResultMaxRowsDescription", targetId: "data" }, { id: "data-grid-header-comments", category: "data", titleKey: "settings.showColumnCommentsInHeader", descriptionKey: "settings.showColumnCommentsInHeaderDescription", targetId: "data" }, diff --git a/apps/desktop/src/lib/settings/settingsTransfer.ts b/apps/desktop/src/lib/settings/settingsTransfer.ts index 0a8606534c..7ca1051ffa 100644 --- a/apps/desktop/src/lib/settings/settingsTransfer.ts +++ b/apps/desktop/src/lib/settings/settingsTransfer.ts @@ -143,6 +143,7 @@ const SETTINGS_TRANSFER_CATEGORY_KEYS: Record ({ + defaultAutoKeepResults: false, analyzeEditableQueryEditability: vi.fn(), cancelQuery: vi.fn(), closeClientConnectionSession: vi.fn(), @@ -42,7 +43,7 @@ vi.mock("@/stores/connectionStore", () => ({ vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ - editorSettings: { autoCalculateTotalRows: false, pageSize: 100, continueOnErrorOnBatch: false }, + editorSettings: { autoCalculateTotalRows: false, pageSize: 100, continueOnErrorOnBatch: false, defaultAutoKeepResults: mocks.defaultAutoKeepResults }, }), })); @@ -112,6 +113,7 @@ function structuredSqlError(detail = "duplicate key") { describe("queryStore multi-statement errors", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.defaultAutoKeepResults = false; vi.unstubAllGlobals(); mocks.tabResultSnapshots.clear(); installLocalStorage(); @@ -1221,4 +1223,103 @@ describe("queryStore multi-statement errors", () => { expect(tab.resultRuns).toBeUndefined(); expect(tab.activeResultRunId).toBeUndefined(); }); + + async function prepareDiskBackedAutoKeptResult() { + mocks.defaultAutoKeepResults = true; + const { useQueryStore } = await import("@/stores/queryStore"); + const cache = await import("@/lib/tabs/tabResultCache"); + const store = useQueryStore(); + const id = store.createTab("mysql-1", "app", "Restore"); + mocks.executeMulti.mockResolvedValue([{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]); + await store.executeCurrentSql("SELECT 1"); + const tab = store.tabs.find((item) => item.id === id)!; + const run = tab.resultRuns![0]!; + const snapshot = mocks.tabResultSnapshots.get(run.resultCacheKey!) as Awaited>; + tab.result = undefined; + tab.results = undefined; + run.result = undefined; + run.results = undefined; + mocks.executeMulti.mockClear(); + const read = deferred>>(); + vi.mocked(cache.readTabResultSnapshot).mockImplementationOnce(() => read.promise); + return { store, id, tab, read, snapshot }; + } + + it("reserves execution before restoring retained results and rejects a repeated run", async () => { + const { store, tab, read, snapshot } = await prepareDiskBackedAutoKeptResult(); + const execution = store.executeCurrentSql("SELECT 2"); + expect(tab.isExecuting).toBe(true); + expect(tab.executionId).toBeTruthy(); + await expect(store.executeCurrentSql("SELECT 2")).resolves.toBe(false); + expect(mocks.executeMulti).not.toHaveBeenCalled(); + read.resolve(snapshot); + await execution; + expect(mocks.executeMulti).toHaveBeenCalledTimes(1); + expect(tab.resultRuns).toHaveLength(2); + expect(tab.isExecuting).toBe(false); + expect(tab.executionId).toBeUndefined(); + }); + + it("cancels result restoration without dispatch and ignores its late payload", async () => { + const { store, id, tab, read, snapshot } = await prepareDiskBackedAutoKeptResult(); + const execution = store.executeCurrentSql("SELECT 2"); + await expect(store.cancelTabExecution(id)).resolves.toBe(true); + expect(tab.isExecuting).toBe(false); + expect(mocks.cancelQuery).not.toHaveBeenCalled(); + expect(mocks.executeMulti).not.toHaveBeenCalled(); + mocks.executeMulti.mockResolvedValueOnce([{ columns: ["value"], rows: [[3]], affected_rows: 0, execution_time_ms: 1 }]); + await store.executeCurrentSql("SELECT 3"); + read.resolve(snapshot); + await expect(execution).resolves.toBe(false); + expect(mocks.executeMulti).toHaveBeenCalledTimes(1); + expect(tab.result?.rows).toEqual([[3]]); + expect(tab.isExecuting).toBe(false); + }); + + it("releases the preparation reservation when result restoration rejects", async () => { + const { store, tab, read } = await prepareDiskBackedAutoKeptResult(); + const execution = store.executeCurrentSql("SELECT 2"); + const rejected = expect(execution).rejects.toThrow("disk read failed"); + read.reject(new Error("disk read failed")); + await rejected; + expect(tab.isExecuting).toBe(false); + expect(tab.executionId).toBeUndefined(); + expect(tab.queryExecutionStartedAt).toBeUndefined(); + expect(mocks.executeMulti).not.toHaveBeenCalled(); + await store.executeCurrentSql("SELECT 3"); + expect(mocks.executeMulti).toHaveBeenCalledTimes(1); + }); + + it("does not revive a closed tab when its pending result read completes", async () => { + const { store, id, tab, read, snapshot } = await prepareDiskBackedAutoKeptResult(); + const execution = store.executeCurrentSql("SELECT 2"); + store.closeTab(id, { force: true }); + read.resolve(snapshot); + await expect(execution).resolves.toBe(false); + expect(store.tabs.some((item) => item.id === id)).toBe(false); + expect(tab.result).toBeUndefined(); + expect(mocks.executeMulti).not.toHaveBeenCalled(); + }); + + it("preserves an imported plain result on the first query when auto-keep is the default", async () => { + mocks.defaultAutoKeepResults = true; + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const sourceId = store.createTab("mysql-1", "app", "Source"); + store.toggleResultAutoSave(sourceId); + mocks.executeMulti.mockResolvedValueOnce([{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]); + await store.executeCurrentSql("SELECT 1"); + const bytes = await store.exportResultArchive(sourceId); + const importedId = await store.importResultArchive(bytes!); + const tab = store.tabs.find((item) => item.id === importedId)!; + expect(tab.resultAutoSave).toBe(true); + expect(tab.resultRuns).toBeUndefined(); + expect(tab.result?.rows).toEqual([[1]]); + mocks.executeMulti.mockResolvedValueOnce([{ columns: ["value"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 }]); + await store.executeCurrentSql("SELECT 2"); + expect(tab.resultRuns).toHaveLength(2); + expect(tab.result?.rows).toEqual([[2]]); + await expect(store.setActiveResultRun(importedId!, tab.resultRuns![0]!.id)).resolves.toBe(true); + expect(tab.result?.rows).toEqual([[1]]); + }); }); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 1bb3a9f918..9abf4431c3 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -1028,6 +1028,23 @@ export const useQueryStore = defineStore("query", () => { return true; } + function initializeResultAutoSave(tab: QueryTab) { + if (tab.mode === "query" && tab.resultAutoSave === undefined && settingsStore.editorSettings.defaultAutoKeepResults === true) { + tab.resultAutoSave = true; + } + } + + // Apply after the settings mutation completes, never during tab registration + // or restoration while the workspace still has incomplete group membership. + watch( + () => settingsStore.editorSettings.defaultAutoKeepResults, + (enabled) => { + for (const tab of tabs.value) { + if (tab.mode === "query") setResultAutoSave(tab, enabled === true); + } + }, + ); + /** * Atomically registers a freshly built tab: the tab joins the focused group * and (by default) becomes that group's active tab and the global active tab @@ -1037,6 +1054,7 @@ export const useQueryStore = defineStore("query", () => { */ function registerOpenTab(tab: QueryTab, options: { activate?: boolean; insertAfterTabId?: string } = {}): string { assertUpdateAllowsInteraction(); + initializeResultAutoSave(tab); const anchorIndex = options.insertAfterTabId ? tabs.value.findIndex((item) => item.id === options.insertAfterTabId) : -1; if (anchorIndex >= 0) { tabs.value.splice(anchorIndex + 1, 0, tab); @@ -1205,6 +1223,7 @@ export const useQueryStore = defineStore("query", () => { const savedSqlEditorPositionTimers = new Map>(); const pendingTabSessionResets = new Map>(); const pendingResultRunRestores = new Map(); + const pendingResultRunPreparations = new WeakMap(); const multiDbExecutionWorkers = new Map(); const multiDbExecutionWorkerScopes = new Map>(); let resultCacheTrimScheduled = false; @@ -1615,7 +1634,7 @@ export const useQueryStore = defineStore("query", () => { return true; } - async function restoreResultRunPayload(tab: QueryTab, runId: string) { + async function restoreResultRunPayload(tab: QueryTab, runId: string, isCurrent?: () => boolean) { const run = tab.resultRuns?.find((item) => item.id === runId); if (!run || run.result || run.results?.length) return run; @@ -1623,6 +1642,7 @@ export const useQueryStore = defineStore("query", () => { if (!cacheKey) return run; const snapshot = await readTabResultSnapshot(cacheKey); + if (isCurrent && !isCurrent()) return undefined; const snapshotRun = snapshot?.resultRuns?.find((item) => item.id === runId); if (!snapshotRun) return run; @@ -1651,11 +1671,12 @@ export const useQueryStore = defineStore("query", () => { return restoredRun; } - async function setActiveResultRun(id: string, runId: string, options: { evictInactive?: boolean } = {}) { + async function setActiveResultRun(id: string, runId: string, options: { evictInactive?: boolean; isCurrent?: () => boolean } = {}) { const tab = findExecutionTab(id); if (!tab) return false; const existingRun = tab.resultRuns?.find((item) => item.id === runId); - const run = existingRun && resultRunHasPayload(existingRun) ? existingRun : await restoreResultRunPayload(tab, runId); + const run = existingRun && resultRunHasPayload(existingRun) ? existingRun : await restoreResultRunPayload(tab, runId, options.isCurrent); + if (options.isCurrent && !options.isCurrent()) return false; if (!run?.result && !run?.results?.length) return false; projectResultRun(tab, run); if (options.evictInactive !== false) evictInactiveResultRunPayloads(tab); @@ -1974,11 +1995,15 @@ export const useQueryStore = defineStore("query", () => { function toggleResultAutoSave(id: string): boolean { const tab = tabs.value.find((t) => t.id === id); if (!tab || tab.mode !== "query") return false; - tab.resultAutoSave = tab.resultAutoSave ? undefined : true; - if (tab.resultAutoSave && tab.result && !tab.activeResultRunId) { + setResultAutoSave(tab, tab.resultAutoSave !== true); + return tab.resultAutoSave === true; + } + + function setResultAutoSave(tab: QueryTab, enabled: boolean) { + tab.resultAutoSave = enabled; + if (enabled && !tab.isExecuting && tab.result && !tab.activeResultRunId) { captureDisplayedResultRun(tab, tab.resultBaseSql ?? tab.lastExecutedSql ?? tab.sql); } - return tab.resultAutoSave === true; } function syncActiveResultRunFromDisplayed(tab: QueryTab, sql?: string) { @@ -2142,6 +2167,7 @@ export const useQueryStore = defineStore("query", () => { tab.schema = connectionObjectTreeNodeSchema(connection, tab.database, tab.schema); } } + restored.tabs.forEach(initializeResultAutoSave); tabs.value = restored.tabs; for (const tab of restored.tabs) { if (tab.mode === "data") { @@ -2450,6 +2476,7 @@ export const useQueryStore = defineStore("query", () => { const restoredResult = restoreCachedResultPayload(restoredTab, await readTabResultSnapshot(handoff.resultCacheKey)); if (!restoredResult) restoredTab.resultCacheState = "missing"; } + initializeResultAutoSave(restoredTab); const existingIndex = tabs.value.findIndex((tab) => tab.id === handoff.tabId); if (existingIndex >= 0) { tabs.value.splice(existingIndex, 1, restoredTab); @@ -3770,6 +3797,7 @@ export const useQueryStore = defineStore("query", () => { whereInput: original.whereInput, previewSql: original.previewSql, }; + initializeResultAutoSave(newTab); tabs.value.splice(idx + 1, 0, newTab); const owner = groupForTab(id); @@ -4629,6 +4657,7 @@ export const useQueryStore = defineStore("query", () => { const executionTabId = options?.tabId ?? activeTabId.value; if (!executionTabId) return; const tab = tabs.value.find((item) => item.id === executionTabId); + if (tab && pendingResultRunPreparations.has(tab)) return false; const previousGridKey = tab ? resultGridInstanceKey(tab) : undefined; if (tab?.mode === "query") { tab.resultSortColumn = undefined; @@ -5353,7 +5382,9 @@ export const useQueryStore = defineStore("query", () => { const tab = tabs.value.find((t) => t.id === tabId); if (!tab || tab.result !== result) return; queryExecutionLog("info", "metadata:start", { traceId, elapsed: elapsed() }); - const patch = await buildQueryMetadataPatch(tab, sql, executionDatabase, traceId, elapsed, hiddenPrimaryKeys); + // Metadata requests outlive the displayed result when another query starts + // or a retained run is selected. Analyze this result, not the live tab. + const patch = await buildQueryMetadataPatch({ ...tab, result }, sql, executionDatabase, traceId, elapsed, hiddenPrimaryKeys); if (patch?.queryAnalysis && hasHiddenPhysicalRowKey(databaseType, hiddenPrimaryKeys)) { patch.queryAnalysis = { ...patch.queryAnalysis, allowInsert: false }; } @@ -5543,6 +5574,7 @@ export const useQueryStore = defineStore("query", () => { assertUpdateAllowsInteraction(); const tab = findExecutionTab(id); if (!tab || !sql.trim()) return; + if (pendingResultRunPreparations.has(tab)) return false; const openInNewResultTab = tab.mode === "query" && options?.openInNewResultTab === true; // Auto-saved results need two independent decisions: keep the currently @@ -5550,24 +5582,54 @@ export const useQueryStore = defineStore("query", () => { // response as another run. Previously `resultAutoSave` only made the latter // decision after clearing the displayed payload, which caused the result // toolbar and grid to briefly disappear before the next Run was added. - const captureAutoSavedResultRun = tab.mode === "query" && tab.resultAutoSave === true && !!tab.activeResultRunId && !!tab.result; + const captureAutoSavedResultRun = tab.mode === "query" && tab.resultAutoSave === true && (!!tab.activeResultRunId || !!tab.result); let captureResultRun = openInNewResultTab || captureAutoSavedResultRun; + let resultRunToRestore: string | undefined; + let reuseResultRun = false; if (!captureResultRun && tab.mode === "query" && !tab.resultAutoSave && tab.activeResultRunId) { const activeRun = tab.resultRuns?.find((run) => run.id === tab.activeResultRunId); if (activeRun?.pinned) { const reusableRun = tab.resultRuns?.find((run) => !run.pinned); if (reusableRun) { - // A stale disk snapshot must not make us fall back to overwriting - // the pinned active run. Capture a fresh run instead. - captureResultRun = !(await setActiveResultRun(id, reusableRun.id)); + resultRunToRestore = reusableRun.id; + reuseResultRun = true; } else { captureResultRun = true; } } } - if (captureResultRun && tab.activeResultRunId && !tab.result) { - await setActiveResultRun(id, tab.activeResultRunId); - if (findExecutionTab(id) !== tab) return false; + if (captureResultRun && tab.activeResultRunId && !tab.result) resultRunToRestore = tab.activeResultRunId; + if (resultRunToRestore) { + // Reserve before disk I/O so another click cannot queue a second SQL. + const preparationId = uuid(); + pendingResultRunPreparations.set(tab, preparationId); + tab.isExecuting = true; + tab.isCancelling = false; + tab.executionId = preparationId; + tab.executingResultRunId = null; + tab.queryExecutionStartedAt = Date.now(); + const isCurrent = () => findExecutionTab(id) === tab && tab.executionId === preparationId && pendingResultRunPreparations.get(tab) === preparationId; + let prepared = false; + try { + const restored = await setActiveResultRun(id, resultRunToRestore, { isCurrent }); + if (!isCurrent()) return false; + if (reuseResultRun) captureResultRun = !restored; + prepared = true; + } catch (error) { + if (!isCurrent()) return false; + throw error; + } finally { + if (pendingResultRunPreparations.get(tab) === preparationId) { + pendingResultRunPreparations.delete(tab); + if (!prepared && tab.executionId === preparationId) { + tab.isExecuting = false; + tab.isCancelling = false; + tab.executionId = undefined; + tab.executingResultRunId = undefined; + tab.queryExecutionStartedAt = undefined; + } + } + } } const executionId = uuid(); const executionEditorFingerprint = tab.mode === "query" ? sqlTextFingerprint(tab.sql) : undefined; @@ -7147,6 +7209,16 @@ export const useQueryStore = defineStore("query", () => { // 单调递增、不随取消结果回退:导航流程据此判断"执行期间用户请求过停止" // (isCancelling 在取消失败或查询先完成时会被清掉,无法承担这个语义) tab.cancelRequestCount = (tab.cancelRequestCount ?? 0) + 1; + if (pendingResultRunPreparations.get(tab) === executionId) { + // No SQL was dispatched; invalidate the disk read without a backend cancel. + pendingResultRunPreparations.delete(tab); + tab.isExecuting = false; + tab.isCancelling = false; + tab.executionId = undefined; + tab.executingResultRunId = undefined; + tab.queryExecutionStartedAt = undefined; + return true; + } const cancellationStartedAt = performance.now(); try { const canceled = await withCancelQueryTimeout(api.cancelQuery(executionId)); diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index af9c41fe28..eebe0e9b95 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -804,6 +804,7 @@ export interface EditorSettings { dataGridExtractorOptions: DataGridExtractorOptions; dataGridExtractorOptionsMigrationVersion: number; resultRunDisplayMode: ResultRunDisplayMode; + defaultAutoKeepResults: boolean; multiStatementDefaultView: MultiStatementDefaultView; dataGridAutoTransposeSingleRow: boolean; dataGridCellDetailButtonVisible: boolean; @@ -1040,6 +1041,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { dataGridExtractorOptions: normalizeDataGridExtractorOptions(DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS), dataGridExtractorOptionsMigrationVersion: DATA_GRID_EXTRACTOR_OPTIONS_MIGRATION_VERSION, resultRunDisplayMode: "tabs", + defaultAutoKeepResults: false, multiStatementDefaultView: "result", dataGridAutoTransposeSingleRow: false, dataGridCellDetailButtonVisible: true, @@ -1497,6 +1499,7 @@ export function normalizeEditorSettings(settings: Partial, exist dataGridExtractorOptions, dataGridExtractorOptionsMigrationVersion: DATA_GRID_EXTRACTOR_OPTIONS_MIGRATION_VERSION, resultRunDisplayMode: normalizeResultRunDisplayMode(settings.resultRunDisplayMode), + defaultAutoKeepResults: settings.defaultAutoKeepResults === true, multiStatementDefaultView: normalizeMultiStatementDefaultView(settings.multiStatementDefaultView), dataGridAutoTransposeSingleRow: settings.dataGridAutoTransposeSingleRow === true, dataGridCellDetailButtonVisible: typeof settings.dataGridCellDetailButtonVisible === "boolean" ? settings.dataGridCellDetailButtonVisible : DEFAULT_EDITOR_SETTINGS.dataGridCellDetailButtonVisible, @@ -2255,6 +2258,7 @@ export const useSettingsStore = defineStore("settings", () => { if (partial.dataGridExtractorOptions !== undefined) editorSettings.value.dataGridExtractorOptions = normalizeDataGridExtractorOptions(partial.dataGridExtractorOptions); if (partial.dataGridExtractorOptionsMigrationVersion !== undefined) editorSettings.value.dataGridExtractorOptionsMigrationVersion = DATA_GRID_EXTRACTOR_OPTIONS_MIGRATION_VERSION; if (partial.resultRunDisplayMode !== undefined) editorSettings.value.resultRunDisplayMode = normalizeResultRunDisplayMode(partial.resultRunDisplayMode); + if (partial.defaultAutoKeepResults !== undefined) editorSettings.value.defaultAutoKeepResults = partial.defaultAutoKeepResults === true; if (partial.multiStatementDefaultView !== undefined) editorSettings.value.multiStatementDefaultView = normalizeMultiStatementDefaultView(partial.multiStatementDefaultView); if (partial.dataGridAutoTransposeSingleRow !== undefined) editorSettings.value.dataGridAutoTransposeSingleRow = partial.dataGridAutoTransposeSingleRow === true; if (partial.dataGridCellDetailButtonVisible !== undefined) editorSettings.value.dataGridCellDetailButtonVisible = typeof partial.dataGridCellDetailButtonVisible === "boolean" ? partial.dataGridCellDetailButtonVisible : DEFAULT_EDITOR_SETTINGS.dataGridCellDetailButtonVisible; diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index af55650539..373f724198 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -1213,6 +1213,7 @@ export interface QueryTab { resultViewGeneration?: string; resultRuns?: QueryResultRun[]; activeResultRunId?: string; + /** Undefined inherits the default on open; false preserves an explicit per-tab opt-out. */ resultAutoSave?: boolean; explainPlan?: import("@/lib/diagram/explainPlan").ParsedExplainPlan; /** MySQL's regular EXPLAIN result, kept alongside its JSON visual plan. */