feat(redis): add in-content search for string values

This commit is contained in:
onenewcode authored and GitHub committed 2026-07-24 09:32:30 +08:00
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}