feat(redis): add in-content search for string values
This commit is contained in:
1 parent
405dca5bb4
commit
192d874554
7 files changed
+753
-23
No files matched your search
@@ -0,0 +1,232 @@
|
||||
<script setup lang="ts">
|
||||
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";
|
||||
|
||||
/**
|
||||
* Floating find panel (EditorSearchPanel look).
|
||||
* Uses absolute + translate (not fixed) so it works inside Reka Dialog focus traps
|
||||
* and is not clipped by dialog overflow / transform containing blocks.
|
||||
*/
|
||||
defineOptions({ name: "TextContentSearchBar" });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string;
|
||||
status: string;
|
||||
matchCount: number;
|
||||
showNavigation?: boolean;
|
||||
placeholder?: string;
|
||||
}>(),
|
||||
{
|
||||
showNavigation: true,
|
||||
placeholder: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string];
|
||||
activate: [delta: -1 | 1];
|
||||
prev: [];
|
||||
next: [];
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null);
|
||||
/** Drag offset from the default top-right anchor (absolute right/top). */
|
||||
const offsetX = ref(0);
|
||||
const offsetY = ref(0);
|
||||
const dragging = ref(false);
|
||||
|
||||
let dragPointerId: number | null = null;
|
||||
let startClientX = 0;
|
||||
let startClientY = 0;
|
||||
let originX = 0;
|
||||
let originY = 0;
|
||||
|
||||
function resolvedPlaceholder() {
|
||||
return props.placeholder ?? t("editor.search.find");
|
||||
}
|
||||
|
||||
function onEnter(event: KeyboardEvent) {
|
||||
emit("activate", event.shiftKey ? -1 : 1);
|
||||
}
|
||||
|
||||
function detachDragListeners() {
|
||||
window.removeEventListener("pointermove", onDragMove, true);
|
||||
window.removeEventListener("pointerup", onDragEnd, true);
|
||||
window.removeEventListener("pointercancel", onDragEnd, true);
|
||||
}
|
||||
|
||||
function onDragMove(event: PointerEvent) {
|
||||
if (!dragging.value) return;
|
||||
if (dragPointerId != null && event.pointerId !== dragPointerId) return;
|
||||
event.preventDefault();
|
||||
offsetX.value = originX + (event.clientX - startClientX);
|
||||
offsetY.value = originY + (event.clientY - startClientY);
|
||||
}
|
||||
|
||||
function onDragEnd(event: PointerEvent) {
|
||||
if (dragPointerId != null && event.pointerId !== dragPointerId) return;
|
||||
event.preventDefault();
|
||||
dragging.value = false;
|
||||
dragPointerId = null;
|
||||
document.body.classList.remove("dbx-search-panel-dragging");
|
||||
detachDragListeners();
|
||||
}
|
||||
|
||||
function startDrag(event: PointerEvent) {
|
||||
if (event.pointerType === "mouse" && event.button !== 0) return;
|
||||
if (!isTextContentSearchDragSource(event.target)) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
dragging.value = true;
|
||||
dragPointerId = event.pointerId;
|
||||
startClientX = event.clientX;
|
||||
startClientY = event.clientY;
|
||||
originX = offsetX.value;
|
||||
originY = offsetY.value;
|
||||
document.body.classList.add("dbx-search-panel-dragging");
|
||||
|
||||
window.addEventListener("pointermove", onDragMove, true);
|
||||
window.addEventListener("pointerup", onDragEnd, true);
|
||||
window.addEventListener("pointercancel", onDragEnd, true);
|
||||
|
||||
try {
|
||||
(event.currentTarget as HTMLElement | null)?.setPointerCapture?.(event.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function focusInput(select = true) {
|
||||
void nextTick(() => {
|
||||
const el = inputRef.value;
|
||||
if (!el) return;
|
||||
el.focus({ preventScroll: true });
|
||||
if (select) el.select();
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
offsetX.value = 0;
|
||||
offsetY.value = 0;
|
||||
void nextTick(() => focusInput(true));
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.body.classList.remove("dbx-search-panel-dragging");
|
||||
detachDragListeners();
|
||||
});
|
||||
|
||||
defineExpose({ focusInput, inputEl: inputRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-text-content-search
|
||||
data-redis-value-search
|
||||
data-draggable-search-panel
|
||||
data-search-drag-chrome
|
||||
class="dbx-text-search-panel absolute right-3 top-3 z-50 isolate flex flex-col gap-1 rounded-lg border border-border bg-popover p-1.5 text-popover-foreground shadow-xl ring-1 ring-border/60"
|
||||
:class="{ 'is-dragging': dragging }"
|
||||
:style="{ transform: `translate(${offsetX}px, ${offsetY}px)` }"
|
||||
@pointerdown="startDrag"
|
||||
>
|
||||
<div class="flex items-center gap-1" data-search-drag-chrome>
|
||||
<button type="button" data-drag-handle class="dbx-search-drag-handle flex h-8 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" :title="t('editor.search.find')" :aria-label="t('editor.search.find')" @pointerdown="startDrag">
|
||||
<GripVertical class="pointer-events-none h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div class="flex h-8 w-64 items-center rounded-md border border-input bg-background focus-within:border-ring focus-within:ring-1 focus-within:ring-ring" data-no-drag @pointerdown.stop>
|
||||
<input
|
||||
ref="inputRef"
|
||||
data-text-content-search-input
|
||||
data-redis-value-search-input
|
||||
:value="modelValue"
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="dbx-search-input h-full min-w-0 flex-1 bg-transparent px-2 text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
||||
:placeholder="resolvedPlaceholder()"
|
||||
@mousedown.stop
|
||||
@pointerdown.stop
|
||||
@click.stop
|
||||
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
@keydown.enter.prevent="onEnter"
|
||||
@keydown.escape.prevent.stop="emit('close')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span data-search-drag-chrome class="min-w-[3.4rem] shrink-0 select-none text-center text-xs tabular-nums" :class="modelValue && matchCount === 0 ? 'text-destructive' : 'text-muted-foreground'">
|
||||
{{ status }}
|
||||
</span>
|
||||
|
||||
<template v-if="showNavigation">
|
||||
<button
|
||||
type="button"
|
||||
data-no-drag
|
||||
class="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
|
||||
:title="t('editor.search.prevMatch')"
|
||||
:disabled="matchCount === 0"
|
||||
@pointerdown.stop
|
||||
@click="emit('prev')"
|
||||
>
|
||||
<ChevronUp class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-no-drag
|
||||
class="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
|
||||
:title="t('editor.search.nextMatch')"
|
||||
:disabled="matchCount === 0"
|
||||
@pointerdown.stop
|
||||
@click="emit('next')"
|
||||
>
|
||||
<ChevronDown class="h-4 w-4" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button type="button" data-no-drag class="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" :title="t('editor.search.close')" @pointerdown.stop @click="emit('close')">
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dbx-text-search-panel {
|
||||
max-width: min(calc(100vw - 2rem), 620px);
|
||||
}
|
||||
|
||||
.dbx-search-drag-handle {
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.dbx-search-input {
|
||||
user-select: text;
|
||||
touch-action: manipulation;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.dbx-text-search-panel.is-dragging,
|
||||
.dbx-text-search-panel.is-dragging .dbx-search-drag-handle {
|
||||
cursor: grabbing;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body.dbx-search-panel-dragging {
|
||||
cursor: grabbing !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -14,11 +14,17 @@ const props = withDefaults(
|
||||
saveDisabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
wordWrap?: boolean;
|
||||
/**
|
||||
* When false, Mod+F is left to a parent find surface (RedisValueViewer).
|
||||
* Default true so DocumentBrowser and other callers keep CodeMirror find.
|
||||
*/
|
||||
enableBuiltinFind?: boolean;
|
||||
}>(),
|
||||
{
|
||||
saveDisabled: false,
|
||||
readOnly: false,
|
||||
wordWrap: false,
|
||||
enableBuiltinFind: true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -37,6 +43,7 @@ const editor = useCellDetailEditor({
|
||||
folding: true,
|
||||
lineWrapping: () => props.wordWrap,
|
||||
readOnly: () => props.readOnly,
|
||||
enableBuiltinFind: props.enableBuiltinFind,
|
||||
onChange(value) {
|
||||
emit("update:modelValue", value);
|
||||
},
|
||||
@@ -65,6 +72,16 @@ watch(
|
||||
if (editor.getValue() !== value) editor.setValue(value, "json");
|
||||
},
|
||||
);
|
||||
|
||||
function openSearch(): boolean {
|
||||
return editor.openSearch();
|
||||
}
|
||||
|
||||
function selectRange(from: number, to: number, options?: { focus?: boolean }): boolean {
|
||||
return editor.selectRange(from, to, options);
|
||||
}
|
||||
|
||||
defineExpose({ openSearch, selectRange });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -76,6 +76,7 @@ const isFetchingAll = ref(false);
|
||||
const fetchAllStopRequested = ref(false);
|
||||
const fetchAllLoadedCount = ref(0);
|
||||
const rootRef = ref<HTMLElement>();
|
||||
const valueViewerRef = ref<{ focusSearch: () => boolean } | null>(null);
|
||||
const commandTerminalRef = ref<HTMLElement>();
|
||||
const searchPattern = ref("");
|
||||
const searchMode = ref<RedisSearchMode>("key");
|
||||
@@ -994,6 +995,9 @@ function getCommandInput(): HTMLInputElement | null {
|
||||
}
|
||||
|
||||
function focusSearch(): boolean {
|
||||
if (activeSidePanel.value === "detail" && valueViewerRef.value?.focusSearch()) {
|
||||
return true;
|
||||
}
|
||||
const input = getSearchInput();
|
||||
if (!input) return false;
|
||||
input.focus();
|
||||
@@ -1313,7 +1317,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
</div>
|
||||
|
||||
<TabsContent value="detail" class="m-0 min-h-0 flex-1 flex flex-col">
|
||||
<RedisValueViewer v-if="selectedKey" :key="selectedKey.key_raw" :connection-id="connectionId" :db="db" :key-display="selectedKey.key_display" :key-raw="selectedKey.key_raw" :metadata="selectedKey" @deleted="onKeyDeleted" @loaded="onKeyLoaded" />
|
||||
<RedisValueViewer v-if="selectedKey" ref="valueViewerRef" :key="selectedKey.key_raw" :connection-id="connectionId" :db="db" :key-display="selectedKey.key_display" :key-raw="selectedKey.key_raw" :metadata="selectedKey" @deleted="onKeyDeleted" @loaded="onKeyLoaded" />
|
||||
<div v-else class="flex-1 flex items-center justify-center text-xs text-muted-foreground">
|
||||
{{ t("redis.selectKeyForDetail") }}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, nextTick, onBeforeUnmount, onMounted } from "vue";
|
||||
import { computed, ref, nextTick, onBeforeUnmount, onMounted, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { onClickOutside } from "@vueuse/core";
|
||||
import { DynamicScroller, DynamicScrollerItem, RecycleScroller } from "vue-virtual-scroller";
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
type RedisCollectionItem,
|
||||
type RedisValueFormat,
|
||||
} from "@/lib/redis/redisValuePresentation";
|
||||
import { canFullHighlightRedisText, findRedisTextMatches, nextRedisSearchMatchIndex, REDIS_VALUE_SEARCH_MATCH_LIMIT, renderRedisTextSearchHtml, redisValueSearchStatus } from "@/lib/redis/redisValueSearch";
|
||||
import TextContentSearchBar from "@/components/common/TextContentSearchBar.vue";
|
||||
import { formatJsonSource } from "@/lib/common/safeJsonFormat";
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -159,9 +161,27 @@ function stopAutoRefresh() {
|
||||
|
||||
const hashSortBy = ref<"field" | "value" | null>(null);
|
||||
const hashSortDir = ref<"asc" | "desc">("asc");
|
||||
/**
|
||||
* In-content find (Ctrl+F) for:
|
||||
* - Redis STRING keys
|
||||
* - Member detail dialog (set/list/hash/zset field values — string-like body)
|
||||
* Hash field list keeps its own toolbar search. No collection-list filter.
|
||||
*/
|
||||
const valueSearchOpen = ref(false);
|
||||
const valueSearchQuery = ref("");
|
||||
const valueSearchMatchIndex = ref(0);
|
||||
const valueSearchHasNavigated = ref(false);
|
||||
const hashSearchQuery = ref("");
|
||||
const activeHashSearchQuery = ref("");
|
||||
const searchLoading = ref(false);
|
||||
const valueSearchBarRef = ref<{ focusInput: (select?: boolean) => void } | null>(null);
|
||||
type JsonEditorHandle = { openSearch: () => boolean; selectRange?: (from: number, to: number, options?: { focus?: boolean }) => boolean };
|
||||
const stringJsonEditorRef = ref<JsonEditorHandle | null>(null);
|
||||
const redisJsonEditorRef = ref<JsonEditorHandle | null>(null);
|
||||
const memberJsonEditorRef = ref<JsonEditorHandle | null>(null);
|
||||
const stringTextareaRef = ref<HTMLTextAreaElement | null>(null);
|
||||
const memberTextareaRef = ref<HTMLTextAreaElement | null>(null);
|
||||
const valueViewerSearchActive = ref(false);
|
||||
|
||||
function toggleHashSort(column: "field" | "value") {
|
||||
if (hashSortBy.value === column && hashSortDir.value === "desc") {
|
||||
@@ -305,6 +325,37 @@ const zsetRows = computed<RedisCollectionRow<RedisZsetItem>[]>(() =>
|
||||
: [],
|
||||
);
|
||||
|
||||
const usesJsonEditorForMain = computed(() => (isStringLikeKind.value && stringValueView.value === "json" && Boolean(stringValueDetail.value?.json)) || redisKind.value === "json");
|
||||
const usesJsonEditorForMember = computed(() => isEditingHashJson.value);
|
||||
/** True when Ctrl+F content find applies (STRING, RedisJSON, or open member detail). */
|
||||
const valueSearchSupported = computed(() => showMemberDetail.value || isStringLikeKind.value || redisKind.value === "json");
|
||||
const contentSearchText = computed(() => {
|
||||
if (showMemberDetail.value) {
|
||||
if (isEditingMember.value || isEditingHashJson.value) return memberEditValue.value;
|
||||
return detailTextForFormat(selectedMemberDetail.value, memberValueView.value);
|
||||
}
|
||||
if (redisKind.value === "json") return editValue.value;
|
||||
if (!isStringLikeKind.value || !stringValueDetail.value) return "";
|
||||
if (stringValueView.value === "json" && stringValueDetail.value.json) return editValue.value;
|
||||
if (stringValueView.value === "utf8" && canEditCurrentStringFormat.value) return editValue.value;
|
||||
return detailTextForFormat(stringValueDetail.value, stringValueView.value);
|
||||
});
|
||||
const contentSearchMatches = computed(() => findRedisTextMatches(contentSearchText.value, valueSearchQuery.value));
|
||||
const contentSearchMatchLimited = computed(() => contentSearchMatches.value.length >= REDIS_VALUE_SEARCH_MATCH_LIMIT);
|
||||
const contentSearchActiveIndex = computed(() => {
|
||||
if (contentSearchMatches.value.length === 0) return 0;
|
||||
return Math.min(valueSearchMatchIndex.value, contentSearchMatches.value.length - 1);
|
||||
});
|
||||
const valueSearchStatus = computed(() => redisValueSearchStatus(contentSearchActiveIndex.value, contentSearchMatches.value.length, contentSearchMatchLimited.value));
|
||||
const valueSearchMatchCount = computed(() => contentSearchMatches.value.length);
|
||||
const contentSearchHighlightedHtml = computed(() => {
|
||||
if (!valueSearchOpen.value || !valueSearchQuery.value) return "";
|
||||
return renderRedisTextSearchHtml(contentSearchText.value, valueSearchQuery.value, contentSearchActiveIndex.value);
|
||||
});
|
||||
const canHighlightContentSearch = computed(() => valueSearchOpen.value && Boolean(valueSearchQuery.value) && canFullHighlightRedisText(contentSearchText.value.length));
|
||||
const canHighlightStringSurface = computed(() => canHighlightContentSearch.value && !showMemberDetail.value);
|
||||
const canHighlightMemberSurface = computed(() => canHighlightContentSearch.value && showMemberDetail.value);
|
||||
|
||||
let hashSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let hashSearchRequestId = 0;
|
||||
let hashResizeStartX = 0;
|
||||
@@ -552,6 +603,7 @@ async function load(options: { selectDefaultMember?: boolean; preserveDraft?: bo
|
||||
hashSearchQuery.value = "";
|
||||
activeHashSearchQuery.value = "";
|
||||
searchLoading.value = false;
|
||||
resetValueSearch();
|
||||
data.value = loadedValue;
|
||||
emit("loaded", loadedValue);
|
||||
scanCursor.value = redisValueCollectionScanCursor(loadedValue);
|
||||
@@ -1206,7 +1258,143 @@ function formatValue(val: unknown): string {
|
||||
return JSON.stringify(val, null, 2);
|
||||
}
|
||||
|
||||
function resetValueSearch() {
|
||||
valueSearchOpen.value = false;
|
||||
valueSearchQuery.value = "";
|
||||
valueSearchMatchIndex.value = 0;
|
||||
valueSearchHasNavigated.value = false;
|
||||
}
|
||||
|
||||
function openValueSearch(): boolean {
|
||||
if (!data.value || !valueSearchSupported.value) return false;
|
||||
valueSearchOpen.value = true;
|
||||
valueSearchHasNavigated.value = false;
|
||||
valueViewerSearchActive.value = true;
|
||||
// Keep caret in the find input — do not jump focus into the value body.
|
||||
void nextTick(() => {
|
||||
valueSearchBarRef.value?.focusInput(true);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function closeValueSearch() {
|
||||
valueSearchOpen.value = false;
|
||||
valueSearchHasNavigated.value = false;
|
||||
valueSearchMatchIndex.value = 0;
|
||||
valueSearchQuery.value = "";
|
||||
}
|
||||
|
||||
function moveContentSearchMatch(delta: -1 | 1) {
|
||||
const count = contentSearchMatches.value.length;
|
||||
if (count === 0) return;
|
||||
valueSearchMatchIndex.value = nextRedisSearchMatchIndex(contentSearchActiveIndex.value, delta, count);
|
||||
valueSearchHasNavigated.value = true;
|
||||
void scrollContentSearchMatchIntoView();
|
||||
}
|
||||
|
||||
function activateValueSearchMatch(delta: -1 | 1) {
|
||||
if (contentSearchMatches.value.length === 0) return;
|
||||
if (!valueSearchHasNavigated.value) {
|
||||
valueSearchHasNavigated.value = true;
|
||||
void scrollContentSearchMatchIntoView();
|
||||
return;
|
||||
}
|
||||
moveContentSearchMatch(delta);
|
||||
}
|
||||
|
||||
/** Scroll value body to the active match without stealing focus from the find input. */
|
||||
function scrollTextareaToMatch(textarea: HTMLTextAreaElement, match: { start: number; end: number }) {
|
||||
const lineHeight = Number.parseFloat(getComputedStyle(textarea).lineHeight || "20") || 20;
|
||||
const textBefore = textarea.value.slice(0, match.start);
|
||||
const line = textBefore.split("\n").length - 1;
|
||||
textarea.scrollTop = Math.max(0, line * lineHeight - textarea.clientHeight / 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Never focus the value body here — the find panel must keep the caret so typing stays in the search box.
|
||||
* Enter / prev / next only change the active match index and scroll the body into view.
|
||||
*/
|
||||
async function scrollContentSearchMatchIntoView() {
|
||||
await nextTick();
|
||||
const match = contentSearchMatches.value[contentSearchActiveIndex.value];
|
||||
if (!match) return;
|
||||
|
||||
// CM: update selection + scroll, but do not focus (keeps find input active).
|
||||
if (showMemberDetail.value) {
|
||||
if (usesJsonEditorForMember.value && memberJsonEditorRef.value?.selectRange?.(match.start, match.end, { focus: false })) return;
|
||||
if (isEditingMember.value && memberTextareaRef.value) {
|
||||
scrollTextareaToMatch(memberTextareaRef.value, match);
|
||||
return;
|
||||
}
|
||||
} else if (redisKind.value === "json") {
|
||||
if (redisJsonEditorRef.value?.selectRange?.(match.start, match.end, { focus: false })) return;
|
||||
} else {
|
||||
if (usesJsonEditorForMain.value && stringJsonEditorRef.value?.selectRange?.(match.start, match.end, { focus: false })) return;
|
||||
if (stringValueView.value === "utf8" && canEditCurrentStringFormat.value && stringTextareaRef.value) {
|
||||
scrollTextareaToMatch(stringTextareaRef.value, match);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelector<HTMLElement>('[data-document-search-active="true"]')?.scrollIntoView({ block: "center", inline: "nearest" });
|
||||
}
|
||||
|
||||
/** Ctrl/Cmd+F on STRING body or member detail → floating find. */
|
||||
function focusSearch(): boolean {
|
||||
// Member detail is portaled; treat an open dialog as an active value surface.
|
||||
if (!valueViewerSearchActive.value && !valueSearchOpen.value && !showMemberDetail.value) return false;
|
||||
return openValueSearch();
|
||||
}
|
||||
|
||||
function handleValueViewerPointerDown(event: PointerEvent) {
|
||||
const target = event.target;
|
||||
valueViewerSearchActive.value = target instanceof Element && !!target.closest("[data-redis-value-viewer], [data-redis-value-search], [data-text-content-search], [data-draggable-search-panel], [data-redis-member-detail]");
|
||||
}
|
||||
|
||||
watch(valueSearchQuery, () => {
|
||||
// Typing: recompute matches/highlights; keep caret in the find input.
|
||||
valueSearchMatchIndex.value = 0;
|
||||
valueSearchHasNavigated.value = false;
|
||||
if (valueSearchOpen.value) void scrollContentSearchMatchIntoView();
|
||||
});
|
||||
|
||||
watch(contentSearchText, () => {
|
||||
valueSearchMatchIndex.value = 0;
|
||||
valueSearchHasNavigated.value = false;
|
||||
if (valueSearchOpen.value) void scrollContentSearchMatchIntoView();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.keyRaw,
|
||||
() => {
|
||||
resetValueSearch();
|
||||
valueViewerSearchActive.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
watch(stringValueView, () => {
|
||||
if (!showMemberDetail.value) {
|
||||
valueSearchMatchIndex.value = 0;
|
||||
valueSearchHasNavigated.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
watch(memberValueView, () => {
|
||||
if (showMemberDetail.value) {
|
||||
valueSearchMatchIndex.value = 0;
|
||||
valueSearchHasNavigated.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
watch(showMemberDetail, (open) => {
|
||||
valueSearchMatchIndex.value = 0;
|
||||
valueSearchHasNavigated.value = false;
|
||||
// Closing the dialog ends member-scoped search.
|
||||
if (!open && !isStringLikeKind.value) resetValueSearch();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("pointerdown", handleValueViewerPointerDown, true);
|
||||
void load();
|
||||
void createShikiJsonHighlighter({
|
||||
appearance: () => redisJsonAppearance.value,
|
||||
@@ -1219,15 +1407,33 @@ onMounted(() => {
|
||||
});
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("pointerdown", handleValueViewerPointerDown, true);
|
||||
stopAutoRefresh();
|
||||
stopResizeHashColumns();
|
||||
stopResizeZsetColumns();
|
||||
if (hashSearchTimer) clearTimeout(hashSearchTimer);
|
||||
});
|
||||
|
||||
defineExpose({ focusSearch });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col overflow-hidden" :style="editorFontFamilyStyle">
|
||||
<div data-redis-value-viewer class="relative h-full flex flex-col overflow-hidden" :style="editorFontFamilyStyle">
|
||||
<!-- STRING body find — mounted inside the value pane (not teleported out of focus). -->
|
||||
<TextContentSearchBar
|
||||
v-if="valueSearchOpen && data && valueSearchSupported && !showMemberDetail"
|
||||
ref="valueSearchBarRef"
|
||||
v-model="valueSearchQuery"
|
||||
:status="valueSearchStatus"
|
||||
:match-count="valueSearchMatchCount"
|
||||
:show-navigation="true"
|
||||
:placeholder="t('editor.search.find')"
|
||||
@activate="activateValueSearchMatch"
|
||||
@prev="moveContentSearchMatch(-1)"
|
||||
@next="moveContentSearchMatch(1)"
|
||||
@close="closeValueSearch"
|
||||
/>
|
||||
|
||||
<div v-if="loading" class="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
@@ -1284,7 +1490,17 @@ onBeforeUnmount(() => {
|
||||
<Switch size="sm" :model-value="redisJsonWordWrap" @update:model-value="setRedisJsonWordWrap(Boolean($event))" />
|
||||
</label>
|
||||
</div>
|
||||
<RedisJsonEditor v-if="stringValueView === 'json' && stringValueDetail.json" v-model="editValue" class="min-h-0 flex-1" :save-disabled="savingString || !stringValueChanged" :read-only="savingString" :word-wrap="redisJsonWordWrap" @save="saveString" />
|
||||
<RedisJsonEditor
|
||||
v-if="stringValueView === 'json' && stringValueDetail.json"
|
||||
ref="stringJsonEditorRef"
|
||||
v-model="editValue"
|
||||
class="min-h-0 flex-1"
|
||||
:save-disabled="savingString || !stringValueChanged"
|
||||
:read-only="savingString"
|
||||
:word-wrap="redisJsonWordWrap"
|
||||
:enable-builtin-find="false"
|
||||
@save="saveString"
|
||||
/>
|
||||
<div v-else-if="stringValueView === 'javaserialize' && stringValueDetail.javaSerialized" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-4 text-sm leading-6">
|
||||
<JsonTree :value="stringValueDetail.javaSerialized.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
|
||||
</div>
|
||||
@@ -1293,19 +1509,22 @@ onBeforeUnmount(() => {
|
||||
<span>{{ t("grid.hexViewer") }}</span>
|
||||
<span>{{ t("grid.hexViewerByteCount", { count: stringValueDetail.byteCount }) }}</span>
|
||||
</div>
|
||||
<pre v-if="stringValueDetail.hexRows.length > 0" class="dbx-editor-font-family w-full min-w-0 max-w-full select-all whitespace-pre-wrap break-all">{{ detailTextForFormat(stringValueDetail, "hex") }}</pre>
|
||||
<pre v-if="stringValueDetail.hexRows.length > 0 && canHighlightStringSurface" class="dbx-editor-font-family w-full min-w-0 max-w-full select-all whitespace-pre-wrap break-all" v-html="contentSearchHighlightedHtml" />
|
||||
<pre v-else-if="stringValueDetail.hexRows.length > 0" class="dbx-editor-font-family w-full min-w-0 max-w-full select-all whitespace-pre-wrap break-all">{{ detailTextForFormat(stringValueDetail, "hex") }}</pre>
|
||||
<div v-else class="text-muted-foreground">{{ t("grid.hexViewerEmpty") }}</div>
|
||||
</div>
|
||||
<pre v-else-if="stringValueView === 'base64' && canHighlightStringSurface" class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-4 text-sm leading-6 whitespace-pre-wrap break-all" v-html="contentSearchHighlightedHtml" />
|
||||
<pre v-else-if="stringValueView === 'base64'" class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-4 text-sm leading-6 whitespace-pre-wrap break-all">{{ stringValueDetail.base64Text }}</pre>
|
||||
<textarea
|
||||
v-else-if="stringValueView === 'utf8' && canEditCurrentStringFormat"
|
||||
ref="stringTextareaRef"
|
||||
v-model="editValue"
|
||||
class="dbx-editor-font-family flex-1 resize-none bg-background p-4 text-sm outline-none"
|
||||
:class="redisJsonWordWrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'"
|
||||
:readonly="!canEditCurrentStringFormat || savingString"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<pre v-else-if="stringValueView === 'utf8'" class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-4 text-sm leading-6" :class="detailTextClass(stringValueView)">{{ detailTextForFormat(stringValueDetail, stringValueView) }}</pre>
|
||||
<pre v-else-if="canHighlightStringSurface" class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-4 text-sm leading-6" :class="detailTextClass(stringValueView)" v-html="contentSearchHighlightedHtml" />
|
||||
<pre v-else class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-4 text-sm leading-6" :class="detailTextClass(stringValueView)">{{ detailTextForFormat(stringValueDetail, stringValueView) }}</pre>
|
||||
<div v-if="isBinaryStringValue" class="px-4 py-2 border-t text-xs text-muted-foreground shrink-0">
|
||||
{{ t("redis.binaryStringReadonlyHint") }}
|
||||
@@ -1326,7 +1545,7 @@ onBeforeUnmount(() => {
|
||||
<Switch size="sm" :model-value="redisJsonWordWrap" @update:model-value="setRedisJsonWordWrap(Boolean($event))" />
|
||||
</label>
|
||||
</div>
|
||||
<RedisJsonEditor v-model="editValue" class="min-h-0 flex-1" :save-disabled="savingJson || !redisJsonValueChanged" :read-only="savingJson" :word-wrap="redisJsonWordWrap" @save="saveJson" />
|
||||
<RedisJsonEditor ref="redisJsonEditorRef" v-model="editValue" class="min-h-0 flex-1" :save-disabled="savingJson || !redisJsonValueChanged" :read-only="savingJson" :word-wrap="redisJsonWordWrap" :enable-builtin-find="false" @save="saveJson" />
|
||||
<div v-if="redisJsonValueChanged" class="px-4 py-2 border-t flex justify-end gap-2 shrink-0">
|
||||
<Button variant="ghost" size="sm" :disabled="savingJson" @click="discardRedisJsonEdit">{{ t("grid.discard") }}</Button>
|
||||
<Button size="sm" :disabled="savingJson" @click="saveJson"><Loader2 v-if="savingJson" class="w-3 h-3 mr-1 animate-spin" /><Save v-else class="w-3 h-3 mr-1" /> {{ t("grid.save") }}</Button>
|
||||
@@ -1592,7 +1811,24 @@ onBeforeUnmount(() => {
|
||||
<DangerConfirmDialog v-model:open="showDeleteConfirm" :message="t('dangerDialog.deleteMessage')" :details="deleteDetails" :confirm-label="t('dangerDialog.deleteConfirm')" @confirm="confirmDelete" />
|
||||
|
||||
<Dialog :open="showMemberDetail" @update:open="handleMemberDetailOpenChange">
|
||||
<DialogContent class="flex h-[min(760px,85vh)] w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[960px]" :style="editorFontFamilyStyle" @close-auto-focus="finishMemberDetailClose" @pointer-down-outside.prevent @interact-outside.prevent>
|
||||
<DialogContent data-redis-member-detail class="relative flex h-[min(760px,85vh)] w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[960px]" :style="editorFontFamilyStyle" @close-auto-focus="finishMemberDetailClose" @pointer-down-outside.prevent @interact-outside.prevent>
|
||||
<!--
|
||||
Inside DialogContent (focus trap). absolute — not fixed — so dialog
|
||||
transform/overflow do not break hit-testing or caret.
|
||||
-->
|
||||
<TextContentSearchBar
|
||||
v-if="valueSearchOpen && showMemberDetail"
|
||||
ref="valueSearchBarRef"
|
||||
v-model="valueSearchQuery"
|
||||
:status="valueSearchStatus"
|
||||
:match-count="valueSearchMatchCount"
|
||||
:show-navigation="true"
|
||||
:placeholder="t('editor.search.find')"
|
||||
@activate="activateValueSearchMatch"
|
||||
@prev="moveContentSearchMatch(-1)"
|
||||
@next="moveContentSearchMatch(1)"
|
||||
@close="closeValueSearch"
|
||||
/>
|
||||
<DialogHeader class="border-b px-5 py-4 pr-12">
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<span class="truncate">{{ selectedMemberTitle ? formatValue(selectedMemberTitle) : t("redis.memberDetail") }}</span>
|
||||
@@ -1600,7 +1836,7 @@ onBeforeUnmount(() => {
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<template v-if="isEditingMember">
|
||||
<textarea v-model="memberEditValue" class="dbx-editor-font-family min-h-0 flex-1 resize-none bg-background p-5 text-[13px] leading-6 outline-none" :readonly="savingMember" spellcheck="false" />
|
||||
<textarea ref="memberTextareaRef" v-model="memberEditValue" class="dbx-editor-font-family min-h-0 flex-1 resize-none bg-background p-5 text-[13px] leading-6 outline-none" :readonly="savingMember" spellcheck="false" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex h-9 items-center gap-2 border-b px-5 text-xs">
|
||||
@@ -1625,7 +1861,7 @@ onBeforeUnmount(() => {
|
||||
<Switch size="sm" :model-value="redisJsonWordWrap" @update:model-value="setRedisJsonWordWrap(Boolean($event))" />
|
||||
</label>
|
||||
</div>
|
||||
<RedisJsonEditor v-if="isEditingHashJson" v-model="memberEditValue" class="min-h-0 flex-1" :save-disabled="savingMember || !memberValueChanged" :read-only="savingMember" :word-wrap="redisJsonWordWrap" @save="saveMemberEdit" />
|
||||
<RedisJsonEditor v-if="isEditingHashJson" ref="memberJsonEditorRef" v-model="memberEditValue" class="min-h-0 flex-1" :save-disabled="savingMember || !memberValueChanged" :read-only="savingMember" :word-wrap="redisJsonWordWrap" :enable-builtin-find="false" @save="saveMemberEdit" />
|
||||
<div v-else-if="memberValueView === 'json' && selectedMemberDetail.json" class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background p-5 text-[13px] leading-6">
|
||||
<JsonTree :value="selectedMemberDetail.json.value" :word-wrap="redisJsonWordWrap" :highlight-json="highlightRedisJson" />
|
||||
</div>
|
||||
@@ -1637,10 +1873,13 @@ onBeforeUnmount(() => {
|
||||
<span>{{ t("grid.hexViewer") }}</span>
|
||||
<span>{{ t("grid.hexViewerByteCount", { count: selectedMemberDetail.byteCount }) }}</span>
|
||||
</div>
|
||||
<pre v-if="selectedMemberDetail.hexRows.length > 0" class="dbx-editor-font-family w-full min-w-0 max-w-full select-all whitespace-pre-wrap break-all">{{ detailTextForFormat(selectedMemberDetail, "hex") }}</pre>
|
||||
<pre v-if="selectedMemberDetail.hexRows.length > 0 && canHighlightMemberSurface" class="dbx-editor-font-family w-full min-w-0 max-w-full select-all whitespace-pre-wrap break-all" v-html="contentSearchHighlightedHtml" />
|
||||
<pre v-else-if="selectedMemberDetail.hexRows.length > 0" class="dbx-editor-font-family w-full min-w-0 max-w-full select-all whitespace-pre-wrap break-all">{{ detailTextForFormat(selectedMemberDetail, "hex") }}</pre>
|
||||
<div v-else class="text-muted-foreground">{{ t("grid.hexViewerEmpty") }}</div>
|
||||
</div>
|
||||
<pre v-else-if="memberValueView === 'base64' && canHighlightMemberSurface" class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-5 text-[13px] leading-6 whitespace-pre-wrap break-all" v-html="contentSearchHighlightedHtml" />
|
||||
<pre v-else-if="memberValueView === 'base64'" class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-5 text-[13px] leading-6 whitespace-pre-wrap break-all">{{ selectedMemberDetail.base64Text }}</pre>
|
||||
<pre v-else-if="canHighlightMemberSurface" class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-5 text-[13px] leading-6" :class="detailTextClass(memberValueView)" v-html="contentSearchHighlightedHtml" />
|
||||
<pre v-else class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-5 text-[13px] leading-6" :class="detailTextClass(memberValueView)">{{ detailTextForFormat(selectedMemberDetail, memberValueView) }}</pre>
|
||||
</template>
|
||||
<DialogFooter class="mx-0 mb-0 shrink-0 border-t px-5 py-3">
|
||||
@@ -1677,3 +1916,31 @@ onBeforeUnmount(() => {
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.document-search-match),
|
||||
:deep(.redis-value-search-match) {
|
||||
border-radius: 2px;
|
||||
background: #fde68a;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.document-search-match-active),
|
||||
:deep(.redis-value-search-match-active) {
|
||||
background: #f59e0b;
|
||||
color: #111827;
|
||||
outline: 1px solid #d97706;
|
||||
}
|
||||
|
||||
:global(.dark) :deep(.document-search-match),
|
||||
:global(.dark) :deep(.redis-value-search-match) {
|
||||
background: #854d0e;
|
||||
}
|
||||
|
||||
:global(.dark) :deep(.document-search-match-active),
|
||||
:global(.dark) :deep(.redis-value-search-match-active) {
|
||||
background: #fbbf24;
|
||||
color: #111827;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
import { shallowRef, onBeforeUnmount, getCurrentInstance, type ShallowRef, createApp, watch } from "vue";
|
||||
import { EditorState, Compartment } from "@codemirror/state";
|
||||
import { EditorSelection, EditorState, Compartment } from "@codemirror/state";
|
||||
import { EditorView, keymap, drawSelection, dropCursor, highlightSpecialChars, highlightActiveLine, highlightActiveLineGutter, lineNumbers } from "@codemirror/view";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { search as cmSearch } from "@codemirror/search";
|
||||
@@ -31,6 +31,11 @@ export interface UseCellDetailEditorOptions {
|
||||
lineWrapping?: () => boolean;
|
||||
/** Add CodeMirror fold controls and keyboard bindings for structured source. */
|
||||
folding?: boolean;
|
||||
/**
|
||||
* When false, Mod+F / replace are not bound so a parent (e.g. Redis value viewer)
|
||||
* can own the unified find surface. Default true for grid cell editors.
|
||||
*/
|
||||
enableBuiltinFind?: boolean;
|
||||
editorTheme: () => EditorTheme;
|
||||
appAppearance: () => AppThemeAppearance;
|
||||
appPalette: () => AppThemePalette;
|
||||
@@ -44,6 +49,7 @@ export interface UseCellDetailEditorReturn {
|
||||
getValue: () => string;
|
||||
openSearch: () => boolean;
|
||||
openReplace: () => boolean;
|
||||
selectRange: (from: number, to: number, options?: { focus?: boolean }) => boolean;
|
||||
destroy: () => void;
|
||||
view: Readonly<ShallowRef<EditorView | null>>;
|
||||
}
|
||||
@@ -224,16 +230,21 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...(options.folding ? foldKeymap : []),
|
||||
{
|
||||
key: shortcutToCodeMirrorKey(shortcuts.find),
|
||||
preventDefault: true,
|
||||
run: () => openSearch(),
|
||||
},
|
||||
{
|
||||
key: shortcutToCodeMirrorKey(shortcuts.replace),
|
||||
preventDefault: true,
|
||||
run: () => openReplace(),
|
||||
},
|
||||
// Callers may disable find so a parent surface (e.g. RedisValueViewer) owns Mod+F.
|
||||
...(options.enableBuiltinFind === false
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key: shortcutToCodeMirrorKey(shortcuts.find),
|
||||
preventDefault: true,
|
||||
run: () => openSearch(),
|
||||
},
|
||||
{
|
||||
key: shortcutToCodeMirrorKey(shortcuts.replace),
|
||||
preventDefault: true,
|
||||
run: () => openReplace(),
|
||||
},
|
||||
]),
|
||||
]),
|
||||
languageComp.of(currentIsJson ? json() : []),
|
||||
lineWrappingComp.of(options.lineWrapping?.() ? EditorView.lineWrapping : []),
|
||||
@@ -339,6 +350,21 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel
|
||||
return (searchInstance as any)?.openReplace?.() ?? false;
|
||||
}
|
||||
|
||||
function selectRange(from: number, to: number, options?: { focus?: boolean }): boolean {
|
||||
const editor = view.value;
|
||||
if (!editor || destroyed) return false;
|
||||
const max = editor.state.doc.length;
|
||||
const start = Math.max(0, Math.min(from, max));
|
||||
const end = Math.max(0, Math.min(to, max));
|
||||
editor.dispatch({
|
||||
selection: EditorSelection.range(start, end),
|
||||
scrollIntoView: true,
|
||||
});
|
||||
// Default focus for explicit navigation; callers can pass focus:false while typing in a find box.
|
||||
if (options?.focus !== false) editor.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
const alreadyDestroyed = destroyed;
|
||||
destroyed = true;
|
||||
@@ -365,5 +391,5 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel
|
||||
});
|
||||
}
|
||||
|
||||
return { create, setValue, getValue, openSearch, openReplace, destroy, view };
|
||||
return { create, setValue, getValue, openSearch, openReplace, selectRange, destroy, view };
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/** Match helpers for Redis STRING value Ctrl+F (in-content find only). */
|
||||
|
||||
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, "\\$&");
|
||||
}
|
||||
Reference in new issue
Block a user