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); + }); +}); 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 */} + +
+ ); +} diff --git a/apps/web/src/components/redis-commander/key-browser.tsx b/apps/web/src/components/redis-commander/key-browser.tsx index 243b5809..7f48ea74 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, getMatchIndices } 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,61 @@ 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; + userModeOverride: SearchMode | null; + regexError: string | null; + matchCount: number; + showAdvanced: boolean; +} + interface KeyBrowserProps { redisUrl: string; selectedKey: string | null; @@ -34,14 +92,141 @@ 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 allKeysRef = useRef([]); + useEffect(() => { + allKeysRef.current = allKeys; + }, [allKeys]); + + const handleClearSearch = useCallback(() => { + if (debouncedSearchRef.current) { + clearTimeout(debouncedSearchRef.current); + } + setDisplayedKeys(allKeysRef.current); + setSearchState((prev) => ({ + ...prev, + input: "", + regexError: null, + matchCount: allKeysRef.current.length, + detectedMode: "fuzzy", + userModeOverride: null, + })); + }, []); // No allKeys dep — uses ref so identity stays stable + + 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 +248,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 +256,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 +295,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 +323,15 @@ export function KeyBrowser({ return () => observer.disconnect(); }, [hasMore, fetchPage]); - const filtered = filter - ? keys.filter((k) => k.key.toLowerCase().includes(filter.toLowerCase())) - : keys; + // Clear search when connection changes + useEffect(() => { + handleClearSearch(); + }, [redisUrl, handleClearSearch]); function handleSearch() { cursorRef.current = "0"; - setKeys([]); - setCursor("0"); + setAllKeys([]); + setDisplayedKeys([]); setHasMore(false); fetchPage(true); } @@ -146,59 +360,87 @@ 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) => ( - - ))} + + {item.type} + + + + + {item.ttl > 0 && ( + + {item.ttl}s + + )} + + + ); + })}
{/* Infinite scroll sentinel */} @@ -209,9 +451,9 @@ export function KeyBrowser({ Loading… )} - {!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-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/search-utils.ts b/apps/web/src/components/redis-commander/search-utils.ts new file mode 100644 index 00000000..b039d170 --- /dev/null +++ b/apps/web/src/components/redis-commander/search-utils.ts @@ -0,0 +1,160 @@ +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. + * Returns an array of individual character positions. + */ +export function getFuzzyMatchIndices(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 : []; +} + +/** + * 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 []; +} 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; }