feat(query): auto-keep results by default and fix restore race

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-10 15:57:27 +08:00
1 parent 5b5d58cd2a
commit e0cc787f0a
19 files changed
+231 -17

No files matched your search

@@ -532,6 +532,7 @@ const editDataGridFilterEditorView = ref<DataGridFilterEditorView>(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<MultiStatementDefaultView>(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"
/>
</div>
<div data-settings-search-id="default-auto-keep-results" :class="['settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2', settingsSearchTargetClass('default-auto-keep-results')]">
<div class="min-w-0 space-y-1">
<Label for="default-auto-keep-results">{{ t("settings.defaultAutoKeepResults") }}</Label>
<p class="text-xs text-muted-foreground">{{ t("settings.defaultAutoKeepResultsDescription") }}</p>
</div>
<Switch id="default-auto-keep-results" v-model="editDefaultAutoKeepResults" :aria-label="t('settings.defaultAutoKeepResults')" />
</div>
<div data-settings-search-id="multi-statement-default-view" :class="['settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2', settingsSearchTargetClass('multi-statement-default-view')]">
<div class="min-w-0 space-y-1">
<Label for="multi-statement-default-view">{{ t("settings.multiStatementDefaultView") }}</Label>
+2
View File
@@ -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ı",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -6398,6 +6398,8 @@ export default withEnglishFallback({
tableOpenPageSizeDescription: "新しいテーブルを開くときに 1 ページあたり読み込む行数です。テーブルで行数を変更すると、今後のテーブルタブのデフォルトも更新されます。",
queryPageSize: "クエリ結果のデフォルト行数",
queryPageSizeDescription: "クエリ結果の 1 ページあたりの行数です。最大 {max} 行まで指定できます。",
defaultAutoKeepResults: "クエリ結果を既定で自動保持",
defaultAutoKeepResultsDescription: "適用すると開いているクエリタブを更新し、新しいタブの既定値にも設定します。タブごとに切り替えられます。",
multiStatementDefaultView: "複数ステートメントの既定ビュー",
multiStatementDefaultViewDescription: "複数の SQL ステートメントを含むバッチを、結果テーブルまたは実行サマリーのどちらで開くか選択します。",
queryResultMaxRows: "クエリ結果の最大行数",
+2
View File
@@ -6105,6 +6105,8 @@ export default withEnglishFallback({
tableOpenPageSizeDescription: "새 테이블을 열 때 페이지당 로드되는 행 수입니다. 테이블에서 페이지당 행을 변경하면 향후 테이블 탭의 기본값이 업데이트됩니다.",
queryPageSize: "쿼리 결과 기본 페이지당 행 수",
queryPageSizeDescription: "쿼리 결과 페이지당 로드할 행 수입니다. 최대 {max}행을 지원합니다.",
defaultAutoKeepResults: "기본적으로 쿼리 결과 자동 보관",
defaultAutoKeepResultsDescription: "적용하면 열린 쿼리 탭을 업데이트하고 새 탭의 기본값으로 설정합니다. 탭별로 전환할 수 있습니다.",
multiStatementDefaultView: "여러 구문 기본 보기",
multiStatementDefaultViewDescription: "여러 SQL 구문이 있는 배치를 결과 테이블 또는 실행 요약 중 어디에서 열지 선택합니다.",
queryResultMaxRows: "쿼리 결과 최대 행 수",
+2
View File
@@ -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",
+2
View File
@@ -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ı",
+2
View File
@@ -6775,6 +6775,8 @@ export default withEnglishFallback({
tableOpenPageSizeDescription: "新打开数据表时每页加载的行数。在表数据页修改每页行数后,也会更新后续新建表标签页的默认值。",
queryPageSize: "查询结果默认每页行数",
queryPageSizeDescription: "每个查询结果分页加载的行数,最大支持 {max} 行。",
defaultAutoKeepResults: "默认自动保留查询结果",
defaultAutoKeepResultsDescription: "应用后同步更新已打开的查询标签,新建查询标签也使用此设置。仍可在标签内单独切换。",
multiStatementDefaultView: "多语句执行默认视图",
multiStatementDefaultViewDescription: "选择多条 SQL 语句批量执行后默认打开数据表或执行摘要。",
queryResultMaxRows: "查询结果最大行数",
+2
View File
@@ -5679,6 +5679,8 @@ export default withEnglishFallback({
tableOpenPageSizeDescription: "新開啟資料表時每頁載入的列數。在資料表頁面調整每頁列數後,也會更新後續新建資料表分頁的預設值。",
queryPageSize: "查詢結果預設每頁列數",
queryPageSizeDescription: "每個查詢結果分頁載入的列數,最多支援 {max} 列。",
defaultAutoKeepResults: "預設自動保留查詢結果",
defaultAutoKeepResultsDescription: "套用後同步更新已開啟的查詢分頁,新建查詢分頁也使用此設定。仍可在分頁內個別切換。",
multiStatementDefaultView: "多語句執行預設檢視",
multiStatementDefaultViewDescription: "選擇多條 SQL 語句批次執行後預設開啟資料表或執行摘要。",
queryResultMaxRows: "查詢結果最大列數",
@@ -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;
@@ -52,6 +52,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"dataGridFilterEditorView",
"dataGridKeepFilterEditorExpanded",
"dataGridTextFilterPanelHeight",
"defaultAutoKeepResults",
"multiStatementDefaultView",
"dataGridAutoTransposeSingleRow",
"dataGridCellDetailButtonVisible",
@@ -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" },
@@ -143,6 +143,7 @@ const SETTINGS_TRANSFER_CATEGORY_KEYS: Record<SettingsTransferCategoryId, readon
"dataGridFilterEditorView",
"dataGridKeepFilterEditorExpanded",
"dataGridTextFilterPanelHeight",
"defaultAutoKeepResults",
"multiStatementDefaultView",
"dataGridAutoTransposeSingleRow",
"dataGridCellDetailButtonVisible",
@@ -4,6 +4,7 @@ import { BackendErrorException } from "@/lib/backend/errorUtils";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
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<ReturnType<typeof cache.readTabResultSnapshot>>;
tab.result = undefined;
tab.results = undefined;
run.result = undefined;
run.results = undefined;
mocks.executeMulti.mockClear();
const read = deferred<Awaited<ReturnType<typeof cache.readTabResultSnapshot>>>();
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]]);
});
});
+86 -14
View File
@@ -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<string, ReturnType<typeof setTimeout>>();
const pendingTabSessionResets = new Map<string, Promise<void>>();
const pendingResultRunRestores = new Map<string, string>();
const pendingResultRunPreparations = new WeakMap<QueryTab, string>();
const multiDbExecutionWorkers = new Map<string, QueryTab>();
const multiDbExecutionWorkerScopes = new Map<string, Set<string>>();
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));
+4
View File
@@ -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<EditorSettings>, 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;
+1
View File
@@ -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. */