feat(sql-library): show matching line snippets with jump in search

This commit is contained in:
lxk955
2026-10-01 23:47:24 +08:00
committed by GitHub
parent eeeb67e62c
commit 51f9dfc854
19 changed files with 686 additions and 106 deletions
@@ -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>
@@ -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);
});
});
+2
View File
@@ -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: {
+2
View File
@@ -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: {
+2
View File
@@ -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: {
+2
View File
@@ -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: {
+2
View File
@@ -805,6 +805,8 @@ export default withEnglishFallback({
sortByDate: "更新日時で並べ替え",
sortByFolder: "フォルダ構造で並べ替え",
collapseAll: "すべて折りたたむ",
moreMatches: "+ 他 {count} 件の一致",
collapseMatches: "折りたたむ",
},
connection: {
title: "新しい接続",
+2
View File
@@ -807,6 +807,8 @@ export default withEnglishFallback({
sortByDate: "수정 날짜순 정렬",
sortByFolder: "폴더 구조순 정렬",
collapseAll: "모두 접기",
moreMatches: "+ {count}개 더 일치",
collapseMatches: "접기",
},
connection: {
title: "새 연결",
+2
View File
@@ -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: {
+2
View File
@@ -764,6 +764,8 @@ export default withEnglishFallback({
sortByDate: "Сортировать по дате изменения",
sortByFolder: "Сортировать по структуре папок",
collapseAll: "Свернуть все",
moreMatches: "+ ещё {count} совпадений",
collapseMatches: "Свернуть",
},
connection: {
title: "Новое подключение",
+2
View File
@@ -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: {
+2
View File
@@ -735,6 +735,8 @@ export default withEnglishFallback({
sortByDate: "按修改日期排序",
sortByFolder: "按文件夹结构排序",
collapseAll: "全部折叠",
moreMatches: "+ {count} 项更多匹配",
collapseMatches: "收起",
},
connection: {
+2
View File
@@ -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,
}),
);
});
});
+6 -1
View File
@@ -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,
};
+11 -2
View File
@@ -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,