feat(updater): download and prepare desktop updates in background
This commit is contained in:
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user