mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(sql-library): show matching line snippets with jump in search
This commit is contained in:
@@ -10,6 +10,8 @@ import CustomContextMenu, { type ContextMenuItem as CtxMenuItem } from "@/compon
|
||||
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
|
||||
import HelpTooltip from "@/components/ui/tooltip/HelpTooltip.vue";
|
||||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
import SqlLibrarySearchSnippet from "@/components/layout/SqlLibrarySearchSnippet.vue";
|
||||
import type { SavedSqlLineMatch } from "@/lib/savedSql/savedSqlSearch";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import * as api from "@/lib/backend/api";
|
||||
@@ -57,6 +59,12 @@ const activeConnectionIds = computed(() => new Set(connectionStore.connections.m
|
||||
const searchText = ref("");
|
||||
const searchQuery = computed(() => searchText.value.trim().toLowerCase());
|
||||
|
||||
watch(searchText, (text) => {
|
||||
if (text.trim()) {
|
||||
void savedSqlStore.ensureAllFilesLoaded();
|
||||
}
|
||||
});
|
||||
|
||||
// Sort mode: "folder" (default tree structure) or "date" (flat list by update date)
|
||||
const sortMode = ref<"folder" | "date">("folder");
|
||||
|
||||
@@ -875,16 +883,27 @@ async function moveFilesToFolder(fileIds: string[], folderId?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function openFile(file: SavedSqlFile, targetMode?: SavedSqlOpenTargetMode) {
|
||||
async function openFile(file: SavedSqlFile, targetMode?: SavedSqlOpenTargetMode, reveal?: { line: number; column?: number }) {
|
||||
if (suppressNextRowClick.value) return;
|
||||
const loadedFile = await savedSqlStore.ensureFileContent(file.id);
|
||||
if (!loadedFile) return;
|
||||
const tabId = queryStore.openSavedSql(loadedFile, { targetMode });
|
||||
const tabId = queryStore.openSavedSql(loadedFile, { targetMode, reveal });
|
||||
const openedConnectionId = queryStore.tabs.find((tab) => tab.id === tabId)?.connectionId ?? loadedFile.connectionId;
|
||||
if (openedConnectionId) connectionStore.activeConnectionId = openedConnectionId;
|
||||
void savedSqlStore.recordFileUsage(loadedFile.id);
|
||||
}
|
||||
|
||||
function handleMatchClick(file: SavedSqlFile, match: SavedSqlLineMatch) {
|
||||
if (suppressNextRowClick.value) return;
|
||||
const currentIndex = allSelectableItems.value.findIndex((item) => item.type === "file" && item.id === file.id);
|
||||
if (currentIndex >= 0) {
|
||||
clearSelection();
|
||||
lastClickedItemIndex.value = currentIndex;
|
||||
setActiveItem(file.id, "file");
|
||||
}
|
||||
openFile(file, undefined, { line: match.lineNumber, column: match.column });
|
||||
}
|
||||
|
||||
function handleFileClick(file: SavedSqlFile, event: MouseEvent) {
|
||||
if (suppressNextRowClick.value) return;
|
||||
|
||||
@@ -1356,7 +1375,16 @@ function showDropInside(targetId: string) {
|
||||
<div class="border-b shrink-0 px-2 py-1">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
|
||||
<input data-sql-library-search v-model="searchText" autocapitalize="off" autocorrect="off" spellcheck="false" class="w-full h-6 pl-7 pr-6 text-[13px] rounded border border-border bg-background focus:outline-none focus:ring-1 focus:ring-ring" :placeholder="t('grid.search')" />
|
||||
<input
|
||||
data-sql-library-search
|
||||
v-model="searchText"
|
||||
@focus="savedSqlStore.ensureAllFilesLoaded()"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="w-full h-6 pl-7 pr-6 text-[13px] rounded border border-border bg-background focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
:placeholder="t('grid.search')"
|
||||
/>
|
||||
<button v-if="searchText" type="button" class="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" @click="searchText = ''">
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
@@ -1422,36 +1450,38 @@ function showDropInside(targetId: string) {
|
||||
</LightTooltip>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="relative flex cursor-default items-center gap-1 px-2 py-1.5 text-[13px] group"
|
||||
:class="[fileRowClass(item.item.id), isDraggingItem(item.item.id) ? 'opacity-50' : '']"
|
||||
@mousedown="handleDragMouseDown($event, item.item.id, 'file')"
|
||||
@click="handleFileClick(item.item, $event)"
|
||||
@contextmenu.capture="contextTarget = item.item"
|
||||
@contextmenu.prevent="
|
||||
contextTarget = item.item;
|
||||
onContextMenu($event);
|
||||
"
|
||||
>
|
||||
<FileText class="h-3.5 w-3.5 text-blue-400 shrink-0" />
|
||||
<span v-if="isFileDirty(item.item)" aria-hidden="true" class="dirty-sql-library-marker">*</span>
|
||||
<template v-if="isRenamingFile(item.item.id)">
|
||||
<input
|
||||
:ref="setRenameInputRef"
|
||||
v-model="renameValue"
|
||||
data-no-drag="true"
|
||||
class="min-w-0 flex-1 rounded border border-primary/50 bg-transparent px-1 text-[13px] outline-none"
|
||||
@keydown.enter.prevent="confirmRename"
|
||||
@keydown.escape.prevent="cancelRename"
|
||||
@blur="confirmRename"
|
||||
@mousedown.stop
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
<span v-else class="dbx-sql-library-drag-label min-w-0 flex-1 truncate" :title="fileTitleLabel(item.item)" :style="fileTitleStyle(item.item)">{{ item.item.name }}</span>
|
||||
<span class="min-w-0 max-w-[45%] shrink truncate text-[13px]" :class="fileMetaClass(item.item.id)" :title="getConnectionLabel(item.item.connectionId)">[{{ getConnectionLabel(item.item.connectionId) }}]</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
class="relative flex cursor-default items-center gap-1 px-2 py-1.5 text-[13px] group"
|
||||
:class="[fileRowClass(item.item.id), isDraggingItem(item.item.id) ? 'opacity-50' : '']"
|
||||
@mousedown="handleDragMouseDown($event, item.item.id, 'file')"
|
||||
@click="handleFileClick(item.item, $event)"
|
||||
@contextmenu.capture="contextTarget = item.item"
|
||||
@contextmenu.prevent="
|
||||
contextTarget = item.item;
|
||||
onContextMenu($event);
|
||||
"
|
||||
>
|
||||
<FileText class="h-3.5 w-3.5 text-blue-400 shrink-0" />
|
||||
<span v-if="isFileDirty(item.item)" aria-hidden="true" class="dirty-sql-library-marker">*</span>
|
||||
<template v-if="isRenamingFile(item.item.id)">
|
||||
<input
|
||||
:ref="setRenameInputRef"
|
||||
v-model="renameValue"
|
||||
data-no-drag="true"
|
||||
class="min-w-0 flex-1 rounded border border-primary/50 bg-transparent px-1 text-[13px] outline-none"
|
||||
@keydown.enter.prevent="confirmRename"
|
||||
@keydown.escape.prevent="cancelRename"
|
||||
@blur="confirmRename"
|
||||
@mousedown.stop
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
<span v-else class="dbx-sql-library-drag-label min-w-0 flex-1 truncate" :title="fileTitleLabel(item.item)" :style="fileTitleStyle(item.item)">{{ item.item.name }}</span>
|
||||
<span class="min-w-0 max-w-[45%] shrink truncate text-[13px]" :class="fileMetaClass(item.item.id)" :title="getConnectionLabel(item.item.connectionId)">[{{ getConnectionLabel(item.item.connectionId) }}]</span>
|
||||
</div>
|
||||
<SqlLibrarySearchSnippet v-if="searchQuery" :file="item.item" :query="searchQuery" @select-match="(match) => handleMatchClick(item.item, match)" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1507,41 +1537,43 @@ function showDropInside(targetId: string) {
|
||||
</LightTooltip>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="relative flex cursor-default items-center gap-1 py-1.5 pr-2 text-[13px] group"
|
||||
:style="{ paddingLeft: `${8 + row.depth * 16}px` }"
|
||||
:class="[fileRowClass(row.file.id), isDraggingItem(row.file.id) ? 'opacity-50' : '']"
|
||||
@mousedown="handleDragMouseDown($event, row.file.id, 'file')"
|
||||
@mousemove="updateDropTarget($event, row.file.id, 'file')"
|
||||
@mouseleave="clearDropTarget(row.file.id)"
|
||||
@click="handleFileClick(row.file, $event)"
|
||||
@contextmenu.capture="contextTarget = row.file"
|
||||
@contextmenu.prevent="
|
||||
contextTarget = row.file;
|
||||
onContextMenu($event);
|
||||
"
|
||||
>
|
||||
<div v-if="showDropBefore(row.file.id)" class="absolute left-2 right-2 top-0 border-t-2 border-primary" />
|
||||
<div v-if="showDropAfter(row.file.id)" class="absolute left-2 right-2 bottom-0 border-b-2 border-primary" />
|
||||
<FileText class="h-3.5 w-3.5 text-blue-400 shrink-0" />
|
||||
<span v-if="isFileDirty(row.file)" aria-hidden="true" class="dirty-sql-library-marker">*</span>
|
||||
<template v-if="isRenamingFile(row.file.id)">
|
||||
<input
|
||||
:ref="setRenameInputRef"
|
||||
v-model="renameValue"
|
||||
data-no-drag="true"
|
||||
class="min-w-0 flex-1 rounded border border-primary/50 bg-transparent px-1 text-[13px] outline-none"
|
||||
@keydown.enter.prevent="confirmRename"
|
||||
@keydown.escape.prevent="cancelRename"
|
||||
@blur="confirmRename"
|
||||
@mousedown.stop
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
<span v-else class="dbx-sql-library-drag-label min-w-0 flex-1 truncate" :title="fileTitleLabel(row.file)" :style="fileTitleStyle(row.file)">{{ row.file.name }}</span>
|
||||
<span class="min-w-0 max-w-[45%] shrink truncate text-[13px]" :class="fileMetaClass(row.file.id)" :title="getConnectionLabel(row.file.connectionId)">[{{ getConnectionLabel(row.file.connectionId) }}]</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
class="relative flex cursor-default items-center gap-1 py-1.5 pr-2 text-[13px] group"
|
||||
:style="{ paddingLeft: `${8 + row.depth * 16}px` }"
|
||||
:class="[fileRowClass(row.file.id), isDraggingItem(row.file.id) ? 'opacity-50' : '']"
|
||||
@mousedown="handleDragMouseDown($event, row.file.id, 'file')"
|
||||
@mousemove="updateDropTarget($event, row.file.id, 'file')"
|
||||
@mouseleave="clearDropTarget(row.file.id)"
|
||||
@click="handleFileClick(row.file, $event)"
|
||||
@contextmenu.capture="contextTarget = row.file"
|
||||
@contextmenu.prevent="
|
||||
contextTarget = row.file;
|
||||
onContextMenu($event);
|
||||
"
|
||||
>
|
||||
<div v-if="showDropBefore(row.file.id)" class="absolute left-2 right-2 top-0 border-t-2 border-primary" />
|
||||
<div v-if="showDropAfter(row.file.id)" class="absolute left-2 right-2 bottom-0 border-b-2 border-primary" />
|
||||
<FileText class="h-3.5 w-3.5 text-blue-400 shrink-0" />
|
||||
<span v-if="isFileDirty(row.file)" aria-hidden="true" class="dirty-sql-library-marker">*</span>
|
||||
<template v-if="isRenamingFile(row.file.id)">
|
||||
<input
|
||||
:ref="setRenameInputRef"
|
||||
v-model="renameValue"
|
||||
data-no-drag="true"
|
||||
class="min-w-0 flex-1 rounded border border-primary/50 bg-transparent px-1 text-[13px] outline-none"
|
||||
@keydown.enter.prevent="confirmRename"
|
||||
@keydown.escape.prevent="cancelRename"
|
||||
@blur="confirmRename"
|
||||
@mousedown.stop
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
<span v-else class="dbx-sql-library-drag-label min-w-0 flex-1 truncate" :title="fileTitleLabel(row.file)" :style="fileTitleStyle(row.file)">{{ row.file.name }}</span>
|
||||
<span class="min-w-0 max-w-[45%] shrink truncate text-[13px]" :class="fileMetaClass(row.file.id)" :title="getConnectionLabel(row.file.connectionId)">[{{ getConnectionLabel(row.file.connectionId) }}]</span>
|
||||
</div>
|
||||
<SqlLibrarySearchSnippet v-if="searchQuery" :file="row.file" :query="searchQuery" :depth="row.depth" @select-match="(match) => handleMatchClick(row.file, match)" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="visibleFiles.length > 0 || dragState.draggedType === 'file'">
|
||||
@@ -1554,41 +1586,42 @@ function showDropInside(targetId: string) {
|
||||
>
|
||||
{{ t("sqlLibrary.unfiled") }}
|
||||
</div>
|
||||
<div
|
||||
v-for="file in visibleFiles"
|
||||
:key="file.id"
|
||||
class="relative flex cursor-default items-center gap-1 px-2 py-1.5 text-[13px] group"
|
||||
:class="[fileRowClass(file.id), isDraggingItem(file.id) ? 'opacity-50' : '']"
|
||||
@mousedown="handleDragMouseDown($event, file.id, 'file')"
|
||||
@mousemove="updateDropTarget($event, file.id, 'file')"
|
||||
@mouseleave="clearDropTarget(file.id)"
|
||||
@click="handleFileClick(file, $event)"
|
||||
@contextmenu.capture="contextTarget = file"
|
||||
@contextmenu.prevent="
|
||||
contextTarget = file;
|
||||
onContextMenu($event);
|
||||
"
|
||||
>
|
||||
<div v-if="showDropBefore(file.id)" class="absolute left-2 right-2 top-0 border-t-2 border-primary" />
|
||||
<div v-if="showDropAfter(file.id)" class="absolute left-2 right-2 bottom-0 border-b-2 border-primary" />
|
||||
<FileText class="h-3.5 w-3.5 text-blue-400 shrink-0" />
|
||||
<span v-if="isFileDirty(file)" aria-hidden="true" class="dirty-sql-library-marker">*</span>
|
||||
<template v-if="isRenamingFile(file.id)">
|
||||
<input
|
||||
:ref="setRenameInputRef"
|
||||
v-model="renameValue"
|
||||
data-no-drag="true"
|
||||
class="min-w-0 flex-1 rounded border border-primary/50 bg-transparent px-1 text-[13px] outline-none"
|
||||
@keydown.enter.prevent="confirmRename"
|
||||
@keydown.escape.prevent="cancelRename"
|
||||
@blur="confirmRename"
|
||||
@mousedown.stop
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
<span v-else class="dbx-sql-library-drag-label min-w-0 flex-1 truncate" :title="fileTitleLabel(file)" :style="fileTitleStyle(file)">{{ file.name }}</span>
|
||||
<span class="min-w-0 max-w-[45%] shrink truncate text-[13px]" :class="fileMetaClass(file.id)" :title="getConnectionLabel(file.connectionId)">[{{ getConnectionLabel(file.connectionId) }}]</span>
|
||||
</div>
|
||||
<template v-for="file in visibleFiles" :key="file.id">
|
||||
<div
|
||||
class="relative flex cursor-default items-center gap-1 px-2 py-1.5 text-[13px] group"
|
||||
:class="[fileRowClass(file.id), isDraggingItem(file.id) ? 'opacity-50' : '']"
|
||||
@mousedown="handleDragMouseDown($event, file.id, 'file')"
|
||||
@mousemove="updateDropTarget($event, file.id, 'file')"
|
||||
@mouseleave="clearDropTarget(file.id)"
|
||||
@click="handleFileClick(file, $event)"
|
||||
@contextmenu.capture="contextTarget = file"
|
||||
@contextmenu.prevent="
|
||||
contextTarget = file;
|
||||
onContextMenu($event);
|
||||
"
|
||||
>
|
||||
<div v-if="showDropBefore(file.id)" class="absolute left-2 right-2 top-0 border-t-2 border-primary" />
|
||||
<div v-if="showDropAfter(file.id)" class="absolute left-2 right-2 bottom-0 border-b-2 border-primary" />
|
||||
<FileText class="h-3.5 w-3.5 text-blue-400 shrink-0" />
|
||||
<span v-if="isFileDirty(file)" aria-hidden="true" class="dirty-sql-library-marker">*</span>
|
||||
<template v-if="isRenamingFile(file.id)">
|
||||
<input
|
||||
:ref="setRenameInputRef"
|
||||
v-model="renameValue"
|
||||
data-no-drag="true"
|
||||
class="min-w-0 flex-1 rounded border border-primary/50 bg-transparent px-1 text-[13px] outline-none"
|
||||
@keydown.enter.prevent="confirmRename"
|
||||
@keydown.escape.prevent="cancelRename"
|
||||
@blur="confirmRename"
|
||||
@mousedown.stop
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
<span v-else class="dbx-sql-library-drag-label min-w-0 flex-1 truncate" :title="fileTitleLabel(file)" :style="fileTitleStyle(file)">{{ file.name }}</span>
|
||||
<span class="min-w-0 max-w-[45%] shrink truncate text-[13px]" :class="fileMetaClass(file.id)" :title="getConnectionLabel(file.connectionId)">[{{ getConnectionLabel(file.connectionId) }}]</span>
|
||||
</div>
|
||||
<SqlLibrarySearchSnippet v-if="searchQuery" :file="file" :query="searchQuery" @select-match="(match) => handleMatchClick(file, match)" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- End tree structure -->
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { SavedSqlFile } from "@/types/database";
|
||||
import { findSavedSqlSearchMatches, formatMatchTooltip, type SavedSqlLineMatch } from "@/lib/savedSql/savedSqlSearch";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
file: SavedSqlFile;
|
||||
query: string;
|
||||
depth?: number;
|
||||
}>(),
|
||||
{
|
||||
depth: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectMatch: [match: SavedSqlLineMatch];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const expanded = ref(false);
|
||||
|
||||
const searchResult = computed(() => {
|
||||
if (!props.query || !props.file.sql) {
|
||||
return { matches: [], totalMatches: 0 };
|
||||
}
|
||||
return findSavedSqlSearchMatches(props.file.sql, props.query, {
|
||||
maxMatches: expanded.value ? 100 : 3,
|
||||
});
|
||||
});
|
||||
|
||||
const matches = computed(() => searchResult.value.matches);
|
||||
const totalMatches = computed(() => searchResult.value.totalMatches);
|
||||
const hasHiddenMatches = computed(() => totalMatches.value > 3);
|
||||
|
||||
function toggleExpanded() {
|
||||
expanded.value = !expanded.value;
|
||||
}
|
||||
|
||||
function handleSnippetClick(match: SavedSqlLineMatch) {
|
||||
emit("selectMatch", match);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="matches.length > 0" class="dbx-sql-library-snippets mb-1 mt-0.5 space-y-0.5 select-none" :style="{ paddingLeft: `${24 + depth * 16}px` }" data-no-drag="true" @mousedown.stop>
|
||||
<div
|
||||
v-for="match in matches"
|
||||
:key="match.lineNumber"
|
||||
class="group/snippet flex cursor-pointer items-center gap-1.5 rounded py-0.5 pr-2 font-mono text-[11px] text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
:title="formatMatchTooltip(match)"
|
||||
@click.stop="handleSnippetClick(match)"
|
||||
>
|
||||
<span class="shrink-0 text-[10px] text-muted-foreground/70 select-none"> L{{ match.lineNumber }} </span>
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
<template v-for="(seg, idx) in match.segments" :key="idx">
|
||||
<mark v-if="seg.matched" class="rounded-[2px] bg-amber-300/80 px-0.5 text-foreground dark:bg-amber-500/40">{{ seg.text }}</mark>
|
||||
<span v-else>{{ seg.text }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="hasHiddenMatches" class="pt-0.5 text-[11px]">
|
||||
<button type="button" class="text-[11px] font-normal text-primary hover:underline hover:text-primary/80 select-none" @click.stop="toggleExpanded">
|
||||
{{ expanded ? t("sqlLibrary.collapseMatches") : t("sqlLibrary.moreMatches", { count: totalMatches - 3 }) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, nextTick, type App } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import type { SavedSqlFile } from "@/types/database";
|
||||
import SqlLibrarySearchSnippet from "../SqlLibrarySearchSnippet.vue";
|
||||
|
||||
const mounted: Array<{ app: App; host: HTMLElement }> = [];
|
||||
|
||||
function sampleFile(sql: string): SavedSqlFile {
|
||||
return {
|
||||
id: "test-file-1",
|
||||
connectionId: "conn-1",
|
||||
name: "users_query.sql",
|
||||
database: "main",
|
||||
sql,
|
||||
sqlLoaded: true,
|
||||
createdAt: "2026-10-01T00:00:00Z",
|
||||
updatedAt: "2026-10-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function mountSnippet(file: SavedSqlFile, query: string, onSelectMatch?: (match: any) => void) {
|
||||
setActivePinia(createPinia());
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(SqlLibrarySearchSnippet, {
|
||||
file,
|
||||
query,
|
||||
onSelectMatch,
|
||||
});
|
||||
app.use(i18n);
|
||||
app.mount(host);
|
||||
mounted.push({ app, host });
|
||||
return host;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, host } of mounted.splice(0)) {
|
||||
app.unmount();
|
||||
host.remove();
|
||||
}
|
||||
});
|
||||
|
||||
describe("SqlLibrarySearchSnippet", () => {
|
||||
it("renders nothing when there are no matches", async () => {
|
||||
const host = mountSnippet(sampleFile("SELECT 1;"), "orders");
|
||||
await nextTick();
|
||||
|
||||
const snippetContainer = host.querySelector(".dbx-sql-library-snippets");
|
||||
expect(snippetContainer).toBeNull();
|
||||
});
|
||||
|
||||
it("renders matching line with line number and highlight mark", async () => {
|
||||
const sql = ["-- comments", "SELECT id, username FROM users", "WHERE active = true;"].join("\n");
|
||||
|
||||
const host = mountSnippet(sampleFile(sql), "users");
|
||||
await nextTick();
|
||||
|
||||
const container = host.querySelector(".dbx-sql-library-snippets");
|
||||
expect(container).not.toBeNull();
|
||||
|
||||
const snippetRows = host.querySelectorAll(".group\\/snippet");
|
||||
expect(snippetRows).toHaveLength(1);
|
||||
|
||||
const lineBadge = snippetRows[0].querySelector("span");
|
||||
expect(lineBadge?.textContent?.trim()).toBe("L2");
|
||||
|
||||
const mark = snippetRows[0].querySelector("mark");
|
||||
expect(mark).not.toBeNull();
|
||||
expect(mark?.textContent).toBe("users");
|
||||
|
||||
// Tooltip should contain multi-line context
|
||||
const titleAttr = snippetRows[0].getAttribute("title");
|
||||
expect(titleAttr).toContain("1 | -- comments");
|
||||
expect(titleAttr).toContain("2 | SELECT id, username FROM users");
|
||||
expect(titleAttr).toContain("3 | WHERE active = true;");
|
||||
});
|
||||
|
||||
it("emits selectMatch with line and column on click", async () => {
|
||||
const sql = "SELECT * FROM orders WHERE status = 'pending';";
|
||||
const onSelectMatch = vi.fn();
|
||||
const host = mountSnippet(sampleFile(sql), "orders", onSelectMatch);
|
||||
await nextTick();
|
||||
|
||||
const snippetRow = host.querySelector<HTMLElement>(".group\\/snippet");
|
||||
expect(snippetRow).not.toBeNull();
|
||||
snippetRow!.click();
|
||||
await nextTick();
|
||||
|
||||
expect(onSelectMatch).toHaveBeenCalledTimes(1);
|
||||
expect(onSelectMatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lineNumber: 1,
|
||||
column: 15,
|
||||
lineText: "SELECT * FROM orders WHERE status = 'pending';",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("limits to 3 matches initially and expands on button click", async () => {
|
||||
const sql = ["SELECT 1 FROM tbl;", "SELECT 2 FROM tbl;", "SELECT 3 FROM tbl;", "SELECT 4 FROM tbl;", "SELECT 5 FROM tbl;"].join("\n");
|
||||
|
||||
const host = mountSnippet(sampleFile(sql), "SELECT");
|
||||
await nextTick();
|
||||
|
||||
let snippetRows = host.querySelectorAll(".group\\/snippet");
|
||||
expect(snippetRows).toHaveLength(3);
|
||||
|
||||
const toggleButton = host.querySelector<HTMLButtonElement>("button");
|
||||
expect(toggleButton).not.toBeNull();
|
||||
expect(toggleButton?.textContent).toContain("2");
|
||||
|
||||
// Click to expand
|
||||
toggleButton!.click();
|
||||
await nextTick();
|
||||
|
||||
snippetRows = host.querySelectorAll(".group\\/snippet");
|
||||
expect(snippetRows).toHaveLength(5);
|
||||
expect(toggleButton?.textContent?.trim()).toBe(i18n.global.t("sqlLibrary.collapseMatches"));
|
||||
|
||||
// Click to collapse
|
||||
toggleButton!.click();
|
||||
await nextTick();
|
||||
|
||||
snippetRows = host.querySelectorAll(".group\\/snippet");
|
||||
expect(snippetRows).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -582,6 +582,8 @@ export default withEnglishFallback({
|
||||
sortByDate: "Dəyişiklik tarixinə görə sırala",
|
||||
sortByFolder: "Qovluq quruluşuna görə sırala",
|
||||
collapseAll: "Hamısını yığ",
|
||||
moreMatches: "+ {count} uyğunluq daha",
|
||||
collapseMatches: "Yığ",
|
||||
},
|
||||
|
||||
connection: {
|
||||
|
||||
@@ -819,6 +819,8 @@ export default {
|
||||
sortByDate: "Sort by Date Modified",
|
||||
sortByFolder: "Sort by Folder Structure",
|
||||
collapseAll: "Collapse all",
|
||||
moreMatches: "+ {count} more matches",
|
||||
collapseMatches: "Collapse",
|
||||
},
|
||||
|
||||
connection: {
|
||||
|
||||
@@ -813,6 +813,8 @@ export default withEnglishFallback({
|
||||
sortByDate: "Ordenar por fecha de modificación",
|
||||
sortByFolder: "Ordenar por estructura de carpetas",
|
||||
collapseAll: "Contraer todo",
|
||||
moreMatches: "+ {count} coincidencias más",
|
||||
collapseMatches: "Contraer",
|
||||
},
|
||||
|
||||
connection: {
|
||||
|
||||
@@ -804,6 +804,8 @@ export default withEnglishFallback({
|
||||
sortByDate: "Ordina per data di modifica",
|
||||
sortByFolder: "Ordina per struttura cartelle",
|
||||
collapseAll: "Comprimi tutto",
|
||||
moreMatches: "+ altre {count} corrispondenze",
|
||||
collapseMatches: "Comprimi",
|
||||
},
|
||||
|
||||
connection: {
|
||||
|
||||
@@ -805,6 +805,8 @@ export default withEnglishFallback({
|
||||
sortByDate: "更新日時で並べ替え",
|
||||
sortByFolder: "フォルダ構造で並べ替え",
|
||||
collapseAll: "すべて折りたたむ",
|
||||
moreMatches: "+ 他 {count} 件の一致",
|
||||
collapseMatches: "折りたたむ",
|
||||
},
|
||||
connection: {
|
||||
title: "新しい接続",
|
||||
|
||||
@@ -807,6 +807,8 @@ export default withEnglishFallback({
|
||||
sortByDate: "수정 날짜순 정렬",
|
||||
sortByFolder: "폴더 구조순 정렬",
|
||||
collapseAll: "모두 접기",
|
||||
moreMatches: "+ {count}개 더 일치",
|
||||
collapseMatches: "접기",
|
||||
},
|
||||
connection: {
|
||||
title: "새 연결",
|
||||
|
||||
@@ -805,6 +805,8 @@ export default withEnglishFallback({
|
||||
sortByDate: "Ordenar por data de modificação",
|
||||
sortByFolder: "Ordenar por estrutura de pastas",
|
||||
collapseAll: "Recolher todos",
|
||||
moreMatches: "+ {count} mais correspondências",
|
||||
collapseMatches: "Recolher",
|
||||
},
|
||||
|
||||
connection: {
|
||||
|
||||
@@ -764,6 +764,8 @@ export default withEnglishFallback({
|
||||
sortByDate: "Сортировать по дате изменения",
|
||||
sortByFolder: "Сортировать по структуре папок",
|
||||
collapseAll: "Свернуть все",
|
||||
moreMatches: "+ ещё {count} совпадений",
|
||||
collapseMatches: "Свернуть",
|
||||
},
|
||||
connection: {
|
||||
title: "Новое подключение",
|
||||
|
||||
@@ -584,6 +584,8 @@ export default withEnglishFallback({
|
||||
sortByDate: "Değiştirilme Tarihine Göre Sırala",
|
||||
sortByFolder: "Klasör Yapısına Göre Sırala",
|
||||
collapseAll: "Tümünü daralt",
|
||||
moreMatches: "+ {count} eşleşme daha",
|
||||
collapseMatches: "Daralt",
|
||||
},
|
||||
|
||||
connection: {
|
||||
|
||||
@@ -735,6 +735,8 @@ export default withEnglishFallback({
|
||||
sortByDate: "按修改日期排序",
|
||||
sortByFolder: "按文件夹结构排序",
|
||||
collapseAll: "全部折叠",
|
||||
moreMatches: "+ {count} 项更多匹配",
|
||||
collapseMatches: "收起",
|
||||
},
|
||||
|
||||
connection: {
|
||||
|
||||
@@ -808,6 +808,8 @@ export default withEnglishFallback({
|
||||
sortByDate: "依修改日期排序",
|
||||
sortByFolder: "依資料夾結構排序",
|
||||
collapseAll: "全部摺疊",
|
||||
moreMatches: "+ {count} 項更多相符",
|
||||
collapseMatches: "收起",
|
||||
},
|
||||
|
||||
connection: {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findSavedSqlSearchMatches, formatMatchTooltip, formatSnippetLine, splitSearchMatchSegments } from "@/lib/savedSql/savedSqlSearch";
|
||||
|
||||
describe("splitSearchMatchSegments", () => {
|
||||
it("returns empty or untouched segments when input or query is empty", () => {
|
||||
expect(splitSearchMatchSegments("", "select")).toEqual([]);
|
||||
expect(splitSearchMatchSegments("SELECT 1", "")).toEqual([{ text: "SELECT 1", matched: false }]);
|
||||
});
|
||||
|
||||
it("handles text without matches", () => {
|
||||
expect(splitSearchMatchSegments("SELECT 1", "from")).toEqual([{ text: "SELECT 1", matched: false }]);
|
||||
});
|
||||
|
||||
it("matches case-insensitively and preserves text casing", () => {
|
||||
const segments = splitSearchMatchSegments("Select user_id, userName From Users", "user");
|
||||
expect(segments).toEqual([
|
||||
{ text: "Select ", matched: false },
|
||||
{ text: "user", matched: true },
|
||||
{ text: "_id, ", matched: false },
|
||||
{ text: "user", matched: true },
|
||||
{ text: "Name From ", matched: false },
|
||||
{ text: "User", matched: true },
|
||||
{ text: "s", matched: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSnippetLine", () => {
|
||||
it("keeps short line as trimmed", () => {
|
||||
expect(formatSnippetLine(" SELECT * FROM users; ", "users", 50)).toBe("SELECT * FROM users;");
|
||||
});
|
||||
|
||||
it("truncates long lines and centers context when match is deep", () => {
|
||||
const longLine = "SELECT a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u, v, w, x, y, z, orders.id FROM very_long_table_name";
|
||||
const snippet = formatSnippetLine(longLine, "orders", 30);
|
||||
expect(snippet).toContain("orders");
|
||||
expect(snippet.startsWith("...")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findSavedSqlSearchMatches", () => {
|
||||
it("returns empty result on empty sql or query", () => {
|
||||
expect(findSavedSqlSearchMatches("", "select")).toEqual({ matches: [], totalMatches: 0 });
|
||||
expect(findSavedSqlSearchMatches("SELECT 1", "")).toEqual({ matches: [], totalMatches: 0 });
|
||||
});
|
||||
|
||||
it("finds matching lines with 1-based line numbers and columns", () => {
|
||||
const sql = ["-- comments", "SELECT id, name", " FROM customers", " WHERE status = 'active';"].join("\n");
|
||||
|
||||
const result = findSavedSqlSearchMatches(sql, "from");
|
||||
expect(result.totalMatches).toBe(1);
|
||||
expect(result.matches).toHaveLength(1);
|
||||
|
||||
const match = result.matches[0];
|
||||
expect(match.lineNumber).toBe(3);
|
||||
// original line has 2 spaces before FROM, so column is 3
|
||||
expect(match.column).toBe(3);
|
||||
expect(match.lineText).toBe("FROM customers");
|
||||
expect(match.contextBefore).toEqual({ lineNumber: 2, text: "SELECT id, name" });
|
||||
expect(match.contextAfter).toEqual({ lineNumber: 4, text: "WHERE status = 'active';" });
|
||||
expect(match.segments).toEqual([
|
||||
{ text: "FROM", matched: true },
|
||||
{ text: " customers", matched: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("respects maxMatches while maintaining totalMatches", () => {
|
||||
const sql = ["SELECT 1;", "SELECT 2;", "SELECT 3;", "SELECT 4;", "SELECT 5;"].join("\n");
|
||||
|
||||
const result = findSavedSqlSearchMatches(sql, "select", { maxMatches: 2 });
|
||||
expect(result.totalMatches).toBe(5);
|
||||
expect(result.matches).toHaveLength(2);
|
||||
expect(result.matches.map((m) => m.lineNumber)).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMatchTooltip", () => {
|
||||
it("formats context before, match line, and context after with line numbers", () => {
|
||||
const tooltip = formatMatchTooltip({
|
||||
lineNumber: 10,
|
||||
column: 1,
|
||||
lineText: "SELECT * FROM orders",
|
||||
segments: [],
|
||||
contextBefore: { lineNumber: 9, text: "-- Fetch active orders" },
|
||||
contextAfter: { lineNumber: 11, text: "WHERE status = 'active'" },
|
||||
});
|
||||
|
||||
expect(tooltip).toBe("9 | -- Fetch active orders\n10 | SELECT * FROM orders\n11 | WHERE status = 'active'");
|
||||
});
|
||||
|
||||
it("handles missing context lines gracefully", () => {
|
||||
const tooltip = formatMatchTooltip({
|
||||
lineNumber: 1,
|
||||
column: 1,
|
||||
lineText: "SELECT 1;",
|
||||
segments: [],
|
||||
});
|
||||
|
||||
expect(tooltip).toBe("1 | SELECT 1;");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
export interface SavedSqlMatchSegment {
|
||||
text: string;
|
||||
matched: boolean;
|
||||
}
|
||||
|
||||
export interface SavedSqlLineMatch {
|
||||
lineNumber: number;
|
||||
column: number;
|
||||
lineText: string;
|
||||
segments: SavedSqlMatchSegment[];
|
||||
contextBefore?: { lineNumber: number; text: string };
|
||||
contextAfter?: { lineNumber: number; text: string };
|
||||
}
|
||||
|
||||
export interface SavedSqlSearchMatchResult {
|
||||
matches: SavedSqlLineMatch[];
|
||||
totalMatches: number;
|
||||
}
|
||||
|
||||
export interface FindSavedSqlSearchMatchesOptions {
|
||||
maxMatches?: number;
|
||||
maxLineLength?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits text into matched and unmatched segments based on case-insensitive query matching.
|
||||
* Preserves the original character casing from the input text.
|
||||
*/
|
||||
export function splitSearchMatchSegments(text: string, query: string): SavedSqlMatchSegment[] {
|
||||
if (!text || !query) return text ? [{ text, matched: false }] : [];
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const segments: SavedSqlMatchSegment[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < text.length) {
|
||||
const matchIndex = lowerText.indexOf(lowerQuery, cursor);
|
||||
if (matchIndex < 0) {
|
||||
segments.push({ text: text.slice(cursor), matched: false });
|
||||
break;
|
||||
}
|
||||
if (matchIndex > cursor) {
|
||||
segments.push({ text: text.slice(cursor, matchIndex), matched: false });
|
||||
}
|
||||
segments.push({
|
||||
text: text.slice(matchIndex, matchIndex + query.length),
|
||||
matched: true,
|
||||
});
|
||||
cursor = matchIndex + query.length;
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates a line around the first match occurrence if it exceeds maxLineLength,
|
||||
* prepending/appending ellipsis when truncated.
|
||||
*/
|
||||
export function formatSnippetLine(line: string, query: string, maxLineLength = 100): string {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length <= maxLineLength) return trimmed;
|
||||
|
||||
const lowerTrimmed = trimmed.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const matchIndex = lowerTrimmed.indexOf(lowerQuery);
|
||||
if (matchIndex < 0) {
|
||||
return `${trimmed.slice(0, maxLineLength)}...`;
|
||||
}
|
||||
|
||||
// Keep some context characters before and after the match
|
||||
const leadContext = 20;
|
||||
if (matchIndex <= leadContext + 5) {
|
||||
return `${trimmed.slice(0, maxLineLength)}...`;
|
||||
}
|
||||
|
||||
const start = Math.max(0, matchIndex - leadContext);
|
||||
const end = Math.min(trimmed.length, start + maxLineLength);
|
||||
const prefix = start > 0 ? "..." : "";
|
||||
const suffix = end < trimmed.length ? "..." : "";
|
||||
return `${prefix}${trimmed.slice(start, end)}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches SQL text for lines matching the query, returning snippets and context lines.
|
||||
*/
|
||||
export function findSavedSqlSearchMatches(sql: string, query: string, options: FindSavedSqlSearchMatchesOptions = {}): SavedSqlSearchMatchResult {
|
||||
const trimmedQuery = query.trim();
|
||||
if (!sql || !trimmedQuery) {
|
||||
return { matches: [], totalMatches: 0 };
|
||||
}
|
||||
|
||||
const maxMatches = options.maxMatches ?? 3;
|
||||
const maxLineLength = options.maxLineLength ?? 100;
|
||||
const lines = sql.split(/\r?\n/);
|
||||
const lowerQuery = trimmedQuery.toLowerCase();
|
||||
const matches: SavedSqlLineMatch[] = [];
|
||||
let totalMatches = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const rawLine = lines[i];
|
||||
const lowerLine = rawLine.toLowerCase();
|
||||
const matchCol = lowerLine.indexOf(lowerQuery);
|
||||
if (matchCol >= 0) {
|
||||
totalMatches++;
|
||||
if (matches.length < maxMatches) {
|
||||
const snippetText = formatSnippetLine(rawLine, trimmedQuery, maxLineLength);
|
||||
const segments = splitSearchMatchSegments(snippetText, trimmedQuery);
|
||||
|
||||
const contextBefore = i > 0 && lines[i - 1].trim() ? { lineNumber: i, text: lines[i - 1].trim() } : undefined;
|
||||
const contextAfter = i < lines.length - 1 && lines[i + 1].trim() ? { lineNumber: i + 2, text: lines[i + 1].trim() } : undefined;
|
||||
|
||||
matches.push({
|
||||
lineNumber: i + 1,
|
||||
column: matchCol + 1,
|
||||
lineText: snippetText,
|
||||
segments,
|
||||
contextBefore,
|
||||
contextAfter,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { matches, totalMatches };
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a 3-line code context snippet for hover tooltips.
|
||||
*/
|
||||
export function formatMatchTooltip(match: SavedSqlLineMatch): string {
|
||||
const parts: string[] = [];
|
||||
if (match.contextBefore) {
|
||||
parts.push(`${match.contextBefore.lineNumber} | ${match.contextBefore.text}`);
|
||||
}
|
||||
parts.push(`${match.lineNumber} | ${match.lineText}`);
|
||||
if (match.contextAfter) {
|
||||
parts.push(`${match.contextAfter.lineNumber} | ${match.contextAfter.text}`);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import type { SavedSqlFile } from "@/types/database";
|
||||
|
||||
const savedFile: SavedSqlFile = {
|
||||
id: "saved-sql-reveal-1",
|
||||
connectionId: "conn-1",
|
||||
database: "main",
|
||||
name: "query.sql",
|
||||
sql: "SELECT 1;\nSELECT 2;\nSELECT 3;",
|
||||
sqlLoaded: true,
|
||||
createdAt: "2026-10-01T00:00:00.000Z",
|
||||
updatedAt: "2026-10-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
function installLocalStorage() {
|
||||
const data = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (key: string) => data.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => data.set(key, value),
|
||||
removeItem: (key: string) => data.delete(key),
|
||||
});
|
||||
}
|
||||
|
||||
describe("queryStore openSavedSql with reveal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
installLocalStorage();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("sets editorRevealRequest when opening a new tab with reveal", () => {
|
||||
const store = useQueryStore();
|
||||
const tabId = store.openSavedSql(savedFile, {
|
||||
reveal: { line: 2, column: 5 },
|
||||
});
|
||||
|
||||
const tab = store.tabs.find((t) => t.id === tabId);
|
||||
expect(tab).toBeDefined();
|
||||
expect(tab?.editorRevealRequest).toEqual(
|
||||
expect.objectContaining({
|
||||
line: 2,
|
||||
column: 5,
|
||||
}),
|
||||
);
|
||||
expect(tab?.editorRevealRequest?.id).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("updates editorRevealRequest when focusing an existing tab with reveal", () => {
|
||||
const store = useQueryStore();
|
||||
const tabId = store.openSavedSql(savedFile);
|
||||
const tab = store.tabs.find((t) => t.id === tabId)!;
|
||||
expect(tab.editorRevealRequest).toBeUndefined();
|
||||
|
||||
// Reopen same file with reveal
|
||||
store.openSavedSql(savedFile, {
|
||||
reveal: { line: 3, column: 1 },
|
||||
});
|
||||
|
||||
expect(tab.editorRevealRequest).toEqual(
|
||||
expect.objectContaining({
|
||||
line: 3,
|
||||
column: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -212,8 +212,9 @@ interface BuildQueryResultExportRequestOptions {
|
||||
insertMode?: SqlInsertMode;
|
||||
}
|
||||
|
||||
interface OpenSavedSqlOptions {
|
||||
export interface OpenSavedSqlOptions {
|
||||
targetMode?: SavedSqlOpenTargetMode;
|
||||
reveal?: { line: number; column?: number };
|
||||
}
|
||||
|
||||
interface OpenObjectSourceTabOptions {
|
||||
@@ -5432,6 +5433,9 @@ export const useQueryStore = defineStore("query", () => {
|
||||
existing.editorViewport = restored.viewport;
|
||||
}
|
||||
applySavedSqlExecutionTarget(existing, target);
|
||||
if (options.reveal) {
|
||||
existing.editorRevealRequest = { id: ++contentRevealSeq, line: options.reveal.line, column: options.reveal.column };
|
||||
}
|
||||
switchTab(existing.id);
|
||||
return existing.id;
|
||||
}
|
||||
@@ -5455,6 +5459,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
isExplaining: false,
|
||||
mode: "query",
|
||||
autoCommit: defaultAutoCommitForDbTypeWithSetting(dbType),
|
||||
editorRevealRequest: options.reveal ? { id: ++contentRevealSeq, line: options.reveal.line, column: options.reveal.column } : undefined,
|
||||
editorSelection: restoredPosition.selection,
|
||||
editorViewport: restoredPosition.viewport,
|
||||
};
|
||||
|
||||
@@ -690,8 +690,9 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function syncEntries() {
|
||||
if (files.value.some((file) => file.sqlLoaded === false)) {
|
||||
async function ensureAllFilesLoaded() {
|
||||
if (!files.value.some((file) => file.sqlLoaded === false)) return;
|
||||
try {
|
||||
const loadedFiles = await api.loadSavedSqlFilesForSync();
|
||||
const loadedById = new Map(loadedFiles.map((file) => [file.id, file]));
|
||||
files.value = files.value.map((file) => {
|
||||
@@ -700,7 +701,14 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
|
||||
return loaded ? { ...file, sql: loaded.sql, sqlLoaded: true } : file;
|
||||
});
|
||||
bumpVersion();
|
||||
} catch {
|
||||
const unloaded = files.value.filter((file) => file.sqlLoaded === false);
|
||||
await Promise.allSettled(unloaded.map((file) => ensureFileContent(file.id)));
|
||||
}
|
||||
}
|
||||
|
||||
async function syncEntries() {
|
||||
await ensureAllFilesLoaded();
|
||||
|
||||
const folderById = new Map(folders.value.map((folder) => [folder.id, folder]));
|
||||
const folderPath = (folderId?: string): string | undefined => {
|
||||
@@ -941,6 +949,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
|
||||
listFiles,
|
||||
getFile,
|
||||
ensureFileContent,
|
||||
ensureAllFilesLoaded,
|
||||
createFolder,
|
||||
renameFolder,
|
||||
deleteFolder,
|
||||
|
||||
Reference in New Issue
Block a user