From 896580f1c5ea6ea3e533e3b00bcc7bf36f286b86 Mon Sep 17 00:00:00 2001 From: zipg Date: Sun, 5 Jul 2026 00:34:00 +0800 Subject: [PATCH] feat(saved-sql): restore editor position --- .../__tests__/savedSqlEditorPosition.spec.ts | 109 ++++++++++++ .../src/lib/app/savedSqlEditorPosition.ts | 168 ++++++++++++++++++ apps/desktop/src/stores/queryStore.ts | 47 +++++ apps/desktop/src/stores/savedSqlStore.ts | 2 + 4 files changed, 326 insertions(+) create mode 100644 apps/desktop/src/lib/__tests__/savedSqlEditorPosition.spec.ts create mode 100644 apps/desktop/src/lib/app/savedSqlEditorPosition.ts diff --git a/apps/desktop/src/lib/__tests__/savedSqlEditorPosition.spec.ts b/apps/desktop/src/lib/__tests__/savedSqlEditorPosition.spec.ts new file mode 100644 index 0000000000..0f9734e256 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/savedSqlEditorPosition.spec.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createSavedSqlEditorPosition, forgetSavedSqlEditorPosition, restoreSavedSqlEditorPosition, saveSavedSqlEditorPosition, SAVED_SQL_EDITOR_POSITIONS_STORAGE_KEY } from "../app/savedSqlEditorPosition"; + +const storage = new Map(); + +beforeEach(() => { + storage.clear(); + 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), + }); +}); + +describe("savedSqlEditorPosition", () => { + it("restores the saved cursor and viewport when the file is unchanged", () => { + const sql = "select 1;\n\nselect 2;\n"; + const head = sql.indexOf("select 2"); + + saveSavedSqlEditorPosition( + createSavedSqlEditorPosition({ + savedSqlId: "file-a", + sql, + selection: { anchor: head, head }, + viewport: { scrollTop: 320, scrollLeft: 12 }, + now: 1, + }), + ); + + expect(restoreSavedSqlEditorPosition("file-a", sql)).toEqual({ + selection: { anchor: head, head }, + viewport: { scrollTop: 320, scrollLeft: 12 }, + }); + }); + + it("relocates the cursor by nearby SQL text when content is inserted before it", () => { + const sql = "select 1;\n\nselect 2;\n"; + const head = sql.indexOf("select 2"); + const nextSql = "-- inserted header\n" + sql; + + saveSavedSqlEditorPosition( + createSavedSqlEditorPosition({ + savedSqlId: "file-b", + sql, + selection: { anchor: head, head }, + viewport: { scrollTop: 320, scrollLeft: 12 }, + now: 1, + }), + ); + + expect(restoreSavedSqlEditorPosition("file-b", nextSql)).toEqual({ + selection: { anchor: nextSql.indexOf("select 2"), head: nextSql.indexOf("select 2") }, + viewport: undefined, + }); + }); + + it("falls back to a safe offset when the previous context no longer exists", () => { + const sql = "select 1;\n\nselect 2;\n"; + const head = sql.indexOf("select 2"); + + saveSavedSqlEditorPosition( + createSavedSqlEditorPosition({ + savedSqlId: "file-c", + sql, + selection: { anchor: head, head }, + now: 1, + }), + ); + + const restored = restoreSavedSqlEditorPosition("file-c", "select 1;"); + + expect(restored.selection).toEqual({ anchor: "select 1;".length, head: "select 1;".length }); + expect(restored.viewport).toBeUndefined(); + }); + + it("forgets a deleted saved SQL file position", () => { + saveSavedSqlEditorPosition( + createSavedSqlEditorPosition({ + savedSqlId: "file-d", + sql: "select 1;", + selection: { anchor: 3, head: 3 }, + now: 1, + }), + ); + + forgetSavedSqlEditorPosition("file-d"); + + expect(restoreSavedSqlEditorPosition("file-d", "select 1;")).toEqual({}); + }); + + it("keeps only the most recent saved SQL file positions", () => { + for (let index = 0; index < 205; index += 1) { + saveSavedSqlEditorPosition( + createSavedSqlEditorPosition({ + savedSqlId: `file-${index}`, + sql: "select 1;", + selection: { anchor: 0, head: 0 }, + now: index, + }), + ); + } + + const stored = JSON.parse(storage.get(SAVED_SQL_EDITOR_POSITIONS_STORAGE_KEY) ?? "[]") as Array<{ savedSqlId: string }>; + + expect(stored).toHaveLength(200); + expect(stored.some((item) => item.savedSqlId === "file-0")).toBe(false); + expect(stored[0]?.savedSqlId).toBe("file-204"); + }); +}); diff --git a/apps/desktop/src/lib/app/savedSqlEditorPosition.ts b/apps/desktop/src/lib/app/savedSqlEditorPosition.ts new file mode 100644 index 0000000000..0f9020fe7e --- /dev/null +++ b/apps/desktop/src/lib/app/savedSqlEditorPosition.ts @@ -0,0 +1,168 @@ +export const SAVED_SQL_EDITOR_POSITIONS_STORAGE_KEY = "dbx-saved-sql-editor-positions"; + +const MAX_SAVED_SQL_EDITOR_POSITIONS = 200; +const ANCHOR_CONTEXT_CHARS = 80; + +export interface SavedSqlEditorSelection { + anchor: number; + head: number; +} + +export interface SavedSqlEditorViewport { + scrollTop: number; + scrollLeft: number; +} + +export interface SavedSqlEditorPosition { + savedSqlId: string; + selection: SavedSqlEditorSelection; + viewport?: SavedSqlEditorViewport; + anchor: { + before: string; + after: string; + head: number; + docLength: number; + }; + updatedAt: number; +} + +function clampOffset(value: number, docLength: number) { + if (!Number.isFinite(value)) return 0; + return Math.min(Math.max(0, Math.trunc(value)), docLength); +} + +function normalizeSelection(selection: SavedSqlEditorSelection | undefined, docLength: number): SavedSqlEditorSelection { + return { + anchor: clampOffset(selection?.anchor ?? selection?.head ?? 0, docLength), + head: clampOffset(selection?.head ?? selection?.anchor ?? 0, docLength), + }; +} + +function normalizeViewport(viewport: SavedSqlEditorViewport | undefined): SavedSqlEditorViewport | undefined { + if (!viewport) return undefined; + return { + scrollTop: Math.max(0, Number.isFinite(viewport.scrollTop) ? viewport.scrollTop : 0), + scrollLeft: Math.max(0, Number.isFinite(viewport.scrollLeft) ? viewport.scrollLeft : 0), + }; +} + +function findClosestOccurrence(text: string, query: string, target: number): number | null { + if (!query) return null; + let best: number | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + let from = 0; + while (from <= text.length) { + const index = text.indexOf(query, from); + if (index < 0) break; + const distance = Math.abs(index - target); + if (distance < bestDistance) { + best = index; + bestDistance = distance; + } + from = index + 1; + } + return best; +} + +function restoredHeadFromAnchor(position: SavedSqlEditorPosition, sql: string): number { + const targetHead = clampOffset(position.anchor.head, sql.length); + const before = position.anchor.before; + const after = position.anchor.after; + const combined = `${before}${after}`; + if (combined) { + const combinedIndex = findClosestOccurrence(sql, combined, Math.max(0, targetHead - before.length)); + if (combinedIndex !== null) return clampOffset(combinedIndex + before.length, sql.length); + } + + const beforeIndex = findClosestOccurrence(sql, before, Math.max(0, targetHead - before.length)); + if (beforeIndex !== null) return clampOffset(beforeIndex + before.length, sql.length); + + const afterIndex = findClosestOccurrence(sql, after, targetHead); + if (afterIndex !== null) return clampOffset(afterIndex, sql.length); + + return targetHead; +} + +function parseSavedPositions(raw: string | null): SavedSqlEditorPosition[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter((item): item is SavedSqlEditorPosition => { + return ( + !!item && + typeof item === "object" && + typeof item.savedSqlId === "string" && + !!item.selection && + typeof item.selection.anchor === "number" && + typeof item.selection.head === "number" && + !!item.anchor && + typeof item.anchor.before === "string" && + typeof item.anchor.after === "string" && + typeof item.anchor.head === "number" && + typeof item.anchor.docLength === "number" && + typeof item.updatedAt === "number" + ); + }); + } catch { + return []; + } +} + +function readSavedPositions(): SavedSqlEditorPosition[] { + try { + return parseSavedPositions(localStorage.getItem(SAVED_SQL_EDITOR_POSITIONS_STORAGE_KEY)); + } catch { + return []; + } +} + +function writeSavedPositions(positions: SavedSqlEditorPosition[]) { + try { + localStorage.setItem(SAVED_SQL_EDITOR_POSITIONS_STORAGE_KEY, JSON.stringify([...positions].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, MAX_SAVED_SQL_EDITOR_POSITIONS))); + } catch {} +} + +export function createSavedSqlEditorPosition(input: { savedSqlId: string; sql: string; selection?: SavedSqlEditorSelection; viewport?: SavedSqlEditorViewport; now?: number }): SavedSqlEditorPosition { + const selection = normalizeSelection(input.selection, input.sql.length); + const head = selection.head; + return { + savedSqlId: input.savedSqlId, + selection, + viewport: normalizeViewport(input.viewport), + anchor: { + before: input.sql.slice(Math.max(0, head - ANCHOR_CONTEXT_CHARS), head), + after: input.sql.slice(head, Math.min(input.sql.length, head + ANCHOR_CONTEXT_CHARS)), + head, + docLength: input.sql.length, + }, + updatedAt: input.now ?? Date.now(), + }; +} + +export function saveSavedSqlEditorPosition(position: SavedSqlEditorPosition) { + const next = readSavedPositions().filter((item) => item.savedSqlId !== position.savedSqlId); + next.unshift(position); + writeSavedPositions(next); +} + +export function restoreSavedSqlEditorPosition(savedSqlId: string, sql: string): { selection?: SavedSqlEditorSelection; viewport?: SavedSqlEditorViewport } { + const position = readSavedPositions().find((item) => item.savedSqlId === savedSqlId); + if (!position) return {}; + + const restoredHead = restoredHeadFromAnchor(position, sql); + const originalSelection = normalizeSelection(position.selection, position.anchor.docLength); + const anchorOffsetFromHead = originalSelection.anchor - originalSelection.head; + const canReuseViewport = sql.length === position.anchor.docLength && restoredHead === originalSelection.head; + return { + selection: { + anchor: clampOffset(restoredHead + anchorOffsetFromHead, sql.length), + head: restoredHead, + }, + viewport: canReuseViewport ? normalizeViewport(position.viewport) : undefined, + }; +} + +export function forgetSavedSqlEditorPosition(savedSqlId: string) { + writeSavedPositions(readSavedPositions().filter((item) => item.savedSqlId !== savedSqlId)); +} diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index da60cb9fd5..d9d2ac6cef 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -44,12 +44,14 @@ import * as api from "@/lib/backend/api"; import { useConnectionStore } from "@/stores/connectionStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { useSavedSqlStore } from "@/stores/savedSqlStore"; +import { createSavedSqlEditorPosition, restoreSavedSqlEditorPosition, saveSavedSqlEditorPosition } from "@/lib/app/savedSqlEditorPosition"; import type { SavedSqlFile } from "@/types/database"; const ORACLE_LIKE_METADATA_TYPES = new Set(["oracle", "dameng", "oceanbase-oracle"]); const BACKGROUND_CLIENT_SESSION_SUFFIXES = ["count", "explain", "export"] as const; const CANCEL_QUERY_TIMEOUT_MS = 10_000; const CANCEL_ACK_SETTLE_TIMEOUT_MS = 2_000; +const SAVED_SQL_EDITOR_POSITION_PERSIST_DELAY_MS = 500; type CloseConfirmContext = "tab" | "batch" | "app"; function cloneTabDraft(value: T): T { @@ -222,6 +224,7 @@ export const useQueryStore = defineStore("query", () => { if (tab.mode === "data") void deleteTabResultSnapshot(tabResultCacheKey(tab.id)); } const tableStructureRefreshVersions = ref>({}); + const savedSqlEditorPositionTimers = new Map>(); function tableStructureKey(connectionId: string, database: string, schema: string | undefined, tableName: string): string { return [connectionId, database, schema || "", tableName].map((part) => part.toLowerCase()).join("\u0000"); @@ -898,6 +901,36 @@ export const useQueryStore = defineStore("query", () => { if (tab) tab.originalSql = tab.sql; } + function persistSavedSqlEditorPosition(tab: QueryTab | undefined) { + if (!tab?.savedSqlId || tab.mode !== "query") return; + const pending = savedSqlEditorPositionTimers.get(tab.savedSqlId); + if (pending) { + clearTimeout(pending); + savedSqlEditorPositionTimers.delete(tab.savedSqlId); + } + saveSavedSqlEditorPosition( + createSavedSqlEditorPosition({ + savedSqlId: tab.savedSqlId, + sql: tab.sql, + selection: tab.editorSelection, + viewport: tab.editorViewport, + }), + ); + } + + function queueSavedSqlEditorPositionPersist(tab: QueryTab | undefined) { + if (!tab?.savedSqlId || tab.mode !== "query") return; + const pending = savedSqlEditorPositionTimers.get(tab.savedSqlId); + if (pending) clearTimeout(pending); + const tabId = tab.id; + const savedSqlId = tab.savedSqlId; + const timer = setTimeout(() => { + savedSqlEditorPositionTimers.delete(savedSqlId); + persistSavedSqlEditorPosition(tabs.value.find((item) => item.id === tabId)); + }, SAVED_SQL_EDITOR_POSITION_PERSIST_DELAY_MS); + savedSqlEditorPositionTimers.set(savedSqlId, timer); + } + function discardTabChanges(id: string) { const tab = tabs.value.find((item) => item.id === id); if (!tab || tab.mode !== "query") return false; @@ -969,6 +1002,7 @@ export const useQueryStore = defineStore("query", () => { } const idx = tabs.value.findIndex((t) => t.id === id); if (idx < 0) return; + persistSavedSqlEditorPosition(tabs.value[idx]); clearDataGridPendingSnapshotsForTab(id); if (tabs.value[idx].txnSessionId) void rollbackTransaction(id); if (tabs.value[idx].isExecuting) void cancelTabExecution(id); @@ -1305,6 +1339,7 @@ export const useQueryStore = defineStore("query", () => { const tab = tabs.value.find((t) => t.id === id); if (tab) { tab.sql = sql; + queueSavedSqlEditorPositionPersist(tab); } } @@ -1355,12 +1390,14 @@ export const useQueryStore = defineStore("query", () => { const tab = tabs.value.find((t) => t.id === id); if (!tab) return; tab.editorViewport = viewport; + queueSavedSqlEditorPositionPersist(tab); } function updateEditorSelection(id: string, selection: { anchor: number; head: number }) { const tab = tabs.value.find((t) => t.id === id); if (!tab) return; tab.editorSelection = selection; + queueSavedSqlEditorPositionPersist(tab); } function renameTab(id: string, title: string) { @@ -1399,15 +1436,20 @@ export const useQueryStore = defineStore("query", () => { function openSavedSql(file: SavedSqlFile) { const existing = tabs.value.find((tab) => tab.savedSqlId === file.id); if (existing) { + persistSavedSqlEditorPosition(existing); if (!existing.sql && file.sql) { existing.sql = file.sql; existing.originalSql = file.sql; + const restored = restoreSavedSqlEditorPosition(file.id, file.sql); + existing.editorSelection = restored.selection; + existing.editorViewport = restored.viewport; } activeTabId.value = existing.id; return existing.id; } const id = uuid(); + const restoredPosition = restoreSavedSqlEditorPosition(file.id, file.sql); const tab: QueryTab = { id, title: file.name, @@ -1422,6 +1464,8 @@ export const useQueryStore = defineStore("query", () => { isCancelling: false, isExplaining: false, mode: "query", + editorSelection: restoredPosition.selection, + editorViewport: restoredPosition.viewport, }; tabs.value.push(tab); activeTabId.value = id; @@ -1440,6 +1484,9 @@ export const useQueryStore = defineStore("query", () => { tab.schema = file.schema; tab.sql = file.sql; tab.originalSql = file.sql; + const restored = restoreSavedSqlEditorPosition(file.id, file.sql); + tab.editorSelection = restored.selection; + tab.editorViewport = restored.viewport; } } diff --git a/apps/desktop/src/stores/savedSqlStore.ts b/apps/desktop/src/stores/savedSqlStore.ts index 7ad82619dd..314eedd94c 100644 --- a/apps/desktop/src/stores/savedSqlStore.ts +++ b/apps/desktop/src/stores/savedSqlStore.ts @@ -2,6 +2,7 @@ import { defineStore } from "pinia"; import { computed, ref } from "vue"; import { uuid } from "@/lib/common/utils"; import * as api from "@/lib/backend/api"; +import { forgetSavedSqlEditorPosition } from "@/lib/app/savedSqlEditorPosition"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { useSettingsStore } from "@/stores/settingsStore"; import type { SavedSqlFile, SavedSqlFolder, SavedSqlLibrary } from "@/types/database"; @@ -294,6 +295,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => { for (const tab of tabsToClose) { queryStore.closeTab(tab.id, { force: true }); } + forgetSavedSqlEditorPosition(id); } async function persistFolders(nextFolders: SavedSqlFolder[]) {