feat(updater): download and prepare desktop updates in background

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-10 11:44:18 +08:00
1 parent 7affad8ae0
commit e96af4aa30
77 files changed
+10536 -814

No files matched your search

@@ -71,15 +71,15 @@ describe("queryStore adoptDetachedTab", () => {
expect(queryStore.isTabDirty(tab)).toBe(false);
});
it("does not touch the backend when the handoff already carries unsaved edits", async () => {
it.each(["SELECT 2;", ""])("does not replace unsaved edits %j with file content", async (sql) => {
const { useQueryStore } = await import("@/stores/queryStore");
const queryStore = useQueryStore();
const tabId = await queryStore.adoptDetachedTab(handoffFor({ sql: "SELECT 2;", originalSql: "SELECT 1;" }));
const tabId = await queryStore.adoptDetachedTab(handoffFor({ sql, originalSql: "SELECT 1;" }));
expect(mocks.loadSavedSqlFile).not.toHaveBeenCalled();
const tab = queryStore.tabs.find((candidate) => candidate.id === tabId)!;
expect(tab.sql).toBe("SELECT 2;");
expect(tab.sql).toBe(sql);
expect(queryStore.isTabDirty(tab)).toBe(true);
});
@@ -0,0 +1,96 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
const savedFile = { id: "saved", connectionId: "conn", database: "db", name: "saved.sql", sql: "SELECT 1;", createdAt: "2026-09-10T00:00:00Z", updatedAt: "2026-09-10T00:00:00Z" };
const mocks = vi.hoisted(() => ({ saved: null as any }));
vi.mock("@/lib/backend/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/backend/api")>()),
loadOpenTabsState: async () => mocks.saved,
saveOpenTabsState: async (payload: unknown) => {
mocks.saved = payload;
},
listDetachedTabHandoffs: async () => [],
}));
describe("update draft recovery", () => {
beforeEach(() => {
vi.resetModules();
mocks.saved = null;
const storage = new Map<string, string>();
vi.stubGlobal("localStorage", { getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => storage.set(key, value), removeItem: (key: string) => storage.delete(key) });
setActivePinia(createPinia());
});
it.each(["", "SELECT 2;"])("preserves a saved SQL draft %j through recovery and file hydration", async (sql) => {
const { useQueryStore } = await import("@/stores/queryStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
const { useSavedSqlStore } = await import("@/stores/savedSqlStore");
const { UPDATE_RESTORE_KEY } = await import("@/lib/app/updatePreparation");
const store = useQueryStore();
const id = store.createTab("conn", "db");
Object.assign(store.tabs.find((tab) => tab.id === id)!, { savedSqlId: "saved", sql, originalSql: "SELECT 1;" });
await store.flushPendingPersist();
localStorage.setItem(UPDATE_RESTORE_KEY, "1");
setActivePinia(createPinia());
useSettingsStore().updateEditorSettings({ openTabsRestoreMode: "none", confirmUnsavedSqlClose: false });
const ensureFileContent = vi.spyOn(useSavedSqlStore(), "ensureFileContent");
const restored = useQueryStore();
await restored.initOpenTabs({ validConnectionIds: ["conn"] });
await restored.hydrateSavedSqlTabs();
const tab = restored.tabs.find((tab) => tab.id === id)!;
expect(tab.sql).toBe(sql);
expect(tab.originalSql).toBe("SELECT 1;");
expect(restored.isTabDirty(tab)).toBe(true);
expect(ensureFileContent).not.toHaveBeenCalled();
expect(localStorage.getItem(UPDATE_RESTORE_KEY)).toBeNull();
expect(restored.openSavedSql(savedFile)).toBe(id);
expect(tab.sql).toBe(sql);
expect(tab.originalSql).toBe("SELECT 1;");
expect(restored.isTabDirty(tab)).toBe(true);
ensureFileContent.mockRestore();
});
it("loads file content when reopening a clean restored SQL library tab", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const { UPDATE_RESTORE_KEY } = await import("@/lib/app/updatePreparation");
const store = useQueryStore();
const id = store.openSavedSql(savedFile);
await store.flushPendingPersist();
localStorage.setItem(UPDATE_RESTORE_KEY, "1");
setActivePinia(createPinia());
const restored = useQueryStore();
await restored.initOpenTabs({ validConnectionIds: ["conn"] });
const tab = restored.tabs.find((tab) => tab.id === id)!;
expect(tab.sql).toBe("");
expect(tab.originalSql).toBeUndefined();
expect(restored.openSavedSql(savedFile)).toBe(id);
expect(tab.sql).toBe(savedFile.sql);
expect(tab.originalSql).toBe(savedFile.sql);
expect(restored.isTabDirty(tab)).toBe(false);
});
it("recovers SQL and structure drafts once even when normal restore and close protection are disabled", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
const { UPDATE_RESTORE_KEY } = await import("@/lib/app/updatePreparation");
const settings = useSettingsStore();
settings.updateEditorSettings({ openTabsRestoreMode: "none", confirmUnsavedSqlClose: false });
const store = useQueryStore();
const sqlId = store.createTab("conn", "db");
store.updateSql(sqlId, "select unsaved;");
const structureId = store.openTableStructure("conn", "db", undefined, "users");
const draft = { dirty: true, initialized: true, ddlDraft: "alter table users add column name text" };
store.tabs.find((tab) => tab.id === structureId)!.structureDraft = draft as any;
await store.flushPendingPersist();
localStorage.setItem(UPDATE_RESTORE_KEY, "1");
setActivePinia(createPinia());
useSettingsStore().updateEditorSettings({ openTabsRestoreMode: "none", confirmUnsavedSqlClose: false });
const restored = useQueryStore();
await restored.initOpenTabs({ validConnectionIds: ["conn"] });
expect(restored.tabs.find((tab) => tab.id === sqlId)?.sql).toBe("select unsaved;");
expect(restored.tabs.find((tab) => tab.id === structureId)?.structureDraft).toEqual(draft);
expect(localStorage.getItem(UPDATE_RESTORE_KEY)).toBeNull();
setActivePinia(createPinia());
useSettingsStore().updateEditorSettings({ openTabsRestoreMode: "none" });
const subsequent = useQueryStore();
await subsequent.initOpenTabs({ validConnectionIds: ["conn"] });
expect(subsequent.tabs).toEqual([]);
});
});
+15 -8
View File
@@ -1,3 +1,4 @@
import { UPDATE_RESTORE_KEY, assertUpdateAllowsInteraction } from "@/lib/app/updatePreparation";
import { defineStore } from "pinia";
import { uuid } from "@/lib/common/utils";
import { computed, markRaw, nextTick, onScopeDispose, reactive, ref, watch } from "vue";
@@ -741,7 +742,7 @@ function restoreSavedTabsFromPayload(
payload: { tabs?: unknown; activeTabId?: unknown; groups?: unknown; focusedGroupId?: unknown; orientation?: unknown; sizes?: unknown } | null | undefined,
options: { validConnectionIds?: Iterable<string> } = {},
): { tabs: QueryTab[]; activeTabId: string | null; workspace?: unknown } {
const restoreMode = useSettingsStore().editorSettings.openTabsRestoreMode;
const restoreMode = safeLocalStorageGet(UPDATE_RESTORE_KEY) === "1" ? "all" : useSettingsStore().editorSettings.openTabsRestoreMode;
if (restoreMode === "none") {
return { tabs: [], activeTabId: null, workspace: undefined };
}
@@ -764,7 +765,7 @@ function restoreSavedTabsFromPayload(
}
function restoreLegacySavedTabs(options: { validConnectionIds?: Iterable<string> } = {}): { tabs: QueryTab[]; activeTabId: string | null; workspace?: undefined } {
const restoreMode = useSettingsStore().editorSettings.openTabsRestoreMode;
const restoreMode = safeLocalStorageGet(UPDATE_RESTORE_KEY) === "1" ? "all" : useSettingsStore().editorSettings.openTabsRestoreMode;
if (restoreMode === "none") {
return { tabs: [], activeTabId: null, workspace: undefined };
}
@@ -1034,6 +1035,7 @@ export const useQueryStore = defineStore("query", () => {
* the new tab ownerless until a post-flush watcher repaired the groups.
*/
function registerOpenTab(tab: QueryTab, options: { activate?: boolean; insertAfterTabId?: string } = {}): string {
assertUpdateAllowsInteraction();
const anchorIndex = options.insertAfterTabId ? tabs.value.findIndex((item) => item.id === options.insertAfterTabId) : -1;
if (anchorIndex >= 0) {
tabs.value.splice(anchorIndex + 1, 0, tab);
@@ -2215,7 +2217,7 @@ export const useQueryStore = defineStore("query", () => {
if (saved?.tabs && Array.isArray(saved.tabs)) {
const restored = restoreSavedTabsFromPayload(saved, options);
applyRestoredOpenTabs(restored);
if (useSettingsStore().editorSettings.openTabsRestoreMode === "none") {
if (safeLocalStorageGet(UPDATE_RESTORE_KEY) !== "1" && useSettingsStore().editorSettings.openTabsRestoreMode === "none") {
// Restore is explicitly disabled, so stale saved payloads should not
// reappear if the user later changes the setting.
clearLegacySavedTabs();
@@ -2223,6 +2225,7 @@ export const useQueryStore = defineStore("query", () => {
}
await recoverDetachedTabsToMain(options);
await saveTabs(tabs.value, activeTabId.value).catch(() => undefined);
safeLocalStorageRemove(UPDATE_RESTORE_KEY);
isOpenTabsLoaded.value = true;
scheduleResultCacheMaintenance();
return;
@@ -2232,7 +2235,7 @@ export const useQueryStore = defineStore("query", () => {
if (legacy.rawTabs || legacy.rawActiveTabId) {
const restored = restoreLegacySavedTabs(options);
applyRestoredOpenTabs(restored);
if (useSettingsStore().editorSettings.openTabsRestoreMode === "none") {
if (safeLocalStorageGet(UPDATE_RESTORE_KEY) !== "1" && useSettingsStore().editorSettings.openTabsRestoreMode === "none") {
// Restore is explicitly disabled, so keeping the legacy startup payload
// would resurrect old tabs if the user later changes the setting.
clearLegacySavedTabs();
@@ -2287,6 +2290,7 @@ export const useQueryStore = defineStore("query", () => {
autoCommit: t.autoCommit,
resultAutoSave: t.resultAutoSave,
structureTableName: t.structureTableName,
structureDraft: t.structureDraft,
objectBrowser: t.objectBrowser,
objectSource: t.objectSource,
sourceView: t.sourceView,
@@ -2431,7 +2435,7 @@ export const useQueryStore = defineStore("query", () => {
// A freshly created detached window never runs the main-window bootstrap that
// populates savedSqlStore's local file index, so useSavedSqlStore().ensureFileContent()
// can't find the file locally either — go straight to the backend instead.
if (restoredTab.savedSqlId && restoredTab.mode === "query" && !restoredTab.sql) {
if (restoredTab.savedSqlId && restoredTab.mode === "query" && !restoredTab.sql && restoredTab.originalSql === undefined) {
const file = await api.loadSavedSqlFile(restoredTab.savedSqlId).catch(() => undefined);
if (file) {
restoredTab.title = restoredTab.customTitle ? restoredTab.title : file.name;
@@ -3155,7 +3159,7 @@ export const useQueryStore = defineStore("query", () => {
return !!tab.structureDraft && tab.structureDraft.dirty !== false;
}
if (tab.mode !== "query") return false;
if (!tab.externalSqlPath && !tab.sql.trim()) return false;
if (!tab.externalSqlPath && !tab.sql.trim() && !(tab.savedSqlId && tab.originalSql !== undefined)) return false;
const original = tab.originalSql;
if (original === undefined) return !!tab.savedSqlId;
return tab.sql !== original;
@@ -4277,7 +4281,7 @@ export const useQueryStore = defineStore("query", () => {
const existing = tabs.value.find((tab) => tab.savedSqlId === file.id);
if (existing) {
persistSavedSqlEditorPosition(existing);
if (!existing.sql && file.sql) {
if (!existing.sql && file.sql && existing.originalSql === undefined) {
existing.sql = file.sql;
existing.originalSql = file.sql;
const restored = restoreSavedSqlEditorPosition(file.id, file.sql);
@@ -4317,7 +4321,7 @@ export const useQueryStore = defineStore("query", () => {
async function hydrateSavedSqlTabs() {
await initSavedSqlEditorPositions();
const savedSqlStore = useSavedSqlStore();
const linkedTabs = tabs.value.filter((tab) => tab.savedSqlId && tab.sql === "");
const linkedTabs = tabs.value.filter((tab) => tab.savedSqlId && tab.sql === "" && tab.originalSql === undefined);
for (const tab of linkedTabs) {
const file = await savedSqlStore.ensureFileContent(tab.savedSqlId!);
if (!file) continue;
@@ -4602,6 +4606,7 @@ export const useQueryStore = defineStore("query", () => {
}
async function executeCurrentTab() {
assertUpdateAllowsInteraction();
const tab = tabs.value.find((t) => t.id === activeTabId.value);
if (!tab || !tab.sql.trim()) return;
@@ -5525,6 +5530,7 @@ export const useQueryStore = defineStore("query", () => {
batchResume?: BatchSqlResumeOptions;
},
) {
assertUpdateAllowsInteraction();
const tab = findExecutionTab(id);
if (!tab || !sql.trim()) return;
@@ -6780,6 +6786,7 @@ export const useQueryStore = defineStore("query", () => {
}
async function explainTabSql(id: string, sql: string, databaseType?: DatabaseType, explainMode?: string) {
assertUpdateAllowsInteraction();
const tab = tabs.value.find((t) => t.id === id);
if (!tab) return { ok: false as const, reason: "empty" as const };
const conn = useConnectionStore().getConfig(tab.connectionId);