feat(saved-sql): restore editor position
This commit is contained in:
parent
5ec5b221dd
commit
896580f1c5
|
|
@ -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<string, string>();
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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<string>(["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<T>(value: T): T {
|
||||
|
|
@ -222,6 +224,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
if (tab.mode === "data") void deleteTabResultSnapshot(tabResultCacheKey(tab.id));
|
||||
}
|
||||
const tableStructureRefreshVersions = ref<Record<string, number>>({});
|
||||
const savedSqlEditorPositionTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue