feat(elasticsearch): add response content search

This commit is contained in:
onenewcode 2026-08-06 17:09:15 +08:00 committed by GitHub
parent b7606ceee3
commit 939c8d493d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 508 additions and 113 deletions

View File

@ -1,14 +1,14 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { computed, nextTick, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Code2, Copy } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import TextContentSearchBar from "@/components/common/TextContentSearchBar.vue";
import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue";
import { useToast } from "@/composables/useToast";
import { useTheme } from "@/composables/useTheme";
import { copyToClipboard } from "@/lib/common/clipboard";
import { parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
import { createShikiJsonHighlighter, type JsonHighlighter } from "@/lib/common/shikiJsonHighlighter";
import JsonTree from "./JsonTree.vue";
import { formatJsonSource } from "@/lib/common/safeJsonFormat";
import { TEXT_CONTENT_SEARCH_MATCH_LIMIT, canFullHighlightTextContent, findTextContentMatches, nextTextContentSearchMatchIndex, renderTextContentMatchesHtml, textContentSearchStatus, type TextContentMatch } from "@/lib/common/textContentSearch";
const props = defineProps<{
status: number;
@ -23,16 +23,22 @@ const emit = defineEmits<{
const { t } = useI18n();
const { toast } = useToast();
const { isDark } = useTheme();
const responseView = ref<"raw" | "json">("json");
const jsonTreeRef = ref<{ refresh: () => void }>();
const jsonHighlighter = ref<JsonHighlighter>();
const responsePanelRef = ref<HTMLElement>();
const rawPreRef = ref<HTMLPreElement>();
const jsonEditorRef = ref<{ openSearch: () => boolean }>();
const responseSearchBarRef = ref<{ focusInput: (select?: boolean) => void }>();
const responseSearchOpen = ref(false);
const responseSearchQuery = ref("");
const responseSearchMatchIndex = ref(0);
const responseSearchHasNavigated = ref(false);
const parsedBody = computed(() => {
const formattedBody = computed(() => {
try {
return { valid: true, value: parseJsonPreservingLargeNumbers(props.body) };
// Match Redis JSON rendering: a lossless, read-only CodeMirror JSON view.
return { valid: true, text: formatJsonSource(props.body, 2) };
} catch {
return { valid: false, value: null };
return { valid: false, text: "" };
}
});
@ -44,18 +50,30 @@ const statusClass = computed(() => {
});
const statusLabel = computed(() => `HTTP ${props.status}`);
const jsonAppearance = computed(() => (isDark.value ? "dark" : "light"));
const rawSearchMatches = computed(() => findTextContentMatches(props.body, responseSearchQuery.value));
const responseSearchActiveIndex = computed(() => {
const count = rawSearchMatches.value.length;
if (count === 0) return 0;
return Math.min(responseSearchMatchIndex.value, count - 1);
});
const responseSearchLimited = computed(() => rawSearchMatches.value.length >= TEXT_CONTENT_SEARCH_MATCH_LIMIT);
const responseSearchStatus = computed(() => textContentSearchStatus(responseSearchActiveIndex.value, rawSearchMatches.value.length, responseSearchLimited.value));
const canHighlightRawSearch = computed(() => responseSearchOpen.value && Boolean(responseSearchQuery.value) && canFullHighlightTextContent(props.body.length));
const highlightedRawResponse = computed(() => renderTextContentMatchesHtml(props.body, rawSearchMatches.value, { activeMatchIndex: responseSearchActiveIndex.value }));
watch(
() => props.body,
() => {
responseView.value = parsedBody.value.valid ? "json" : "raw";
resetResponseSearch();
responseView.value = formattedBody.value.valid ? "json" : "raw";
},
{ immediate: true },
);
watch(responseView, (view) => {
if (view === "json") void nextTick(() => jsonTreeRef.value?.refresh());
watch(responseSearchQuery, () => {
responseSearchMatchIndex.value = 0;
responseSearchHasNavigated.value = false;
if (responseSearchOpen.value) void scrollResponseSearchMatchIntoView();
});
async function copyResponse() {
@ -67,34 +85,120 @@ async function copyResponse() {
}
}
function highlightJson(value: string): string {
return jsonHighlighter.value?.(value, jsonAppearance.value) ?? escapeHtml(value);
function handleResponsePanelPointerDown() {
// The read-only editor manages its own focus. The plain raw response needs a
// focus target so the global Cmd/Ctrl+F shortcut can be routed here.
if (responseView.value === "raw") responsePanelRef.value?.focus({ preventScroll: true });
}
function escapeHtml(value: string): string {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
function focusSearch(): boolean {
if (!responsePanelRef.value?.contains(document.activeElement)) return false;
if (responseView.value === "json") {
void nextTick(() => jsonEditorRef.value?.openSearch());
return true;
}
responseSearchOpen.value = true;
responseSearchHasNavigated.value = false;
void nextTick(() => responseSearchBarRef.value?.focusInput(true));
return true;
}
onMounted(() => {
void createShikiJsonHighlighter({ appearance: () => jsonAppearance.value })
.then((highlight) => {
jsonHighlighter.value = highlight;
})
.catch(() => {
jsonHighlighter.value = undefined;
});
});
function resetResponseSearch(restoreFocus = false) {
const wasOpen = responseSearchOpen.value;
responseSearchOpen.value = false;
responseSearchQuery.value = "";
responseSearchMatchIndex.value = 0;
responseSearchHasNavigated.value = false;
// Resetting after a new response or a view switch must not steal focus.
if (restoreFocus && wasOpen) void nextTick(() => responsePanelRef.value?.focus({ preventScroll: true }));
}
function closeResponseSearch() {
resetResponseSearch(true);
}
function switchResponseView(view: "raw" | "json") {
if (responseView.value === view) return;
responseView.value = view;
if (view !== "raw") resetResponseSearch();
}
function moveResponseSearchMatch(delta: -1 | 1) {
const count = rawSearchMatches.value.length;
if (count === 0) return;
responseSearchMatchIndex.value = nextTextContentSearchMatchIndex(responseSearchActiveIndex.value, delta, count);
responseSearchHasNavigated.value = true;
void scrollResponseSearchMatchIntoView();
}
function activateResponseSearchMatch(delta: -1 | 1) {
if (rawSearchMatches.value.length === 0) return;
if (!responseSearchHasNavigated.value) {
responseSearchHasNavigated.value = true;
void scrollResponseSearchMatchIntoView();
return;
}
moveResponseSearchMatch(delta);
}
async function scrollResponseSearchMatchIntoView() {
await nextTick();
const activeMark = rawPreRef.value?.querySelector<HTMLElement>('[data-document-search-active="true"]');
if (activeMark) {
activeMark.scrollIntoView({ block: "center", inline: "nearest" });
return;
}
const match = rawSearchMatches.value[responseSearchActiveIndex.value];
if (match) scrollRawTextRangeIntoView(match);
}
function scrollRawTextRangeIntoView(match: TextContentMatch) {
const pre = rawPreRef.value;
const textNode = [...(pre?.childNodes ?? [])].find((node) => node.nodeType === Node.TEXT_NODE);
if (!pre || !textNode || typeof document.createRange !== "function") return;
const start = Math.min(match.start, textNode.textContent?.length ?? 0);
const end = Math.min(match.end, textNode.textContent?.length ?? 0);
if (end <= start) return;
const range = document.createRange();
range.setStart(textNode, start);
range.setEnd(textNode, end);
const rangeRect = range.getBoundingClientRect();
const preRect = pre.getBoundingClientRect();
pre.scrollTop = Math.max(0, pre.scrollTop + rangeRect.top - preRect.top - pre.clientHeight / 2);
pre.scrollLeft = Math.max(0, pre.scrollLeft + rangeRect.left - preRect.left - pre.clientWidth / 2);
}
defineExpose({ focusSearch });
</script>
<template>
<section data-elasticsearch-json-response-root class="flex h-full min-h-0 flex-col bg-background" :aria-label="t('redis.jsonView')">
<section ref="responsePanelRef" data-elasticsearch-json-response-root tabindex="-1" class="relative flex h-full min-h-0 flex-col bg-background" :aria-label="t('redis.jsonView')" @pointerdown="handleResponsePanelPointerDown">
<TextContentSearchBar
v-if="responseSearchOpen"
ref="responseSearchBarRef"
v-model="responseSearchQuery"
:status="responseSearchStatus"
:match-count="rawSearchMatches.length"
:show-navigation="true"
:placeholder="t('editor.search.find')"
@activate="activateResponseSearchMatch"
@prev="moveResponseSearchMatch(-1)"
@next="moveResponseSearchMatch(1)"
@close="closeResponseSearch"
/>
<header class="flex min-h-11 shrink-0 items-center gap-2 border-b bg-muted/25 px-3 py-1.5 text-xs">
<div class="flex min-w-0 flex-1 items-center gap-2">
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-sm" aria-hidden="true">
<Code2 class="h-3.5 w-3.5" />
</span>
<div class="inline-flex h-7 items-center rounded-md border bg-muted/45 p-0.5">
<button type="button" class="h-6 rounded-[4px] px-2 text-xs transition-colors" :class="responseView === 'raw' ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" :aria-pressed="responseView === 'raw'" @click="responseView = 'raw'">
<button type="button" class="h-6 rounded-[4px] px-2 text-xs transition-colors" :class="responseView === 'raw' ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" :aria-pressed="responseView === 'raw'" @click="switchResponseView('raw')">
{{ t("redis.rawContent") }}
</button>
<button
@ -102,8 +206,8 @@ onMounted(() => {
class="h-6 rounded-[4px] px-2 text-xs transition-colors"
:class="responseView === 'json' ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="responseView === 'json'"
:disabled="!parsedBody.valid"
@click="responseView = 'json'"
:disabled="!formattedBody.valid"
@click="switchResponseView('json')"
>
{{ t("redis.jsonView") }}
</button>
@ -119,11 +223,34 @@ onMounted(() => {
<Copy class="h-3.5 w-3.5" />
</Button>
</header>
<div class="min-h-0 flex-1 overflow-hidden bg-background p-4">
<pre v-show="responseView === 'raw' || !parsedBody.valid" class="m-0 h-full overflow-auto bg-transparent p-0 font-mono text-sm leading-6 whitespace-pre">{{ body }}</pre>
<div v-if="parsedBody.valid" v-show="responseView === 'json'" class="h-full min-h-0">
<JsonTree ref="jsonTreeRef" :value="parsedBody.value" :highlight-json="highlightJson" :virtualized="true" class="dbx-editor-font-family text-sm leading-6" />
</div>
<div class="min-h-0 flex-1 overflow-hidden bg-background">
<pre v-if="responseView === 'raw' && canHighlightRawSearch" ref="rawPreRef" class="m-0 h-full overflow-auto bg-transparent p-4 font-mono text-sm leading-6 whitespace-pre" v-html="highlightedRawResponse" />
<pre v-else-if="responseView === 'raw'" ref="rawPreRef" class="m-0 h-full overflow-auto bg-transparent p-4 font-mono text-sm leading-6 whitespace-pre">{{ body }}</pre>
<RedisJsonEditor v-else-if="formattedBody.valid" ref="jsonEditorRef" :model-value="formattedBody.text" read-only class="min-h-0 flex-1" />
</div>
</section>
</template>
<style scoped>
:deep(.document-search-match) {
border-radius: 2px;
background: #fde68a;
color: inherit;
padding: 0;
}
:deep(.document-search-match-active) {
background: #f59e0b;
color: #111827;
outline: 1px solid #d97706;
}
:global(.dark) :deep(.document-search-match) {
background: #854d0e;
}
:global(.dark) :deep(.document-search-match-active) {
background: #fbbf24;
color: #111827;
}
</style>

View File

@ -2,7 +2,7 @@
import { nextTick, onBeforeUnmount, onMounted, ref } from "vue";
import { ChevronDown, ChevronUp, GripVertical, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { isTextContentSearchDragSource } from "@/lib/redis/redisValueSearch";
import { isTextContentSearchDragSource } from "@/lib/common/textContentSearch";
/**
* Floating find panel (EditorSearchPanel look).

View File

@ -0,0 +1,224 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, ref, type App, type ComponentPublicInstance } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
openJsonSearch: vi.fn().mockReturnValue(true),
}));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false } }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
vi.mock("@/components/ui/button", async () => {
const { defineComponent, h } = await import("vue");
return {
Button: defineComponent({
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => h("button", attrs, slots.default?.());
},
}),
};
});
vi.mock("@/components/redis/RedisJsonEditor.vue", async () => {
const { defineComponent, h } = await import("vue");
return {
default: defineComponent({
props: {
modelValue: { type: String, required: true },
readOnly: { type: Boolean, default: false },
},
setup(props, { expose }) {
expose({ openSearch: mocks.openJsonSearch });
return () => h("div", { "data-redis-json-editor-stub": "", "data-read-only": String(props.readOnly) }, props.modelValue);
},
}),
};
});
vi.mock("@/components/common/TextContentSearchBar.vue", async () => {
const { defineComponent, h } = await import("vue");
return {
default: defineComponent({
props: {
modelValue: { type: String, required: true },
status: { type: String, required: true },
matchCount: { type: Number, required: true },
},
emits: ["update:modelValue", "activate", "prev", "next", "close"],
setup(props, { emit, expose }) {
const input = ref<HTMLInputElement>();
expose({ focusInput: () => input.value?.focus() });
return () =>
h("div", { "data-elasticsearch-response-search": "" }, [
h("input", {
ref: input,
"data-elasticsearch-response-search-input": "",
value: props.modelValue,
onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value),
}),
h("span", { "data-elasticsearch-response-search-status": "" }, props.status),
h("button", { "data-elasticsearch-response-search-prev": "", onClick: () => emit("prev") }),
h("button", { "data-elasticsearch-response-search-next": "", onClick: () => emit("next") }),
h("button", { "data-elasticsearch-response-search-close": "", onClick: () => emit("close") }),
]);
},
}),
};
});
import ElasticsearchJsonResponsePanel from "@/components/common/ElasticsearchJsonResponsePanel.vue";
type SearchablePanel = ComponentPublicInstance & { focusSearch: () => boolean };
let app: App<Element> | null = null;
let root: HTMLDivElement | null = null;
async function flushUi() {
for (let index = 0; index < 4; index += 1) {
await Promise.resolve();
await nextTick();
}
}
async function mountResponsePanel(body = '{"first":"needle","second":"<needle>"}', raw = true) {
const panelRef = ref<SearchablePanel>();
const bodyRef = ref(body);
root = document.createElement("div");
document.body.appendChild(root);
app = createApp(
defineComponent({
setup() {
return () => h(ElasticsearchJsonResponsePanel, { ref: panelRef, status: 200, body: bodyRef.value });
},
}),
);
app.mount(root);
await flushUi();
const panelElement = root.querySelector<HTMLElement>("[data-elasticsearch-json-response-root]");
const rawButton = [...root.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent === "redis.rawContent");
if (!panelElement || !rawButton || !panelRef.value) throw new Error("Failed to mount Elasticsearch response panel");
if (raw) {
rawButton.click();
await flushUi();
}
return {
panel: panelRef.value,
panelElement,
async setBody(nextBody: string) {
bodyRef.value = nextBody;
await flushUi();
},
};
}
afterEach(() => {
app?.unmount();
app = null;
root?.remove();
root = null;
mocks.openJsonSearch.mockClear();
});
describe("ElasticsearchJsonResponsePanel search", () => {
it("uses the read-only Redis JSON editor for formatted JSON responses", async () => {
const { panel, panelElement } = await mountResponsePanel('{"name":"Ada"}', false);
const editor = root!.querySelector<HTMLElement>("[data-redis-json-editor-stub]");
expect(editor?.getAttribute("data-read-only")).toBe("true");
expect(editor?.textContent).toBe('{\n "name": "Ada"\n}');
panelElement.focus();
expect(panel.focusSearch()).toBe(true);
await flushUi();
expect(mocks.openJsonSearch).toHaveBeenCalledOnce();
});
it("opens a find panel only while the response has focus, highlights literal matches, and navigates them", async () => {
const { panel, panelElement } = await mountResponsePanel();
expect(panel.focusSearch()).toBe(false);
panelElement.focus();
expect(panel.focusSearch()).toBe(true);
await flushUi();
const input = root!.querySelector<HTMLInputElement>("[data-elasticsearch-response-search-input]");
expect(input).not.toBeNull();
expect(document.activeElement).toBe(input);
input!.value = "needle";
input!.dispatchEvent(new Event("input", { bubbles: true }));
await flushUi();
expect(root!.querySelector("[data-elasticsearch-response-search-status]")?.textContent).toBe("1/2");
expect(root!.querySelectorAll(".document-search-match")).toHaveLength(2);
expect(root!.querySelector('[data-document-search-active="true"]')?.getAttribute("data-document-search-match")).toBe("0");
expect(root!.querySelector("needle")).toBeNull();
root!.querySelector<HTMLButtonElement>("[data-elasticsearch-response-search-next]")!.click();
await flushUi();
expect(root!.querySelector('[data-document-search-active="true"]')?.getAttribute("data-document-search-match")).toBe("1");
});
it("clears search state when closed", async () => {
const { panel, panelElement } = await mountResponsePanel();
panelElement.focus();
panel.focusSearch();
await flushUi();
const input = root!.querySelector<HTMLInputElement>("[data-elasticsearch-response-search-input]")!;
input.value = "needle";
input.dispatchEvent(new Event("input", { bubbles: true }));
await flushUi();
root!.querySelector<HTMLButtonElement>("[data-elasticsearch-response-search-close]")!.click();
await flushUi();
expect(root!.querySelector("[data-elasticsearch-response-search]")).toBeNull();
expect(root!.querySelectorAll(".document-search-match")).toHaveLength(0);
expect(document.activeElement).toBe(panelElement);
});
it("resets an open raw search when the response changes", async () => {
const { panel, panelElement, setBody } = await mountResponsePanel();
panelElement.focus();
panel.focusSearch();
await flushUi();
const input = root!.querySelector<HTMLInputElement>("[data-elasticsearch-response-search-input]")!;
input.value = "needle";
input.dispatchEvent(new Event("input", { bubbles: true }));
await flushUi();
expect(root!.querySelectorAll(".document-search-match")).toHaveLength(2);
await setBody('{"fresh":"response"}');
expect(root!.querySelector("[data-elasticsearch-response-search]")).toBeNull();
expect(root!.querySelectorAll(".document-search-match")).toHaveLength(0);
expect(root!.querySelector("[data-redis-json-editor-stub]")?.textContent).toBe('{\n "fresh": "response"\n}');
});
it("keeps invalid response bodies searchable in the raw view", async () => {
const invalid = await mountResponsePanel("not JSON", false);
expect([...root!.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent === "redis.jsonView")?.disabled).toBe(true);
invalid.panelElement.focus();
expect(invalid.panel.focusSearch()).toBe(true);
await flushUi();
expect(root!.querySelector("[data-elasticsearch-response-search-input]")).not.toBeNull();
});
it("searches large raw responses without rendering all highlight nodes", async () => {
const large = await mountResponsePanel(`${"x".repeat(256_001)}needle`, false);
large.panelElement.focus();
large.panel.focusSearch();
await flushUi();
const input = root!.querySelector<HTMLInputElement>("[data-elasticsearch-response-search-input]")!;
input.value = "needle";
input.dispatchEvent(new Event("input", { bubbles: true }));
await flushUi();
expect(root!.querySelector("[data-elasticsearch-response-search-status]")?.textContent).toBe("1/1");
expect(root!.querySelectorAll(".document-search-match")).toHaveLength(0);
});
});

View File

@ -130,6 +130,10 @@ type SearchableBrowserHandle = {
executeCommand?: (command: string) => Promise<boolean>;
};
type ElasticsearchJsonResponsePanelHandle = {
focusSearch: () => boolean;
};
const props = defineProps<{
activeTab: QueryTab;
activeConnection?: ConnectionConfig;
@ -210,6 +214,7 @@ const columnInfoLoading = ref(false);
const columnInfoError = ref<string | undefined>(undefined);
const dataGridRef = ref<DataGridHandle>();
const queryEditorRef = ref<InstanceType<typeof QueryEditor>>();
const elasticsearchJsonResponsePanelRef = ref<ElasticsearchJsonResponsePanelHandle>();
const tableStructureEditorRef = ref<{ applyChanges: () => Promise<boolean> }>();
const standaloneResultToolbarRef = ref<HTMLElement | null>(null);
const standaloneResultToolbarWidth = ref(0);
@ -755,6 +760,7 @@ function onHandleCloseColumnPanel() {
}
function focusSearch(): boolean {
if (elasticsearchJsonResponsePanelRef.value?.focusSearch()) return true;
if (props.activeTab.mode === "mongo") return documentBrowserRef.value?.focusSearch() ?? false;
if (props.activeTab.mode === "redis") return redisKeyBrowserRef.value?.focusSearch() ?? false;
if (props.activeTab.mode === "etcd") return etcdKeyBrowserRef.value?.focusSearch() ?? false;
@ -1409,8 +1415,8 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
</div>
<template v-else>
<ElasticsearchJsonResponsePanel v-if="activeElasticsearchJsonResponse" class="flex-1 min-h-0" :status="activeElasticsearchJsonResponse.status" :body="activeElasticsearchJsonResponse.body" />
<ElasticsearchJsonResponsePanel v-else-if="showElasticsearchRawJson && activeElasticsearchRawBody" class="flex-1 min-h-0" :status="200" :body="activeElasticsearchRawBody" can-show-table @show-table="showElasticsearchRawJson = false" />
<ElasticsearchJsonResponsePanel v-if="activeElasticsearchJsonResponse" ref="elasticsearchJsonResponsePanelRef" class="flex-1 min-h-0" :status="activeElasticsearchJsonResponse.status" :body="activeElasticsearchJsonResponse.body" />
<ElasticsearchJsonResponsePanel v-else-if="showElasticsearchRawJson && activeElasticsearchRawBody" ref="elasticsearchJsonResponsePanelRef" class="flex-1 min-h-0" :status="200" :body="activeElasticsearchRawBody" can-show-table @show-table="showElasticsearchRawJson = false" />
<DataGrid
v-else-if="activeTab.result && hasTabularResult"
ref="dataGridRef"

View File

@ -0,0 +1,90 @@
/** Shared in-content find helpers for read-only text surfaces. */
export const TEXT_CONTENT_SEARCH_MATCH_LIMIT = 1000;
export const TEXT_CONTENT_SEARCH_FULL_HIGHLIGHT_MAX_CHARS = 256_000;
export interface TextContentMatch {
start: number;
end: number;
}
export interface TextContentSearchRenderOptions {
activeMatchIndex?: number;
matchClass?: string;
activeClass?: string;
matchAttribute?: (index: number) => string;
activeAttribute?: (index: number) => string;
}
export function findTextContentMatches(text: string, query: string, limit = TEXT_CONTENT_SEARCH_MATCH_LIMIT): TextContentMatch[] {
if (!query || limit <= 0) return [];
// Match the original UTF-16 text so Unicode case folding keeps offsets valid.
const pattern = new RegExp(escapeRegExp(query), "giu");
const matches: TextContentMatch[] = [];
for (const match of text.matchAll(pattern)) {
matches.push({ start: match.index, end: match.index + match[0].length });
if (matches.length >= limit) break;
}
return matches;
}
export function textContentSearchStatus(activeIndex: number, matchCount: number, limited = false): string {
if (matchCount <= 0) return "0/0";
return `${activeIndex + 1}/${limited ? `${matchCount}+` : matchCount}`;
}
export function nextTextContentSearchMatchIndex(current: number, delta: -1 | 1, count: number): number {
if (count <= 0) return 0;
return (current + delta + count) % count;
}
export function canFullHighlightTextContent(textLength: number): boolean {
return textLength <= TEXT_CONTENT_SEARCH_FULL_HIGHLIGHT_MAX_CHARS;
}
export function renderTextContentMatchesHtml(text: string, matches: readonly TextContentMatch[], options: TextContentSearchRenderOptions = {}): string {
if (matches.length === 0) return escapeTextContentHtml(text);
const activeMatchIndex = options.activeMatchIndex ?? -1;
const matchClass = options.matchClass ?? "document-search-match";
const activeClass = options.activeClass ?? "document-search-match-active";
const matchAttribute = options.matchAttribute ?? ((index) => `data-document-search-match="${index}"`);
const activeAttribute = options.activeAttribute ?? (() => 'data-document-search-active="true"');
let html = "";
let cursor = 0;
for (let index = 0; index < matches.length; index += 1) {
const match = matches[index];
if (match.start > cursor) html += escapeTextContentHtml(text.slice(cursor, match.start));
const active = index === activeMatchIndex;
const className = active ? `${matchClass} ${activeClass}` : matchClass;
const attributes = `${matchAttribute(index)}${active ? ` ${activeAttribute(index)}` : ""}`;
html += `<mark class="${className}" ${attributes}>${escapeTextContentHtml(text.slice(match.start, match.end))}</mark>`;
cursor = match.end;
}
if (cursor < text.length) html += escapeTextContentHtml(text.slice(cursor));
return html;
}
export function renderTextContentSearchHtml(text: string, query: string, activeMatchIndex = 0, limit = TEXT_CONTENT_SEARCH_MATCH_LIMIT): string {
if (!query || !canFullHighlightTextContent(text.length)) return escapeTextContentHtml(text);
return renderTextContentMatchesHtml(text, findTextContentMatches(text, query, limit), { activeMatchIndex });
}
/** Grip is a <button>; allow it before blocking other interactive controls. */
export function isTextContentSearchDragSource(target: EventTarget | null): boolean {
if (target == null || typeof target !== "object") return false;
const el = target as { closest?: (selector: string) => unknown };
if (typeof el.closest !== "function") return false;
if (el.closest("[data-drag-handle]")) return true;
if (el.closest("[data-search-drag-chrome]")) return true;
if (el.closest("input, textarea, select, button, a, [data-no-drag]")) return false;
return !!el.closest("[data-draggable-search-panel]");
}
export function escapeTextContentHtml(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@ -1,74 +1,12 @@
/** Match helpers for Redis STRING value Ctrl+F (in-content find only). */
/** Compatibility exports for Redis STRING value Ctrl+F. */
import { TEXT_CONTENT_SEARCH_FULL_HIGHLIGHT_MAX_CHARS, TEXT_CONTENT_SEARCH_MATCH_LIMIT, canFullHighlightTextContent, findTextContentMatches, nextTextContentSearchMatchIndex, renderTextContentSearchHtml, textContentSearchStatus, type TextContentMatch } from "@/lib/common/textContentSearch";
export const REDIS_VALUE_SEARCH_MATCH_LIMIT = 1000;
export const REDIS_VALUE_SEARCH_FULL_HIGHLIGHT_MAX_CHARS = 256_000;
export interface RedisTextMatch {
start: number;
end: number;
}
export function findRedisTextMatches(text: string, query: string, limit = REDIS_VALUE_SEARCH_MATCH_LIMIT): RedisTextMatch[] {
if (!query || limit <= 0) return [];
// Match against the original UTF-16 text so Unicode case folding cannot
// change string length and corrupt the offsets used for highlighting.
const pattern = new RegExp(escapeRegExp(query), "giu");
const matches: RedisTextMatch[] = [];
for (const match of text.matchAll(pattern)) {
const start = match.index;
matches.push({ start, end: start + match[0].length });
if (matches.length >= limit) break;
}
return matches;
}
export function redisValueSearchStatus(activeIndex: number, matchCount: number, limited = false): string {
if (matchCount <= 0) return "0/0";
return `${activeIndex + 1}/${limited ? `${matchCount}+` : matchCount}`;
}
export function nextRedisSearchMatchIndex(current: number, delta: -1 | 1, count: number): number {
if (count <= 0) return 0;
return (current + delta + count) % count;
}
export function canFullHighlightRedisText(textLength: number): boolean {
return textLength <= REDIS_VALUE_SEARCH_FULL_HIGHLIGHT_MAX_CHARS;
}
export function renderRedisTextSearchHtml(text: string, query: string, activeMatchIndex = 0, limit = REDIS_VALUE_SEARCH_MATCH_LIMIT): string {
if (!query || !canFullHighlightRedisText(text.length)) return escapeHtml(text);
const matches = findRedisTextMatches(text, query, limit);
if (matches.length === 0) return escapeHtml(text);
let html = "";
let cursor = 0;
for (let index = 0; index < matches.length; index += 1) {
const match = matches[index];
if (match.start > cursor) html += escapeHtml(text.slice(cursor, match.start));
const activeClass = index === activeMatchIndex ? " document-search-match-active" : "";
const activeAttribute = index === activeMatchIndex ? ' data-document-search-active="true"' : "";
html += `<mark class="document-search-match${activeClass}" data-document-search-match="${index}"${activeAttribute}>${escapeHtml(text.slice(match.start, match.end))}</mark>`;
cursor = match.end;
}
if (cursor < text.length) html += escapeHtml(text.slice(cursor));
return html;
}
/** Grip is a <button>; allow it before blocking other buttons. */
export function isTextContentSearchDragSource(target: EventTarget | null): boolean {
if (target == null || typeof target !== "object") return false;
const el = target as { closest?: (selector: string) => unknown };
if (typeof el.closest !== "function") return false;
if (el.closest("[data-drag-handle]")) return true;
if (el.closest("[data-search-drag-chrome]")) return true;
if (el.closest("input, textarea, select, button, a, [data-no-drag]")) return false;
return !!el.closest("[data-draggable-search-panel]");
}
function escapeHtml(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export const REDIS_VALUE_SEARCH_MATCH_LIMIT = TEXT_CONTENT_SEARCH_MATCH_LIMIT;
export const REDIS_VALUE_SEARCH_FULL_HIGHLIGHT_MAX_CHARS = TEXT_CONTENT_SEARCH_FULL_HIGHLIGHT_MAX_CHARS;
export type RedisTextMatch = TextContentMatch;
export const findRedisTextMatches = findTextContentMatches;
export const redisValueSearchStatus = textContentSearchStatus;
export const nextRedisSearchMatchIndex = nextTextContentSearchMatchIndex;
export const canFullHighlightRedisText = canFullHighlightTextContent;
export const renderRedisTextSearchHtml = renderTextContentSearchHtml;
export { isTextContentSearchDragSource } from "@/lib/common/textContentSearch";

View File

@ -1,4 +1,5 @@
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import { test } from "vitest";
import { elasticsearchJsonResponseForResult } from "../../apps/desktop/src/lib/elasticsearch/elasticsearchJsonResponse.ts";
import type { QueryResult } from "../../apps/desktop/src/types/database.ts";
@ -95,3 +96,12 @@ test("uses the supplied result source statement to classify the response", () =>
assert.equal(elasticsearchJsonResponseForResult("elasticsearch", "SELECT * FROM products", result), undefined);
assert.equal(elasticsearchJsonResponseForResult("elasticsearch", undefined, result), undefined);
});
test("routes focused find shortcuts to both Elasticsearch response-panel entry points", () => {
const contentArea = readFileSync(new URL("../../apps/desktop/src/components/layout/ContentArea.vue", import.meta.url), "utf8");
const focusSearch = contentArea.slice(contentArea.indexOf("function focusSearch()"), contentArea.indexOf("function refreshData()"));
assert.match(focusSearch, /elasticsearchJsonResponsePanelRef\.value\?\.focusSearch\(\)/);
assert.match(contentArea, /<ElasticsearchJsonResponsePanel v-if="activeElasticsearchJsonResponse" ref="elasticsearchJsonResponsePanelRef"/);
assert.match(contentArea, /<ElasticsearchJsonResponsePanel v-else-if="showElasticsearchRawJson && activeElasticsearchRawBody" ref="elasticsearchJsonResponsePanelRef"/);
});