feat(meilisearch): add document and settings views

This commit is contained in:
Freedom authored and GitHub committed 2026-08-20 01:51:57 +08:00
1 parent 39ce50b681
commit 884744df4c
32 files changed
+3673 -57

No files matched your search

+47 -20
View File
@@ -2,7 +2,7 @@
import { computed, defineComponent, h, nextTick, ref, shallowRef, watch, type VNodeChild } from "vue";
import { DynamicScroller, DynamicScrollerItem } from "vue-virtual-scroller";
import { ChevronDown, ChevronRight } from "@lucide/vue";
import { createJsonTreeRoot, getJsonTreeChildren, getVisibleJsonTreeNodes, isJsonTreeContainer, isJsonTreeInitiallyExpanded, jsonTreeContainerKind, jsonTreeContainerSummary, type JsonTreeNode } from "@/lib/common/jsonTree";
import { createJsonTreeRoot, getJsonTreeChildren, getVisibleJsonTreeNodes, isJsonTreeClosingNode, isJsonTreeContainer, isJsonTreeInitiallyExpanded, jsonTreeContainerKind, jsonTreeContainerSummary, type JsonTreeNode } from "@/lib/common/jsonTree";
import { isLosslessJsonNumber } from "@/lib/common/safeJsonFormat";
defineOptions({ name: "JsonTree" });
@@ -137,12 +137,21 @@ function renderJsonNode(node: JsonTreeNode): VNodeChild {
if (containerValue !== undefined) {
const kind = jsonTreeContainerKind(containerValue);
rowChildren.push(h("span", { class: `json-tree-bracket is-${kind}` }, kind === "array" ? "[" : "{"), h("span", { class: "json-tree-summary" }, jsonTreeContainerSummary(containerValue, expanded)), h("span", { class: `json-tree-bracket is-${kind}` }, kind === "array" ? "]" : "}"));
rowChildren.push(h("span", { class: `json-tree-bracket is-${kind}` }, kind === "array" ? "[" : "{"));
if (!expanded) {
rowChildren.push(h("span", { class: "json-tree-summary" }, jsonTreeContainerSummary(containerValue)), h("span", { class: `json-tree-bracket is-${kind}` }, kind === "array" ? "]" : "}"));
}
} else {
rowChildren.push(highlightedJsonSpan(scalarClass(node.value), scalarText(node.value)));
}
const children = containerValue !== undefined && expanded ? h("div", { class: "json-tree-children" }, getJsonTreeChildren(node).map(renderJsonNode)) : null;
const children =
containerValue !== undefined && expanded
? h("div", { class: "json-tree-children" }, [
...getJsonTreeChildren(node).map(renderJsonNode),
h("div", { class: "json-tree-row", style: { paddingInlineStart: indent } }, [h("span", { class: `json-tree-bracket json-tree-closing is-${jsonTreeContainerKind(containerValue)}` }, jsonTreeContainerKind(containerValue) === "array" ? "]" : "}")]),
])
: null;
return h("div", { key: node.path, class: "json-tree-node", "data-json-path": node.path }, [h("div", { class: "json-tree-row", style: { paddingInlineStart: indent } }, rowChildren), children]);
}
@@ -154,7 +163,7 @@ const JsonTreeRoot = defineComponent({
},
});
const visibleNodes = computed(() => getVisibleJsonTreeNodes(rootNode.value, isNodeExpanded));
const visibleNodes = computed(() => getVisibleJsonTreeNodes(rootNode.value, isNodeExpanded, props.virtualized));
function refresh(resetScroll = false) {
if (!props.virtualized) return;
@@ -180,24 +189,31 @@ defineExpose({ expandAll, collapseAll, resetExpansion, refresh });
<DynamicScrollerItem :item="node" :active="active" :size-dependencies="[wordWrap, node.path, node.label, scalarText(node.value), isNodeExpanded(node)]" :data-index="index">
<div class="json-tree-node" :data-json-path="node.path">
<div class="json-tree-row" :style="{ paddingInlineStart: `${node.depth * 16}px` }">
<button v-if="isJsonTreeContainer(node.value)" type="button" class="json-tree-toggle" :aria-expanded="isNodeExpanded(node)" :aria-label="nodeAccessibleLabel(node)" :title="nodeAccessibleLabel(node)" @click="toggleNode(node)">
<ChevronDown v-if="isNodeExpanded(node)" class="h-3.5 w-3.5" aria-hidden="true" />
<ChevronRight v-else class="h-3.5 w-3.5" aria-hidden="true" />
</button>
<span v-else class="json-tree-spacer" aria-hidden="true" />
<template v-if="node.parentKind !== 'root'">
<span v-if="node.parentKind === 'array'" class="json-tree-index">[{{ node.label }}]</span>
<span v-else class="json-tree-key" v-html="highlightedJson(JSON.stringify(node.label))" />
<span class="json-tree-punctuation">:</span>
<template v-if="isJsonTreeClosingNode(node)">
<span class="json-tree-bracket json-tree-closing" :class="`is-${jsonTreeContainerKind(node.value)}`">{{ jsonTreeContainerKind(node.value) === "array" ? "]" : "}" }}</span>
</template>
<template v-else>
<button v-if="isJsonTreeContainer(node.value)" type="button" class="json-tree-toggle" :aria-expanded="isNodeExpanded(node)" :aria-label="nodeAccessibleLabel(node)" :title="nodeAccessibleLabel(node)" @click="toggleNode(node)">
<ChevronDown v-if="isNodeExpanded(node)" class="h-3.5 w-3.5" aria-hidden="true" />
<ChevronRight v-else class="h-3.5 w-3.5" aria-hidden="true" />
</button>
<span v-else class="json-tree-spacer" aria-hidden="true" />
<template v-if="isJsonTreeContainer(node.value)">
<span class="json-tree-bracket" :class="`is-${jsonTreeContainerKind(node.value)}`">{{ jsonTreeContainerKind(node.value) === "array" ? "[" : "{" }}</span>
<span class="json-tree-summary">{{ jsonTreeContainerSummary(node.value, isNodeExpanded(node)) }}</span>
<span class="json-tree-bracket" :class="`is-${jsonTreeContainerKind(node.value)}`">{{ jsonTreeContainerKind(node.value) === "array" ? "]" : "}" }}</span>
<template v-if="node.parentKind !== 'root'">
<span v-if="node.parentKind === 'array'" class="json-tree-index">[{{ node.label }}]</span>
<span v-else class="json-tree-key" v-html="highlightedJson(JSON.stringify(node.label))" />
<span class="json-tree-punctuation">:</span>
</template>
<template v-if="isJsonTreeContainer(node.value)">
<span class="json-tree-bracket" :class="`is-${jsonTreeContainerKind(node.value)}`">{{ jsonTreeContainerKind(node.value) === "array" ? "[" : "{" }}</span>
<template v-if="!isNodeExpanded(node)">
<span class="json-tree-summary">{{ jsonTreeContainerSummary(node.value) }}</span>
<span class="json-tree-bracket" :class="`is-${jsonTreeContainerKind(node.value)}`">{{ jsonTreeContainerKind(node.value) === "array" ? "]" : "}" }}</span>
</template>
</template>
<span v-else :class="scalarClass(node.value)" v-html="highlightedJson(scalarText(node.value))" />
</template>
<span v-else :class="scalarClass(node.value)" v-html="highlightedJson(scalarText(node.value))" />
</div>
</div>
</DynamicScrollerItem>
@@ -241,6 +257,9 @@ defineExpose({ expandAll, collapseAll, resetExpansion, refresh });
min-height: 24px;
align-items: flex-start;
gap: 4px;
padding-block: 2px;
/* Fixed line box so the hover background wraps the glyphs symmetrically. */
line-height: 20px;
border-radius: var(--dbx-radius-fixed-4);
}
@@ -249,7 +268,7 @@ defineExpose({ expandAll, collapseAll, resetExpansion, refresh });
}
.json-tree-toggle {
margin-top: 1px;
align-self: center;
display: inline-flex;
height: 18px;
width: 18px;
@@ -275,6 +294,14 @@ defineExpose({ expandAll, collapseAll, resetExpansion, refresh });
flex: 0 0 auto;
}
/* Closing brackets sit in the toggle's slot so they line up with the chevron above. */
.json-tree-closing {
display: inline-flex;
width: 18px;
flex: 0 0 auto;
justify-content: center;
}
.json-tree-key {
color: var(--json-tree-key);
}
@@ -86,6 +86,7 @@ const ZooKeeperKeyBrowser = defineAsyncComponent(() => import("@/components/zook
const ConsulOverview = defineAsyncComponent(() => import("@/components/consul/ConsulOverview.vue"));
const ConsulWorkspace = defineAsyncComponent(() => import("@/components/consul/ConsulWorkspace.vue"));
const DocumentBrowser = defineAsyncComponent(() => import("@/components/document/DocumentBrowser.vue"));
const MeilisearchIndexView = defineAsyncComponent(() => import("@/components/meilisearch/MeilisearchIndexView.vue"));
const MongoGridFsBrowser = defineAsyncComponent(() => import("@/components/document/MongoGridFsBrowser.vue"));
const MongoBucketBrowser = defineAsyncComponent(() => import("@/components/document/MongoBucketBrowser.vue"));
const VectorBrowser = defineAsyncComponent(() => import("@/components/vector/VectorBrowser.vue"));
@@ -2092,6 +2093,13 @@ defineExpose({
</div>
</template>
<!-- Meilisearch index detail -->
<template v-else-if="activeTab.mode === 'meilisearch'">
<div class="flex-1 min-h-0">
<MeilisearchIndexView :key="activeTab.id" :connection-id="activeTab.connectionId" :index="activeTab.sql" />
</div>
</template>
<template v-else-if="activeTab.mode === 'mongo-gridfs'">
<div class="flex-1 min-h-0">
<MongoGridFsBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" />
@@ -0,0 +1,801 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import type { Component } from "vue";
import { useI18n } from "vue-i18n";
import { ArrowDown, ArrowUp, Braces, ChevronLeft, ChevronRight, Copy, Download, LayoutGrid, LoaderCircle, Pencil, Save, Search, Table2, Trash2 } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import ErrorBanner from "@/components/ui/ErrorBanner.vue";
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import JsonTree from "@/components/common/JsonTree.vue";
import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue";
import * as api from "@/lib/backend/api";
import { compactLocalTimestamp, sanitizeExportBaseName, saveTextFile } from "@/lib/export/saveTextFile";
import { parseDocumentStoreJsonDocument, serializeDocumentStoreId, stringifyDocumentStoreValue } from "@/lib/app/documentJsonValues";
import { parseJsonPreservingLargeNumbers, safeJsonFormat } from "@/lib/common/safeJsonFormat";
import { useToast } from "@/composables/useToast";
const props = defineProps<{
connectionId: string;
index: string;
}>();
const emit = defineEmits<{
"refresh-stats": [];
}>();
type ViewMode = "json" | "table" | "grid";
/**
* Search hit from the backend: `id` is the primary-key value and `formatted` /
* `rankingScore` are requested response metadata — all kept outside `document`,
* which is always the pure user payload.
*/
type Hit = { id?: unknown; document: Record<string, any>; formatted?: Record<string, any>; rankingScore?: unknown };
const { t } = useI18n();
const { toast } = useToast();
const q = ref("");
const filter = ref("");
const sort = ref("");
const limit = ref(20);
const offset = ref(0);
const AUTO_REFRESH_INTERVAL_MS = 7_000;
const autoRefresh = ref(false);
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null;
const hybridOpen = ref(false);
const hybridEnabled = ref(false);
const hybridEmbedder = ref("");
const hybridSemanticRatio = ref(0.5);
const embedders = ref<string[]>([]);
const showRankingScore = ref(false);
const rankingScoreThreshold = ref(0);
const hits = ref<Hit[]>([]);
const totalHits = ref(0);
const processingTimeMs = ref(0);
const loading = ref(false);
const error = ref("");
const viewMode = ref<ViewMode>("json");
const editOpen = ref(false);
const editingHit = ref<Hit | null>(null);
const editJson = ref("");
const editError = ref("");
const isSaving = ref(false);
/** True while the canonical document is being fetched for the editor. */
const editLoading = ref(false);
const deleteOpen = ref(false);
const deletingHit = ref<Hit | null>(null);
const isDeleting = ref(false);
const cellDetailOpen = ref(false);
const cellDetailColumn = ref("");
const cellDetailValue = ref<unknown>(null);
function openCellDetail(hit: Hit, column: string) {
cellDetailColumn.value = column;
cellDetailValue.value = cellValue(hit, column);
cellDetailOpen.value = true;
}
/** Plain-text rendering of the cell value with highlight tags removed. */
const cellDetailText = computed(() => String(stripSearchMarks(cellDetailValue.value) ?? ""));
/** Smart-render a cell value: objects/arrays (or JSON strings) as a tree, plain text otherwise. */
const cellDetailJson = computed<unknown>(() => {
const value = cellDetailValue.value;
if (value !== null && typeof value === "object") return value;
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
return parseJsonPreservingLargeNumbers(trimmed);
} catch {
return undefined;
}
}
}
return undefined;
});
const views = computed<Array<{ value: ViewMode; label: string; icon: Component }>>(() => [
{ value: "json", label: t("meilisearch.viewJson"), icon: Braces },
{ value: "table", label: t("meilisearch.viewTable"), icon: Table2 },
{ value: "grid", label: t("meilisearch.viewGrid"), icon: LayoutGrid },
]);
const page = computed(() => Math.max(0, Math.floor(offset.value / Math.max(1, limit.value))));
const totalPages = computed(() => Math.max(1, Math.ceil(totalHits.value / Math.max(1, limit.value))));
const canGoNext = computed(() => offset.value + limit.value < totalHits.value);
const resultSummary = computed(() => t("meilisearch.resultSummary", { count: totalHits.value, time: processingTimeMs.value }));
function escapeHtml(value: string): string {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function formatCellValue(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "object") {
try {
return stringifyDocumentStoreValue(value, "meilisearch");
} catch {
return String(value);
}
}
return String(value);
}
function renderMarkHtml(value: string): string {
return escapeHtml(value)
.replace(/&lt;mark&gt;/g, "<mark>")
.replace(/&lt;\/mark&gt;/g, "</mark>");
}
/**
* Display value for the JSON/grid cards: prefer the hoisted `formatted` payload
* so search `<mark>` highlights stay visible. `document` is already the pure
* user payload — no name-based stripping, so user fields named `_formatted` or
* `_rankingScore` survive untouched.
*/
function displayValue(hit: Hit): Record<string, any> {
const formatted = hit?.formatted;
return formatted && typeof formatted === "object" ? { ...formatted } : { ...hit.document };
}
/** Deep-remove `<mark>` search highlight tags to reconstruct the stored document. */
function stripSearchMarks(value: unknown): unknown {
if (typeof value === "string") return value.replace(/<\/?mark>/g, "");
if (Array.isArray(value)) return value.map(stripSearchMarks);
if (value !== null && typeof value === "object") {
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, entry]) => [key, stripSearchMarks(entry)]));
}
return value;
}
/** The user payload exactly as returned by the search — already free of dbx/Meilisearch metadata. */
function rawDocument(hit: Hit): Record<string, unknown> {
return { ...hit.document };
}
function rankingScore(hit: Hit): number | null {
const value = hit?.rankingScore;
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
const tableColumns = computed(() => {
// Columns mirror the stored document fields in their natural order.
const keys: string[] = [];
const seen = new Set<string>();
for (const hit of hits.value) {
for (const key of Object.keys(displayValue(hit))) {
if (!seen.has(key)) {
seen.add(key);
keys.push(key);
}
}
}
return keys;
});
/** Grid cards show the stored document as flat field rows instead of a JSON tree. */
function gridFields(hit: Hit): Array<[string, unknown]> {
return Object.entries(displayValue(hit));
}
function cellValue(hit: Hit, column: string): unknown {
const formatted = hit?.formatted;
if (formatted && typeof formatted === "object" && column in formatted) return formatted[column];
return hit?.document?.[column];
}
function isMarkedCell(hit: Hit, column: string): boolean {
const value = cellValue(hit, column);
return typeof value === "string" && value.includes("<mark>");
}
function markCellHtml(hit: Hit, column: string): string {
return renderMarkHtml(String(cellValue(hit, column)));
}
function cellText(hit: Hit, column: string): string {
return formatCellValue(cellValue(hit, column));
}
function documentId(hit: Hit | null | undefined): string {
const id = hit?.id;
if (id == null) return "";
// The store-id serializer prefixes string ids so the backend keeps their
// type — a string primary key like "123" must not round-trip as numeric 123.
return serializeDocumentStoreId(id, "meilisearch");
}
/** Search params shared by the results view and the export. */
function buildSearchParams(batchLimit: number, batchOffset: number) {
return {
q: q.value.trim() || null,
filter: filter.value.trim() || null,
sort: sort.value.trim() || null,
limit: Math.max(1, batchLimit),
offset: Math.max(0, batchOffset),
hybridEmbedder: hybridEnabled.value && hybridEmbedder.value.trim() ? hybridEmbedder.value.trim() : null,
hybridSemanticRatio: hybridEnabled.value ? hybridSemanticRatio.value : null,
showRankingScore: showRankingScore.value,
rankingScoreThreshold: rankingScoreThreshold.value > 0 ? rankingScoreThreshold.value : null,
};
}
async function runSearch() {
loading.value = true;
error.value = "";
try {
const result = await api.meilisearchSearchDocuments(props.connectionId, props.index, buildSearchParams(limit.value, offset.value));
hits.value = result.hits ?? [];
totalHits.value = result.totalHits ?? 0;
processingTimeMs.value = result.processingTimeMs ?? 0;
} catch (e: any) {
hits.value = [];
totalHits.value = 0;
error.value = e?.message || String(e);
} finally {
loading.value = false;
}
}
function prevPage() {
offset.value = Math.max(0, offset.value - limit.value);
void runSearch();
}
function nextPage() {
offset.value = offset.value + limit.value;
void runSearch();
}
/**
* Open the editor with the canonical stored document. Search hits may be
* partial (`displayedAttributes`) and are never used as the write payload.
*/
async function startEdit(hit: Hit) {
const id = documentId(hit);
if (!id) return;
editingHit.value = hit;
editError.value = "";
editJson.value = "";
editOpen.value = true;
editLoading.value = true;
try {
const canonicalJson = await api.meilisearchGetDocument(props.connectionId, props.index, id);
editJson.value = safeJsonFormat(canonicalJson, 2);
} catch (e: any) {
editError.value = e?.message || String(e);
} finally {
editLoading.value = false;
}
}
/** Download every hit matching the current search as a JSON file of the stored documents. */
const exporting = ref(false);
const EXPORT_BATCH_SIZE = 1000;
async function exportResults() {
if (exporting.value || totalHits.value === 0) return;
if (q.value.trim() || hybridEnabled.value || rankingScoreThreshold.value > 0) {
toast(t("meilisearch.exportSearchUnsupported"), 5000);
return;
}
exporting.value = true;
try {
const documents: Record<string, unknown>[] = [];
let batchOffset = 0;
while (true) {
const page = await api.meilisearchFetchDocuments(props.connectionId, props.index, {
filter: filter.value.trim() || null,
sort: sort.value.trim() || null,
limit: EXPORT_BATCH_SIZE,
offset: batchOffset,
});
const batch = page.documents ?? [];
documents.push(...batch);
if (batch.length < EXPORT_BATCH_SIZE) break;
batchOffset += batch.length;
}
const content = stringifyDocumentStoreValue(documents, "meilisearch", 2);
const baseName = sanitizeExportBaseName(props.index) || "search-results";
await saveTextFile(content, `${baseName}-${compactLocalTimestamp()}.json`, "JSON", "json");
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
exporting.value = false;
}
}
async function copyDocument(hit: Hit) {
try {
await navigator.clipboard.writeText(stringifyDocumentStoreValue(rawDocument(hit), "meilisearch", 2));
toast(t("meilisearch.copied"));
} catch {
// Clipboard may be unavailable (e.g. permission denied); stay silent.
}
}
async function copyCellDetail() {
const value = stripSearchMarks(cellDetailValue.value);
const text = value !== null && typeof value === "object" ? stringifyDocumentStoreValue(value, "meilisearch", 2) : String(value ?? "");
try {
await navigator.clipboard.writeText(text);
toast(t("meilisearch.copied"));
} catch {
// Clipboard may be unavailable (e.g. permission denied); stay silent.
}
}
async function saveEdit() {
const id = documentId(editingHit.value);
if (!id) {
editError.value = t("meilisearch.invalidJson");
return;
}
const parsed = parseDocumentStoreJsonDocument(editJson.value, "meilisearch");
if (!parsed.ok) {
editError.value = t("meilisearch.invalidJson");
return;
}
isSaving.value = true;
editError.value = "";
try {
await api.documentUpdateDocument(props.connectionId, "default", props.index, id, editJson.value);
toast(t("meilisearch.documentSaved"));
editOpen.value = false;
emit("refresh-stats");
await runSearch();
} catch (e: any) {
editError.value = e?.message || String(e);
} finally {
isSaving.value = false;
}
}
function startDelete(hit: Hit) {
deletingHit.value = hit;
deleteOpen.value = true;
}
async function confirmDelete() {
const id = documentId(deletingHit.value);
if (!id) return;
isDeleting.value = true;
try {
await api.documentDeleteDocument(props.connectionId, "default", props.index, id);
toast(t("meilisearch.documentDeleted"));
deleteOpen.value = false;
emit("refresh-stats");
await runSearch();
// Deleting the last row of a page leaves offset out of range; step back one page.
if (!error.value && hits.value.length === 0 && offset.value > 0) {
offset.value = Math.max(0, offset.value - limit.value);
await runSearch();
}
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
isDeleting.value = false;
}
}
function clearAutoRefreshTimer() {
if (autoRefreshTimer !== null) {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
}
watch(autoRefresh, (enabled) => {
clearAutoRefreshTimer();
if (enabled) {
autoRefreshTimer = setInterval(() => {
if (!loading.value) void runSearch();
}, AUTO_REFRESH_INTERVAL_MS);
}
});
/** Sortable attributes advertised by the index settings, for the sort picker. */
const sortableAttributes = ref<string[]>([]);
async function loadIndexSettings() {
try {
const settings = await api.meilisearchGetIndexSettings(props.connectionId, props.index);
const configured = settings?.embedders;
embedders.value = configured && typeof configured === "object" && !Array.isArray(configured) ? Object.keys(configured) : [];
if (!hybridEmbedder.value && embedders.value.length > 0) {
hybridEmbedder.value = embedders.value[0];
}
const sortable = settings?.sortableAttributes;
sortableAttributes.value = Array.isArray(sortable) ? sortable.filter((item): item is string => typeof item === "string") : [];
} catch {
// Settings discovery is best-effort; the sort/filter inputs stay free-form.
sortableAttributes.value = [];
}
}
/** Hover/focus-driven sort picker anchored under the sort input. */
const sortPickerOpen = ref(false);
let sortPickerCloseTimer: ReturnType<typeof setTimeout> | undefined;
function openSortPicker() {
if (sortPickerCloseTimer !== undefined) {
clearTimeout(sortPickerCloseTimer);
sortPickerCloseTimer = undefined;
}
sortPickerOpen.value = true;
}
function scheduleSortPickerClose() {
if (sortPickerCloseTimer !== undefined) clearTimeout(sortPickerCloseTimer);
// Small delay so the pointer can travel from the input into the panel.
sortPickerCloseTimer = setTimeout(() => {
sortPickerOpen.value = false;
sortPickerCloseTimer = undefined;
}, 150);
}
type SortDirection = "asc" | "desc";
function parseSortEntries(): Array<{ field: string; direction: SortDirection }> {
return sort.value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => {
const separator = entry.lastIndexOf(":");
const field = (separator >= 0 ? entry.slice(0, separator) : entry).trim();
const direction: SortDirection =
separator >= 0 &&
entry
.slice(separator + 1)
.trim()
.toLowerCase() === "desc"
? "desc"
: "asc";
return { field, direction };
})
.filter((entry) => entry.field.length > 0);
}
function sortDirectionFor(field: string): SortDirection | null {
return parseSortEntries().find((entry) => entry.field === field)?.direction ?? null;
}
/** Toggle `field:direction` in the sort expression; clicking the active direction removes it. */
function toggleSortField(field: string, direction: SortDirection) {
const entries = parseSortEntries();
const index = entries.findIndex((entry) => entry.field === field);
if (index >= 0 && entries[index].direction === direction) entries.splice(index, 1);
else if (index >= 0) entries[index] = { field, direction };
else entries.push({ field, direction });
sort.value = entries.map((entry) => `${entry.field}:${entry.direction}`).join(", ");
}
onMounted(() => {
void runSearch();
void loadIndexSettings();
});
onBeforeUnmount(() => {
clearAutoRefreshTimer();
if (sortPickerCloseTimer !== undefined) clearTimeout(sortPickerCloseTimer);
});
</script>
<template>
<div class="h-full flex flex-col overflow-hidden">
<!-- Search form -->
<div class="p-3 border-b shrink-0">
<div class="rounded-lg border bg-card p-3 space-y-3">
<div class="relative">
<Search class="pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input v-model="q" class="pl-8" :placeholder="t('meilisearch.keywordPlaceholder')" autofocus @keydown.enter="runSearch" />
</div>
<div class="grid grid-cols-2 gap-2 lg:grid-cols-4">
<div class="space-y-1 min-w-0">
<Label class="text-xs text-muted-foreground">{{ t("meilisearch.filter") }}</Label>
<Input v-model="filter" :placeholder="t('meilisearch.filterPlaceholder')" @keydown.enter="runSearch" />
</div>
<div class="space-y-1 min-w-0">
<Label class="text-xs text-muted-foreground">{{ t("meilisearch.sort") }}</Label>
<Popover v-model:open="sortPickerOpen">
<PopoverAnchor as-child>
<Input v-model="sort" class="w-full" :placeholder="t('meilisearch.sortPlaceholder')" @keydown.enter="runSearch" @mouseenter="openSortPicker" @mouseleave="scheduleSortPickerClose" @focus="openSortPicker" />
</PopoverAnchor>
<PopoverContent side="bottom" align="start" class="w-(--reka-popover-anchor-width) p-0" @open-auto-focus.prevent @mouseenter="openSortPicker" @mouseleave="scheduleSortPickerClose">
<p class="border-b px-3 py-2 text-xs text-muted-foreground">{{ t("meilisearch.sortHint") }}</p>
<p v-if="sortableAttributes.length === 0" class="px-3 py-2 text-xs text-muted-foreground">{{ t("meilisearch.sortNoAttributes") }}</p>
<div v-else class="max-h-56 overflow-y-auto p-1">
<div v-for="attr in sortableAttributes" :key="attr" class="flex items-center gap-1 rounded px-2 py-1 hover:bg-muted/50">
<span class="min-w-0 flex-1 truncate font-mono text-xs" :title="attr">{{ attr }}</span>
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :title="t('meilisearch.sortAsc')" @click="toggleSortField(attr, 'asc')">
<ArrowUp class="h-3.5 w-3.5" :class="sortDirectionFor(attr) === 'asc' ? 'text-primary' : 'text-muted-foreground/50'" />
</Button>
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :title="t('meilisearch.sortDesc')" @click="toggleSortField(attr, 'desc')">
<ArrowDown class="h-3.5 w-3.5" :class="sortDirectionFor(attr) === 'desc' ? 'text-primary' : 'text-muted-foreground/50'" />
</Button>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<div class="space-y-1 min-w-0">
<Label class="text-xs text-muted-foreground">{{ t("meilisearch.limit") }}</Label>
<Input v-model.number="limit" type="number" min="1" @keydown.enter="runSearch" />
</div>
<div class="space-y-1 min-w-0">
<Label class="text-xs text-muted-foreground">{{ t("meilisearch.offset") }}</Label>
<Input v-model.number="offset" type="number" min="0" @keydown.enter="runSearch" />
</div>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<div class="flex shrink-0 items-center gap-1.5">
<span class="whitespace-nowrap text-xs text-muted-foreground">{{ t("meilisearch.showRankingScore") }}</span>
<Switch v-model="showRankingScore" />
</div>
<div class="flex shrink-0 items-center gap-1.5">
<span class="whitespace-nowrap text-xs text-muted-foreground">{{ t("meilisearch.rankingScoreThreshold") }}</span>
<Input v-model.number="rankingScoreThreshold" type="number" min="0" max="1" step="0.1" class="h-8 w-16" @keydown.enter="runSearch" />
</div>
<div class="flex shrink-0 items-center gap-1.5">
<span class="whitespace-nowrap text-xs text-muted-foreground">{{ t("meilisearch.autoRefresh") }}</span>
<Switch v-model="autoRefresh" />
</div>
<Popover v-model:open="hybridOpen">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 shrink-0 gap-1.5">
{{ t("meilisearch.hybrid") }}
<Badge :variant="hybridEnabled ? 'default' : 'secondary'" class="h-4 px-1 text-[10px]">{{ hybridEnabled ? "ON" : "OFF" }}</Badge>
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-64 space-y-3 p-3">
<div class="flex items-center justify-between gap-2">
<Label class="text-xs">{{ t("meilisearch.hybrid") }}</Label>
<Switch v-model="hybridEnabled" />
</div>
<div class="space-y-1">
<Label class="text-xs text-muted-foreground">{{ t("meilisearch.hybridEmbedder") }}</Label>
<div v-if="embedders.length > 0" class="flex flex-wrap gap-1">
<button
v-for="embedder in embedders"
:key="embedder"
type="button"
class="rounded-md border px-2 py-0.5 font-mono text-xs transition-colors disabled:opacity-50"
:class="hybridEmbedder === embedder ? 'border-primary/40 bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-muted/70 hover:text-foreground'"
:disabled="!hybridEnabled"
@click="hybridEmbedder = embedder"
>
{{ embedder }}
</button>
</div>
<template v-else>
<Input v-model="hybridEmbedder" :disabled="!hybridEnabled" placeholder="default" />
<p class="text-xs text-muted-foreground">{{ t("meilisearch.hybridNoEmbedders") }}</p>
</template>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between gap-2">
<Label class="text-xs text-muted-foreground">{{ t("meilisearch.hybridSemanticRatio") }}</Label>
<span class="text-xs tabular-nums text-foreground/80">{{ hybridSemanticRatio.toFixed(2) }}</span>
</div>
<input v-model.number="hybridSemanticRatio" type="range" min="0" max="1" step="0.05" :disabled="!hybridEnabled" class="w-full accent-primary" />
</div>
</PopoverContent>
</Popover>
<Button size="sm" class="h-8 shrink-0 gap-1" :disabled="loading" @click="runSearch">
<LoaderCircle v-if="loading" class="h-3.5 w-3.5 animate-spin" />
<Search v-else class="h-3.5 w-3.5" />
{{ t("meilisearch.search") }}
</Button>
</div>
</div>
</div>
<!-- Result toolbar -->
<div class="flex items-center gap-3 px-3 pt-3 shrink-0">
<span class="text-sm font-semibold">{{ t("meilisearch.results") }}</span>
<div class="grid w-auto grid-cols-3 rounded-md bg-muted/40 p-0.5">
<button
v-for="view in views"
:key="view.value"
type="button"
class="inline-flex h-5 min-w-0 items-center gap-1 truncate whitespace-nowrap rounded-[5px] px-1.5 text-xs transition-colors"
:class="viewMode === view.value ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
@click="viewMode = view.value"
>
<component :is="view.icon" class="h-3 w-3 shrink-0" />
<span>{{ view.label }}</span>
</button>
</div>
<div class="flex-1" />
<Button v-if="totalHits > 0" variant="ghost" size="sm" class="h-6 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground" :disabled="exporting" @click="exportResults">
<Download class="h-3.5 w-3.5" />
{{ t("meilisearch.exportResults") }} ({{ totalHits }})
</Button>
<span v-if="!loading && !error" class="text-muted-foreground text-xs tabular-nums">{{ resultSummary }}</span>
</div>
<!-- Results -->
<div class="meilisearch-results flex-1 min-h-0 overflow-y-auto px-3 pb-3 pt-2">
<QueryLoadingState v-if="loading" class="h-full" />
<ErrorBanner v-else-if="error" :message="error" />
<div v-else-if="hits.length === 0" class="rounded-md border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
{{ t("meilisearch.empty") }}
</div>
<!-- JSON view -->
<div v-else-if="viewMode === 'json'" class="space-y-3">
<div v-for="(hit, idx) in hits" :key="documentId(hit) || idx" class="group relative rounded-lg border bg-card p-3">
<Badge v-if="rankingScore(hit) != null" variant="secondary" class="absolute right-2 bottom-2 tabular-nums" :title="t('meilisearch.showRankingScore')">
{{ rankingScore(hit)!.toFixed(2) }}
</Badge>
<div class="absolute right-2 top-2 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" class="h-6 w-7" :title="t('meilisearch.copyDocument')" @click="copyDocument(hit)">
<Copy class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-6 w-7" :title="t('meilisearch.editDocument')" :disabled="!documentId(hit)" @click="startEdit(hit)">
<Pencil class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-6 w-7 text-destructive" :title="t('meilisearch.deleteDocument')" :disabled="!documentId(hit)" @click="startDelete(hit)">
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
<JsonTree :value="displayValue(hit)" :highlight-json="renderMarkHtml" :initial-expanded-depth="2" class="font-mono text-xs" />
</div>
</div>
<!-- Table view -->
<div v-else-if="viewMode === 'table'" class="rounded-lg border bg-card overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full table-fixed text-xs">
<thead>
<tr class="border-b bg-muted/40 text-left text-muted-foreground">
<th v-for="column in tableColumns" :key="column" class="px-3 py-2 font-medium">
<span class="block truncate" :title="column">{{ column }}</span>
</th>
<th class="w-24 px-3 py-2" />
</tr>
</thead>
<tbody>
<tr v-for="(hit, idx) in hits" :key="documentId(hit) || idx" class="group/row border-b last:border-b-0 hover:bg-muted/30">
<td v-for="column in tableColumns" :key="column" class="cursor-pointer px-3 py-2 font-mono" @click="openCellDetail(hit, column)">
<div class="truncate">
<span v-if="isMarkedCell(hit, column)" v-html="markCellHtml(hit, column)" />
<template v-else>{{ cellText(hit, column) }}</template>
</div>
</td>
<td class="px-2 py-1">
<div class="flex items-center justify-end gap-0.5 opacity-0 transition-opacity group-hover/row:opacity-100">
<Button variant="ghost" size="icon" class="h-6 w-6" :title="t('meilisearch.copyDocument')" @click="copyDocument(hit)">
<Copy class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-6 w-6" :title="t('meilisearch.editDocument')" :disabled="!documentId(hit)" @click="startEdit(hit)">
<Pencil class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-6 w-6 text-destructive" :title="t('meilisearch.deleteDocument')" :disabled="!documentId(hit)" @click="startDelete(hit)">
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Grid view -->
<div v-else class="grid grid-cols-2 gap-4 lg:grid-cols-3">
<div v-for="(hit, idx) in hits" :key="documentId(hit) || idx" class="group relative rounded-lg border bg-card px-4 py-3.5">
<Badge v-if="rankingScore(hit) != null" variant="secondary" class="absolute right-2 bottom-2 tabular-nums" :title="t('meilisearch.showRankingScore')">
{{ rankingScore(hit)!.toFixed(2) }}
</Badge>
<div class="absolute right-2 top-2 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" class="h-6 w-7" :title="t('meilisearch.copyDocument')" @click="copyDocument(hit)">
<Copy class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-6 w-7" :title="t('meilisearch.editDocument')" :disabled="!documentId(hit)" @click="startEdit(hit)">
<Pencil class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-6 w-7 text-destructive" :title="t('meilisearch.deleteDocument')" :disabled="!documentId(hit)" @click="startDelete(hit)">
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
<dl class="space-y-2">
<div v-for="[key, value] in gridFields(hit)" :key="key" class="flex cursor-pointer items-baseline gap-3 rounded px-1.5 py-1 text-xs hover:bg-muted/40" @click="openCellDetail(hit, key)">
<dt class="w-28 shrink-0 truncate text-right text-muted-foreground" :title="key">{{ key }}</dt>
<dd class="min-w-0 flex-1 truncate font-mono" :title="formatCellValue(stripSearchMarks(value))">
<span v-if="isMarkedCell(hit, key)" v-html="markCellHtml(hit, key)" />
<template v-else>{{ formatCellValue(stripSearchMarks(value)) }}</template>
</dd>
</div>
</dl>
</div>
</div>
</div>
<!-- Pagination -->
<div class="h-9 flex items-center justify-end gap-1 px-3 border-t shrink-0 text-xs text-muted-foreground">
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="offset <= 0" @click="prevPage">
<ChevronLeft class="h-3 w-3" />
</Button>
<span class="tabular-nums">{{ page + 1 }} / {{ totalPages }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="!canGoNext" @click="nextPage">
<ChevronRight class="h-3 w-3" />
</Button>
</div>
<!-- Edit dialog -->
<Dialog v-model:open="editOpen">
<DialogContent class="sm:max-w-2xl h-[70vh] max-h-[70vh] flex flex-col">
<DialogHeader>
<DialogTitle>{{ t("meilisearch.editDocument") }}</DialogTitle>
</DialogHeader>
<div class="flex-1 min-h-0 overflow-hidden rounded-md border bg-muted/20">
<QueryLoadingState v-if="editLoading" class="h-full" />
<RedisJsonEditor v-else v-model="editJson" class="h-full" :read-only="isSaving" />
</div>
<div v-if="editError" class="shrink-0 text-xs text-destructive">{{ editError }}</div>
<DialogFooter class="shrink-0">
<Button variant="outline" :disabled="isSaving" @click="editOpen = false">{{ t("common.cancel") }}</Button>
<Button class="gap-1" :disabled="isSaving || editLoading || !editJson" @click="saveEdit">
<Save class="h-3.5 w-3.5" />
{{ t("common.save") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Cell detail dialog -->
<Dialog v-model:open="cellDetailOpen">
<DialogContent class="sm:max-w-2xl max-h-[70vh] flex flex-col">
<DialogHeader>
<DialogTitle class="flex items-center gap-2 font-mono text-sm">
<span class="min-w-0 truncate">{{ cellDetailColumn }}</span>
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :title="t('common.copy')" @click="copyCellDetail">
<Copy class="h-3.5 w-3.5" />
</Button>
</DialogTitle>
</DialogHeader>
<div class="min-h-0 flex-1 overflow-auto rounded-md border bg-muted/20">
<JsonTree v-if="cellDetailJson !== undefined" :value="cellDetailJson" :highlight-json="renderMarkHtml" class="p-3 font-mono text-xs" />
<div v-else class="whitespace-pre-wrap break-words p-3 font-mono text-xs">{{ cellDetailText }}</div>
</div>
</DialogContent>
</Dialog>
<!-- Delete confirm -->
<DangerConfirmDialog v-model:open="deleteOpen" :message="t('meilisearch.deleteDocumentMessage')" :confirm-label="t('meilisearch.deleteDocument')" :loading="isDeleting" @confirm="confirmDelete" />
</div>
</template>
<style scoped>
.meilisearch-results :deep(mark) {
background: color-mix(in srgb, var(--primary) 20%, transparent);
color: var(--foreground);
border-radius: var(--dbx-radius-sm);
padding-inline: 0.125rem;
}
</style>
@@ -0,0 +1,145 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import "dayjs/locale/zh-cn";
import "dayjs/locale/zh-tw";
import "dayjs/locale/es";
import "dayjs/locale/it";
import "dayjs/locale/ja";
import "dayjs/locale/ko";
import "dayjs/locale/pt-br";
import { Copy, FileText, Settings } from "@lucide/vue";
import * as api from "@/lib/backend/api";
import type { MeilisearchIndexOverview } from "@/lib/backend/tauri";
import { formatBytes } from "@/lib/database/serverMetrics";
import { useToast } from "@/composables/useToast";
import MeilisearchDocumentsPage from "./MeilisearchDocumentsPage.vue";
import MeilisearchSettingsPage from "./MeilisearchSettingsPage.vue";
dayjs.extend(relativeTime);
const DAYJS_LOCALES: Record<string, string> = {
"zh-CN": "zh-cn",
"zh-TW": "zh-tw",
"pt-BR": "pt-br",
es: "es",
it: "it",
ja: "ja",
ko: "ko",
};
const props = defineProps<{
connectionId: string;
index: string;
}>();
type ActiveSection = "documents" | "settings";
const { t, locale } = useI18n();
const { toast } = useToast();
const activeSection = ref<ActiveSection>("documents");
const overview = ref<MeilisearchIndexOverview | null>(null);
const navSections = computed<Array<{ value: ActiveSection; label: string; icon: typeof FileText }>>(() => [
{ value: "documents", label: t("meilisearch.documents"), icon: FileText },
{ value: "settings", label: t("meilisearch.settings"), icon: Settings },
]);
const updatedAtLabel = computed(() => {
const value = overview.value?.updatedAt;
if (!value) return "-";
return dayjs(value)
.locale(DAYJS_LOCALES[locale.value] ?? "en")
.fromNow();
});
async function refreshStats() {
try {
overview.value = await api.meilisearchGetIndexOverview(props.connectionId, props.index);
} catch {
// Overview is best-effort; the tab still works without it.
}
}
async function copyText(value: string | null | undefined) {
if (!value) return;
try {
await navigator.clipboard.writeText(value);
toast(t("meilisearch.copied"));
} catch {
// Clipboard may be unavailable (e.g. permission denied); stay silent.
}
}
onMounted(() => {
void refreshStats();
});
</script>
<template>
<div class="h-full flex overflow-hidden">
<!-- Left column: index meta + navigation -->
<nav class="w-44 shrink-0 border-r flex flex-col gap-1 overflow-y-auto p-2">
<div class="px-1 pb-2">
<div class="truncate text-sm font-semibold text-foreground" :title="index">{{ index }}</div>
<div v-if="overview?.isIndexing" class="mt-0.5 text-xs text-primary">{{ t("meilisearch.isIndexing") }}</div>
</div>
<div class="space-y-2.5 px-1 pb-2 text-xs">
<div>
<div class="text-muted-foreground">{{ t("meilisearch.uid") }}</div>
<div class="mt-0.5 flex items-center gap-1">
<span class="min-w-0 truncate font-mono text-foreground/80" :title="overview?.uid ?? index">{{ overview?.uid ?? index }}</span>
<button type="button" class="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground" :title="t('common.copy')" @click="copyText(overview?.uid ?? index)">
<Copy class="h-3 w-3" />
</button>
</div>
</div>
<div>
<div class="text-muted-foreground">{{ t("meilisearch.updatedAt") }}</div>
<div class="mt-0.5 text-foreground/80" :title="overview?.updatedAt ?? undefined">{{ updatedAtLabel }}</div>
</div>
<div>
<div class="text-muted-foreground">{{ t("meilisearch.primaryKey") }}</div>
<div class="mt-0.5 flex items-center gap-1">
<span class="min-w-0 truncate font-mono text-foreground/80" :title="overview?.primaryKey ?? undefined">{{ overview?.primaryKey ?? "-" }}</span>
<button v-if="overview?.primaryKey" type="button" class="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground" :title="t('common.copy')" @click="copyText(overview.primaryKey)">
<Copy class="h-3 w-3" />
</button>
</div>
</div>
<div>
<div class="text-muted-foreground">{{ t("meilisearch.documentCountLabel") }}</div>
<div class="mt-0.5 tabular-nums text-foreground/80">{{ overview ? overview.numberOfDocuments : "-" }}</div>
</div>
<div v-if="overview?.databaseSize != null">
<div class="text-muted-foreground">{{ t("meilisearch.databaseSize") }}</div>
<div class="mt-0.5 tabular-nums text-foreground/80">{{ formatBytes(overview.databaseSize) }}</div>
</div>
</div>
<div class="mx-1 border-t border-border/60" />
<button
v-for="section in navSections"
:key="section.value"
type="button"
class="mt-1 flex items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-xs transition-colors"
:class="activeSection === section.value ? 'bg-accent text-foreground font-medium' : 'text-muted-foreground hover:bg-muted/70 hover:text-foreground'"
@click="activeSection = section.value"
>
<component :is="section.icon" class="h-3.5 w-3.5 shrink-0" />
<span>{{ section.label }}</span>
</button>
</nav>
<!-- Content -->
<div class="flex-1 min-h-0 min-w-0 overflow-hidden">
<MeilisearchDocumentsPage v-if="activeSection === 'documents'" :connection-id="connectionId" :index="index" @refresh-stats="refreshStats" />
<MeilisearchSettingsPage v-else :connection-id="connectionId" :index="index" @refresh-stats="refreshStats" />
</div>
</div>
</template>
@@ -0,0 +1,184 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Save } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import ErrorBanner from "@/components/ui/ErrorBanner.vue";
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import JsonTree from "@/components/common/JsonTree.vue";
import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue";
import * as api from "@/lib/backend/api";
import { useToast } from "@/composables/useToast";
import { useQueryStore } from "@/stores/queryStore";
const props = defineProps<{
connectionId: string;
index: string;
}>();
const emit = defineEmits<{
"refresh-stats": [];
}>();
const { t } = useI18n();
const { toast } = useToast();
const queryStore = useQueryStore();
const settingsText = ref("");
const settingsValue = ref<Record<string, any>>({});
const loading = ref(false);
const error = ref("");
const editMode = ref(false);
const isSaving = ref(false);
const saveError = ref("");
const clearConfirmOpen = ref(false);
const isClearing = ref(false);
const deleteIndexConfirmOpen = ref(false);
const isDeletingIndex = ref(false);
async function loadSettings() {
loading.value = true;
error.value = "";
try {
const settings = await api.meilisearchGetIndexSettings(props.connectionId, props.index);
settingsValue.value = settings ?? {};
settingsText.value = JSON.stringify(settingsValue.value, null, 2);
} catch (e: any) {
settingsValue.value = {};
settingsText.value = "{}";
error.value = e?.message || String(e);
} finally {
loading.value = false;
}
}
function startEdit() {
saveError.value = "";
editMode.value = true;
}
function cancelEdit() {
saveError.value = "";
editMode.value = false;
}
async function saveSettings() {
let parsed: unknown;
try {
parsed = JSON.parse(settingsText.value);
} catch {
saveError.value = t("meilisearch.invalidJson");
return;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
saveError.value = t("meilisearch.invalidJson");
return;
}
isSaving.value = true;
saveError.value = "";
try {
await api.meilisearchUpdateIndexSettings(props.connectionId, props.index, parsed as Record<string, any>);
toast(t("meilisearch.settingsSaved"));
settingsValue.value = parsed as Record<string, any>;
settingsText.value = JSON.stringify(parsed, null, 2);
editMode.value = false;
} catch (e: any) {
saveError.value = e?.message || String(e);
} finally {
isSaving.value = false;
}
}
async function confirmClearDocuments() {
isClearing.value = true;
try {
await api.meilisearchDeleteAllDocuments(props.connectionId, props.index);
toast(t("meilisearch.documentsCleared"));
clearConfirmOpen.value = false;
emit("refresh-stats");
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
isClearing.value = false;
}
}
async function confirmDeleteIndex() {
// Capture the owning tab id up front: the delete is awaited, and the user
// may switch tabs meanwhile, so reading the active tab afterwards could
// close an unrelated tab.
const ownerTabId = queryStore.activeTabId;
isDeletingIndex.value = true;
try {
await api.meilisearchDeleteIndex(props.connectionId, props.index);
toast(t("meilisearch.indexDeleted"));
deleteIndexConfirmOpen.value = false;
// The tab may have been closed while the delete was in flight.
if (ownerTabId && queryStore.tabs.some((tab) => tab.id === ownerTabId)) queryStore.closeTab(ownerTabId);
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
isDeletingIndex.value = false;
}
}
onMounted(() => {
void loadSettings();
});
</script>
<template>
<div class="h-full flex flex-col overflow-hidden">
<div class="flex-1 min-h-0 overflow-y-auto p-3 space-y-3">
<QueryLoadingState v-if="loading" class="py-8" />
<ErrorBanner v-else-if="error" :message="error" />
<template v-else>
<!-- Settings JSON -->
<div class="rounded-lg border bg-card p-3 space-y-2">
<div class="flex items-center justify-between gap-2">
<div class="text-sm font-medium">{{ t("meilisearch.settingsJson") }}</div>
<div class="flex items-center gap-1">
<template v-if="!editMode">
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="startEdit">{{ t("common.edit") }}</Button>
</template>
<template v-else>
<Button variant="ghost" size="sm" class="h-6 text-xs" :disabled="isSaving" @click="cancelEdit">{{ t("common.cancel") }}</Button>
<Button size="sm" class="h-6 gap-1 text-xs" :disabled="isSaving" @click="saveSettings">
<Save class="h-3 w-3" />
{{ t("common.save") }}
</Button>
</template>
</div>
</div>
<div v-if="!editMode" class="max-h-[60vh] overflow-auto rounded-md border bg-muted/20 p-3 font-mono text-xs">
<JsonTree :value="settingsValue" />
</div>
<div v-else class="h-[60vh] min-h-0 overflow-hidden rounded-md border bg-muted/20">
<RedisJsonEditor v-model="settingsText" class="h-full" :read-only="isSaving" />
</div>
<div v-if="saveError" class="text-xs text-destructive">{{ saveError }}</div>
</div>
<!-- Danger zone -->
<div class="rounded-lg border border-destructive/40 bg-destructive/5 p-3 space-y-2">
<div class="text-sm font-medium text-destructive">{{ t("meilisearch.dangerZone") }}</div>
<div class="flex flex-wrap items-center gap-2">
<Button variant="destructive" size="sm" class="h-7 text-xs" @click="clearConfirmOpen = true">
{{ t("meilisearch.clearDocuments") }}
</Button>
<Button variant="destructive" size="sm" class="h-7 text-xs" @click="deleteIndexConfirmOpen = true">
{{ t("meilisearch.deleteIndex") }}
</Button>
</div>
</div>
</template>
</div>
<DangerConfirmDialog v-model:open="clearConfirmOpen" :message="t('meilisearch.clearDocumentsConfirmMessage')" :confirm-label="t('meilisearch.clearDocuments')" :loading="isClearing" @confirm="confirmClearDocuments" />
<DangerConfirmDialog v-model:open="deleteIndexConfirmOpen" :title="t('meilisearch.deleteIndex')" :message="t('meilisearch.deleteIndexConfirmMessage')" :confirm-label="t('meilisearch.deleteIndex')" :loading="isDeletingIndex" @confirm="confirmDeleteIndex" />
</div>
</template>
@@ -0,0 +1,135 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
searchDocuments: vi.fn(),
fetchDocuments: vi.fn(),
getIndexSettings: vi.fn(),
saveTextFile: vi.fn(),
toast: vi.fn(),
}));
function passthrough(tag: string): Component {
return defineComponent({
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => h(tag, attrs, slots.default?.());
},
});
}
vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string) => key }),
}));
vi.mock("@lucide/vue", () => ({
ArrowDown: passthrough("span"),
ArrowUp: passthrough("span"),
Braces: passthrough("span"),
ChevronLeft: passthrough("span"),
ChevronRight: passthrough("span"),
Copy: passthrough("span"),
Download: passthrough("span"),
LayoutGrid: passthrough("span"),
LoaderCircle: passthrough("span"),
Pencil: passthrough("span"),
Save: passthrough("span"),
Search: passthrough("span"),
Table2: passthrough("span"),
Trash2: passthrough("span"),
}));
vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
vi.mock("@/components/ui/badge", () => ({ Badge: passthrough("span") }));
vi.mock("@/components/ui/input", () => ({ Input: passthrough("input") }));
vi.mock("@/components/ui/label", () => ({ Label: passthrough("label") }));
vi.mock("@/components/ui/switch", () => ({ Switch: passthrough("div") }));
vi.mock("@/components/ui/popover", () => ({
Popover: passthrough("div"),
PopoverAnchor: passthrough("div"),
PopoverContent: passthrough("div"),
PopoverTrigger: passthrough("div"),
}));
vi.mock("@/components/ui/dialog", () => ({
Dialog: passthrough("div"),
DialogContent: passthrough("div"),
DialogFooter: passthrough("div"),
DialogHeader: passthrough("div"),
DialogTitle: passthrough("div"),
}));
vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({ default: passthrough("div") }));
vi.mock("@/components/ui/ErrorBanner.vue", () => ({ default: passthrough("div") }));
vi.mock("@/components/common/QueryLoadingState.vue", () => ({ default: passthrough("div") }));
vi.mock("@/components/common/JsonTree.vue", () => ({ default: passthrough("div") }));
vi.mock("@/components/redis/RedisJsonEditor.vue", () => ({ default: passthrough("div") }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("@/lib/export/saveTextFile", () => ({
saveTextFile: mocks.saveTextFile,
compactLocalTimestamp: () => "20260101-000000",
sanitizeExportBaseName: (value: string) => value,
}));
vi.mock("@/lib/backend/api", () => ({
meilisearchSearchDocuments: mocks.searchDocuments,
meilisearchFetchDocuments: mocks.fetchDocuments,
meilisearchGetIndexSettings: mocks.getIndexSettings,
}));
import MeilisearchDocumentsPage from "@/components/meilisearch/MeilisearchDocumentsPage.vue";
let app: ReturnType<typeof createApp> | undefined;
let root: HTMLDivElement | undefined;
beforeEach(() => {
mocks.getIndexSettings.mockResolvedValue({});
mocks.saveTextFile.mockResolvedValue(undefined);
});
afterEach(() => {
app?.unmount();
root?.remove();
app = undefined;
root = undefined;
vi.clearAllMocks();
});
describe("MeilisearchDocumentsPage export", () => {
it("uses full-document pagination instead of capped displayed search hits", async () => {
const batch = (size: number, from: number) => Array.from({ length: size }, (_, index) => ({ movie_id: from + index, title: `Movie ${from + index}`, internal_notes: `note ${from + index}` }));
mocks.searchDocuments.mockResolvedValue({
hits: [{ id: 0, document: { movie_id: 0, title: "Movie 0" } }],
totalHits: 1000,
processingTimeMs: 1,
});
mocks.fetchDocuments.mockImplementation((_connectionId: string, _index: string, params: { limit: number; offset: number }) => {
if (params.offset === 0) return Promise.resolve({ documents: batch(1000, 0), total: 1300 });
if (params.offset === 1000) return Promise.resolve({ documents: batch(300, 1000), total: 1300 });
return Promise.resolve({ documents: [], total: 1300 });
});
root = document.createElement("div");
document.body.append(root);
app = createApp(MeilisearchDocumentsPage, { connectionId: "c1", index: "movies" });
app.mount(root);
await vi.waitFor(() => expect(mocks.searchDocuments).toHaveBeenCalledTimes(1));
await nextTick();
const exportButton = Array.from(root.querySelectorAll("button")).find((button) => button.textContent?.includes("meilisearch.exportResults"));
expect(exportButton?.textContent).toContain("1000");
exportButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await vi.waitFor(() => expect(mocks.saveTextFile).toHaveBeenCalledTimes(1));
expect(mocks.searchDocuments).toHaveBeenCalledTimes(1);
const offsets = mocks.fetchDocuments.mock.calls.map((call) => (call[2] as { offset: number }).offset);
expect(offsets).toEqual([0, 1000]);
const [content, fileName] = mocks.saveTextFile.mock.calls[0] as [string, string];
expect(fileName).toBe("movies-20260101-000000.json");
const exported = JSON.parse(content) as Array<Record<string, unknown>>;
expect(exported).toHaveLength(1300);
// Exported entries are the stored documents, without search metadata.
expect(exported[0]).toEqual({ movie_id: 0, title: "Movie 0", internal_notes: "note 0" });
expect(exported[1299]).toEqual({ movie_id: 1299, title: "Movie 1299", internal_notes: "note 1299" });
});
});
@@ -0,0 +1,172 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
searchDocuments: vi.fn(),
getIndexSettings: vi.fn(),
getDocument: vi.fn(),
deleteDocument: vi.fn(),
toast: vi.fn(),
}));
function passthrough(tag: string): Component {
return defineComponent({
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => h(tag, attrs, slots.default?.());
},
});
}
/** Renders a confirm trigger while open, so tests drive the danger dialog. */
function confirmDialog(): Component {
return defineComponent({
inheritAttrs: false,
emits: ["confirm", "update:open"],
setup(_, { attrs, emit }) {
return () => (attrs.open ? h("button", { "data-confirm-dialog": String(attrs.confirmLabel ?? ""), onClick: () => emit("confirm") }, "confirm") : null);
},
});
}
/** Editor stub that exposes the current model value as text for assertions. */
function jsonEditor(): Component {
return defineComponent({
inheritAttrs: false,
setup(_, { attrs }) {
return () => h("pre", { "data-json-editor": "true" }, String(attrs.modelValue ?? ""));
},
});
}
vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string) => key }),
}));
vi.mock("@lucide/vue", () => ({
ArrowDown: passthrough("span"),
ArrowUp: passthrough("span"),
Braces: passthrough("span"),
ChevronLeft: passthrough("span"),
ChevronRight: passthrough("span"),
Copy: passthrough("span"),
Download: passthrough("span"),
LayoutGrid: passthrough("span"),
LoaderCircle: passthrough("span"),
Pencil: passthrough("span"),
Save: passthrough("span"),
Search: passthrough("span"),
Table2: passthrough("span"),
Trash2: passthrough("span"),
}));
vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
vi.mock("@/components/ui/badge", () => ({ Badge: passthrough("span") }));
vi.mock("@/components/ui/input", () => ({ Input: passthrough("input") }));
vi.mock("@/components/ui/label", () => ({ Label: passthrough("label") }));
vi.mock("@/components/ui/switch", () => ({ Switch: passthrough("div") }));
vi.mock("@/components/ui/popover", () => ({
Popover: passthrough("div"),
PopoverAnchor: passthrough("div"),
PopoverContent: passthrough("div"),
PopoverTrigger: passthrough("div"),
}));
vi.mock("@/components/ui/dialog", () => ({
Dialog: passthrough("div"),
DialogContent: passthrough("div"),
DialogFooter: passthrough("div"),
DialogHeader: passthrough("div"),
DialogTitle: passthrough("div"),
}));
vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({ default: confirmDialog() }));
vi.mock("@/components/ui/ErrorBanner.vue", () => ({ default: passthrough("div") }));
vi.mock("@/components/common/QueryLoadingState.vue", () => ({ default: passthrough("div") }));
vi.mock("@/components/common/JsonTree.vue", () => ({ default: passthrough("div") }));
vi.mock("@/components/redis/RedisJsonEditor.vue", () => ({ default: jsonEditor() }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("@/lib/backend/api", () => ({
meilisearchSearchDocuments: mocks.searchDocuments,
meilisearchGetIndexSettings: mocks.getIndexSettings,
meilisearchGetDocument: mocks.getDocument,
documentDeleteDocument: mocks.deleteDocument,
}));
import MeilisearchDocumentsPage from "@/components/meilisearch/MeilisearchDocumentsPage.vue";
let app: ReturnType<typeof createApp> | undefined;
let root: HTMLDivElement | undefined;
beforeEach(() => {
mocks.getIndexSettings.mockResolvedValue({});
mocks.getDocument.mockResolvedValue('{"movie_id":"123","title":"Alien","internal_notes":"canonical only"}');
mocks.deleteDocument.mockResolvedValue(1);
// The search hit is partial: it lacks the `internal_notes` field.
mocks.searchDocuments.mockResolvedValue({
hits: [{ id: "123", document: { movie_id: "123", title: "Alien" } }],
totalHits: 1,
processingTimeMs: 1,
});
});
afterEach(() => {
app?.unmount();
root?.remove();
app = undefined;
root = undefined;
vi.clearAllMocks();
});
async function mountPage(): Promise<HTMLDivElement> {
root = document.createElement("div");
document.body.append(root);
app = createApp(MeilisearchDocumentsPage, { connectionId: "c1", index: "movies" });
app.mount(root);
await vi.waitFor(() => expect(mocks.searchDocuments).toHaveBeenCalledTimes(1));
await nextTick();
return root;
}
function findButton(container: HTMLElement, title: string): HTMLButtonElement {
const button = Array.from(container.querySelectorAll("button")).find((candidate) => candidate.title === title);
if (!button) throw new Error(`button not found: ${title}`);
return button;
}
describe("MeilisearchDocumentsPage identity and canonical documents", () => {
it("serializes numeric-looking string ids with the store-id serializer on delete", async () => {
const container = await mountPage();
findButton(container, "meilisearch.deleteDocument").dispatchEvent(new MouseEvent("click", { bubbles: true }));
await nextTick();
const confirm = container.querySelector("[data-confirm-dialog]");
confirm?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await vi.waitFor(() => expect(mocks.deleteDocument).toHaveBeenCalledTimes(1));
// The string id "123" must not round-trip as numeric 123.
expect(mocks.deleteDocument).toHaveBeenCalledWith("c1", "default", "movies", '__dbx_meilisearch_string_id__"123"');
});
it("edits the canonical document instead of the partial search hit", async () => {
const container = await mountPage();
findButton(container, "meilisearch.editDocument").dispatchEvent(new MouseEvent("click", { bubbles: true }));
await vi.waitFor(() => expect(mocks.getDocument).toHaveBeenCalledTimes(1));
expect(mocks.getDocument).toHaveBeenCalledWith("c1", "movies", '__dbx_meilisearch_string_id__"123"');
await vi.waitFor(() => {
const editor = container.querySelector("[data-json-editor]");
expect(editor?.textContent).toContain("internal_notes");
});
// The editor content comes from the canonical fetch, not the partial hit.
const editor = container.querySelector("[data-json-editor]");
expect(editor?.textContent).toContain('"title": "Alien"');
});
it("disables edit and delete when the hit has no identity", async () => {
mocks.searchDocuments.mockResolvedValue({ hits: [{ document: { title: "No PK" } }], totalHits: 1, processingTimeMs: 1 });
const container = await mountPage();
expect(findButton(container, "meilisearch.editDocument").disabled).toBe(true);
expect(findButton(container, "meilisearch.deleteDocument").disabled).toBe(true);
});
});
@@ -0,0 +1,142 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, reactive, type Component } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
deleteIndex: vi.fn(),
getIndexSettings: vi.fn(),
closeTab: vi.fn(),
toast: vi.fn(),
}));
function passthrough(tag: string): Component {
return defineComponent({
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => h(tag, attrs, slots.default?.());
},
});
}
/** Renders a confirm trigger while open, so tests drive the danger dialogs. */
function confirmDialog(): Component {
return defineComponent({
inheritAttrs: false,
emits: ["confirm", "update:open"],
setup(_, { attrs, emit }) {
return () =>
attrs.open
? h(
"button",
{
"data-confirm-dialog": String(attrs.confirmLabel ?? ""),
onClick: () => emit("confirm"),
},
"confirm",
)
: null;
},
});
}
vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string) => key }),
}));
vi.mock("@lucide/vue", () => ({ Save: passthrough("span") }));
vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({ default: confirmDialog() }));
vi.mock("@/components/ui/ErrorBanner.vue", () => ({ default: passthrough("div") }));
vi.mock("@/components/common/QueryLoadingState.vue", () => ({ default: passthrough("div") }));
vi.mock("@/components/common/JsonTree.vue", () => ({ default: passthrough("div") }));
vi.mock("@/components/redis/RedisJsonEditor.vue", () => ({ default: passthrough("div") }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("@/lib/backend/api", () => ({
meilisearchGetIndexSettings: mocks.getIndexSettings,
meilisearchDeleteIndex: mocks.deleteIndex,
}));
const store = reactive({
activeTabId: "tab-a" as string | null,
tabs: [{ id: "tab-a" }] as Array<{ id: string }>,
closeTab: mocks.closeTab,
});
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => store }));
import MeilisearchSettingsPage from "@/components/meilisearch/MeilisearchSettingsPage.vue";
let app: ReturnType<typeof createApp> | undefined;
let root: HTMLDivElement | undefined;
beforeEach(() => {
store.activeTabId = "tab-a";
store.tabs = [{ id: "tab-a" }];
mocks.getIndexSettings.mockResolvedValue({});
});
afterEach(() => {
app?.unmount();
root?.remove();
app = undefined;
root = undefined;
vi.clearAllMocks();
});
async function mountPage(): Promise<HTMLDivElement> {
root = document.createElement("div");
document.body.append(root);
app = createApp(MeilisearchSettingsPage, { connectionId: "c1", index: "movies" });
app.mount(root);
await vi.waitFor(() => expect(mocks.getIndexSettings).toHaveBeenCalledTimes(1));
await nextTick();
return root;
}
async function confirmDeleteIndex(container: HTMLDivElement) {
const deleteButton = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.trim() === "meilisearch.deleteIndex");
deleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await nextTick();
const confirmButton = container.querySelector("[data-confirm-dialog]");
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
}
describe("MeilisearchSettingsPage index deletion", () => {
it("closes the tab that started the delete even after switching tabs mid-flight", async () => {
let resolveDelete!: () => void;
mocks.deleteIndex.mockImplementation(() => new Promise<void>((resolve) => (resolveDelete = resolve)));
const container = await mountPage();
await confirmDeleteIndex(container);
await vi.waitFor(() => expect(mocks.deleteIndex).toHaveBeenCalledTimes(1));
// The user switches to another tab while the delete is in flight.
store.tabs.push({ id: "tab-b" });
store.activeTabId = "tab-b";
resolveDelete();
await vi.waitFor(() => expect(mocks.closeTab).toHaveBeenCalledTimes(1));
expect(mocks.closeTab).toHaveBeenCalledWith("tab-a");
expect(mocks.closeTab).not.toHaveBeenCalledWith("tab-b");
});
it("does not close anything when the owning tab is already gone", async () => {
let resolveDelete!: () => void;
mocks.deleteIndex.mockImplementation(() => new Promise<void>((resolve) => (resolveDelete = resolve)));
const container = await mountPage();
await confirmDeleteIndex(container);
await vi.waitFor(() => expect(mocks.deleteIndex).toHaveBeenCalledTimes(1));
// The user closed the tab themselves while the delete was in flight.
store.tabs = [];
store.activeTabId = null;
resolveDelete();
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("meilisearch.indexDeleted"));
await nextTick();
expect(mocks.closeTab).not.toHaveBeenCalled();
});
});
@@ -826,8 +826,13 @@ async function toggle(requestId = beginNavigationRequest()) {
await connectionStore.loadTableGroups(node.connectionId, node.database, node.label, node.schema, node.id);
} else if (node.type === "elasticsearch-index" && node.connectionId) {
await connectionStore.ensureConnected(node.connectionId);
const tab = queryStore.createTab(node.connectionId, node.database || "default", node.label, "mongo");
queryStore.updateSql(tab, node.label);
if (connectionStore.getConfig(node.connectionId)?.db_type === "meilisearch") {
const tab = queryStore.createTab(node.connectionId, node.database || "default", node.label, "meilisearch");
queryStore.updateSql(tab, node.label);
} else {
const tab = queryStore.createTab(node.connectionId, node.database || "default", node.label, "mongo");
queryStore.updateSql(tab, node.label);
}
} else if (node.type === "vector-collection" && node.connectionId) {
await connectionStore.ensureConnected(node.connectionId);
const collectionRef = (node.meta as { collectionId?: string } | undefined)?.collectionId ?? node.label;