feat: optimize tab result lifecycle caching
This commit is contained in:
parent
b226b445d6
commit
2b5300722f
|
|
@ -1844,6 +1844,7 @@ name = "dbx"
|
|||
version = "0.5.29"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
"calamine",
|
||||
"chrono",
|
||||
"csv",
|
||||
|
|
|
|||
|
|
@ -279,7 +279,10 @@ function isGlobalUiZoomTarget(target: EventTarget | null): target is Element {
|
|||
|
||||
watch(
|
||||
() => queryStore.activeTabId,
|
||||
(id) => {
|
||||
(id, previousId) => {
|
||||
if (previousId && previousId !== id && typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("dbx:before-tab-switch", { detail: { tabId: id, fromTabId: previousId } }));
|
||||
}
|
||||
if (id) newQueryContextSource.value = "tab";
|
||||
selectedSql.value = "";
|
||||
activeOutputView.value = "result";
|
||||
|
|
@ -862,11 +865,6 @@ async function reconnectRestoredTabs() {
|
|||
await connectionStore.ensureConnected(activeConnectionId);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const tab = activeTab.value;
|
||||
if (tab?.mode === "data" && tab.tableMeta && tab.sql) {
|
||||
queryStore.executeTabSql(tab.id, tab.sql).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function handleContextMenu(e: MouseEvent) {
|
||||
|
|
@ -1058,7 +1056,7 @@ onUnmounted(() => {
|
|||
@set-default-database="setActiveDatabaseAsDefault"
|
||||
@clear-default-database="clearActiveDefaultDatabase"
|
||||
/>
|
||||
<KeepAlive :max="8">
|
||||
<KeepAlive :max="4">
|
||||
<ContentArea
|
||||
ref="contentAreaRef"
|
||||
:key="activeTab.id"
|
||||
|
|
|
|||
|
|
@ -1766,6 +1766,8 @@ function markGridScrolling() {
|
|||
|
||||
function onScrollerScroll(e: Event) {
|
||||
syncHeaderScroll(e);
|
||||
const target = e.target;
|
||||
recordScrollPosition(target instanceof HTMLElement ? { top: target.scrollTop, left: target.scrollLeft } : undefined);
|
||||
markGridScrolling();
|
||||
}
|
||||
|
||||
|
|
@ -2125,6 +2127,7 @@ const {
|
|||
exitTransaction,
|
||||
startEdit,
|
||||
commitEdit,
|
||||
commitEditFromBlur,
|
||||
applyCellValue,
|
||||
cancelEdit,
|
||||
onEditKeydown,
|
||||
|
|
@ -2147,6 +2150,7 @@ const {
|
|||
getResetScrollAfterResult,
|
||||
clearResetScrollAfterResult,
|
||||
cleanupFrames,
|
||||
recordScrollPosition,
|
||||
} = editor;
|
||||
|
||||
const saveActionMode = computed(() =>
|
||||
|
|
@ -3415,6 +3419,7 @@ function onCanvasScroll(event: Event) {
|
|||
const gutter = scrollbarGutterWidth(scroller);
|
||||
if (gridScrollbarGutter.value !== gutter) gridScrollbarGutter.value = gutter;
|
||||
if (headerRef.value && headerRef.value.scrollLeft !== scrollLeft) headerRef.value.scrollLeft = scrollLeft;
|
||||
recordScrollPosition({ top: scrollTop, left: scrollLeft });
|
||||
markGridScrolling();
|
||||
scheduleCanvasDraw();
|
||||
}
|
||||
|
|
@ -4606,6 +4611,8 @@ function updateTransposeViewport() {
|
|||
|
||||
function onTransposeScroll() {
|
||||
updateTransposeViewport();
|
||||
const el = transposeScrollElement();
|
||||
recordScrollPosition(el ? { top: el.scrollTop, left: el.scrollLeft } : undefined);
|
||||
markGridScrolling();
|
||||
}
|
||||
|
||||
|
|
@ -6312,7 +6319,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="cell-edit-input absolute inset-0 bg-background border-2 border-primary px-1.5 py-0 text-xs leading-[26px] outline-none z-10"
|
||||
@blur="commitEdit"
|
||||
@blur="commitEditFromBlur"
|
||||
@click.stop
|
||||
@keydown.stop="onEditKeydown"
|
||||
@paste.stop
|
||||
|
|
@ -6948,7 +6955,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="cell-edit-input absolute inset-0 bg-background border-2 border-primary px-2.5 py-0 text-xs leading-[22px] outline-none z-10"
|
||||
@blur="commitEdit"
|
||||
@blur="commitEditFromBlur"
|
||||
@click.stop
|
||||
@keydown.stop="onEditKeydown"
|
||||
@paste.stop
|
||||
|
|
@ -7123,7 +7130,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="cell-edit-input absolute inset-0 bg-background border-2 border-primary px-2.5 py-0 text-xs leading-[22px] outline-none z-10"
|
||||
@blur="commitEdit"
|
||||
@blur="commitEditFromBlur"
|
||||
@click.stop
|
||||
@keydown.stop="onEditKeydown"
|
||||
@paste.stop
|
||||
|
|
|
|||
|
|
@ -229,17 +229,19 @@ function tabMenuIcon(tab: QueryTab) {
|
|||
return Code2;
|
||||
}
|
||||
|
||||
function activateTab(tabId: string) {
|
||||
tabScrollBehavior.value = "auto";
|
||||
queryStore.activeTabId = tabId;
|
||||
emit("close-driver-store");
|
||||
}
|
||||
|
||||
function handleTabClick(tab: QueryTab) {
|
||||
if (tabDrag.state.wasDragged) return;
|
||||
activateTab(tab.id);
|
||||
}
|
||||
|
||||
function handleTabMouseDown(event: MouseEvent, tabId: string) {
|
||||
if (event.button === 0) {
|
||||
dispatchBeforeTabSwitch(tabId);
|
||||
event.preventDefault();
|
||||
}
|
||||
tabDrag.startDrag(event, tabId);
|
||||
}
|
||||
|
||||
function tabDropStyle(tabId: string) {
|
||||
if (!tabDrag.state.active) return {};
|
||||
if (tabDrag.state.draggedId === tabId) return { opacity: 0.4 };
|
||||
|
|
@ -266,6 +268,20 @@ const tabOverflowControlClass = computed(() =>
|
|||
? "h-full w-8 border-r border-border/80 dark:border-border/45 bg-background/80 text-foreground/75 hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-40"
|
||||
: "h-7 w-7 rounded-md border border-border/60 bg-background text-foreground/70 hover:border-border hover:text-foreground",
|
||||
);
|
||||
|
||||
function dispatchBeforeTabSwitch(tabId: string) {
|
||||
if (tabId === queryStore.activeTabId) return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("dbx:before-tab-switch", { detail: { tabId, fromTabId: queryStore.activeTabId } }),
|
||||
);
|
||||
}
|
||||
|
||||
function activateTab(tabId: string) {
|
||||
dispatchBeforeTabSwitch(tabId);
|
||||
tabScrollBehavior.value = "auto";
|
||||
queryStore.activeTabId = tabId;
|
||||
emit("close-driver-store");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -331,7 +347,7 @@ const tabOverflowControlClass = computed(() =>
|
|||
@click="handleTabClick(tab)"
|
||||
@dblclick.stop="startRenameTab(tab)"
|
||||
@mousedown.middle.prevent="queryStore.closeTab(tab.id)"
|
||||
@mousedown="tabDrag.startDrag($event, tab.id)"
|
||||
@mousedown="handleTabMouseDown($event, tab.id)"
|
||||
@mouseenter="tabDrag.updateTarget($event, tab.id)"
|
||||
@mousemove="tabDrag.updateTarget($event, tab.id)"
|
||||
@mouseleave="tabDrag.clearTarget(tab.id)"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,16 @@
|
|||
import { ref, computed, nextTick, watch, onBeforeUnmount, type ComputedRef, type Ref } from "vue";
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
nextTick,
|
||||
watch,
|
||||
getCurrentInstance,
|
||||
onActivated,
|
||||
onBeforeUnmount,
|
||||
onDeactivated,
|
||||
onMounted,
|
||||
type ComputedRef,
|
||||
type Ref,
|
||||
} from "vue";
|
||||
import * as api from "@/lib/api";
|
||||
import { normalizeDataGridSaveError } from "@/lib/dataGridSql";
|
||||
import { rowStatusFilterAfterAddingRow, type RowStatusFilter } from "@/lib/gridRowStatus";
|
||||
|
|
@ -86,10 +98,41 @@ interface PendingChangesSnapshot {
|
|||
newRows: CellValue[][];
|
||||
dirtyRows: Map<number, Map<number, CellValue>>;
|
||||
deletedRows: Set<number>;
|
||||
editingCell?: { rowId: number; col: number } | null;
|
||||
editValue?: string;
|
||||
transactionActive?: boolean;
|
||||
scroll?: { top: number; left: number };
|
||||
columnCount: number;
|
||||
rowCount: number;
|
||||
}
|
||||
|
||||
const pendingChangesCache = new Map<string, PendingChangesSnapshot>();
|
||||
const closingPendingSnapshotTabs = new Set<string>();
|
||||
const BEFORE_TAB_SWITCH_EVENT = "dbx:before-tab-switch";
|
||||
|
||||
function cacheKeyBelongsToTab(cacheKey: string, tabId: string) {
|
||||
return cacheKey === tabId || cacheKey.startsWith(`${tabId}-`);
|
||||
}
|
||||
|
||||
function closedTabIdForCacheKey(cacheKey: string): string | undefined {
|
||||
for (const tabId of closingPendingSnapshotTabs) {
|
||||
if (cacheKeyBelongsToTab(cacheKey, tabId)) return tabId;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function clearDataGridPendingSnapshotsForTab(tabId: string) {
|
||||
closingPendingSnapshotTabs.add(tabId);
|
||||
if (typeof window !== "undefined") {
|
||||
window.setTimeout(() => closingPendingSnapshotTabs.delete(tabId), 5000);
|
||||
} else {
|
||||
setTimeout(() => closingPendingSnapshotTabs.delete(tabId), 5000);
|
||||
}
|
||||
pendingChangesCache.delete(tabId);
|
||||
for (const key of pendingChangesCache.keys()) {
|
||||
if (cacheKeyBelongsToTab(key, tabId)) pendingChangesCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
const connectionStore = useConnectionStore();
|
||||
|
|
@ -123,15 +166,26 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
|||
const dirtyRows = ref<Map<number, Map<number, CellValue>>>(new Map());
|
||||
const newRows = ref<CellValue[][]>([]);
|
||||
const deletedRows = ref<Set<number>>(new Set());
|
||||
let restoredEditingCell = false;
|
||||
let restoredTransactionActive = false;
|
||||
let suppressNextBlurCommit = false;
|
||||
let pendingScrollRestore: PendingChangesSnapshot["scroll"] | undefined;
|
||||
let saveScrollSnapshotTimer = 0;
|
||||
let componentActive = true;
|
||||
|
||||
// Restore cached pending changes from a previous instance (e.g. after result eviction + reload)
|
||||
const key = cacheKey?.value;
|
||||
if (key) {
|
||||
const cached = pendingChangesCache.get(key);
|
||||
if (cached && cached.columnCount === result.value.columns.length) {
|
||||
if (cached && cached.columnCount === result.value.columns.length && cached.rowCount === result.value.rows.length) {
|
||||
newRows.value = cached.newRows;
|
||||
dirtyRows.value = cached.dirtyRows;
|
||||
deletedRows.value = cached.deletedRows;
|
||||
editingCell.value = cached.editingCell ?? null;
|
||||
editValue.value = cached.editValue ?? "";
|
||||
restoredEditingCell = !!cached.editingCell;
|
||||
restoredTransactionActive = cached.transactionActive === true;
|
||||
pendingScrollRestore = cached.scroll;
|
||||
pendingChangesCache.delete(key);
|
||||
} else {
|
||||
pendingChangesCache.delete(key);
|
||||
|
|
@ -159,6 +213,34 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
|||
if (hasPendingChanges.value && useTransaction.value) {
|
||||
transactionActive.value = true;
|
||||
}
|
||||
if (restoredTransactionActive && useTransaction.value) transactionActive.value = true;
|
||||
if (restoredEditingCell) {
|
||||
focusEditInput();
|
||||
}
|
||||
|
||||
function focusEditInput(select = true) {
|
||||
const focusInput = () => {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = getScrollerElement()?.closest("[data-grid-root]");
|
||||
const input = (root ?? document).querySelector(".cell-edit-input") as HTMLInputElement | null;
|
||||
input?.focus();
|
||||
if (select && input) {
|
||||
input.select();
|
||||
input.setSelectionRange?.(0, input.value.length);
|
||||
}
|
||||
};
|
||||
nextTick(() => {
|
||||
focusInput();
|
||||
if (typeof requestAnimationFrame === "undefined") return;
|
||||
let attempts = 0;
|
||||
const focusNextFrame = () => {
|
||||
focusInput();
|
||||
attempts += 1;
|
||||
if (attempts < 3) requestAnimationFrame(focusNextFrame);
|
||||
};
|
||||
requestAnimationFrame(focusNextFrame);
|
||||
});
|
||||
}
|
||||
|
||||
function enterTransaction() {
|
||||
transactionActive.value = true;
|
||||
|
|
@ -218,6 +300,42 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
|||
};
|
||||
}
|
||||
|
||||
function readScrollPosition(): PendingChangesSnapshot["scroll"] | undefined {
|
||||
const el = getScrollerElement();
|
||||
if (!el) return undefined;
|
||||
const top = Math.max(0, el.scrollTop);
|
||||
const left = Math.max(0, el.scrollLeft);
|
||||
if (top === 0 && left === 0) return undefined;
|
||||
return { top, left };
|
||||
}
|
||||
|
||||
function applyScrollPosition(scroll: PendingChangesSnapshot["scroll"] | undefined) {
|
||||
if (!scroll) return;
|
||||
const restoreScroll = () => {
|
||||
const scroller = scrollerRef.value;
|
||||
if (scroller && !(scroller instanceof HTMLElement)) {
|
||||
scroller.scrollToPosition?.(scroll.top);
|
||||
}
|
||||
const el = getScrollerElement();
|
||||
if (!el) return;
|
||||
el.scrollTo?.({ top: scroll.top, left: scroll.left });
|
||||
el.scrollTop = scroll.top;
|
||||
el.scrollLeft = scroll.left;
|
||||
};
|
||||
restoreScrollAcrossFrames(restoreScroll);
|
||||
}
|
||||
|
||||
function recordScrollPosition(scroll = readScrollPosition()) {
|
||||
pendingScrollRestore = scroll;
|
||||
const k = cacheKey?.value;
|
||||
if (!k || typeof window === "undefined") return;
|
||||
if (saveScrollSnapshotTimer) window.clearTimeout(saveScrollSnapshotTimer);
|
||||
saveScrollSnapshotTimer = window.setTimeout(() => {
|
||||
saveScrollSnapshotTimer = 0;
|
||||
savePendingSnapshot(true, true);
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function focusScrollerWithoutScrolling() {
|
||||
const el = getScrollerElement();
|
||||
if (!el) return;
|
||||
|
|
@ -230,14 +348,18 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
|||
restoreScroll();
|
||||
nextTick(() => {
|
||||
restoreScroll();
|
||||
cancelScrollRestoreFrame = requestAnimationFrame(() => {
|
||||
let attempts = 0;
|
||||
const restoreNextFrame = () => {
|
||||
restoreScroll();
|
||||
cancelScrollRestoreFrame = requestAnimationFrame(() => {
|
||||
restoreScroll();
|
||||
attempts += 1;
|
||||
if (attempts >= 8) {
|
||||
cancelScrollRestoreFrame = 0;
|
||||
isCancelling = false;
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
cancelScrollRestoreFrame = requestAnimationFrame(restoreNextFrame);
|
||||
};
|
||||
cancelScrollRestoreFrame = requestAnimationFrame(restoreNextFrame);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -252,6 +374,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
|||
function cleanupFrames() {
|
||||
if (resetScrollFrame) cancelAnimationFrame(resetScrollFrame);
|
||||
if (cancelScrollRestoreFrame) cancelAnimationFrame(cancelScrollRestoreFrame);
|
||||
if (saveScrollSnapshotTimer) window.clearTimeout(saveScrollSnapshotTimer);
|
||||
}
|
||||
|
||||
// --- Cell value coercion ---
|
||||
|
|
@ -311,14 +434,11 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
|||
if (!item || item.isDeleted) return;
|
||||
if (!item.isNew && !canEditExistingRows.value) return;
|
||||
isCancelling = false;
|
||||
suppressNextBlurCommit = false;
|
||||
editingCell.value = { rowId, col: colIdx };
|
||||
const val = item?.data[colIdx] ?? null;
|
||||
editValue.value = val === null ? "" : typeof val === "object" ? JSON.stringify(val) : String(val);
|
||||
nextTick(() => {
|
||||
const input = document.querySelector(".cell-edit-input") as HTMLInputElement;
|
||||
input?.focus();
|
||||
input?.select();
|
||||
});
|
||||
focusEditInput();
|
||||
}
|
||||
|
||||
function commitEdit() {
|
||||
|
|
@ -366,6 +486,14 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
|||
editingCell.value = null;
|
||||
}
|
||||
|
||||
function commitEditFromBlur() {
|
||||
if (suppressNextBlurCommit) {
|
||||
suppressNextBlurCommit = false;
|
||||
return;
|
||||
}
|
||||
commitEdit();
|
||||
}
|
||||
|
||||
function applyCellValue(rowId: number, col: number, value: string | null) {
|
||||
if (!canEditColumn(col)) return;
|
||||
const item = getRowItem(rowId);
|
||||
|
|
@ -772,24 +900,78 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
|||
watch(
|
||||
() => result.value.rows,
|
||||
() => {
|
||||
pendingScrollRestore = undefined;
|
||||
discardChanges();
|
||||
},
|
||||
);
|
||||
|
||||
// Save pending changes before the component is destroyed so they can be
|
||||
// restored if a new DataGrid instance is created for the same tab
|
||||
// (e.g. after result eviction + reload).
|
||||
onBeforeUnmount(() => {
|
||||
function savePendingSnapshot(includeEditing = false, includeScroll = false) {
|
||||
const k = cacheKey?.value;
|
||||
if (k && hasPendingChanges.value) {
|
||||
pendingChangesCache.set(k, {
|
||||
newRows: newRows.value.map((r) => [...r]),
|
||||
dirtyRows: new Map([...dirtyRows.value].map(([i, m]) => [i, new Map(m)])),
|
||||
deletedRows: new Set(deletedRows.value),
|
||||
columnCount: result.value.columns.length,
|
||||
});
|
||||
if (!k) return;
|
||||
if (closedTabIdForCacheKey(k)) {
|
||||
pendingChangesCache.delete(k);
|
||||
return;
|
||||
}
|
||||
});
|
||||
const scroll = includeScroll ? (readScrollPosition() ?? pendingScrollRestore) : undefined;
|
||||
if (includeScroll) pendingScrollRestore = scroll;
|
||||
if (!hasPendingChanges.value && !(includeEditing && editingCell.value) && !scroll) {
|
||||
pendingChangesCache.delete(k);
|
||||
return;
|
||||
}
|
||||
pendingChangesCache.set(k, {
|
||||
newRows: newRows.value.map((r) => [...r]),
|
||||
dirtyRows: new Map([...dirtyRows.value].map(([i, m]) => [i, new Map(m)])),
|
||||
deletedRows: new Set(deletedRows.value),
|
||||
editingCell: includeEditing && editingCell.value ? { ...editingCell.value } : null,
|
||||
editValue: editValue.value,
|
||||
transactionActive: transactionActive.value,
|
||||
scroll,
|
||||
columnCount: result.value.columns.length,
|
||||
rowCount: result.value.rows.length,
|
||||
});
|
||||
}
|
||||
|
||||
function restorePendingSnapshotFocus() {
|
||||
suppressNextBlurCommit = false;
|
||||
if (editingCell.value) focusEditInput(true);
|
||||
applyScrollPosition(pendingScrollRestore);
|
||||
}
|
||||
|
||||
function onBeforeTabSwitch() {
|
||||
if (!componentActive) return;
|
||||
savePendingSnapshot(true, true);
|
||||
if (editingCell.value) suppressNextBlurCommit = true;
|
||||
}
|
||||
|
||||
const componentInstance = getCurrentInstance();
|
||||
if (componentInstance && typeof window !== "undefined") {
|
||||
window.addEventListener(BEFORE_TAB_SWITCH_EVENT, onBeforeTabSwitch);
|
||||
}
|
||||
|
||||
if (componentInstance) {
|
||||
onMounted(() => {
|
||||
componentActive = true;
|
||||
applyScrollPosition(pendingScrollRestore);
|
||||
});
|
||||
onActivated(() => {
|
||||
componentActive = true;
|
||||
restorePendingSnapshotFocus();
|
||||
});
|
||||
onDeactivated(() => {
|
||||
savePendingSnapshot(true, true);
|
||||
componentActive = false;
|
||||
});
|
||||
|
||||
// Save pending changes before the component is destroyed so they can be
|
||||
// restored if a new DataGrid instance is created for the same tab
|
||||
// (e.g. after result eviction + reload).
|
||||
onBeforeUnmount(() => {
|
||||
savePendingSnapshot(true, true);
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener(BEFORE_TAB_SWITCH_EVENT, onBeforeTabSwitch);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
editingCell,
|
||||
|
|
@ -811,6 +993,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
|||
exitTransaction,
|
||||
startEdit,
|
||||
commitEdit,
|
||||
commitEditFromBlur,
|
||||
applyCellValue,
|
||||
cancelEdit,
|
||||
onEditKeydown,
|
||||
|
|
@ -836,6 +1019,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
|||
getResetScrollAfterResult,
|
||||
clearResetScrollAfterResult,
|
||||
cleanupFrames,
|
||||
recordScrollPosition,
|
||||
syncHeaderScroll: (headerRef: Ref<HTMLDivElement | undefined>) => (e: Event) => {
|
||||
if (headerRef.value) {
|
||||
headerRef.value.scrollLeft = (e.target as HTMLElement).scrollLeft;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ export interface SavedOpenTab {
|
|||
objectBrowser?: QueryTab["objectBrowser"];
|
||||
objectSource?: QueryTab["objectSource"];
|
||||
tableMeta?: QueryTab["tableMeta"];
|
||||
resultEvicted?: boolean;
|
||||
resultCacheKey?: string;
|
||||
}
|
||||
|
||||
export interface RestoredOpenTabs {
|
||||
|
|
@ -58,6 +60,10 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
|
|||
objectBrowser: tab.objectBrowser,
|
||||
objectSource: tab.objectSource,
|
||||
tableMeta: tab.tableMeta,
|
||||
...(tab.mode !== "data" && tab.resultEvicted ? { resultEvicted: true } : {}),
|
||||
...(tab.mode !== "data" && tab.resultEvicted && tab.resultCacheKey !== undefined
|
||||
? { resultCacheKey: tab.resultCacheKey }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -86,13 +92,19 @@ export function restoreOpenTabsState(
|
|||
|
||||
const saved = parsed.filter(isSavedOpenTab);
|
||||
const filtered = options.queryOnly ? saved.filter((tab) => (tab.mode ?? "query") === "query") : saved;
|
||||
const tabs: QueryTab[] = filtered.map((tab) => ({
|
||||
...tab,
|
||||
mode: tab.mode ?? "query",
|
||||
isExecuting: false,
|
||||
isCancelling: false,
|
||||
isExplaining: false,
|
||||
}));
|
||||
const tabs: QueryTab[] = filtered.map((tab) => {
|
||||
const mode = tab.mode ?? "query";
|
||||
return {
|
||||
...tab,
|
||||
mode,
|
||||
isExecuting: false,
|
||||
isCancelling: false,
|
||||
isExplaining: false,
|
||||
resultEvicted: mode === "data" ? undefined : tab.resultEvicted,
|
||||
resultCacheKey: mode === "data" ? undefined : tab.resultCacheKey,
|
||||
resultCacheState: mode !== "data" && tab.resultCacheKey ? "disk" : undefined,
|
||||
};
|
||||
});
|
||||
const activeTabId = rawActiveTabId || null;
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,417 @@
|
|||
import type { QueryResult, QueryTab } from "@/types/database";
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
import { toRaw } from "vue";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
|
||||
const DB_NAME = "dbx-tab-runtime-cache";
|
||||
const DB_VERSION = 1;
|
||||
const RESULT_STORE = "resultSnapshots";
|
||||
const PAYLOAD_MAGIC = "DBX_TAB_RESULT_CACHE";
|
||||
const PAYLOAD_VERSION = 1;
|
||||
const PAYLOAD_CODEC = "msgpack-columnar";
|
||||
type CellValue = QueryResult["rows"][number][number];
|
||||
|
||||
export interface TabResultSnapshot {
|
||||
result?: QueryResult;
|
||||
results?: QueryResult[];
|
||||
activeResultIndex?: number;
|
||||
queryAnalysis?: QueryTab["queryAnalysis"];
|
||||
querySourceColumns?: QueryTab["querySourceColumns"];
|
||||
queryEditabilityReason?: QueryTab["queryEditabilityReason"];
|
||||
tableMeta?: QueryTab["tableMeta"];
|
||||
resultPageSql?: string;
|
||||
resultPageLimit?: number;
|
||||
resultPageOffset?: number;
|
||||
resultCountSql?: string;
|
||||
resultTotalRowCount?: number;
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
interface ColumnarQueryResult {
|
||||
columns: string[];
|
||||
columnValues: CellValue[][];
|
||||
rowCount: number;
|
||||
affected_rows: number;
|
||||
execution_time_ms: number;
|
||||
truncated?: boolean;
|
||||
has_more?: boolean;
|
||||
}
|
||||
|
||||
interface TabResultSnapshotPayload extends Omit<TabResultSnapshot, "result" | "results"> {
|
||||
result?: ColumnarQueryResult;
|
||||
results?: ColumnarQueryResult[];
|
||||
}
|
||||
|
||||
interface TabResultCacheEnvelope {
|
||||
magic: typeof PAYLOAD_MAGIC;
|
||||
version: typeof PAYLOAD_VERSION;
|
||||
codec: typeof PAYLOAD_CODEC;
|
||||
cachedAt: number;
|
||||
rowCount: number;
|
||||
columnCount: number;
|
||||
payload: TabResultSnapshotPayload;
|
||||
}
|
||||
|
||||
function indexedDb(): IDBFactory | undefined {
|
||||
return typeof globalThis.indexedDB === "undefined" ? undefined : globalThis.indexedDB;
|
||||
}
|
||||
|
||||
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed"));
|
||||
});
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBDatabase | null> | undefined;
|
||||
const cacheKeyVersions = new Map<string, number>();
|
||||
|
||||
function bumpCacheKeyVersion(key: string): number {
|
||||
const version = (cacheKeyVersions.get(key) ?? 0) + 1;
|
||||
cacheKeyVersions.set(key, version);
|
||||
return version;
|
||||
}
|
||||
|
||||
function isCurrentCacheKeyVersion(key: string, version: number): boolean {
|
||||
return cacheKeyVersions.get(key) === version;
|
||||
}
|
||||
|
||||
function clearCacheKeyVersionIfCurrent(key: string, version: number) {
|
||||
if (isCurrentCacheKeyVersion(key, version)) cacheKeyVersions.delete(key);
|
||||
}
|
||||
|
||||
function openCacheDb(): Promise<IDBDatabase | null> {
|
||||
if (dbPromise) return dbPromise;
|
||||
const idb = indexedDb();
|
||||
if (!idb) return Promise.resolve(null);
|
||||
|
||||
dbPromise = new Promise((resolve) => {
|
||||
const request = idb.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(RESULT_STORE)) db.createObjectStore(RESULT_STORE);
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => {
|
||||
console.warn("[DBX][tab-result-cache:open:error]", request.error);
|
||||
resolve(null);
|
||||
};
|
||||
request.onblocked = () => resolve(null);
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
function clonePlain<T>(value: T): T {
|
||||
const raw = toRaw(value);
|
||||
if (typeof structuredClone === "function") return structuredClone(raw);
|
||||
return JSON.parse(JSON.stringify(raw)) as T;
|
||||
}
|
||||
|
||||
function stripSessionIds(result: QueryResult | undefined): QueryResult | undefined {
|
||||
if (!result) return undefined;
|
||||
return {
|
||||
columns: [...result.columns],
|
||||
rows: result.rows.map((row) => [...row]),
|
||||
affected_rows: result.affected_rows,
|
||||
execution_time_ms: result.execution_time_ms,
|
||||
truncated: result.truncated,
|
||||
session_id: undefined,
|
||||
has_more: result.has_more,
|
||||
};
|
||||
}
|
||||
|
||||
function stripResultSessionIds(results: QueryResult[] | undefined): QueryResult[] | undefined {
|
||||
return results?.map((result) => stripSessionIds(result)!);
|
||||
}
|
||||
|
||||
function toColumnarResult(result: QueryResult | undefined): ColumnarQueryResult | undefined {
|
||||
if (!result) return undefined;
|
||||
const columnValues = result.columns.map((_, colIndex) => result.rows.map((row) => row[colIndex] ?? null));
|
||||
return removeUndefinedFields({
|
||||
columns: [...result.columns],
|
||||
columnValues,
|
||||
rowCount: result.rows.length,
|
||||
affected_rows: result.affected_rows,
|
||||
execution_time_ms: result.execution_time_ms,
|
||||
truncated: result.truncated,
|
||||
has_more: result.has_more,
|
||||
});
|
||||
}
|
||||
|
||||
function fromColumnarResult(result: ColumnarQueryResult | undefined): QueryResult | undefined {
|
||||
if (!result) return undefined;
|
||||
const rows = Array.from({ length: result.rowCount }, (_, rowIndex) =>
|
||||
result.columnValues.map((values) => values[rowIndex] ?? null),
|
||||
);
|
||||
return {
|
||||
columns: [...result.columns],
|
||||
rows,
|
||||
affected_rows: result.affected_rows,
|
||||
execution_time_ms: result.execution_time_ms,
|
||||
truncated: result.truncated,
|
||||
session_id: undefined,
|
||||
has_more: result.has_more,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotToPayload(snapshot: TabResultSnapshot): TabResultSnapshotPayload {
|
||||
return removeUndefinedFields({
|
||||
...snapshot,
|
||||
result: toColumnarResult(snapshot.result),
|
||||
results: snapshot.results?.map((result) => toColumnarResult(result)!),
|
||||
});
|
||||
}
|
||||
|
||||
function payloadToSnapshot(payload: TabResultSnapshotPayload): TabResultSnapshot {
|
||||
return {
|
||||
...payload,
|
||||
result: fromColumnarResult(payload.result),
|
||||
results: payload.results?.map((result) => fromColumnarResult(result)!),
|
||||
};
|
||||
}
|
||||
|
||||
function resultStats(snapshot: TabResultSnapshot): { rowCount: number; columnCount: number } {
|
||||
const result = snapshot.result ?? snapshot.results?.[snapshot.activeResultIndex ?? 0] ?? snapshot.results?.[0];
|
||||
return {
|
||||
rowCount: result?.rows.length ?? 0,
|
||||
columnCount: result?.columns.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
const chunkSize = 0x8000;
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToBytes(value: string): Uint8Array {
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function canUseRemoteRuntimeCache(): boolean {
|
||||
return (
|
||||
typeof btoa !== "undefined" && typeof atob !== "undefined" && (isTauriRuntime() || typeof fetch !== "undefined")
|
||||
);
|
||||
}
|
||||
|
||||
async function writeRemoteRuntimeCache(
|
||||
key: string,
|
||||
bytes: Uint8Array,
|
||||
stats: { rowCount: number; columnCount: number },
|
||||
): Promise<boolean> {
|
||||
if (!canUseRemoteRuntimeCache()) return false;
|
||||
try {
|
||||
if (isTauriRuntime()) {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
await invoke("save_tab_runtime_cache", {
|
||||
key,
|
||||
payloadBase64: bytesToBase64(bytes),
|
||||
rowCount: stats.rowCount,
|
||||
columnCount: stats.columnCount,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
const response = await fetch("/api/tab-runtime-cache", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
key,
|
||||
payloadBase64: bytesToBase64(bytes),
|
||||
rowCount: stats.rowCount,
|
||||
columnCount: stats.columnCount,
|
||||
}),
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.warn("[DBX][tab-result-cache:remote-write:error]", { key, error });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readRemoteRuntimeCache(key: string): Promise<Uint8Array | undefined> {
|
||||
if (!canUseRemoteRuntimeCache()) return undefined;
|
||||
try {
|
||||
if (isTauriRuntime()) {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const entry = await invoke<{ payloadBase64?: string } | null>("load_tab_runtime_cache", { key });
|
||||
return entry?.payloadBase64 ? base64ToBytes(entry.payloadBase64) : undefined;
|
||||
}
|
||||
const response = await fetch(`/api/tab-runtime-cache?key=${encodeURIComponent(key)}`);
|
||||
if (!response.ok) return undefined;
|
||||
const entry = (await response.json()) as { payloadBase64?: string } | null;
|
||||
return entry?.payloadBase64 ? base64ToBytes(entry.payloadBase64) : undefined;
|
||||
} catch (error) {
|
||||
console.warn("[DBX][tab-result-cache:remote-read:error]", { key, error });
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRemoteRuntimeCache(key: string): Promise<void> {
|
||||
if (!canUseRemoteRuntimeCache()) return;
|
||||
try {
|
||||
if (isTauriRuntime()) {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
await invoke("delete_tab_runtime_cache", { key });
|
||||
return;
|
||||
}
|
||||
await fetch(`/api/tab-runtime-cache?key=${encodeURIComponent(key)}`, { method: "DELETE" });
|
||||
} catch (error) {
|
||||
console.warn("[DBX][tab-result-cache:remote-delete:error]", { key, error });
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRemoteRuntimeCacheWrite(
|
||||
key: string,
|
||||
bytes: Uint8Array,
|
||||
stats: { rowCount: number; columnCount: number },
|
||||
version: number,
|
||||
) {
|
||||
window.setTimeout(async () => {
|
||||
if (!isCurrentCacheKeyVersion(key, version)) return;
|
||||
await writeRemoteRuntimeCache(key, bytes, stats);
|
||||
clearCacheKeyVersionIfCurrent(key, version);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function removeUndefinedFields<T>(value: T): T {
|
||||
if (Array.isArray(value)) return value.map((item) => removeUndefinedFields(item)) as T;
|
||||
if (!isRecord(value)) return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter(([, entryValue]) => entryValue !== undefined)
|
||||
.map(([key, entryValue]) => [key, removeUndefinedFields(entryValue)]),
|
||||
) as T;
|
||||
}
|
||||
|
||||
function isBinaryPayload(value: unknown): value is Uint8Array {
|
||||
return value instanceof Uint8Array || value instanceof ArrayBuffer;
|
||||
}
|
||||
|
||||
export function encodeTabResultSnapshot(snapshot: TabResultSnapshot): Uint8Array {
|
||||
const stats = resultStats(snapshot);
|
||||
const envelope: TabResultCacheEnvelope = {
|
||||
magic: PAYLOAD_MAGIC,
|
||||
version: PAYLOAD_VERSION,
|
||||
codec: PAYLOAD_CODEC,
|
||||
cachedAt: snapshot.cachedAt,
|
||||
rowCount: stats.rowCount,
|
||||
columnCount: stats.columnCount,
|
||||
payload: snapshotToPayload(snapshot),
|
||||
};
|
||||
return encode(removeUndefinedFields(envelope));
|
||||
}
|
||||
|
||||
export function decodeTabResultSnapshot(bytes: Uint8Array | ArrayBuffer): TabResultSnapshot | undefined {
|
||||
const decoded = decode(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
|
||||
if (!isRecord(decoded)) return undefined;
|
||||
if (decoded.magic !== PAYLOAD_MAGIC || decoded.version !== PAYLOAD_VERSION || decoded.codec !== PAYLOAD_CODEC) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isRecord(decoded.payload)) return undefined;
|
||||
return payloadToSnapshot(decoded.payload as unknown as TabResultSnapshotPayload);
|
||||
}
|
||||
|
||||
export function tabResultCacheKey(tabId: string): string {
|
||||
return `tab:${tabId}:result`;
|
||||
}
|
||||
|
||||
export function buildTabResultSnapshot(tab: QueryTab): TabResultSnapshot | undefined {
|
||||
if (!tab.result && !tab.results) return undefined;
|
||||
return {
|
||||
result: stripSessionIds(tab.result),
|
||||
results: stripResultSessionIds(tab.results),
|
||||
activeResultIndex: tab.activeResultIndex,
|
||||
queryAnalysis: tab.queryAnalysis ? clonePlain(tab.queryAnalysis) : undefined,
|
||||
querySourceColumns: tab.querySourceColumns ? [...tab.querySourceColumns] : undefined,
|
||||
queryEditabilityReason: tab.queryEditabilityReason,
|
||||
tableMeta: tab.tableMeta ? clonePlain(tab.tableMeta) : undefined,
|
||||
resultPageSql: tab.resultPageSql,
|
||||
resultPageLimit: tab.resultPageLimit,
|
||||
resultPageOffset: tab.resultPageOffset,
|
||||
resultCountSql: tab.resultCountSql,
|
||||
resultTotalRowCount: tab.resultTotalRowCount,
|
||||
cachedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeTabResultSnapshot(key: string, snapshot: TabResultSnapshot | undefined): Promise<boolean> {
|
||||
if (!snapshot) return false;
|
||||
const version = bumpCacheKeyVersion(key);
|
||||
const encoded = encodeTabResultSnapshot(snapshot);
|
||||
const stats = resultStats(snapshot);
|
||||
let wroteLocal = false;
|
||||
try {
|
||||
const db = await openCacheDb();
|
||||
if (db) {
|
||||
const tx = db.transaction(RESULT_STORE, "readwrite");
|
||||
await requestToPromise(tx.objectStore(RESULT_STORE).put(encoded, key));
|
||||
wroteLocal = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[DBX][tab-result-cache:write:error]", { key, error });
|
||||
}
|
||||
if (wroteLocal) {
|
||||
if (typeof window === "undefined") {
|
||||
void writeRemoteRuntimeCache(key, encoded, stats).finally(() => clearCacheKeyVersionIfCurrent(key, version));
|
||||
} else {
|
||||
scheduleRemoteRuntimeCacheWrite(key, encoded, stats, version);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!isCurrentCacheKeyVersion(key, version)) return false;
|
||||
try {
|
||||
return await writeRemoteRuntimeCache(key, encoded, stats);
|
||||
} finally {
|
||||
clearCacheKeyVersionIfCurrent(key, version);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readTabResultSnapshot(key: string): Promise<TabResultSnapshot | undefined> {
|
||||
try {
|
||||
const db = await openCacheDb();
|
||||
if (db) {
|
||||
const value = await requestToPromise(db.transaction(RESULT_STORE, "readonly").objectStore(RESULT_STORE).get(key));
|
||||
if (isBinaryPayload(value)) return decodeTabResultSnapshot(value);
|
||||
if (value) return value as TabResultSnapshot;
|
||||
}
|
||||
const remoteBytes = await readRemoteRuntimeCache(key);
|
||||
const remoteSnapshot = remoteBytes ? decodeTabResultSnapshot(remoteBytes) : undefined;
|
||||
if (remoteBytes && remoteSnapshot && db) {
|
||||
void requestToPromise(db.transaction(RESULT_STORE, "readwrite").objectStore(RESULT_STORE).put(remoteBytes, key));
|
||||
}
|
||||
return remoteSnapshot;
|
||||
} catch (error) {
|
||||
console.warn("[DBX][tab-result-cache:read:error]", { key, error });
|
||||
const remoteBytes = await readRemoteRuntimeCache(key);
|
||||
return remoteBytes ? decodeTabResultSnapshot(remoteBytes) : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTabResultSnapshot(key: string): Promise<void> {
|
||||
const version = bumpCacheKeyVersion(key);
|
||||
const clearVersionLater = () => {
|
||||
const cleanup = () => clearCacheKeyVersionIfCurrent(key, version);
|
||||
if (typeof window !== "undefined") window.setTimeout(cleanup, 5000);
|
||||
else setTimeout(cleanup, 5000);
|
||||
};
|
||||
try {
|
||||
const db = await openCacheDb();
|
||||
if (db) await requestToPromise(db.transaction(RESULT_STORE, "readwrite").objectStore(RESULT_STORE).delete(key));
|
||||
await deleteRemoteRuntimeCache(key);
|
||||
clearVersionLater();
|
||||
} catch (error) {
|
||||
console.warn("[DBX][tab-result-cache:delete:error]", { key, error });
|
||||
await deleteRemoteRuntimeCache(key);
|
||||
clearVersionLater();
|
||||
}
|
||||
}
|
||||
|
|
@ -32,6 +32,14 @@ import { tableMetaForDataTab } from "@/lib/tableDataTabMeta";
|
|||
import { quoteTableIdentifier } from "@/lib/tableSelectSql";
|
||||
import { connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
|
||||
import { queryTimeoutSecsForConnection } from "@/lib/queryTimeout";
|
||||
import { clearDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor";
|
||||
import {
|
||||
buildTabResultSnapshot,
|
||||
deleteTabResultSnapshot,
|
||||
readTabResultSnapshot,
|
||||
tabResultCacheKey,
|
||||
writeTabResultSnapshot,
|
||||
} from "@/lib/tabResultCache";
|
||||
import * as api from "@/lib/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
|
@ -118,6 +126,9 @@ export const useQueryStore = defineStore("query", () => {
|
|||
const restored = loadSavedTabs();
|
||||
const tabs = ref<QueryTab[]>(restored.tabs);
|
||||
const activeTabId = ref<string | null>(restored.activeTabId);
|
||||
for (const tab of restored.tabs) {
|
||||
if (tab.mode === "data") void deleteTabResultSnapshot(tabResultCacheKey(tab.id));
|
||||
}
|
||||
const tableStructureRefreshVersions = ref<Record<string, number>>({});
|
||||
|
||||
function tableStructureKey(
|
||||
|
|
@ -176,7 +187,11 @@ export const useQueryStore = defineStore("query", () => {
|
|||
}
|
||||
|
||||
function touchResult(tab: QueryTab | undefined, accessedAt = Date.now()) {
|
||||
if (tab?.result || tab?.results) tab.resultAccessedAt = accessedAt;
|
||||
if (tab?.result || tab?.results) {
|
||||
tab.resultAccessedAt = accessedAt;
|
||||
tab.resultCacheState = "memory";
|
||||
tab.resultEvicted = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function clearResultPayload(tab: QueryTab, options: { evicted?: boolean } = {}) {
|
||||
|
|
@ -190,10 +205,19 @@ export const useQueryStore = defineStore("query", () => {
|
|||
tab.queryEditabilityReason = undefined;
|
||||
if (tab.mode === "query") tab.tableMeta = undefined;
|
||||
tab.resultEvicted = options.evicted ? true : undefined;
|
||||
tab.resultCacheState = options.evicted ? tab.resultCacheState : undefined;
|
||||
if (!options.evicted) {
|
||||
if (tab.resultCacheKey) void deleteTabResultSnapshot(tab.resultCacheKey);
|
||||
tab.resultCacheKey = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function evictCachedResult(tab: QueryTab) {
|
||||
await closeResultSession(tab);
|
||||
const cacheKey = tabResultCacheKey(tab.id);
|
||||
const cached = await writeTabResultSnapshot(cacheKey, buildTabResultSnapshot(tab));
|
||||
tab.resultCacheKey = cached ? cacheKey : undefined;
|
||||
tab.resultCacheState = cached ? "disk" : "missing";
|
||||
clearResultPayload(tab, { evicted: true });
|
||||
}
|
||||
|
||||
|
|
@ -222,6 +246,8 @@ export const useQueryStore = defineStore("query", () => {
|
|||
objectBrowser: t.objectBrowser,
|
||||
objectSource: t.objectSource,
|
||||
tableMeta: t.tableMeta,
|
||||
resultEvicted: t.resultEvicted,
|
||||
resultCacheKey: t.resultCacheKey,
|
||||
})),
|
||||
);
|
||||
|
||||
|
|
@ -366,6 +392,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
function closeTab(id: string) {
|
||||
const idx = tabs.value.findIndex((t) => t.id === id);
|
||||
if (idx < 0) return;
|
||||
clearDataGridPendingSnapshotsForTab(id);
|
||||
if (tabs.value[idx].isExecuting) void cancelTabExecution(id);
|
||||
if (tabs.value[idx].isExplaining) void cancelTabExplain(id);
|
||||
void closeResultSession(tabs.value[idx]);
|
||||
|
|
@ -381,6 +408,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
tabs.value
|
||||
.filter((tab) => tab.id !== id)
|
||||
.forEach((tab) => {
|
||||
clearDataGridPendingSnapshotsForTab(tab.id);
|
||||
if (tab.isExecuting) void cancelTabExecution(tab.id);
|
||||
if (tab.isExplaining) void cancelTabExplain(tab.id);
|
||||
void closeResultSession(tab);
|
||||
|
|
@ -394,6 +422,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
|
||||
function closeAllTabs() {
|
||||
tabs.value.forEach((tab) => {
|
||||
clearDataGridPendingSnapshotsForTab(tab.id);
|
||||
if (tab.isExecuting) void cancelTabExecution(tab.id);
|
||||
if (tab.isExplaining) void cancelTabExplain(tab.id);
|
||||
void closeResultSession(tab);
|
||||
|
|
@ -412,6 +441,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
tabs.value
|
||||
.filter((tab) => closingIds.has(tab.id))
|
||||
.forEach((tab) => {
|
||||
clearDataGridPendingSnapshotsForTab(tab.id);
|
||||
if (tab.isExecuting) void cancelTabExecution(tab.id);
|
||||
if (tab.isExplaining) void cancelTabExplain(tab.id);
|
||||
void closeResultSession(tab);
|
||||
|
|
@ -1381,10 +1411,44 @@ export const useQueryStore = defineStore("query", () => {
|
|||
touchResult(tabs.value.find((tab) => tab.id === id));
|
||||
});
|
||||
|
||||
function restoreCachedResultPayload(tab: QueryTab, snapshot: Awaited<ReturnType<typeof readTabResultSnapshot>>) {
|
||||
if (!snapshot) return false;
|
||||
const results = snapshot.results ? markQueryResultsRowsRaw(snapshot.results) : undefined;
|
||||
const activeIndex = snapshot.activeResultIndex ?? 0;
|
||||
tab.results = results;
|
||||
tab.activeResultIndex = snapshot.activeResultIndex;
|
||||
tab.result = snapshot.result
|
||||
? markQueryResultRowsRaw(snapshot.result)
|
||||
: results?.[activeIndex]
|
||||
? markQueryResultRowsRaw(results[activeIndex])
|
||||
: undefined;
|
||||
if (!tab.result && !tab.results) return false;
|
||||
|
||||
tab.queryAnalysis = snapshot.queryAnalysis;
|
||||
tab.querySourceColumns = snapshot.querySourceColumns;
|
||||
tab.queryEditabilityReason = snapshot.queryEditabilityReason;
|
||||
tab.tableMeta = snapshot.tableMeta;
|
||||
tab.resultPageSql = snapshot.resultPageSql;
|
||||
tab.resultPageLimit = snapshot.resultPageLimit;
|
||||
tab.resultPageOffset = snapshot.resultPageOffset;
|
||||
tab.resultCountSql = snapshot.resultCountSql;
|
||||
tab.resultTotalRowCount = snapshot.resultTotalRowCount;
|
||||
tab.resultTotalRowCountLoading = false;
|
||||
tab.resultSessionId = undefined;
|
||||
tab.resultEvicted = undefined;
|
||||
tab.resultCacheState = "memory";
|
||||
touchResult(tab);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function reloadEvictedTab(id: string) {
|
||||
const tab = tabs.value.find((t) => t.id === id);
|
||||
const shouldReloadMissingDataTab = tab?.mode === "data" && !tab.result && !tab.isExecuting;
|
||||
if (!tab || (!tab.resultEvicted && !shouldReloadMissingDataTab)) return;
|
||||
if (!tab || !tab.resultEvicted) return;
|
||||
if (tab.resultCacheKey) {
|
||||
const restored = restoreCachedResultPayload(tab, await readTabResultSnapshot(tab.resultCacheKey));
|
||||
if (restored) return;
|
||||
tab.resultCacheState = "missing";
|
||||
}
|
||||
tab.resultEvicted = false;
|
||||
const sql = tab.lastExecutedSql ?? tab.sql;
|
||||
if (!sql?.trim()) return;
|
||||
|
|
|
|||
|
|
@ -363,6 +363,8 @@ export interface QueryTab {
|
|||
resultTotalRowCountLoading?: boolean;
|
||||
resultSessionId?: string;
|
||||
resultAccessedAt?: number;
|
||||
resultCacheKey?: string;
|
||||
resultCacheState?: "memory" | "disk" | "missing";
|
||||
pinned?: boolean;
|
||||
result?: QueryResult;
|
||||
results?: QueryResult[];
|
||||
|
|
|
|||
|
|
@ -17,6 +17,16 @@ pub struct Storage {
|
|||
db: SqliteHandle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TabRuntimeCacheEntry {
|
||||
pub key: String,
|
||||
pub payload: Vec<u8>,
|
||||
pub row_count: i64,
|
||||
pub column_count: i64,
|
||||
pub byte_size: i64,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DesktopSettings {
|
||||
pub show_tray_icon: bool,
|
||||
|
|
@ -99,6 +109,14 @@ const SCHEMA_STATEMENTS: &[&str] = &[
|
|||
payload_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS tab_runtime_cache (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
payload BLOB NOT NULL,
|
||||
row_count INTEGER NOT NULL DEFAULT 0,
|
||||
column_count INTEGER NOT NULL DEFAULT 0,
|
||||
byte_size INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS saved_sql_folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
connection_id TEXT NOT NULL,
|
||||
|
|
@ -953,6 +971,69 @@ impl Storage {
|
|||
}
|
||||
}
|
||||
|
||||
// Tab runtime cache
|
||||
|
||||
impl Storage {
|
||||
pub async fn save_tab_runtime_cache(
|
||||
&self,
|
||||
key: &str,
|
||||
payload: Vec<u8>,
|
||||
row_count: i64,
|
||||
column_count: i64,
|
||||
) -> Result<(), String> {
|
||||
let key = key.to_string();
|
||||
let byte_size = payload.len() as i64;
|
||||
self.with_conn(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO tab_runtime_cache \
|
||||
(cache_key, payload, row_count, column_count, byte_size, updated_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, datetime('now')) \
|
||||
ON CONFLICT(cache_key) DO UPDATE SET \
|
||||
payload = excluded.payload, row_count = excluded.row_count, column_count = excluded.column_count, \
|
||||
byte_size = excluded.byte_size, updated_at = excluded.updated_at",
|
||||
params![key, payload, row_count, column_count, byte_size],
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn load_tab_runtime_cache(&self, key: &str) -> Result<Option<TabRuntimeCacheEntry>, String> {
|
||||
let key = key.to_string();
|
||||
self.with_conn(move |conn| {
|
||||
conn.query_row(
|
||||
"SELECT cache_key, payload, row_count, column_count, byte_size, updated_at \
|
||||
FROM tab_runtime_cache WHERE cache_key = ?1",
|
||||
[key],
|
||||
|row| {
|
||||
Ok(TabRuntimeCacheEntry {
|
||||
key: row.get(0)?,
|
||||
payload: row.get(1)?,
|
||||
row_count: row.get(2)?,
|
||||
column_count: row.get(3)?,
|
||||
byte_size: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_tab_runtime_cache(&self, key: &str) -> Result<(), String> {
|
||||
let key = key.to_string();
|
||||
self.with_conn(move |conn| {
|
||||
conn.execute("DELETE FROM tab_runtime_cache WHERE cache_key = ?1", [key])
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
// JSON migration
|
||||
|
||||
impl Storage {
|
||||
|
|
@ -1248,4 +1329,22 @@ mod tests {
|
|||
);
|
||||
assert_eq!(storage.load_password_hash().await.unwrap(), Some("hash-3".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tab_runtime_cache_roundtrips_binary_payloads() {
|
||||
let path = temp_db_path("tab-runtime-cache");
|
||||
let storage = Storage::open(&path).await.unwrap();
|
||||
|
||||
storage.save_tab_runtime_cache("tab:1:result", vec![1, 2, 3, 4], 10, 3).await.unwrap();
|
||||
let entry = storage.load_tab_runtime_cache("tab:1:result").await.unwrap().unwrap();
|
||||
|
||||
assert_eq!(entry.key, "tab:1:result");
|
||||
assert_eq!(entry.payload, vec![1, 2, 3, 4]);
|
||||
assert_eq!(entry.row_count, 10);
|
||||
assert_eq!(entry.column_count, 3);
|
||||
assert_eq!(entry.byte_size, 4);
|
||||
|
||||
storage.delete_tab_runtime_cache("tab:1:result").await.unwrap();
|
||||
assert_eq!(storage.load_tab_runtime_cache("tab:1:result").await.unwrap(), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,6 +147,12 @@ async fn main() {
|
|||
post(routes::schema_cache::save_schema_cache).get(routes::schema_cache::load_schema_cache),
|
||||
)
|
||||
.route("/schema/cache-prefix", delete(routes::schema_cache::delete_schema_cache_prefix))
|
||||
.route(
|
||||
"/tab-runtime-cache",
|
||||
post(routes::tab_runtime_cache::save_tab_runtime_cache)
|
||||
.get(routes::tab_runtime_cache::load_tab_runtime_cache)
|
||||
.delete(routes::tab_runtime_cache::delete_tab_runtime_cache),
|
||||
)
|
||||
// Query
|
||||
.route("/query/execute", post(routes::query::execute_query))
|
||||
.route("/query/execute-multi", post(routes::query::execute_multi))
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ pub mod schema;
|
|||
pub mod schema_cache;
|
||||
pub mod schema_diff;
|
||||
pub mod sql_file;
|
||||
pub mod tab_runtime_cache;
|
||||
pub mod table_export;
|
||||
pub mod table_import;
|
||||
pub mod text_export;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::Json;
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::state::WebState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveTabRuntimeCacheRequest {
|
||||
pub key: String,
|
||||
pub payload_base64: String,
|
||||
pub row_count: i64,
|
||||
pub column_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TabRuntimeCacheKeyQuery {
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoadTabRuntimeCacheResponse {
|
||||
pub key: String,
|
||||
pub payload_base64: String,
|
||||
pub row_count: i64,
|
||||
pub column_count: i64,
|
||||
pub byte_size: i64,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
pub async fn save_tab_runtime_cache(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<SaveTabRuntimeCacheRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
let payload = BASE64.decode(req.payload_base64).map_err(|e| AppError::bad_request(e.to_string()))?;
|
||||
state
|
||||
.app
|
||||
.storage
|
||||
.save_tab_runtime_cache(&req.key, payload, req.row_count, req.column_count)
|
||||
.await
|
||||
.map_err(AppError::internal)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn load_tab_runtime_cache(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Query(query): Query<TabRuntimeCacheKeyQuery>,
|
||||
) -> Result<Json<Option<LoadTabRuntimeCacheResponse>>, AppError> {
|
||||
let entry = state.app.storage.load_tab_runtime_cache(&query.key).await.map_err(AppError::internal)?;
|
||||
Ok(Json(entry.map(|entry| LoadTabRuntimeCacheResponse {
|
||||
key: entry.key,
|
||||
payload_base64: BASE64.encode(entry.payload),
|
||||
row_count: entry.row_count,
|
||||
column_count: entry.column_count,
|
||||
byte_size: entry.byte_size,
|
||||
updated_at: entry.updated_at,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn delete_tab_runtime_cache(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Query(query): Query<TabRuntimeCacheKeyQuery>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
state.app.storage.delete_tab_runtime_cache(&query.key).await.map_err(AppError::internal)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@
|
|||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.43.0",
|
||||
"@lucide/vue": "1.17.0",
|
||||
"@msgpack/msgpack": "^3.1.3",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
|
|
|
|||
|
|
@ -97,6 +97,51 @@ test("serializes table tabs with reload context", () => {
|
|||
);
|
||||
});
|
||||
|
||||
test("serializes evicted result cache handles", () => {
|
||||
const saved = serializeOpenTabs([
|
||||
queryTab({
|
||||
resultEvicted: true,
|
||||
resultCacheKey: "tab:tab-1:result",
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(saved[0]?.resultEvicted, true);
|
||||
assert.equal(saved[0]?.resultCacheKey, "tab:tab-1:result");
|
||||
});
|
||||
|
||||
test("does not persist table data result cache handles across restarts", () => {
|
||||
const saved = serializeOpenTabs([
|
||||
queryTab({
|
||||
mode: "data",
|
||||
resultEvicted: true,
|
||||
resultCacheKey: "tab:tab-1:result",
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(saved[0]?.resultEvicted, undefined);
|
||||
assert.equal(saved[0]?.resultCacheKey, undefined);
|
||||
});
|
||||
|
||||
test("restores evicted result cache handles as disk-backed runtime state", () => {
|
||||
const raw = JSON.stringify([queryTab({ resultEvicted: true, resultCacheKey: "tab:tab-1:result" })]);
|
||||
|
||||
const restored = restoreOpenTabsState(raw, "tab-1");
|
||||
|
||||
assert.equal(restored.tabs[0]?.resultEvicted, true);
|
||||
assert.equal(restored.tabs[0]?.resultCacheKey, "tab:tab-1:result");
|
||||
assert.equal(restored.tabs[0]?.resultCacheState, "disk");
|
||||
});
|
||||
|
||||
test("ignores legacy table data result cache handles on restore", () => {
|
||||
const raw = JSON.stringify([queryTab({ mode: "data", resultEvicted: true, resultCacheKey: "tab:tab-1:result" })]);
|
||||
|
||||
const restored = restoreOpenTabsState(raw, "tab-1");
|
||||
|
||||
assert.equal(restored.tabs[0]?.resultEvicted, undefined);
|
||||
assert.equal(restored.tabs[0]?.resultCacheKey, undefined);
|
||||
assert.equal(restored.tabs[0]?.resultCacheState, undefined);
|
||||
});
|
||||
|
||||
test("restores unsaved query tabs and active tab after restart", () => {
|
||||
const raw = JSON.stringify([
|
||||
queryTab({ id: "tab-1", sql: "select 1" }),
|
||||
|
|
|
|||
|
|
@ -831,7 +831,7 @@ test("data tab execution preserves pagination offset metadata", async () => {
|
|||
}
|
||||
});
|
||||
|
||||
test("reloading an empty data tab fetches table results again", async () => {
|
||||
test("activating an empty data tab waits for explicit execution", async () => {
|
||||
const restoreStorage = installMemoryStorage();
|
||||
setActivePinia(createPinia());
|
||||
const connectionStore = useConnectionStore();
|
||||
|
|
@ -863,10 +863,8 @@ test("reloading an empty data tab fetches table results again", async () => {
|
|||
try {
|
||||
await store.reloadEvictedTab(tabId);
|
||||
|
||||
assert.equal(executeBody.sql, 'SELECT * FROM "public"."users" LIMIT 50 OFFSET 50;');
|
||||
assert.equal(executeBody.maxRows, 50);
|
||||
assert.equal(executeBody.fetchSize, 50);
|
||||
assert.deepEqual(tab.result?.rows, [[51]]);
|
||||
assert.equal(executeBody, undefined);
|
||||
assert.equal(tab.result, undefined);
|
||||
assert.equal(tab.resultPageLimit, 50);
|
||||
assert.equal(tab.resultPageOffset, 50);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildTabResultSnapshot,
|
||||
decodeTabResultSnapshot,
|
||||
encodeTabResultSnapshot,
|
||||
} from "../../apps/desktop/src/lib/tabResultCache.ts";
|
||||
import type { QueryTab } from "../../apps/desktop/src/types/database.ts";
|
||||
|
||||
function queryTab(overrides: Partial<QueryTab> = {}): QueryTab {
|
||||
return {
|
||||
id: "tab-1",
|
||||
title: "Query 1",
|
||||
connectionId: "conn-1",
|
||||
database: "app",
|
||||
sql: "select * from users",
|
||||
isExecuting: false,
|
||||
mode: "query",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("result snapshots strip live session handles and clone result rows", () => {
|
||||
const tab = queryTab({
|
||||
result: {
|
||||
columns: ["id"],
|
||||
rows: [[1]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
session_id: "live-session",
|
||||
},
|
||||
results: [
|
||||
{
|
||||
columns: ["id"],
|
||||
rows: [[1]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
session_id: "live-session",
|
||||
},
|
||||
],
|
||||
activeResultIndex: 0,
|
||||
});
|
||||
|
||||
const snapshot = buildTabResultSnapshot(tab);
|
||||
|
||||
assert.equal(snapshot?.result?.session_id, undefined);
|
||||
assert.equal(snapshot?.results?.[0]?.session_id, undefined);
|
||||
assert.deepEqual(snapshot?.result?.rows, [[1]]);
|
||||
tab.result!.rows[0]![0] = 2;
|
||||
assert.deepEqual(snapshot?.result?.rows, [[1]]);
|
||||
});
|
||||
|
||||
test("result snapshots encode as binary columnar payloads and decode back to rows", () => {
|
||||
const snapshot = buildTabResultSnapshot(
|
||||
queryTab({
|
||||
result: {
|
||||
columns: ["id", "name", "active"],
|
||||
rows: [
|
||||
[1, "Ada", true],
|
||||
[2, "Linus", false],
|
||||
],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 3,
|
||||
session_id: "live-session",
|
||||
has_more: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.ok(snapshot);
|
||||
|
||||
const encoded = encodeTabResultSnapshot(snapshot);
|
||||
const decoded = decodeTabResultSnapshot(encoded);
|
||||
|
||||
assert.ok(encoded instanceof Uint8Array);
|
||||
assert.deepEqual(decoded?.result?.columns, ["id", "name", "active"]);
|
||||
assert.deepEqual(decoded?.result?.rows, [
|
||||
[1, "Ada", true],
|
||||
[2, "Linus", false],
|
||||
]);
|
||||
assert.equal(decoded?.result?.session_id, undefined);
|
||||
assert.equal(decoded?.result?.has_more, true);
|
||||
assert.equal(decoded?.cachedAt, snapshot.cachedAt);
|
||||
});
|
||||
|
|
@ -41,6 +41,9 @@ importers:
|
|||
'@lucide/vue':
|
||||
specifier: 1.17.0
|
||||
version: 1.17.0(vue@3.5.35(typescript@6.0.3))
|
||||
'@msgpack/msgpack':
|
||||
specifier: ^3.1.3
|
||||
version: 3.1.3
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2.11.0
|
||||
version: 2.11.0
|
||||
|
|
@ -911,6 +914,10 @@ packages:
|
|||
'@cfworker/json-schema':
|
||||
optional: true
|
||||
|
||||
'@msgpack/msgpack@3.1.3':
|
||||
resolution: {integrity: sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.4':
|
||||
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
|
||||
peerDependencies:
|
||||
|
|
@ -4393,6 +4400,8 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@msgpack/msgpack@3.1.3': {}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ tauri-build = { version = "2.5.6", features = [] }
|
|||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
base64 = "0.22"
|
||||
log = "0.4"
|
||||
tauri = { version = "2.10.3", features = ["tray-icon"] }
|
||||
tauri-plugin-log = "2"
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ pub mod schema_cache;
|
|||
pub mod schema_diff;
|
||||
pub mod sql_file;
|
||||
pub mod system_fonts;
|
||||
pub mod tab_runtime_cache;
|
||||
pub mod table_export;
|
||||
pub mod table_import;
|
||||
pub mod text_export;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use dbx_core::connection::AppState;
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoadTabRuntimeCacheResponse {
|
||||
pub key: String,
|
||||
pub payload_base64: String,
|
||||
pub row_count: i64,
|
||||
pub column_count: i64,
|
||||
pub byte_size: i64,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_tab_runtime_cache(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
key: String,
|
||||
payload_base64: String,
|
||||
row_count: i64,
|
||||
column_count: i64,
|
||||
) -> Result<(), String> {
|
||||
let payload = BASE64.decode(payload_base64).map_err(|e| e.to_string())?;
|
||||
state.storage.save_tab_runtime_cache(&key, payload, row_count, column_count).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_tab_runtime_cache(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
key: String,
|
||||
) -> Result<Option<LoadTabRuntimeCacheResponse>, String> {
|
||||
let entry = state.storage.load_tab_runtime_cache(&key).await?;
|
||||
Ok(entry.map(|entry| LoadTabRuntimeCacheResponse {
|
||||
key: entry.key,
|
||||
payload_base64: BASE64.encode(entry.payload),
|
||||
row_count: entry.row_count,
|
||||
column_count: entry.column_count,
|
||||
byte_size: entry.byte_size,
|
||||
updated_at: entry.updated_at,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_tab_runtime_cache(state: State<'_, Arc<AppState>>, key: String) -> Result<(), String> {
|
||||
state.storage.delete_tab_runtime_cache(&key).await
|
||||
}
|
||||
|
|
@ -393,6 +393,9 @@ pub fn run() {
|
|||
commands::schema_cache::save_schema_cache,
|
||||
commands::schema_cache::load_schema_cache,
|
||||
commands::schema_cache::delete_schema_cache_prefix,
|
||||
commands::tab_runtime_cache::save_tab_runtime_cache,
|
||||
commands::tab_runtime_cache::load_tab_runtime_cache,
|
||||
commands::tab_runtime_cache::delete_tab_runtime_cache,
|
||||
commands::query::execute_query,
|
||||
commands::query::execute_multi,
|
||||
commands::query::cancel_query,
|
||||
|
|
|
|||
Loading…
Reference in New Issue