feat(query): auto-keep results by default and fix restore race
This commit is contained in:
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>
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6398,6 +6398,8 @@ export default withEnglishFallback({
|
||||
tableOpenPageSizeDescription: "新しいテーブルを開くときに 1 ページあたり読み込む行数です。テーブルで行数を変更すると、今後のテーブルタブのデフォルトも更新されます。",
|
||||
queryPageSize: "クエリ結果のデフォルト行数",
|
||||
queryPageSizeDescription: "クエリ結果の 1 ページあたりの行数です。最大 {max} 行まで指定できます。",
|
||||
defaultAutoKeepResults: "クエリ結果を既定で自動保持",
|
||||
defaultAutoKeepResultsDescription: "適用すると開いているクエリタブを更新し、新しいタブの既定値にも設定します。タブごとに切り替えられます。",
|
||||
multiStatementDefaultView: "複数ステートメントの既定ビュー",
|
||||
multiStatementDefaultViewDescription: "複数の SQL ステートメントを含むバッチを、結果テーブルまたは実行サマリーのどちらで開くか選択します。",
|
||||
queryResultMaxRows: "クエリ結果の最大行数",
|
||||
|
||||
@@ -6105,6 +6105,8 @@ export default withEnglishFallback({
|
||||
tableOpenPageSizeDescription: "새 테이블을 열 때 페이지당 로드되는 행 수입니다. 테이블에서 페이지당 행을 변경하면 향후 테이블 탭의 기본값이 업데이트됩니다.",
|
||||
queryPageSize: "쿼리 결과 기본 페이지당 행 수",
|
||||
queryPageSizeDescription: "쿼리 결과 페이지당 로드할 행 수입니다. 최대 {max}행을 지원합니다.",
|
||||
defaultAutoKeepResults: "기본적으로 쿼리 결과 자동 보관",
|
||||
defaultAutoKeepResultsDescription: "적용하면 열린 쿼리 탭을 업데이트하고 새 탭의 기본값으로 설정합니다. 탭별로 전환할 수 있습니다.",
|
||||
multiStatementDefaultView: "여러 구문 기본 보기",
|
||||
multiStatementDefaultViewDescription: "여러 SQL 구문이 있는 배치를 결과 테이블 또는 실행 요약 중 어디에서 열지 선택합니다.",
|
||||
queryResultMaxRows: "쿼리 결과 최대 행 수",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -6775,6 +6775,8 @@ export default withEnglishFallback({
|
||||
tableOpenPageSizeDescription: "新打开数据表时每页加载的行数。在表数据页修改每页行数后,也会更新后续新建表标签页的默认值。",
|
||||
queryPageSize: "查询结果默认每页行数",
|
||||
queryPageSizeDescription: "每个查询结果分页加载的行数,最大支持 {max} 行。",
|
||||
defaultAutoKeepResults: "默认自动保留查询结果",
|
||||
defaultAutoKeepResultsDescription: "应用后同步更新已打开的查询标签,新建查询标签也使用此设置。仍可在标签内单独切换。",
|
||||
multiStatementDefaultView: "多语句执行默认视图",
|
||||
multiStatementDefaultViewDescription: "选择多条 SQL 语句批量执行后默认打开数据表或执行摘要。",
|
||||
queryResultMaxRows: "查询结果最大行数",
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user