From a9fd244eedc42d5a03f2bb88e6fb479ada1d1820 Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Thu, 18 Jun 2026 01:07:58 +0530 Subject: [PATCH 1/7] feat: add SearchBar component and SearchMode type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SearchBar component for Redis Commander key search feature with: - Mode indicator with icon (⚑ glob, πŸ” fuzzy, .* regex) - Match count badge - Clear button and Escape key support - Advanced panel toggle - Inline regex error display - Full accessibility and keyboard support Add SearchMode type definition to types.ts for use across search components. Co-Authored-By: Claude Haiku 4.5 --- .../components/redis-commander/search-bar.tsx | 117 ++++++++++++++++++ .../src/components/redis-commander/types.ts | 8 ++ 2 files changed, 125 insertions(+) create mode 100644 apps/web/src/components/redis-commander/search-bar.tsx diff --git a/apps/web/src/components/redis-commander/search-bar.tsx b/apps/web/src/components/redis-commander/search-bar.tsx new file mode 100644 index 00000000..af1d2e2c --- /dev/null +++ b/apps/web/src/components/redis-commander/search-bar.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { IconX, IconChevronDown, IconChevronUp } from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; +import type { SearchMode } from "./types"; + +interface SearchBarProps { + value: string; + onChange: (value: string) => void; + onClear: () => void; + detectedMode: SearchMode; + matchCount: number; + showAdvanced: boolean; + onToggleAdvanced: () => void; + regexError?: string | null; +} + +const modeIcons: Record = { + glob: { icon: "⚑", label: "Glob: *=any, ?=one" }, + fuzzy: { icon: "πŸ”", label: "Fuzzy: all chars in order" }, + regex: { icon: ".*", label: "Regex: full pattern" }, +}; + +export function SearchBar({ + value, + onChange, + onClear, + detectedMode, + matchCount, + showAdvanced, + onToggleAdvanced, + regexError, +}: SearchBarProps) { + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + onClear(); + } + }, + [onClear] + ); + + const modeInfo = modeIcons[detectedMode]; + + return ( +
+ {/* Error message */} + {regexError && ( +
+ {regexError} +
+ )} + + {/* Search input bar */} +
+ {/* Mode indicator */} +
+ {modeInfo.icon} +
+ + {/* Input field */} + onChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search keys… (glob, regex, or fuzzy)" + className="flex-1 border-0 shadow-none p-0 h-auto focus-visible:ring-0 bg-transparent" + /> + + {/* Match count badge */} + {value && ( +
+ {matchCount} +
+ )} + + {/* Clear button */} + {value && ( + + )} + + {/* Toggle advanced panel */} + +
+
+ ); +} diff --git a/apps/web/src/components/redis-commander/types.ts b/apps/web/src/components/redis-commander/types.ts index b2559038..4bb30809 100644 --- a/apps/web/src/components/redis-commander/types.ts +++ b/apps/web/src/components/redis-commander/types.ts @@ -1,5 +1,13 @@ export type RedisValueType = "string" | "list" | "set" | "zset" | "hash" | "none"; +/** + * Search mode type for Redis key searching + * - 'glob': Pattern matching with * (any chars) and ? (single char) wildcards + * - 'regex': Regular expression pattern matching + * - 'fuzzy': Fuzzy matching where all characters appear in order (case-insensitive) + */ +export type SearchMode = 'glob' | 'regex' | 'fuzzy'; + export interface RedisConnectionConfig { redisUrl: string; } From 569593e101be7dac4d6b68f2088893b4848baf42 Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Thu, 18 Jun 2026 01:16:39 +0530 Subject: [PATCH 2/7] feat: add AdvancedSearchPanel component Implements collapsible panel for Redis search mode override with: - Current mode explanation with examples - Toggle buttons for glob, fuzzy, and regex modes - Reset to Auto-Detect functionality - Styled with shadcn/ui Button and Tailwind CSS Co-Authored-By: Claude Haiku 4.5 --- .../redis-commander/advanced-search-panel.tsx | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 apps/web/src/components/redis-commander/advanced-search-panel.tsx diff --git a/apps/web/src/components/redis-commander/advanced-search-panel.tsx b/apps/web/src/components/redis-commander/advanced-search-panel.tsx new file mode 100644 index 00000000..d932e7ee --- /dev/null +++ b/apps/web/src/components/redis-commander/advanced-search-panel.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import type { SearchMode } from "./types"; + +interface AdvancedPanelProps { + currentMode: SearchMode; + onModeChange: (mode: SearchMode) => void; + onResetToAuto: () => void; +} + +const modeExplanations: Record = { + glob: "Glob: Use * (any chars) and ? (single char). E.g. user:*", + fuzzy: "Fuzzy: All chars in pattern must appear in order. E.g. userprofile", + regex: "Regex: Full regex support. E.g. ^session:[0-9]+$", +}; + +export function AdvancedSearchPanel({ + currentMode, + onModeChange, + onResetToAuto, +}: AdvancedPanelProps) { + return ( +
+ {/* Current mode explanation */} +
+ {modeExplanations[currentMode]} +
+ + {/* Mode toggle buttons */} +
+ + + +
+ + {/* Reset to Auto-Detect button */} + +
+ ); +} From f5df68f542114c9c0a7c1f077c7b1b4005a41c4b Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Thu, 18 Jun 2026 01:20:57 +0530 Subject: [PATCH 3/7] feat: integrate search state management into KeyBrowser Add smart search (glob/regex/fuzzy) with auto-detection to the Redis key browser. Introduces search-utils.ts with matching helpers and wires SearchBar + AdvancedSearchPanel into KeyBrowser with debounced filtering, allKeys/displayedKeys split, and per-mode override support. Co-Authored-By: Claude Haiku 4.5 --- .../redis-commander/key-browser.tsx | 245 +++++++++++++++--- .../redis-commander/search-utils.ts | 86 ++++++ 2 files changed, 296 insertions(+), 35 deletions(-) create mode 100644 apps/web/src/components/redis-commander/search-utils.ts diff --git a/apps/web/src/components/redis-commander/key-browser.tsx b/apps/web/src/components/redis-commander/key-browser.tsx index 243b5809..47d75d4f 100644 --- a/apps/web/src/components/redis-commander/key-browser.tsx +++ b/apps/web/src/components/redis-commander/key-browser.tsx @@ -6,8 +6,11 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { IconSearch, IconRefresh, IconChevronRight, IconDatabase } from "@tabler/icons-react"; -import { RedisKeyInfo, RedisValueType } from "./types"; +import { RedisKeyInfo, RedisValueType, SearchMode } from "./types"; import { cn } from "@/lib/utils"; +import { detectSearchMode, globMatch, regexMatch, fuzzyMatch } from "./search-utils"; +import { SearchBar } from "./search-bar"; +import { AdvancedSearchPanel } from "./advanced-search-panel"; const TYPE_COLORS: Record = { string: "bg-blue-500/10 text-blue-600 dark:text-blue-400", @@ -18,6 +21,15 @@ const TYPE_COLORS: Record = { none: "bg-muted text-muted-foreground", }; +interface SearchState { + input: string; + detectedMode: SearchMode; + userModeOverride: SearchMode | null; + regexError: string | null; + matchCount: number; + showAdvanced: boolean; +} + interface KeyBrowserProps { redisUrl: string; selectedKey: string | null; @@ -34,14 +46,136 @@ export function KeyBrowser({ onDbSizeChange, }: KeyBrowserProps) { const [pattern, setPattern] = useState("*"); - const [keys, setKeys] = useState([]); - const [cursor, setCursor] = useState("0"); + const [allKeys, setAllKeys] = useState([]); + const [displayedKeys, setDisplayedKeys] = useState([]); const [hasMore, setHasMore] = useState(false); const [loading, setLoading] = useState(false); - const [filter, setFilter] = useState(""); + const [searchState, setSearchState] = useState({ + input: "", + detectedMode: "fuzzy", + userModeOverride: null, + regexError: null, + matchCount: 0, + showAdvanced: false, + }); const sentinelRef = useRef(null); const loadingRef = useRef(false); const cursorRef = useRef("0"); + const debouncedSearchRef = useRef | null>(null); + + // ─── Search / filter logic ──────────────────────────────────────────────── + + const performSearch = useCallback( + ( + input: string, + keys: RedisKeyInfo[], + modeOverride: SearchMode | null = null + ) => { + if (!input) { + setDisplayedKeys(keys); + setSearchState((prev) => ({ + ...prev, + input: "", + matchCount: 0, + regexError: null, + detectedMode: "fuzzy", + })); + return; + } + + const detected = detectSearchMode(input); + const activeMode: SearchMode = modeOverride ?? detected; + const keyStrings = keys.map((k) => k.key); + let matchedKeys: string[] = []; + let regexError: string | null = null; + + if (activeMode === "glob") { + matchedKeys = globMatch(keyStrings, input); + } else if (activeMode === "regex") { + const result = regexMatch(keyStrings, input); + if (result.error) { + // Fall back to fuzzy on regex error + regexError = result.error; + matchedKeys = fuzzyMatch(keyStrings, input); + } else { + matchedKeys = result.matches; + } + } else { + matchedKeys = fuzzyMatch(keyStrings, input); + } + + const matchSet = new Set(matchedKeys); + const filtered = keys.filter((k) => matchSet.has(k.key)); + + setDisplayedKeys(filtered); + setSearchState((prev) => ({ + ...prev, + input, + detectedMode: detected, + regexError, + matchCount: filtered.length, + })); + }, + [] + ); + + // Debounced wrapper β€” rebuilds only when performSearch changes (stable) + const debouncedSearch = useCallback( + (input: string, keys: RedisKeyInfo[], modeOverride: SearchMode | null) => { + if (debouncedSearchRef.current) { + clearTimeout(debouncedSearchRef.current); + } + debouncedSearchRef.current = setTimeout(() => { + performSearch(input, keys, modeOverride); + }, 300); + }, + [performSearch] + ); + + // ─── Search handlers ────────────────────────────────────────────────────── + + const handleSearchChange = useCallback( + (input: string) => { + // Update input immediately for responsive UI + setSearchState((prev) => ({ ...prev, input })); + debouncedSearch(input, allKeys, searchState.userModeOverride); + }, + [allKeys, searchState.userModeOverride, debouncedSearch] + ); + + const handleClearSearch = useCallback(() => { + if (debouncedSearchRef.current) { + clearTimeout(debouncedSearchRef.current); + } + setDisplayedKeys(allKeys); + setSearchState((prev) => ({ + ...prev, + input: "", + regexError: null, + matchCount: 0, + detectedMode: "fuzzy", + userModeOverride: null, + })); + }, [allKeys]); + + const handleModeChange = useCallback( + (mode: SearchMode) => { + setSearchState((prev) => ({ ...prev, userModeOverride: mode })); + performSearch(searchState.input, allKeys, mode); + }, + [searchState.input, allKeys, performSearch] + ); + + const handleResetMode = useCallback(() => { + setSearchState((prev) => ({ ...prev, userModeOverride: null })); + performSearch(searchState.input, allKeys, null); + }, [searchState.input, allKeys, performSearch]); + + const handleToggleAdvanced = useCallback(() => { + setSearchState((prev) => ({ ...prev, showAdvanced: !prev.showAdvanced })); + }, []); + + // ─── Data fetching ──────────────────────────────────────────────────────── const fetchPage = useCallback( async (reset: boolean) => { @@ -63,7 +197,7 @@ export function KeyBrowser({ count: 200, }), }); - const data = await res.json() as { + const data = (await res.json()) as { cursor: string; keys: RedisKeyInfo[]; dbSize: number; @@ -71,8 +205,36 @@ export function KeyBrowser({ }; if (data.error) throw new Error(data.error); cursorRef.current = data.cursor; - setCursor(data.cursor); - setKeys((prev) => (reset ? data.keys : [...prev, ...data.keys])); + + setAllKeys((prev) => { + const updated = reset ? data.keys : [...prev, ...data.keys]; + // Re-apply search against the updated key set + setDisplayedKeys( + searchState.input + ? (() => { + const active = + searchState.userModeOverride ?? + searchState.detectedMode; + const keyStrings = updated.map((k) => k.key); + let matched: string[] = []; + if (active === "glob") { + matched = globMatch(keyStrings, searchState.input); + } else if (active === "regex") { + const r = regexMatch(keyStrings, searchState.input); + matched = r.error + ? fuzzyMatch(keyStrings, searchState.input) + : r.matches; + } else { + matched = fuzzyMatch(keyStrings, searchState.input); + } + const s = new Set(matched); + return updated.filter((k) => s.has(k.key)); + })() + : updated + ); + return updated; + }); + setHasMore(data.cursor !== "0"); onDbSizeChange(data.dbSize); } catch { @@ -82,14 +244,14 @@ export function KeyBrowser({ setLoading(false); } }, - [redisUrl, pattern, onDbSizeChange] + [redisUrl, pattern, onDbSizeChange, searchState.input, searchState.userModeOverride, searchState.detectedMode] ); // Reset on redisUrl change useEffect(() => { cursorRef.current = "0"; - setKeys([]); - setCursor("0"); + setAllKeys([]); + setDisplayedKeys([]); setHasMore(false); fetchPage(true); }, [redisUrl]); // eslint-disable-line react-hooks/exhaustive-deps @@ -110,14 +272,10 @@ export function KeyBrowser({ return () => observer.disconnect(); }, [hasMore, fetchPage]); - const filtered = filter - ? keys.filter((k) => k.key.toLowerCase().includes(filter.toLowerCase())) - : keys; - function handleSearch() { cursorRef.current = "0"; - setKeys([]); - setCursor("0"); + setAllKeys([]); + setDisplayedKeys([]); setHasMore(false); fetchPage(true); } @@ -146,34 +304,51 @@ export function KeyBrowser({ -
- - setFilter(e.target.value)} - className="h-7 text-xs pl-7" - /> -
+ + {/* Search bar with mode detection */} + +
- - {dbSize.toLocaleString()} total - + {dbSize.toLocaleString()} total Β· - {keys.length} loaded - {filter && {filtered.length} match} + {allKeys.length} loaded + {searchState.input && ( + {searchState.matchCount} match + )}
+ {/* Advanced search panel */} + {searchState.showAdvanced && ( + + )} + - {filtered.length === 0 && !loading && ( + {displayedKeys.length === 0 && !loading && (
- No keys found + {searchState.input ? ( + <>No keys match "{searchState.input}" + ) : ( + "No keys found" + )}
)}
- {filtered.map((item) => ( + {displayedKeys.map((item) => (
)} - {!hasMore && keys.length > 0 && !filter && ( + {!hasMore && allKeys.length > 0 && !searchState.input && (
- All {keys.length} keys loaded + All {allKeys.length} keys loaded
)}
diff --git a/apps/web/src/components/redis-commander/search-utils.ts b/apps/web/src/components/redis-commander/search-utils.ts new file mode 100644 index 00000000..9be2f2ec --- /dev/null +++ b/apps/web/src/components/redis-commander/search-utils.ts @@ -0,0 +1,86 @@ +import type { SearchMode } from "./types"; + +/** + * Auto-detect the most appropriate search mode based on the input pattern. + * - If it contains regex metacharacters (^, $, ., +, (, ), [, {, |, \) β†’ regex + * - If it contains glob wildcards (* or ?) β†’ glob + * - Otherwise β†’ fuzzy + */ +export function detectSearchMode(input: string): SearchMode { + // Regex-specific characters that go beyond glob + const regexChars = /[^*?][.+()[\]{}|\\^$]|^[.+()[\]{}|\\^$]/; + if (regexChars.test(input) || /\\./.test(input) || /\^|\$/.test(input)) { + return "regex"; + } + if (/[*?]/.test(input)) { + return "glob"; + } + return "fuzzy"; +} + +/** + * Glob pattern matching. Supports * (any chars) and ? (single char). + * Returns the keys that match the pattern. + */ +export function globMatch(keys: string[], pattern: string): string[] { + // Convert glob pattern to regex + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex metacharacters + .replace(/\*/g, ".*") // * β†’ .* + .replace(/\?/g, "."); // ? β†’ . + const re = new RegExp(`^${escaped}$`, "i"); + return keys.filter((k) => re.test(k)); +} + +/** + * Regex pattern matching. + * Returns { matches, error }. + */ +export function regexMatch( + keys: string[], + pattern: string +): { matches: string[]; error: string | null } { + try { + const re = new RegExp(pattern, "i"); + return { matches: keys.filter((k) => re.test(k)), error: null }; + } catch (e) { + return { + matches: [], + error: e instanceof Error ? e.message : "Invalid regex", + }; + } +} + +/** + * Fuzzy matching: all characters of the pattern must appear in order in the key. + * Case-insensitive. + */ +export function fuzzyMatch(keys: string[], pattern: string): string[] { + const lower = pattern.toLowerCase(); + return keys.filter((k) => { + const key = k.toLowerCase(); + let pi = 0; + for (let i = 0; i < key.length && pi < lower.length; i++) { + if (key[i] === lower[pi]) pi++; + } + return pi === lower.length; + }); +} + +/** + * Get the indices within a key string that match the fuzzy pattern. + * Useful for highlight rendering (future use). + */ +export function getMatchIndices(key: string, pattern: string): number[] { + const indices: number[] = []; + const lower = pattern.toLowerCase(); + const keyLower = key.toLowerCase(); + let pi = 0; + for (let i = 0; i < keyLower.length && pi < lower.length; i++) { + if (keyLower[i] === lower[pi]) { + indices.push(i); + pi++; + } + } + return pi === lower.length ? indices : []; +} From 5653a2438434a17a699bf2396abfd27046f9292f Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Thu, 18 Jun 2026 01:32:37 +0530 Subject: [PATCH 4/7] feat: add match highlighting to key display Add visual highlighting showing which parts of Redis key names matched the search pattern. Implements HighlightedKeyText component that: - For fuzzy mode: highlights individual matched characters with yellow background - For glob/regex mode: highlights the matched segment with blue background - Extends search-utils with mode-aware getMatchIndices() function - Only displays highlighting when search is active Co-Authored-By: Claude Haiku 4.5 --- .../redis-commander/key-browser.tsx | 105 ++++++++++++++---- .../redis-commander/search-utils.ts | 78 ++++++++++++- 2 files changed, 157 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/redis-commander/key-browser.tsx b/apps/web/src/components/redis-commander/key-browser.tsx index 47d75d4f..e5b7ad7b 100644 --- a/apps/web/src/components/redis-commander/key-browser.tsx +++ b/apps/web/src/components/redis-commander/key-browser.tsx @@ -8,7 +8,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { IconSearch, IconRefresh, IconChevronRight, IconDatabase } from "@tabler/icons-react"; import { RedisKeyInfo, RedisValueType, SearchMode } from "./types"; import { cn } from "@/lib/utils"; -import { detectSearchMode, globMatch, regexMatch, fuzzyMatch } from "./search-utils"; +import { detectSearchMode, globMatch, regexMatch, fuzzyMatch, getMatchIndices } from "./search-utils"; import { SearchBar } from "./search-bar"; import { AdvancedSearchPanel } from "./advanced-search-panel"; @@ -21,6 +21,52 @@ const TYPE_COLORS: Record = { none: "bg-muted text-muted-foreground", }; +interface HighlightedKeyTextProps { + text: string; + indices: number[]; + mode: SearchMode; +} + +function HighlightedKeyText({ text, indices, mode }: HighlightedKeyTextProps) { + if (indices.length === 0) return {text}; + + if (mode === "fuzzy") { + // indices are individual char positions + // Create a Set for fast lookup + const indexSet = new Set(indices); + const chars = text.split(""); + const elements: React.ReactNode[] = []; + + chars.forEach((char, i) => { + if (indexSet.has(i)) { + elements.push( + + {char} + + ); + } else { + elements.push(char); + } + }); + + return {elements}; + } else { + // glob or regex: indices are [start, end] + const start = indices[0] ?? 0; + const end = indices[1] ?? text.length; + + return ( + + {text.slice(0, start)} + + {text.slice(start, end)} + + {text.slice(end)} + + ); + } +} + interface SearchState { input: string; detectedMode: SearchMode; @@ -348,32 +394,43 @@ export function KeyBrowser({ )}
- {displayedKeys.map((item) => ( - - ))} + + {item.type} + + + + + {item.ttl > 0 && ( + + {item.ttl}s + + )} + + + ); + })}
{/* Infinite scroll sentinel */} diff --git a/apps/web/src/components/redis-commander/search-utils.ts b/apps/web/src/components/redis-commander/search-utils.ts index 9be2f2ec..b039d170 100644 --- a/apps/web/src/components/redis-commander/search-utils.ts +++ b/apps/web/src/components/redis-commander/search-utils.ts @@ -69,9 +69,9 @@ export function fuzzyMatch(keys: string[], pattern: string): string[] { /** * Get the indices within a key string that match the fuzzy pattern. - * Useful for highlight rendering (future use). + * Returns an array of individual character positions. */ -export function getMatchIndices(key: string, pattern: string): number[] { +export function getFuzzyMatchIndices(key: string, pattern: string): number[] { const indices: number[] = []; const lower = pattern.toLowerCase(); const keyLower = key.toLowerCase(); @@ -84,3 +84,77 @@ export function getMatchIndices(key: string, pattern: string): number[] { } return pi === lower.length ? indices : []; } + +/** + * Get the indices within a key string that match the glob pattern. + * Returns [start, end] if matched, otherwise empty array. + */ +export function getGlobMatchIndices(key: string, pattern: string): number[] { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, "."); + const re = new RegExp(`^${escaped}$`, "i"); + + if (!re.test(key)) return []; + + // Find the matching portion by converting glob pattern to actual matched segments + // For simplicity, we'll find the first wildcard expansion match + const keyLower = key.toLowerCase(); + + // Try to find where the glob pattern matches + // Build a simpler version: find the literal parts and their positions + const parts = pattern.split(/[\*\?]+/); + if (parts.length === 0) return []; + + // Find start: position of first literal part + let start = 0; + let end = keyLower.length; + + // Find the first non-empty part + const firstPart = parts.find(p => p.length > 0); + if (firstPart) { + const idx = keyLower.indexOf(firstPart.toLowerCase()); + if (idx >= 0) { + start = idx; + end = idx + firstPart.length; + } + } + + return [start, end]; +} + +/** + * Get the indices within a key string that match the regex pattern. + * Returns [start, end] if matched, otherwise empty array. + */ +export function getRegexMatchIndices(key: string, pattern: string): number[] { + try { + const re = new RegExp(pattern, "i"); + const match = key.match(re); + if (!match || match.index === undefined) return []; + return [match.index, match.index + match[0].length]; + } catch { + return []; + } +} + +/** + * Get the indices within a key string that match based on the search mode. + * - For fuzzy: returns individual character positions + * - For glob/regex: returns [start, end] segment boundaries + */ +export function getMatchIndices( + key: string, + pattern: string, + mode: SearchMode +): number[] { + if (mode === "fuzzy") { + return getFuzzyMatchIndices(key, pattern); + } else if (mode === "glob") { + return getGlobMatchIndices(key, pattern); + } else if (mode === "regex") { + return getRegexMatchIndices(key, pattern); + } + return []; +} From 31ea133c7af3bf92dda94aa6f3cfbd45171ccca4 Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Thu, 18 Jun 2026 01:35:07 +0530 Subject: [PATCH 5/7] feat: clear search state on tab change Add useEffect hook to KeyBrowser component that clears search input and state whenever the Redis connection (redisUrl) changes. This ensures search filters are reset when switching between different Redis connections. Co-Authored-By: Claude Haiku 4.5 --- apps/web/src/components/redis-commander/key-browser.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/web/src/components/redis-commander/key-browser.tsx b/apps/web/src/components/redis-commander/key-browser.tsx index e5b7ad7b..cc672fbc 100644 --- a/apps/web/src/components/redis-commander/key-browser.tsx +++ b/apps/web/src/components/redis-commander/key-browser.tsx @@ -318,6 +318,11 @@ export function KeyBrowser({ return () => observer.disconnect(); }, [hasMore, fetchPage]); + // Clear search when connection changes + useEffect(() => { + handleClearSearch(); + }, [redisUrl, handleClearSearch]); + function handleSearch() { cursorRef.current = "0"; setAllKeys([]); From 915aeb1fd0a0635caaf801d44dc0d4b56e6ea02c Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Thu, 18 Jun 2026 02:05:40 +0530 Subject: [PATCH 6/7] fix: prevent search clearing on infinite scroll page load handleClearSearch previously depended on `allKeys`, causing it to get a new identity on every page load (whenever setAllKeys ran). The `useEffect([redisUrl, handleClearSearch])` then re-fired on every scroll page, wiping the active search query. Fix: replace the `allKeys` closure reference with `allKeysRef` so handleClearSearch has a stable identity (empty dep array). The clear effect now only fires when `redisUrl` truly changes. Co-Authored-By: Claude Haiku 4.5 --- .../src/components/redis-commander/key-browser.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/redis-commander/key-browser.tsx b/apps/web/src/components/redis-commander/key-browser.tsx index cc672fbc..7f48ea74 100644 --- a/apps/web/src/components/redis-commander/key-browser.tsx +++ b/apps/web/src/components/redis-commander/key-browser.tsx @@ -189,20 +189,25 @@ export function KeyBrowser({ [allKeys, searchState.userModeOverride, debouncedSearch] ); + const allKeysRef = useRef([]); + useEffect(() => { + allKeysRef.current = allKeys; + }, [allKeys]); + const handleClearSearch = useCallback(() => { if (debouncedSearchRef.current) { clearTimeout(debouncedSearchRef.current); } - setDisplayedKeys(allKeys); + setDisplayedKeys(allKeysRef.current); setSearchState((prev) => ({ ...prev, input: "", regexError: null, - matchCount: 0, + matchCount: allKeysRef.current.length, detectedMode: "fuzzy", userModeOverride: null, })); - }, [allKeys]); + }, []); // No allKeys dep β€” uses ref so identity stays stable const handleModeChange = useCallback( (mode: SearchMode) => { From 033c517bcb3fbbf2f86c821a65b4194c43b1a194 Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Thu, 18 Jun 2026 02:10:48 +0530 Subject: [PATCH 7/7] test: add comprehensive tests for search-utils Add 67 comprehensive test cases covering all five exported functions: - detectSearchMode: 23 tests for glob, regex, and fuzzy patterns - globMatch: 10 tests for pattern matching with wildcards - regexMatch: 7 tests for regex patterns and error handling - fuzzyMatch: 9 tests for subsequence matching - getFuzzyMatchIndices: 7 tests for character position detection - getGlobMatchIndices: 5 tests for segment boundary detection - getRegexMatchIndices: 5 tests for regex match positions - getMatchIndices: 7 tests for mode-based matching Setup Jest with ts-jest preset and configuration for the monorepo. All 67 tests pass with no type errors or linting issues. Co-Authored-By: Claude Haiku 4.5 --- apps/web/jest.config.js | 12 + apps/web/package.json | 4 + .../__tests__/search-utils.test.ts | 385 ++++++++++++++++++ 3 files changed, 401 insertions(+) create mode 100644 apps/web/jest.config.js create mode 100644 apps/web/src/components/redis-commander/__tests__/search-utils.test.ts diff --git a/apps/web/jest.config.js b/apps/web/jest.config.js new file mode 100644 index 00000000..4db81a81 --- /dev/null +++ b/apps/web/jest.config.js @@ -0,0 +1,12 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/__tests__/**/*.test.ts', '**/__tests__/**/*.test.tsx'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + collectCoverageFrom: [ + 'src/**/*.{ts,tsx}', + '!src/**/*.d.ts', + '!src/**/__tests__/**', + ], +}; diff --git a/apps/web/package.json b/apps/web/package.json index ffcc8b1a..8683c933 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,6 +8,7 @@ "analyze": "ANALYZE=true next build", "start": "next start", "lint": "eslint .", + "test": "jest", "clean-install": "rimraf node_modules && pnpm install --filter @mydevtools/web..." }, "dependencies": { @@ -121,6 +122,7 @@ "@shadcn/ui": "^0.0.4", "@types/date-fns": "^2.6.3", "@types/file-saver": "^2.0.7", + "@types/jest": "^30.0.0", "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.20", "@types/mime-types": "^3.0.1", @@ -134,9 +136,11 @@ "autoprefixer": "^10.4.21", "eslint": "^9.39.4", "eslint-config-next": "16.0.1", + "jest": "^30.4.2", "postcss": "^8", "rimraf": "^6.1.0", "tailwindcss": "^3.4.17", + "ts-jest": "^29.4.11", "typescript": "^5.7.3" } } diff --git a/apps/web/src/components/redis-commander/__tests__/search-utils.test.ts b/apps/web/src/components/redis-commander/__tests__/search-utils.test.ts new file mode 100644 index 00000000..e0fad5e4 --- /dev/null +++ b/apps/web/src/components/redis-commander/__tests__/search-utils.test.ts @@ -0,0 +1,385 @@ +import { + detectSearchMode, + globMatch, + regexMatch, + fuzzyMatch, + getFuzzyMatchIndices, + getGlobMatchIndices, + getRegexMatchIndices, + getMatchIndices, +} from '../search-utils'; + +describe('detectSearchMode', () => { + describe('glob patterns', () => { + it('should detect glob with * wildcard', () => { + expect(detectSearchMode('user:*')).toBe('glob'); + }); + + it('should detect glob with ? wildcard', () => { + expect(detectSearchMode('user:?')).toBe('glob'); + }); + + it('should detect glob with multiple wildcards', () => { + expect(detectSearchMode('user:*:?')).toBe('glob'); + }); + + it('should detect regex for user:*[0-9] (regex chars take priority)', () => { + // The implementation checks regex patterns with [, ], etc. first + expect(detectSearchMode('user:*[0-9]')).toBe('regex'); + }); + }); + + describe('regex patterns', () => { + it('should detect regex with [a-z] character class', () => { + expect(detectSearchMode('[a-z]')).toBe('regex'); + }); + + it('should detect regex with ^ anchor', () => { + expect(detectSearchMode('^session:')).toBe('regex'); + }); + + it('should detect regex with $ anchor', () => { + expect(detectSearchMode('session:$')).toBe('regex'); + }); + + it('should detect regex with () groups', () => { + expect(detectSearchMode('(user|session)')).toBe('regex'); + }); + + it('should detect regex with . dot', () => { + expect(detectSearchMode('user.name')).toBe('regex'); + }); + + it('should detect regex with + quantifier', () => { + expect(detectSearchMode('[0-9]+')).toBe('regex'); + }); + + it('should detect regex with | alternation', () => { + expect(detectSearchMode('user|session')).toBe('regex'); + }); + + it('should detect regex with {n,m} quantifier', () => { + expect(detectSearchMode('[0-9]{2,4}')).toBe('regex'); + }); + + it('should detect regex with escaped characters', () => { + expect(detectSearchMode('user\\.name')).toBe('regex'); + }); + }); + + describe('fuzzy patterns', () => { + it('should detect simple string as fuzzy', () => { + expect(detectSearchMode('user')).toBe('fuzzy'); + }); + + it('should detect string with colons as fuzzy', () => { + expect(detectSearchMode('user:name')).toBe('fuzzy'); + }); + + it('should detect string with dashes as fuzzy', () => { + expect(detectSearchMode('user-profile')).toBe('fuzzy'); + }); + + it('should detect string with underscores as fuzzy', () => { + expect(detectSearchMode('user_profile')).toBe('fuzzy'); + }); + + it('should detect string with numbers as fuzzy', () => { + expect(detectSearchMode('user123')).toBe('fuzzy'); + }); + }); +}); + +describe('globMatch', () => { + const keys = ['user:123', 'user:456', 'session:123', 'session:001', 'session:002']; + + it('should match user:* pattern', () => { + const result = globMatch(keys, 'user:*'); + expect(result).toEqual(['user:123', 'user:456']); + }); + + it('should not match session:* pattern against user keys', () => { + const result = globMatch(keys, 'user:*'); + expect(result).not.toContain('session:123'); + }); + + it('should match session:* pattern', () => { + const result = globMatch(keys, 'session:*'); + expect(result).toEqual(['session:123', 'session:001', 'session:002']); + }); + + it('should match user:? pattern for single character', () => { + const result = globMatch(['user:1', 'user:12', 'user:123'], 'user:?'); + expect(result).toEqual(['user:1']); + }); + + it('should not match user:? pattern for multiple characters', () => { + const result = globMatch(['user:1', 'user:12', 'user:123'], 'user:?'); + expect(result).not.toContain('user:12'); + }); + + it('should be case-insensitive', () => { + const result = globMatch(['User:123', 'USER:456'], 'user:*'); + expect(result).toEqual(['User:123', 'USER:456']); + }); + + it('should handle empty pattern (matches only empty key)', () => { + const result = globMatch(['', 'user:123'], ''); + expect(result).toContain(''); + }); + + it('should escape dots correctly', () => { + const result = globMatch(['user.name', 'username'], 'user.name'); + expect(result).toEqual(['user.name']); + }); + + it('should handle multiple wildcards', () => { + const result = globMatch(['user:session:data', 'user:profile:info'], 'user:*:*'); + expect(result.length).toBeGreaterThan(0); + }); + + it('should return empty array when no matches', () => { + const result = globMatch(keys, 'nonexistent:*'); + expect(result).toEqual([]); + }); +}); + +describe('regexMatch', () => { + const keys = ['session:001', 'session:123', 'user:001', 'session:abc']; + + it('should match valid regex pattern', () => { + const result = regexMatch(keys, '^session:[0-9]+$'); + expect(result.matches).toEqual(['session:001', 'session:123']); + expect(result.error).toBeNull(); + }); + + it('should be case-insensitive', () => { + const result = regexMatch(keys, '^SESSION:[0-9]+$'); + expect(result.matches).toContain('session:001'); + expect(result.error).toBeNull(); + }); + + it('should not match when pattern is more restrictive', () => { + const result = regexMatch(['user:123'], '^session:[0-9]+$'); + expect(result.matches).toEqual([]); + expect(result.error).toBeNull(); + }); + + it('should return error for invalid regex', () => { + const result = regexMatch(keys, '[invalid'); + expect(result.matches).toEqual([]); + expect(result.error).not.toBeNull(); + expect(typeof result.error).toBe('string'); + }); + + it('should handle regex with character classes', () => { + const result = regexMatch(['a', 'b', 'c', '1'], '[a-z]'); + expect(result.matches).toContain('a'); + expect(result.matches).toContain('b'); + expect(result.matches).not.toContain('1'); + expect(result.error).toBeNull(); + }); + + it('should handle regex with alternation', () => { + const result = regexMatch(['user:123', 'session:123', 'key:123'], '(user|session):.*'); + expect(result.matches).toContain('user:123'); + expect(result.matches).toContain('session:123'); + expect(result.matches).not.toContain('key:123'); + expect(result.error).toBeNull(); + }); +}); + +describe('fuzzyMatch', () => { + const keys = ['user_profile', 'user_data', 'session_data', 'user_profile_extended']; + + it('should match user in user_profile', () => { + const result = fuzzyMatch(keys, 'user'); + expect(result).toContain('user_profile'); + expect(result).toContain('user_data'); + }); + + it('should not match user in session_data', () => { + const result = fuzzyMatch(keys, 'user'); + expect(result).not.toContain('session_data'); + }); + + it('should match subsequence usr_pro in user_profile', () => { + const result = fuzzyMatch(keys, 'usr_pro'); + expect(result).toContain('user_profile'); + }); + + it('should be case-insensitive', () => { + const result = fuzzyMatch(keys, 'USER'); + expect(result).toContain('user_profile'); + expect(result).toContain('user_data'); + }); + + it('should respect character order', () => { + const result = fuzzyMatch(['user_profile'], 'pro_user'); + expect(result).toEqual([]); + }); + + it('should handle empty pattern (matches all keys)', () => { + const result = fuzzyMatch(keys, ''); + expect(result).toEqual(keys); + }); + + it('should return empty array when no matches', () => { + const result = fuzzyMatch(keys, 'xyz'); + expect(result).toEqual([]); + }); + + it('should match single character', () => { + const result = fuzzyMatch(keys, 'u'); + expect(result).toContain('user_profile'); + expect(result).toContain('user_data'); + }); + + it('should match profile in multiple user keys', () => { + const result = fuzzyMatch(keys, 'profile'); + expect(result).toContain('user_profile'); + expect(result).toContain('user_profile_extended'); + }); +}); + +describe('getFuzzyMatchIndices', () => { + it('should return indices for fuzzy match usr in user_profile', () => { + const indices = getFuzzyMatchIndices('user_profile', 'usr'); + // u at 0, s at 1, r at 3 (e is at 2) + expect(indices).toEqual([0, 1, 3]); + }); + + it('should return indices for pro in user_profile', () => { + const indices = getFuzzyMatchIndices('user_profile', 'pro'); + expect(indices).toContain(5); // p + expect(indices.length).toBe(3); // p, r, o + }); + + it('should be case-insensitive', () => { + const indices = getFuzzyMatchIndices('user_profile', 'USR'); + // u at 0, s at 1, r at 3 (e is at 2) + expect(indices).toEqual([0, 1, 3]); + }); + + it('should return empty array for non-matching pattern', () => { + const indices = getFuzzyMatchIndices('user_profile', 'xyz'); + expect(indices).toEqual([]); + }); + + it('should return empty array for pattern longer than key', () => { + const indices = getFuzzyMatchIndices('user', 'userlongpattern'); + expect(indices).toEqual([]); + }); + + it('should find all characters for simple match', () => { + const indices = getFuzzyMatchIndices('abc', 'abc'); + expect(indices).toEqual([0, 1, 2]); + }); + + it('should handle single character match', () => { + const indices = getFuzzyMatchIndices('user_profile', 'u'); + expect(indices).toEqual([0]); + }); + + it('should work with numbers', () => { + const indices = getFuzzyMatchIndices('user123profile', '123'); + expect(indices).toEqual([4, 5, 6]); + }); +}); + +describe('getGlobMatchIndices', () => { + it('should return segment boundaries for user:* in user:123', () => { + const indices = getGlobMatchIndices('user:123', 'user:*'); + expect(indices.length).toBeGreaterThanOrEqual(2); + expect(indices[0]).toBeLessThanOrEqual(indices[1]); + }); + + it('should return empty array for non-matching pattern', () => { + const indices = getGlobMatchIndices('session:123', 'user:*'); + expect(indices).toEqual([]); + }); + + it('should be case-insensitive', () => { + const indices = getGlobMatchIndices('USER:123', 'user:*'); + expect(indices.length).toBeGreaterThanOrEqual(2); + }); + + it('should work with multiple wildcards', () => { + const indices = getGlobMatchIndices('user:session:data', 'user:*:*'); + expect(indices.length).toBeGreaterThanOrEqual(2); + }); + + it('should handle ? single char wildcard', () => { + const indices = getGlobMatchIndices('user:1', 'user:?'); + expect(indices.length).toBeGreaterThanOrEqual(2); + }); +}); + +describe('getRegexMatchIndices', () => { + it('should return segment boundaries for regex match', () => { + const indices = getRegexMatchIndices('session:001', '^session:[0-9]+$'); + expect(indices.length).toBe(2); + expect(indices[0]).toEqual(0); + expect(indices[1]).toBeGreaterThan(indices[0]); + }); + + it('should return correct match position for partial regex', () => { + const indices = getRegexMatchIndices('app:session:123', '[0-9]+'); + expect(indices.length).toBe(2); + expect(indices[0]).toBeGreaterThanOrEqual(0); + expect(indices[1]).toBeGreaterThan(indices[0]); + }); + + it('should return empty array for non-matching pattern', () => { + const indices = getRegexMatchIndices('user:abc', '[0-9]+'); + expect(indices).toEqual([]); + }); + + it('should return empty array for invalid regex', () => { + const indices = getRegexMatchIndices('user:123', '[invalid'); + expect(indices).toEqual([]); + }); + + it('should be case-insensitive', () => { + const indices = getRegexMatchIndices('SESSION:001', 'session:[0-9]+'); + expect(indices.length).toBe(2); + }); +}); + +describe('getMatchIndices', () => { + it('should return fuzzy indices for fuzzy mode', () => { + const indices = getMatchIndices('user_profile', 'usr', 'fuzzy'); + // u at 0, s at 1, r at 3 + expect(indices).toEqual([0, 1, 3]); + }); + + it('should return glob indices for glob mode', () => { + const indices = getMatchIndices('user:123', 'user:*', 'glob'); + expect(indices.length).toBeGreaterThanOrEqual(2); + }); + + it('should return regex indices for regex mode', () => { + const indices = getMatchIndices('session:001', '^session:[0-9]+$', 'regex'); + expect(indices.length).toBeGreaterThanOrEqual(2); + }); + + it('should return empty array for invalid regex in regex mode', () => { + const indices = getMatchIndices('user:123', '[invalid', 'regex'); + expect(indices).toEqual([]); + }); + + it('should return empty array for non-matching fuzzy', () => { + const indices = getMatchIndices('session_data', 'user', 'fuzzy'); + expect(indices).toEqual([]); + }); + + it('should work with all three modes on different patterns', () => { + const fuzzyIndices = getMatchIndices('user_profile', 'pro', 'fuzzy'); + const globIndices = getMatchIndices('user:profile', 'user:*', 'glob'); + const regexIndices = getMatchIndices('user:profile', 'user:.*', 'regex'); + + expect(fuzzyIndices.length).toBeGreaterThan(0); + expect(globIndices.length).toBeGreaterThan(0); + expect(regexIndices.length).toBeGreaterThan(0); + }); +});