feat(elasticsearch): add response content search
This commit is contained in:
parent
b7606ceee3
commit
939c8d493d
|
|
@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
224
apps/desktop/src/components/common/__tests__/ElasticsearchJsonResponsePanel.spec.ts
vendored
Normal file
224
apps/desktop/src/components/common/__tests__/ElasticsearchJsonResponsePanel.spec.ts
vendored
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
|
@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -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"/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue