@@ -85,7 +85,7 @@ export const ListItems = ({
setSelectedListItem({ key })
}}
className={cn(
- "h-10 border-b border-b-zinc-100 transition-colors hover:bg-zinc-100 dark:border-b-zinc-200 dark:hover:bg-zinc-200"
+ "h-9 border-b border-b-zinc-100 transition-colors hover:bg-zinc-100 dark:border-b-zinc-200 dark:hover:bg-zinc-200"
)}
>
{
+ const { data, isLoading } = useFetchSearchIndex(dataKey)
+
+ const content = data ? JSON.stringify(data, null, 2) : undefined
+
+ return (
+
+
+
+
+ {isLoading ? (
+
+ ) : data === null || data === undefined ? (
+
+ No data found
+
+ ) : (
+
+
+ {JSON.stringify(data, null, 2)}
+
+
+ )}
+
+
+ )
+}
diff --git a/src/components/databrowser/components/display/header-badges.tsx b/src/components/databrowser/components/display/header-badges.tsx
index e5baa7a..a5c6d97 100644
--- a/src/components/databrowser/components/display/header-badges.tsx
+++ b/src/components/databrowser/components/display/header-badges.tsx
@@ -59,8 +59,8 @@ export const HeaderTTLBadge = ({ dataKey }: { dataKey: string }) => {
}
export const Badge = ({ children, label }: { children: React.ReactNode; label: string }) => (
-
- {label}
+
+ {label}
{children}
)
diff --git a/src/components/databrowser/components/display/index.tsx b/src/components/databrowser/components/display/index.tsx
index 8a2890e..1a24436 100644
--- a/src/components/databrowser/components/display/index.tsx
+++ b/src/components/databrowser/components/display/index.tsx
@@ -1,9 +1,11 @@
/* eslint-disable unicorn/no-negated-condition */
+import { useTab } from "@/tab-provider"
+
import { useKeys, useKeyType } from "../../hooks/use-keys"
import { ListDisplay } from "./display-list"
+import { SearchDisplay } from "./display-search"
import { EditorDisplay } from "./display-simple"
-import { useTab } from "@/tab-provider"
export const DataDisplay = () => {
const { selectedKey } = useTab()
@@ -12,7 +14,7 @@ export const DataDisplay = () => {
const type = useKeyType(selectedKey)
return (
-
+
{!selectedKey ? (
) : !type ? (
@@ -27,6 +29,8 @@ export const DataDisplay = () => {
<>
{type === "string" || type === "json" ? (
+ ) : type === "search" ? (
+
) : (
)}
diff --git a/src/components/databrowser/components/display/input/generateTypeDefinitions.tsx b/src/components/databrowser/components/display/input/generateTypeDefinitions.tsx
new file mode 100644
index 0000000..8d12dc6
--- /dev/null
+++ b/src/components/databrowser/components/display/input/generateTypeDefinitions.tsx
@@ -0,0 +1,324 @@
+import type { IndexSchema } from "./query-editor"
+
+// Generate TypeScript type definitions based on the actual schema
+export const generateTypeDefinitions = (schema?: IndexSchema): string => {
+ // Generate schema-specific field types
+ let schemaFieldsInterface = ""
+
+ if (schema && Object.keys(schema).length > 0) {
+ const fieldLines = Object.entries(schema)
+ .map(([fieldName, fieldDef]) => {
+ const fieldType = fieldDef.type
+ let operationType: string
+
+ switch (fieldType) {
+ case "TEXT": {
+ operationType = "StringOperations"
+ break
+ }
+ case "U64":
+ case "I64":
+ case "F64": {
+ operationType = "NumberOperations"
+ break
+ }
+ case "BOOL": {
+ operationType = "BooleanOperations"
+ break
+ }
+ case "DATE": {
+ operationType = "DateOperations"
+ break
+ }
+ default: {
+ operationType = "StringOperations"
+ }
+ }
+
+ // Escape field names with dots by using quotes
+ const escapedFieldName = fieldName.includes(".") ? `"${fieldName}"` : fieldName
+ return ` ${escapedFieldName}?: ${operationType};`
+ })
+ .join("\n")
+
+ schemaFieldsInterface = `
+/** Schema fields for the current index */
+interface SchemaFields {
+${fieldLines}
+}`
+ } else {
+ // Fallback for when no schema is available
+ schemaFieldsInterface = `
+/** Schema fields - no schema available, using dynamic fields */
+interface SchemaFields {
+ [fieldName: string]: StringOperations | NumberOperations | BooleanOperations | DateOperations;
+}`
+ }
+
+ return `
+// String operations for TEXT fields
+type StringOperationMap = {
+ /** Exact match */
+ $eq: string;
+ /** Not equal */
+ $ne: string;
+ /** Match any value in array */
+ $in: string[];
+ /** Fuzzy match with optional distance */
+ $fuzzy: string | { value: string; distance?: number; transpositionCostOne?: boolean };
+ /** Phrase match */
+ $phrase: string;
+ /** Regular expression match */
+ $regex: string;
+};
+
+// Number operations for U64, I64, F64 fields
+type NumberOperationMap = {
+ /** Exact match */
+ $eq: number;
+ /** Not equal */
+ $ne: number;
+ /** Match any value in array */
+ $in: number[];
+ /** Greater than */
+ $gt: number;
+ /** Greater than or equal */
+ $gte: number;
+ /** Less than */
+ $lt: number;
+ /** Less than or equal */
+ $lte: number;
+};
+
+// Boolean operations for BOOL fields
+type BooleanOperationMap = {
+ /** Exact match */
+ $eq: boolean;
+ /** Not equal */
+ $ne: boolean;
+ /** Match any value in array */
+ $in: boolean[];
+};
+
+// Date operations for DATE fields
+type DateOperationMap = {
+ /** Exact match */
+ $eq: string | Date;
+ /** Not equal */
+ $ne: string | Date;
+ /** Match any value in array */
+ $in: (string | Date)[];
+};
+
+// String field operations with optional boost
+type StringOperations =
+ | { $eq: string; $boost?: number }
+ | { $ne: string; $boost?: number }
+ | { $in: string[]; $boost?: number }
+ | { $fuzzy: string | { value: string; distance?: number; transpositionCostOne?: boolean }; $boost?: number }
+ | { $phrase: string; $boost?: number }
+ | { $regex: string; $boost?: number }
+ | string;
+
+// Number field operations with optional boost
+type NumberOperations =
+ | { $eq: number; $boost?: number }
+ | { $ne: number; $boost?: number }
+ | { $in: number[]; $boost?: number }
+ | { $gt: number; $boost?: number }
+ | { $gte: number; $boost?: number }
+ | { $lt: number; $boost?: number }
+ | { $lte: number; $boost?: number }
+ | number;
+
+// Boolean field operations with optional boost
+type BooleanOperations =
+ | { $eq: boolean; $boost?: number }
+ | { $ne: boolean; $boost?: number }
+ | { $in: boolean[]; $boost?: number }
+ | boolean;
+
+// Date field operations with optional boost
+type DateOperations =
+ | { $eq: string | Date; $boost?: number }
+ | { $ne: string | Date; $boost?: number }
+ | { $in: (string | Date)[]; $boost?: number }
+ | string
+ | Date;
+
+${schemaFieldsInterface}
+
+// Query leaf - field conditions without logical operators
+type QueryLeaf = SchemaFields & {
+ $and?: never;
+ $or?: never;
+ $must?: never;
+ $should?: never;
+ $mustNot?: never;
+ $boost?: never;
+};
+
+// Base type for boolean nodes - allows field conditions
+type BoolBase = SchemaFields;
+
+// $and: all conditions must match
+type AndNode = BoolBase & {
+ /** All conditions in this array must match */
+ $and: QueryFilter[];
+ /** Boost score for this node */
+ $boost?: number;
+ $or?: never;
+ $must?: never;
+ $should?: never;
+ $mustNot?: never;
+};
+
+// $or: at least one condition must match
+type OrNode = BoolBase & {
+ /** At least one condition must match */
+ $or: QueryFilter[];
+ /** Boost score for this node */
+ $boost?: number;
+ $and?: never;
+ $must?: never;
+ $should?: never;
+ $mustNot?: never;
+};
+
+// $must only (Elasticsearch-style)
+type MustNode = BoolBase & {
+ /** All conditions must match (similar to $and) */
+ $must: QueryFilter[];
+ /** Boost score for this node */
+ $boost?: number;
+ $and?: never;
+ $or?: never;
+ $should?: never;
+ $mustNot?: never;
+};
+
+// $should only (Elasticsearch-style)
+type ShouldNode = BoolBase & {
+ /** At least one should match (affects scoring) */
+ $should: QueryFilter[];
+ /** Boost score for this node */
+ $boost?: number;
+ $and?: never;
+ $or?: never;
+ $must?: never;
+ $mustNot?: never;
+};
+
+// $must + $should combined
+type MustShouldNode = BoolBase & {
+ /** All these must match */
+ $must: QueryFilter[];
+ /** At least one should match for higher score */
+ $should: QueryFilter[];
+ $and?: never;
+ $or?: never;
+ $mustNot?: never;
+};
+
+// $mustNot only
+type NotNode = BoolBase & {
+ /** None of these conditions should match */
+ $mustNot: QueryFilter[];
+ /** Boost score for this node */
+ $boost?: number;
+ $and?: never;
+ $or?: never;
+ $must?: never;
+ $should?: never;
+};
+
+// $and + $mustNot combined
+type AndNotNode = BoolBase & {
+ $and: QueryFilter[];
+ $mustNot: QueryFilter[];
+ $boost?: number;
+ $or?: never;
+ $must?: never;
+ $should?: never;
+};
+
+// $or + $mustNot combined
+type OrNotNode = BoolBase & {
+ $or: QueryFilter[];
+ $mustNot: QueryFilter[];
+ $boost?: number;
+ $and?: never;
+ $must?: never;
+ $should?: never;
+};
+
+// $should + $mustNot combined
+type ShouldNotNode = BoolBase & {
+ $should: QueryFilter[];
+ $mustNot: QueryFilter[];
+ $boost?: number;
+ $and?: never;
+ $or?: never;
+ $must?: never;
+};
+
+// $must + $mustNot combined
+type MustNotNode = BoolBase & {
+ $must: QueryFilter[];
+ $mustNot: QueryFilter[];
+ $boost?: number;
+ $and?: never;
+ $or?: never;
+ $should?: never;
+};
+
+// Full boolean node: $must + $should + $mustNot
+type BoolNode = BoolBase & {
+ $must: QueryFilter[];
+ $should: QueryFilter[];
+ $mustNot: QueryFilter[];
+ $boost?: number;
+ $and?: never;
+ $or?: never;
+};
+
+// Query filter - union of all node types
+type QueryFilter =
+ | QueryLeaf
+ | AndNode
+ | OrNode
+ | MustNode
+ | ShouldNode
+ | MustShouldNode
+ | NotNode
+ | AndNotNode
+ | OrNotNode
+ | ShouldNotNode
+ | MustNotNode
+ | BoolNode;
+
+// Root-level $or restriction (no field conditions at root with $or)
+type RootOrNode = {
+ $or: QueryFilter[];
+ $boost?: number;
+ $and?: never;
+ $must?: never;
+ $should?: never;
+ $mustNot?: never;
+};
+
+// Root query filter - restricts $or from mixing with fields at root level
+type Query =
+ | QueryLeaf
+ | AndNode
+ | RootOrNode
+ | MustNode
+ | ShouldNode
+ | MustShouldNode
+ | NotNode
+ | AndNotNode
+ | ShouldNotNode
+ | MustNotNode
+ | BoolNode;
+`
+}
diff --git a/src/components/databrowser/components/display/input/query-editor.tsx b/src/components/databrowser/components/display/input/query-editor.tsx
new file mode 100644
index 0000000..db23268
--- /dev/null
+++ b/src/components/databrowser/components/display/input/query-editor.tsx
@@ -0,0 +1,208 @@
+import { useEffect, useMemo, useRef } from "react"
+import { useTheme } from "@/dark-mode-context"
+import { Editor, useMonaco, type BeforeMount, type Monaco } from "@monaco-editor/react"
+
+import { cn, isTest } from "@/lib/utils"
+
+import { generateTypeDefinitions } from "./generateTypeDefinitions"
+
+// Schema field types as returned by index.describe()
+type SchemaFieldType = "TEXT" | "U64" | "I64" | "F64" | "BOOL" | "DATE"
+
+type SchemaField = {
+ type: SchemaFieldType | string
+ fast?: boolean
+}
+
+// Flexible type that accepts any schema structure from the SDK
+export type IndexSchema = Record
+
+type QueryEditorProps = {
+ value: string
+ onChange: (value: string) => void
+ height?: number
+ schema?: IndexSchema
+}
+
+export const QueryEditor = (props: QueryEditorProps) => {
+ // Avoid mounting Monaco at all during Playwright runs
+ if (isTest) {
+ return
+ }
+
+ return
+}
+
+const MonacoQueryEditor = ({ value, onChange, height, schema }: QueryEditorProps) => {
+ const monaco = useMonaco()
+ const editorRef = useRef(null)
+ const theme = useTheme()
+ const libDisposableRef = useRef<{ dispose: () => void } | null>(null)
+
+ // Generate type definitions based on schema
+ const typeDefinitions = useMemo(() => generateTypeDefinitions(schema), [schema])
+
+ // Update type definitions when schema changes
+ useEffect(() => {
+ if (!monaco) return
+
+ // Dispose previous lib if exists
+ if (libDisposableRef.current) {
+ libDisposableRef.current.dispose()
+ }
+
+ // Add new type definitions
+ libDisposableRef.current = monaco.languages.typescript.typescriptDefaults.addExtraLib(
+ typeDefinitions,
+ "file:///query-types.d.ts"
+ )
+
+ return () => {
+ if (libDisposableRef.current) {
+ libDisposableRef.current.dispose()
+ }
+ }
+ }, [monaco, typeDefinitions])
+
+ const handleBeforeMount: BeforeMount = (monaco: Monaco) => {
+ // Configure TypeScript compiler options
+ monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
+ target: monaco.languages.typescript.ScriptTarget.ES2020,
+ allowNonTsExtensions: true,
+ moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
+ module: monaco.languages.typescript.ModuleKind.CommonJS,
+ noEmit: true,
+ esModuleInterop: true,
+ strict: true,
+ allowJs: true,
+ })
+
+ // Add initial type definitions
+ libDisposableRef.current = monaco.languages.typescript.typescriptDefaults.addExtraLib(
+ typeDefinitions,
+ "file:///query-types.d.ts"
+ )
+
+ // Configure diagnostics
+ monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
+ noSemanticValidation: false,
+ noSyntaxValidation: false,
+ })
+
+ // Enable eager model sync for better performance
+ monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true)
+ }
+
+ useEffect(() => {
+ const match = value.startsWith("const query: Query = {")
+ const ending = value.endsWith("}")
+ if (!match || !ending) {
+ onChange("const query: Query = {}")
+ }
+ }, [value, editorRef.current, onChange])
+
+ // Wrap the value to make it a valid TypeScript expression
+ const handleChange = (newValue: string | undefined) => {
+ if (!newValue) {
+ onChange("")
+ return
+ }
+
+ // Check if the value contains the required prefix
+ const match = newValue.startsWith("const query: Query = {")
+ if (match) {
+ onChange(newValue)
+ } else {
+ // Revert the editor content to prevent removing the required prefix
+ const editor = editorRef.current
+ if (editor) {
+ // Use setValue to restore the previous valid value
+ // @ts-expect-error not typing the editor type
+ editor.setValue(value)
+ }
+ }
+ }
+
+ return (
+
+ {
+ editorRef.current = editor
+ }}
+ value={value}
+ onChange={handleChange}
+ defaultLanguage="typescript"
+ path="query.ts"
+ options={{
+ wordWrap: "on",
+ overviewRulerBorder: false,
+ overviewRulerLanes: 0,
+ formatOnPaste: true,
+ formatOnType: true,
+ renderWhitespace: "none",
+ smoothScrolling: true,
+ scrollbar: {
+ verticalScrollbarSize: 12,
+ },
+ autoIndent: "full",
+ guides: { indentation: false },
+ fontSize: 13,
+ cursorBlinking: "smooth",
+ minimap: { enabled: false },
+ folding: true,
+ glyphMargin: false,
+ lineNumbers: "on",
+ parameterHints: { enabled: true },
+ lineDecorationsWidth: 0,
+ automaticLayout: true,
+ scrollBeyondLastLine: false,
+ renderLineHighlight: "line",
+ unusualLineTerminators: "auto",
+ padding: { top: 8, bottom: 8 },
+ quickSuggestions: true,
+ suggest: {
+ showVariables: false,
+ showConstants: false,
+ showFunctions: false,
+ showClasses: false,
+ showInterfaces: false,
+ showModules: false,
+ showKeywords: false,
+ },
+ suggestOnTriggerCharacters: true,
+ acceptSuggestionOnEnter: "on",
+ tabCompletion: "on",
+ wordBasedSuggestions: "off",
+ // Disable navigation features
+ gotoLocation: {
+ multiple: "goto",
+ multipleDefinitions: "goto",
+ multipleTypeDefinitions: "goto",
+ multipleDeclarations: "goto",
+ multipleImplementations: "goto",
+ multipleReferences: "goto",
+ },
+ definitionLinkOpensInPeek: false,
+ contextmenu: false,
+ }}
+ className="[&_.current-line]:!border-none [&_.current-line]:!bg-emerald-50 [&_.monaco-editor-background]:!bg-transparent [&_.monaco-editor]:!bg-transparent [&_[role='presentation']]:!bg-transparent"
+ />
+
+ )
+}
+
+const TestQueryEditor = ({ value, onChange, height }: QueryEditorProps) => {
+ return (
+
+
+ )
+}
diff --git a/src/components/databrowser/components/display/key-actions.tsx b/src/components/databrowser/components/display/key-actions.tsx
index a08b65e..c206565 100644
--- a/src/components/databrowser/components/display/key-actions.tsx
+++ b/src/components/databrowser/components/display/key-actions.tsx
@@ -19,7 +19,10 @@ export function KeyActions({ dataKey, content }: { dataKey: string; content?: st
diff --git a/src/components/databrowser/components/display/ttl-badge.tsx b/src/components/databrowser/components/display/ttl-badge.tsx
index d840ced..5aa24c1 100644
--- a/src/components/databrowser/components/display/ttl-badge.tsx
+++ b/src/components/databrowser/components/display/ttl-badge.tsx
@@ -45,9 +45,9 @@ export const TTLBadge = ({
) : (
-
- {ttl === TTL_INFINITE ? "Forever" : formatTime(ttl)}
-
+
+ {ttl === TTL_INFINITE ? "No" : formatTime(ttl)}
+
)}
diff --git a/src/components/databrowser/components/header-error.tsx b/src/components/databrowser/components/header-error.tsx
new file mode 100644
index 0000000..e301c9f
--- /dev/null
+++ b/src/components/databrowser/components/header-error.tsx
@@ -0,0 +1,27 @@
+import { useKeys } from "../hooks"
+
+/**
+ * Formats an UpstashError message by stripping the "command was: ..." suffix.
+ */
+const formatUpstashErrorMessage = (error: Error): string => {
+ if (error.name !== "UpstashError") return error.message
+
+ // Strip "command was: [...]" suffix
+ const commandIndex = error.message.indexOf(", command was:")
+ if (commandIndex !== -1) {
+ return error.message.slice(0, commandIndex)
+ }
+ return error.message
+}
+
+export const HeaderError = () => {
+ const { query } = useKeys()
+
+ if (!query.error) return null
+
+ return (
+
+ {formatUpstashErrorMessage(query.error)}
+
+ )
+}
diff --git a/src/components/databrowser/components/header/index.tsx b/src/components/databrowser/components/header/index.tsx
new file mode 100644
index 0000000..acc1362
--- /dev/null
+++ b/src/components/databrowser/components/header/index.tsx
@@ -0,0 +1,129 @@
+import { useTab } from "@/tab-provider"
+
+import { queryClient } from "@/lib/clients"
+import { Segmented } from "@/components/ui/segmented"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+
+import {
+ FETCH_KEYS_QUERY_KEY,
+ FETCH_LIST_ITEMS_QUERY_KEY,
+ FETCH_SIMPLE_KEY_QUERY_KEY,
+ useKeys,
+} from "../../hooks"
+import { FETCH_KEY_TYPE_QUERY_KEY } from "../../hooks/use-fetch-key-type"
+import { useFetchSearchIndexes } from "../../hooks/use-fetch-search-indexes"
+import { AddKeyModal } from "../add-key-modal"
+import { ReloadButton } from "../sidebar/reload-button"
+import { SearchInput } from "../sidebar/search-input"
+import { DataTypeSelector } from "../sidebar/type-selector"
+
+export const Header = () => {
+ const { isValuesSearchSelected, setIsValuesSearchSelected } = useTab()
+
+ return (
+
+ ),
+ },
+ ]}
+ value={isValuesSearchSelected ? "values" : "keys"}
+ onChange={(value) => {
+ setIsValuesSearchSelected(value === "values")
+ }}
+ />
+ {isValuesSearchSelected ? (
+ <>
+
+ >
+ ) : (
+ <>
+ {/* Types */}
+
+
+ {/* Search */}
+
+ >
+ )}
+
+ {/* Actions */}
+
+
+
+
+ )
+}
+
+const IndexSelector = () => {
+ const {
+ valuesSearch: { index },
+ setValuesSearchIndex,
+ } = useTab()
+ const { data } = useFetchSearchIndexes()
+
+ return (
+
+ )
+}
+
+const RefreshButton = () => {
+ const { query } = useKeys()
+
+ return (
+ {
+ queryClient.invalidateQueries({
+ queryKey: [FETCH_KEYS_QUERY_KEY],
+ })
+ queryClient.invalidateQueries({
+ queryKey: [FETCH_LIST_ITEMS_QUERY_KEY],
+ })
+ queryClient.invalidateQueries({
+ queryKey: [FETCH_SIMPLE_KEY_QUERY_KEY],
+ })
+ queryClient.invalidateQueries({
+ queryKey: [FETCH_KEY_TYPE_QUERY_KEY],
+ })
+ }}
+ isLoading={query.isFetching}
+ />
+ )
+}
diff --git a/src/components/databrowser/components/item-context-menu.tsx b/src/components/databrowser/components/item-context-menu.tsx
index 35d9ca4..89f30f9 100644
--- a/src/components/databrowser/components/item-context-menu.tsx
+++ b/src/components/databrowser/components/item-context-menu.tsx
@@ -1,8 +1,8 @@
import { useState, type PropsWithChildren } from "react"
-import { IconCopy, IconExternalLink, IconTrash } from "@tabler/icons-react"
import { useDatabrowserStore } from "@/store"
import { type ListDataType } from "@/types"
import { ContextMenuSeparator } from "@radix-ui/react-context-menu"
+import { IconCopy, IconExternalLink, IconTrash } from "@tabler/icons-react"
import {
ContextMenu,
diff --git a/src/components/databrowser/components/query-builder.tsx b/src/components/databrowser/components/query-builder.tsx
new file mode 100644
index 0000000..3c53702
--- /dev/null
+++ b/src/components/databrowser/components/query-builder.tsx
@@ -0,0 +1,35 @@
+import { useState } from "react"
+import { useTab } from "@/tab-provider"
+
+import { parseJSObjectLiteral } from "@/lib/utils"
+
+import { useFetchSearchIndex } from "../hooks/use-fetch-search-index"
+import { PREFIX } from "./databrowser-instance"
+import { QueryEditor } from "./display/input/query-editor"
+
+export const QueryBuilder = () => {
+ const { valuesSearch, setValuesSearchQuery } = useTab()
+ const [value, setValue] = useState(valuesSearch.query)
+ const { data: indexDetails } = useFetchSearchIndex(valuesSearch.index)
+
+ return (
+
+ {
+ setValue(value)
+
+ const sliced = value.slice(PREFIX.length)
+ const parsed = parseJSObjectLiteral(sliced)
+
+ const newValue = value.slice(PREFIX.length)
+ if (parsed && valuesSearch.query !== newValue) {
+ setValuesSearchQuery(newValue)
+ }
+ }}
+ schema={indexDetails?.schema}
+ />
+
+ )
+}
diff --git a/src/components/databrowser/components/sidebar-context-menu.tsx b/src/components/databrowser/components/sidebar-context-menu.tsx
index 647888f..ee35d14 100644
--- a/src/components/databrowser/components/sidebar-context-menu.tsx
+++ b/src/components/databrowser/components/sidebar-context-menu.tsx
@@ -1,8 +1,8 @@
import { useState, type PropsWithChildren } from "react"
-import { IconCopy, IconExternalLink, IconTrash } from "@tabler/icons-react"
import { useDatabrowserStore } from "@/store"
import { useTab } from "@/tab-provider"
import { ContextMenuSeparator } from "@radix-ui/react-context-menu"
+import { IconCopy, IconExternalLink, IconTrash } from "@tabler/icons-react"
import {
ContextMenu,
@@ -19,7 +19,12 @@ export const SidebarContextMenu = ({ children }: PropsWithChildren) => {
const { mutate: deleteKey } = useDeleteKey()
const [isAlertOpen, setAlertOpen] = useState(false)
const [contextKeys, setContextKeys] = useState([])
- const { addTab, setSelectedKey: setSelectedKeyGlobal, selectTab, setSearch } = useDatabrowserStore()
+ const {
+ addTab,
+ setSelectedKey: setSelectedKeyGlobal,
+ selectTab,
+ setSearch,
+ } = useDatabrowserStore()
const { search: currentSearch, selectedKeys, setSelectedKey } = useTab()
return (
diff --git a/src/components/databrowser/components/sidebar/db-size.tsx b/src/components/databrowser/components/sidebar/db-size.tsx
deleted file mode 100644
index 77f2657..0000000
--- a/src/components/databrowser/components/sidebar/db-size.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { useRedis } from "@/redis-context"
-import { useQuery } from "@tanstack/react-query"
-
-import { formatNumber } from "@/lib/utils"
-import { Skeleton } from "@/components/ui/skeleton"
-
-export const FETCH_DB_SIZE_QUERY_KEY = "fetch-db-size"
-
-export const DisplayDbSize = () => {
- const { redis } = useRedis()
-
- const { data: keyCount } = useQuery({
- queryKey: [FETCH_DB_SIZE_QUERY_KEY],
- queryFn: async () => {
- return await redis.dbsize()
- },
- })
-
- if (keyCount === undefined) {
- return (
-
-
-
- )
- }
- return {formatNumber(keyCount)} Keys
-}
diff --git a/src/components/databrowser/components/sidebar/index.tsx b/src/components/databrowser/components/sidebar/index.tsx
index b5489c4..708f3e3 100644
--- a/src/components/databrowser/components/sidebar/index.tsx
+++ b/src/components/databrowser/components/sidebar/index.tsx
@@ -1,67 +1,24 @@
-import { queryClient } from "@/lib/clients"
-
-import { FETCH_LIST_ITEMS_QUERY_KEY, FETCH_SIMPLE_KEY_QUERY_KEY } from "../../hooks"
-import { FETCH_KEY_TYPE_QUERY_KEY } from "../../hooks/use-fetch-key-type"
-import { FETCH_KEYS_QUERY_KEY, useKeys } from "../../hooks/use-keys"
-import { AddKeyModal } from "../add-key-modal"
-import { DisplayDbSize, FETCH_DB_SIZE_QUERY_KEY } from "./db-size"
+import { useKeys } from "../../hooks/use-keys"
import { Empty } from "./empty"
import { InfiniteScroll } from "./infinite-scroll"
import { KeysList } from "./keys-list"
-import { ReloadButton } from "./reload-button"
-import { SearchInput } from "./search-input"
import { LoadingSkeleton } from "./skeleton-buttons"
-import { DataTypeSelector } from "./type-selector"
export function Sidebar() {
const { keys, query } = useKeys()
return (
-
-
- {/* Meta */}
-
-
-
- {
- queryClient.invalidateQueries({
- queryKey: [FETCH_KEYS_QUERY_KEY],
- })
- queryClient.invalidateQueries({
- queryKey: [FETCH_LIST_ITEMS_QUERY_KEY],
- })
- queryClient.invalidateQueries({
- queryKey: [FETCH_SIMPLE_KEY_QUERY_KEY],
- })
- queryClient.invalidateQueries({
- queryKey: [FETCH_DB_SIZE_QUERY_KEY],
- })
- queryClient.invalidateQueries({
- queryKey: [FETCH_KEY_TYPE_QUERY_KEY],
- })
- }}
- isLoading={query.isFetching}
- />
-
-
-
-
- {/* Filter */}
-
- {/* Types */}
-
-
- {/* Search */}
-
-
-
-
+
{query.isLoading && keys.length === 0 ? (
) : keys.length > 0 ? (
// Infinite scroll already has a loader at the bottom
-
+
) : (
diff --git a/src/components/databrowser/components/sidebar/infinite-scroll.tsx b/src/components/databrowser/components/sidebar/infinite-scroll.tsx
index 9cdfc81..cedd9c5 100644
--- a/src/components/databrowser/components/sidebar/infinite-scroll.tsx
+++ b/src/components/databrowser/components/sidebar/infinite-scroll.tsx
@@ -1,11 +1,11 @@
import type { PropsWithChildren } from "react"
+import { useEffect, useRef } from "react"
+import { useTab } from "@/tab-provider"
import { IconLoader2 } from "@tabler/icons-react"
import type { UseInfiniteQueryResult } from "@tanstack/react-query"
-import { useEffect, useRef } from "react"
-import { ScrollArea } from "@/components/ui/scroll-area"
import { cn } from "@/lib/utils"
-import { useTab } from "@/tab-provider"
+import { ScrollArea } from "@/components/ui/scroll-area"
export const InfiniteScroll = ({
query,
@@ -57,10 +57,7 @@ export const InfiniteScroll = ({
type="always"
onScroll={handleScroll}
{...props}
- className={cn(
- "block h-full w-full overflow-visible rounded-lg border border-zinc-200 bg-white p-1 pr-3 transition-all",
- props.className
- )}
+ className={cn("block h-full w-full overflow-visible transition-all", props.className)}
ref={scrollRef}
>
diff --git a/src/components/databrowser/components/sidebar/keys-list.tsx b/src/components/databrowser/components/sidebar/keys-list.tsx
index 0532055..d1d54dc 100644
--- a/src/components/databrowser/components/sidebar/keys-list.tsx
+++ b/src/components/databrowser/components/sidebar/keys-list.tsx
@@ -1,9 +1,9 @@
import { Fragment, useRef } from "react"
import { useTab } from "@/tab-provider"
-import type { DataType, RedisKey } from "@/types"
+import type { RedisKey } from "@/types"
+import { IconChevronRight } from "@tabler/icons-react"
import { cn } from "@/lib/utils"
-import { Button } from "@/components/ui/button"
import { TypeTag } from "@/components/databrowser/components/type-tag"
import { useKeys } from "../../hooks/use-keys"
@@ -27,7 +27,7 @@ export const KeysList = () => {
lastClickedIndexRef={lastClickedIndexRef}
/>
{i !== keys.length - 1 && (
-
+
)}
))}
@@ -36,16 +36,6 @@ export const KeysList = () => {
)
}
-const keyStyles = {
- string: "border-sky-400 !bg-sky-50 text-sky-900",
- hash: "border-amber-400 !bg-amber-50 text-amber-900",
- set: "border-indigo-400 !bg-indigo-50 text-indigo-900",
- zset: "border-pink-400 !bg-pink-50 text-pink-900",
- json: "border-purple-400 !bg-purple-50 text-purple-900",
- list: "border-orange-400 !bg-orange-50 text-orange-900",
- stream: "border-green-400 !bg-green-50 text-green-900",
-} as Record
-
const KeyItem = ({
data,
index,
@@ -85,19 +75,20 @@ const KeyItem = ({
}
return (
-
+ {dataKey}
+ {isKeySelected && }
+
)
}
diff --git a/src/components/databrowser/components/sidebar/reload-button.tsx b/src/components/databrowser/components/sidebar/reload-button.tsx
index d06371e..5028e3e 100644
--- a/src/components/databrowser/components/sidebar/reload-button.tsx
+++ b/src/components/databrowser/components/sidebar/reload-button.tsx
@@ -1,8 +1,8 @@
import { useState } from "react"
+import { IconLoader2, IconRefresh } from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import { SimpleTooltip } from "@/components/ui/tooltip"
-import { IconLoader2, IconRefresh } from "@tabler/icons-react"
export const ReloadButton = ({
onClick,
@@ -26,14 +26,14 @@ export const ReloadButton = ({
diff --git a/src/components/databrowser/components/sidebar/search-input.tsx b/src/components/databrowser/components/sidebar/search-input.tsx
index b901200..2fdb7b9 100644
--- a/src/components/databrowser/components/sidebar/search-input.tsx
+++ b/src/components/databrowser/components/sidebar/search-input.tsx
@@ -83,11 +83,11 @@ export const SearchInput = () => {
0}>
-
+
{
setState(e.currentTarget.value)
diff --git a/src/components/databrowser/components/sidebar/skeleton-buttons.tsx b/src/components/databrowser/components/sidebar/skeleton-buttons.tsx
index aa141a6..779054b 100644
--- a/src/components/databrowser/components/sidebar/skeleton-buttons.tsx
+++ b/src/components/databrowser/components/sidebar/skeleton-buttons.tsx
@@ -3,7 +3,7 @@ import { Skeleton } from "@/components/ui/skeleton"
const DEFAULT_SKELETON_COUNT = 6
export const LoadingSkeleton = () => (
-
+
{Array.from({ length: DEFAULT_SKELETON_COUNT })
.fill(0)
.map((_, idx) => (
diff --git a/src/components/databrowser/components/sidebar/type-selector.tsx b/src/components/databrowser/components/sidebar/type-selector.tsx
index 0452e67..6f4fc30 100644
--- a/src/components/databrowser/components/sidebar/type-selector.tsx
+++ b/src/components/databrowser/components/sidebar/type-selector.tsx
@@ -1,3 +1,4 @@
+import { useTab } from "@/tab-provider"
import { DATA_TYPE_NAMES, type DataType } from "@/types"
import {
@@ -8,7 +9,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
-import { useTab } from "@/tab-provider"
const ALL_TYPES_KEY = "all"
@@ -26,7 +26,7 @@ export function DataTypeSelector() {
}}
value={search.type === undefined ? ALL_TYPES_KEY : search.type}
>
-
+
diff --git a/src/components/databrowser/components/tab-type-icon.tsx b/src/components/databrowser/components/tab-type-icon.tsx
index d3a0686..4c1d136 100644
--- a/src/components/databrowser/components/tab-type-icon.tsx
+++ b/src/components/databrowser/components/tab-type-icon.tsx
@@ -1,4 +1,5 @@
import { Skeleton } from "@/components/ui/skeleton"
+
import { useFetchKeyType } from "../hooks/use-fetch-key-type"
import { TypeTag } from "./type-tag"
diff --git a/src/components/databrowser/components/tab.tsx b/src/components/databrowser/components/tab.tsx
index c8929fc..9ec5504 100644
--- a/src/components/databrowser/components/tab.tsx
+++ b/src/components/databrowser/components/tab.tsx
@@ -1,3 +1,6 @@
+import type { TabId } from "@/store"
+import { useDatabrowserStore } from "@/store"
+import { useTab } from "@/tab-provider"
import {
IconArrowsMinimize,
IconCopyPlus,
@@ -6,7 +9,9 @@ import {
IconSquareX,
IconX,
} from "@tabler/icons-react"
-import { SimpleTooltip } from "@/components/ui/tooltip"
+
+import { cn } from "@/lib/utils"
+import { useOverflow } from "@/hooks/use-overflow"
import {
ContextMenu,
ContextMenuContent,
@@ -14,15 +19,12 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
-import { cn } from "@/lib/utils"
-import type { TabId } from "@/store"
-import { useDatabrowserStore } from "@/store"
+import { SimpleTooltip } from "@/components/ui/tooltip"
+
import { TabTypeIcon } from "./tab-type-icon"
-import { useTab } from "@/tab-provider"
-import { useOverflow } from "@/hooks/use-overflow"
export const Tab = ({ id, isList }: { id: TabId; isList?: boolean }) => {
- const { active, search, selectedKey, pinned } = useTab()
+ const { active, search, selectedKey, valuesSearch, pinned, isValuesSearchSelected } = useTab()
const {
selectTab,
removeTab,
@@ -38,9 +40,15 @@ export const Tab = ({ id, isList }: { id: TabId; isList?: boolean }) => {
const { ref, isOverflow } = useOverflow()
- const label = search.key || selectedKey
- const iconNode = search.key ? (
-
+ const label = isValuesSearchSelected ? valuesSearch.index : search.key || selectedKey
+ const iconNode = isValuesSearchSelected ? (
+
+
+
+ ) : search.key ? (
+
+
+
) : selectedKey ? (
) : undefined
@@ -50,14 +58,13 @@ export const Tab = ({ id, isList }: { id: TabId; isList?: boolean }) => {
id={isList ? `list-tab-${id}` : `tab-${id}`}
onClick={() => selectTab(id)}
className={cn(
- "flex h-9 w-full cursor-pointer items-center gap-2 px-3 text-[13px] transition-colors",
+ "flex h-[40px] w-full cursor-pointer items-center gap-2 rounded-t-lg px-3 text-[13px] transition-colors",
isList && "max-w-[370px]",
- !isList && "rounded-t-lg border border-zinc-200",
!isList &&
- (active ? "border-b-white bg-white text-zinc-900" : "bg-zinc-100 hover:bg-zinc-50")
+ (active ? "bg-white text-zinc-950" : "bg-zinc-200 text-zinc-600 hover:bg-zinc-100")
)}
>
- {iconNode}
+ {iconNode}
{
e.stopPropagation()
removeTab(id)
}}
- className="p-1 text-zinc-300 transition-colors hover:text-zinc-500 dark:text-zinc-400"
+ className="p-[2px] text-zinc-400 transition-colors hover:text-zinc-500"
>
diff --git a/src/components/databrowser/components/type-tag.tsx b/src/components/databrowser/components/type-tag.tsx
index 02ed40e..3e44422 100644
--- a/src/components/databrowser/components/type-tag.tsx
+++ b/src/components/databrowser/components/type-tag.tsx
@@ -7,6 +7,7 @@ import {
IconLayersIntersect,
IconList,
IconQuote,
+ IconSearch,
} from "@tabler/icons-react"
import { cva, type VariantProps } from "class-variance-authority"
@@ -20,6 +21,7 @@ const iconsMap = {
zset: ,
list: ,
stream: ,
+ search: ,
} as const
const tagVariants = cva("inline-flex shrink-0 items-center rounded-md justify-center", {
@@ -32,10 +34,12 @@ const tagVariants = cva("inline-flex shrink-0 items-center rounded-md justify-ce
json: "bg-purple-200 text-purple-800",
list: "bg-orange-200 text-orange-800",
stream: "bg-green-200 text-green-800",
+ search: "bg-rose-200 text-rose-800",
},
type: {
icon: "size-5",
- badge: "h-6 px-2 uppercase whitespace-nowrap text-xs font-medium leading-none tracking-wide",
+ badge:
+ "h-[26px] px-2 uppercase whitespace-nowrap text-xs font-medium leading-none tracking-wide",
},
},
defaultVariants: {
diff --git a/src/components/databrowser/hooks/use-add-key.ts b/src/components/databrowser/hooks/use-add-key.ts
index dc87292..d1b4166 100644
--- a/src/components/databrowser/hooks/use-add-key.ts
+++ b/src/components/databrowser/hooks/use-add-key.ts
@@ -4,7 +4,6 @@ import { useMutation, type InfiniteData } from "@tanstack/react-query"
import { queryClient } from "@/lib/clients"
-import { FETCH_DB_SIZE_QUERY_KEY } from "../components/sidebar/db-size"
import { FETCH_KEYS_QUERY_KEY } from "./use-keys"
export const useAddKey = () => {
@@ -58,9 +57,6 @@ export const useAddKey = () => {
}
},
onSuccess: (_, { key, type }) => {
- queryClient.invalidateQueries({
- queryKey: [FETCH_DB_SIZE_QUERY_KEY],
- })
queryClient.setQueriesData<
InfiniteData<{
keys: RedisKey[]
diff --git a/src/components/databrowser/hooks/use-delete-key-cache.ts b/src/components/databrowser/hooks/use-delete-key-cache.ts
index 5fd99e8..6678ad1 100644
--- a/src/components/databrowser/hooks/use-delete-key-cache.ts
+++ b/src/components/databrowser/hooks/use-delete-key-cache.ts
@@ -1,4 +1,5 @@
import { useCallback } from "react"
+import { useTab } from "@/tab-provider"
import { queryClient } from "@/lib/clients"
@@ -6,7 +7,6 @@ import { FETCH_KEY_TYPE_QUERY_KEY } from "./use-fetch-key-type"
import { FETCH_LIST_ITEMS_QUERY_KEY } from "./use-fetch-list-items"
import { FETCH_SIMPLE_KEY_QUERY_KEY } from "./use-fetch-simple-key"
import { FETCH_KEYS_QUERY_KEY } from "./use-keys"
-import { useTab } from "@/tab-provider"
export const useDeleteKeyCache = () => {
const { setSelectedKey } = useTab()
diff --git a/src/components/databrowser/hooks/use-delete-key.ts b/src/components/databrowser/hooks/use-delete-key.ts
index b76461e..b7127ce 100644
--- a/src/components/databrowser/hooks/use-delete-key.ts
+++ b/src/components/databrowser/hooks/use-delete-key.ts
@@ -1,9 +1,6 @@
import { useRedis } from "@/redis-context"
import { useMutation } from "@tanstack/react-query"
-import { queryClient } from "@/lib/clients"
-
-import { FETCH_DB_SIZE_QUERY_KEY } from "../components/sidebar/db-size"
import { useDeleteKeyCache } from "./use-delete-key-cache"
export const useDeleteKey = () => {
@@ -16,9 +13,6 @@ export const useDeleteKey = () => {
},
onSuccess: (_, key) => {
deleteKeyCache(key)
- queryClient.invalidateQueries({
- queryKey: [FETCH_DB_SIZE_QUERY_KEY],
- })
},
})
diff --git a/src/components/databrowser/hooks/use-fetch-key-length.ts b/src/components/databrowser/hooks/use-fetch-key-length.ts
index 9f6d94c..ea33521 100644
--- a/src/components/databrowser/hooks/use-fetch-key-length.ts
+++ b/src/components/databrowser/hooks/use-fetch-key-length.ts
@@ -26,6 +26,9 @@ export const useFetchKeyLength = ({ dataKey, type }: { dataKey: string; type: Da
case "stream": {
return await redis.xlen(dataKey)
}
+ case "search": {
+ return null
+ }
// No default
}
return null
diff --git a/src/components/databrowser/hooks/use-fetch-search-index.tsx b/src/components/databrowser/hooks/use-fetch-search-index.tsx
new file mode 100644
index 0000000..ab4e62a
--- /dev/null
+++ b/src/components/databrowser/hooks/use-fetch-search-index.tsx
@@ -0,0 +1,18 @@
+import { useRedis } from "@/redis-context"
+import { useQuery } from "@tanstack/react-query"
+
+export const FETCH_SEARCH_INDEX_QUERY_KEY = "fetch-search-index"
+
+export const useFetchSearchIndex = (indexName: string) => {
+ const { redisNoPipeline: redis } = useRedis()
+
+ return useQuery({
+ queryKey: [FETCH_SEARCH_INDEX_QUERY_KEY, indexName],
+ queryFn: async () => {
+ if (!indexName) return
+ const result = await redis.search.index(indexName).describe()
+ return result
+ },
+ enabled: Boolean(indexName),
+ })
+}
diff --git a/src/components/databrowser/hooks/use-fetch-search-indexes.tsx b/src/components/databrowser/hooks/use-fetch-search-indexes.tsx
new file mode 100644
index 0000000..2da95c3
--- /dev/null
+++ b/src/components/databrowser/hooks/use-fetch-search-indexes.tsx
@@ -0,0 +1,32 @@
+import { useRedis } from "@/redis-context"
+import { useQuery } from "@tanstack/react-query"
+
+export const FETCH_SEARCH_INDEXES_QUERY_KEY = "fetch-search-indexes"
+
+export const useFetchSearchIndexes = (match?: string) => {
+ const { redisNoPipeline: redis } = useRedis()
+
+ return useQuery({
+ queryKey: [FETCH_SEARCH_INDEXES_QUERY_KEY],
+ queryFn: async () => {
+ let cursor = "0"
+ const finalResult: string[] = []
+
+ while (true) {
+ const [newCursor, results] = await redis.scan(cursor, {
+ count: 100,
+ type: "search",
+ match,
+ })
+
+ finalResult.push(...results)
+
+ if (newCursor === "0") break
+
+ cursor = newCursor
+ }
+
+ return finalResult
+ },
+ })
+}
diff --git a/src/components/databrowser/hooks/use-keys.tsx b/src/components/databrowser/hooks/use-keys.tsx
index 6e3c868..2d90665 100644
--- a/src/components/databrowser/hooks/use-keys.tsx
+++ b/src/components/databrowser/hooks/use-keys.tsx
@@ -1,10 +1,14 @@
import { createContext, useContext, useMemo, type PropsWithChildren } from "react"
import { useRedis } from "@/redis-context"
+import { useTab } from "@/tab-provider"
import type { DataType, RedisKey } from "@/types"
import { useInfiniteQuery, type UseInfiniteQueryResult } from "@tanstack/react-query"
-import { useTab } from "@/tab-provider"
+
import { queryClient } from "@/lib/clients"
+import { parseJSObjectLiteral } from "@/lib/utils"
+
import { FETCH_KEY_TYPE_QUERY_KEY } from "./use-fetch-key-type"
+import { useFetchSearchIndex } from "./use-fetch-search-index"
const KeysContext = createContext<
| {
@@ -17,13 +21,30 @@ const KeysContext = createContext<
export const FETCH_KEYS_QUERY_KEY = "use-fetch-keys"
const SCAN_COUNTS = [100, 300, 500]
+type ScanResult = { cursor: string; keys: { key: string; type: DataType }[] }
export const KeysProvider = ({ children }: PropsWithChildren) => {
- const { active, search } = useTab()
+ const { active, search, valuesSearch, isValuesSearchSelected } = useTab()
+ const { data: searchIndexDetails, isLoading: isIndexDetailsLoading } = useFetchSearchIndex(
+ valuesSearch.index
+ )
const { redisNoPipeline: redis } = useRedis()
- const performScan = async (count: number, cursor: string) => {
+ const parsedValueQuery = parseJSObjectLiteral(valuesSearch.query)
+
+ const isQueryEnabled =
+ active &&
+ (isValuesSearchSelected ? Boolean(valuesSearch.index) && Boolean(searchIndexDetails) : true)
+
+ // Redis default key scan
+ const redisKeyScan = async ({
+ count,
+ cursor,
+ }: {
+ count: number
+ cursor: string
+ }): Promise => {
const args = [cursor]
if (search.key) {
@@ -38,7 +59,62 @@ export const KeysProvider = ({ children }: PropsWithChildren) => {
if (!search.type) args.push("WITHTYPE")
- return await redis.exec<[string, string[]]>(["SCAN", ...args])
+ const [newCursor, values] = await redis.exec<[string, string[]]>(["SCAN", ...args])
+
+ // Deserialize keys from the SCAN result
+ const keys: { key: string; type: DataType }[] = []
+ let index = 0
+ while (true) {
+ if (search.type) {
+ if (index >= values.length) break
+ keys.push({ key: values[index], type: search.type as DataType })
+ index += 1
+ } else {
+ if (index + 1 >= values.length) break
+ keys.push({ key: values[index], type: values[index + 1] as DataType })
+ index += 2
+ }
+ }
+
+ return { cursor: newCursor, keys }
+ }
+
+ // Redis search value scan
+ const redisValueScan = async ({
+ count,
+ cursor,
+ }: {
+ count: number
+ cursor: string
+ }): Promise => {
+ if (!searchIndexDetails) throw new Error("Attempted search while loading the search index")
+
+ const offset = Number.parseInt(cursor, 10) || 0
+
+ const result = await redis.search.index(valuesSearch.index).query({
+ filter: parsedValueQuery ?? {},
+ limit: count,
+ offset,
+ select: {},
+ })
+
+ const keys = result.map((doc) => ({
+ key: doc.key,
+ type: searchIndexDetails.dataType,
+ }))
+
+ // If we got fewer results than the limit, we've reached the end
+ const hasMore = keys.length >= count
+ const nextCursor = hasMore ? String(offset + keys.length) : "0"
+
+ return { cursor: nextCursor, keys }
+ }
+
+ const performScan = async (count: number, cursor: string) => {
+ const scanFunction = isValuesSearchSelected ? redisValueScan : redisKeyScan
+
+ const result = await scanFunction({ count, cursor })
+ return [result.cursor, result.keys] as const
}
/**
@@ -63,29 +139,14 @@ export const KeysProvider = ({ children }: PropsWithChildren) => {
}
const query = useInfiniteQuery({
- queryKey: [FETCH_KEYS_QUERY_KEY, search],
- // Only fetch when tab is active
- enabled: active,
+ queryKey: [FETCH_KEYS_QUERY_KEY, search, valuesSearch, isValuesSearchSelected],
+ enabled: isQueryEnabled,
initialPageParam: "0",
queryFn: async ({ pageParam: lastCursor }) => {
const [cursor, values] = await scanUntilAvailable(lastCursor)
- const keys: RedisKey[] = []
-
- // Deserialize keys
- let index = 0
- while (true) {
- if (search.type) {
- if (index >= values.length) break
- keys.push([values[index], search.type as DataType])
- index += 1
- } else {
- if (index + 1 >= values.length) break
- keys.push([values[index], values[index + 1] as DataType])
- index += 2
- }
- }
+ const keys: RedisKey[] = values.map((value) => [value.key, value.type])
// Save in cache to not send additional requests with useFetchKeyType
for (const [key, type] of keys) {
@@ -98,6 +159,9 @@ export const KeysProvider = ({ children }: PropsWithChildren) => {
hasNextPage: cursor !== "0",
}
},
+ meta: {
+ hideToast: true,
+ },
select: (data) => data,
getNextPageParam: ({ cursor }) => cursor,
refetchOnMount: false,
@@ -123,7 +187,10 @@ export const KeysProvider = ({ children }: PropsWithChildren) => {
{children}
diff --git a/src/components/databrowser/index.tsx b/src/components/databrowser/index.tsx
index 9fb0011..725158b 100644
--- a/src/components/databrowser/index.tsx
+++ b/src/components/databrowser/index.tsx
@@ -131,7 +131,7 @@ const RedisBrowserRoot = ({
style={{ height: "100%" }}
ref={rootRef}
>
-
+
{!hideTabs && }
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
index 681609e..3d2498c 100644
--- a/src/components/ui/button.tsx
+++ b/src/components/ui/button.tsx
@@ -5,27 +5,27 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
- "inline-flex items-center justify-center rounded-md text-sm " +
+ "inline-flex items-center justify-center rounded-lg text-sm shadow-[0_1px_1px_0_rgba(0,0,0,0.10)]" +
"ring-offset-white transition-colors " +
"focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-zinc-950 focus-visible:ring-offset-2 " +
"disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
- default: "bg-white text-black border shadow-sm border-zinc-300 " + "hover:bg-white/70",
+ default: "bg-white text-black border border-zinc-300 " + "hover:bg-white/70",
destructive: "bg-red-500 text-zinc-50 hover:bg-red-500/90",
- outline: "border border-zinc-200 bg-white " + "hover:bg-zinc-100 hover:text-zinc-900",
- primary: "bg-emerald-500 text-white shadow-sm " + "hover:bg-emerald-600",
+ outline: "border border-zinc-300 bg-white " + "hover:bg-zinc-100 hover:text-zinc-900",
+ primary: "bg-emerald-500 text-white " + "hover:bg-emerald-600",
secondary: "bg-zinc-100 text-zinc-900 hover:bg-zinc-100/80",
ghost: "hover:bg-black/10",
link: "text-zinc-900 underline-offset-4 hover:underline",
},
size: {
default: "h-8 px-4 py-2",
- sm: "px-2 h-7 rounded-md",
+ sm: "px-2 h-[26px] rounded-md",
lg: "h-10 px-8 rounded-md",
icon: "h-8 w-8",
- "icon-sm": "h-7 w-7",
+ "icon-sm": "h-[26px] w-[26px] rounded-md",
"icon-xs": "h-5 w-5",
},
},
diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx
index 5e6d2ff..f46b1c8 100644
--- a/src/components/ui/command.tsx
+++ b/src/components/ui/command.tsx
@@ -1,9 +1,10 @@
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
+import { IconSearch } from "@tabler/icons-react"
import { Command as CommandPrimitive } from "cmdk"
+
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
-import { IconSearch } from "@tabler/icons-react"
const Command = React.forwardRef<
React.ElementRef ,
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx
index 3e17d48..3772e6f 100644
--- a/src/components/ui/dropdown-menu.tsx
+++ b/src/components/ui/dropdown-menu.tsx
@@ -1,8 +1,9 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
-import { cn } from "@/lib/utils"
import { IconCheck, IconChevronRight, IconCircleFilled } from "@tabler/icons-react"
+
import { portalRoot } from "@/lib/portal-root"
+import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx
index 0bc3aec..033645c 100644
--- a/src/components/ui/label.tsx
+++ b/src/components/ui/label.tsx
@@ -12,16 +12,11 @@ export interface LabelProps
extends React.ComponentPropsWithoutRef,
VariantProps {}
-const Label = React.forwardRef<
- React.ElementRef,
- LabelProps
->(({ className, ...props }, ref) => (
-
-))
+const Label = React.forwardRef, LabelProps>(
+ ({ className, ...props }, ref) => (
+
+ )
+)
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx
index ee9239b..be86586 100644
--- a/src/components/ui/scroll-area.tsx
+++ b/src/components/ui/scroll-area.tsx
@@ -5,27 +5,36 @@ import { cn } from "@/lib/utils"
type ScrollAreaProps = React.ComponentPropsWithoutRef & {
disableRoundedInherit?: boolean
+ scrollBarClassName?: string
}
const ScrollArea = React.forwardRef<
React.ElementRef,
ScrollAreaProps
->(({ className, children, onScroll, disableRoundedInherit = false, ...props }, ref) => (
-
- div]:!block", !disableRoundedInherit && "rounded-[inherit]")}
+>(
+ (
+ { className, scrollBarClassName, children, onScroll, disableRoundedInherit = false, ...props },
+ ref
+ ) => (
+
- {children}
-
-
-
-
-))
+ div]:!block",
+ !disableRoundedInherit && "rounded-[inherit]"
+ )}
+ >
+ {children}
+
+
+
+
+ )
+)
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
@@ -35,7 +44,7 @@ const ScrollBar = React.forwardRef<
void
+}) => {
+ return (
+
+ {options.map((option) => (
+
+ ))}
+
+ )
+}
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
index 8a80ade..7ef5221 100644
--- a/src/components/ui/select.tsx
+++ b/src/components/ui/select.tsx
@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
-
-
-
+
{children}
diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx
index 84000f2..9e0e647 100644
--- a/src/components/ui/toast.tsx
+++ b/src/components/ui/toast.tsx
@@ -1,6 +1,6 @@
import * as React from "react"
-import { IconX } from "@tabler/icons-react"
import * as ToastPrimitives from "@radix-ui/react-toast"
+import { IconX } from "@tabler/icons-react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
index 4dda9f2..f256f6a 100644
--- a/src/components/ui/tooltip.tsx
+++ b/src/components/ui/tooltip.tsx
@@ -1,8 +1,8 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
-import { cn } from "@/lib/utils"
import { portalRoot } from "@/lib/portal-root"
+import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
diff --git a/src/globals.css b/src/globals.css
index 94cfeef..b78948a 100644
--- a/src/globals.css
+++ b/src/globals.css
@@ -480,13 +480,13 @@
background: linear-gradient(to left, rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0));
}
-/* Set 1.2 stroke for all tabler icons */
+/* Set 1.5 stroke for all tabler icons */
svg.tabler-icon {
- stroke-width: 1.2 !important;
+ stroke-width: 1.5 !important;
}
.mtk5 {
- color: #336554 !important;
+ color: rgb(var(--color-emerald-700)) !important;
}
.mtk6 {
color: #a626a4 !important;
diff --git a/src/lib/clients.ts b/src/lib/clients.ts
index 4779553..e95770e 100644
--- a/src/lib/clients.ts
+++ b/src/lib/clients.ts
@@ -70,7 +70,10 @@ export const queryClient = new QueryClient({
},
},
queryCache: new QueryCache({
- onError: handleError,
+ onError: (error, query) => {
+ if (query.meta?.hideToast) return
+ handleError(error)
+ },
}),
mutationCache: new MutationCache({
onError: handleError,
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 42f21ed..9bc110e 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -5,6 +5,30 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+export function safeParseJSON(value: string): T | undefined {
+ try {
+ return JSON.parse(value)
+ } catch {
+ return
+ }
+}
+
+/**
+ * Parses a JavaScript object literal string (with unquoted keys) into a JS object.
+ * Adds double quotes around unquoted keys like `$and:` -> `"$and":`
+ */
+export function parseJSObjectLiteral(value: string): T | undefined {
+ try {
+ // Add double quotes around unquoted keys (handles $ prefixed and regular identifiers)
+ let jsonified = value.replaceAll(/([,{]\s*)(\$?[A-Z_a-z]\w*)\s*:/g, '$1"$2":')
+ // Remove trailing commas before closing braces/brackets (valid in JS, invalid in JSON)
+ jsonified = jsonified.replaceAll(/,\s*([\]}])/g, "$1")
+ return JSON.parse(jsonified)
+ } catch {
+ return
+ }
+}
+
export function formatNumberWithCommas(value: number) {
return value.toString().replaceAll(/\B(?=(\d{3})+(?!\d))/g, ",")
}
diff --git a/src/playground/credentials-store.ts b/src/playground/credentials-store.ts
index 7d866d9..b967f6a 100644
--- a/src/playground/credentials-store.ts
+++ b/src/playground/credentials-store.ts
@@ -1,5 +1,6 @@
import { create } from "zustand"
import { persist } from "zustand/middleware"
+
import type { RedisCredentials } from "../redis-context"
interface CredentialsState {
diff --git a/src/store.tsx b/src/store.tsx
index 6523846..a3836b4 100644
--- a/src/store.tsx
+++ b/src/store.tsx
@@ -45,7 +45,7 @@ export const DatabrowserProvider = ({
setItem: (_name, value) => storage.set(JSON.stringify(value)),
removeItem: () => {},
},
- version: 5,
+ version: 6,
migrate: (originalState, version) => {
const state = originalState as DatabrowserStore
@@ -65,6 +65,21 @@ export const DatabrowserProvider = ({
})
}
+ if (version <= 5) {
+ // Add the new valuesSearch and isValuesSearchSelected fields
+ state.tabs = state.tabs.map(([id, data]) => {
+ const oldData = data as any
+ return [
+ id,
+ {
+ ...data,
+ valuesSearch: oldData.valuesSearch ?? { index: "", query: "" },
+ isValuesSearchSelected: oldData.isValuesSearchSelected ?? false,
+ },
+ ] as const
+ })
+ }
+
return state
},
})
@@ -100,6 +115,11 @@ export type SearchFilter = {
type: DataType | undefined
}
+export type ValuesSearchFilter = {
+ index: string
+ query: string
+}
+
export type SelectedItem = {
key: string
isNew?: boolean
@@ -111,6 +131,8 @@ export type TabData = {
selectedListItem?: SelectedItem
search: SearchFilter
+ valuesSearch: ValuesSearchFilter
+ isValuesSearchSelected: boolean
pinned?: boolean
}
@@ -137,10 +159,16 @@ type DatabrowserStore = {
setSelectedKey: (tabId: TabId, key: string | undefined) => void
setSelectedKeys: (tabId: TabId, keys: string[]) => void
setSelectedListItem: (tabId: TabId, item?: { key: string; isNew?: boolean }) => void
+
setSearch: (tabId: TabId, search: SearchFilter) => void
setSearchKey: (tabId: TabId, key: string) => void
setSearchType: (tabId: TabId, type: DataType | undefined) => void
+ setValuesSearch: (tabId: TabId, search: ValuesSearchFilter) => void
+ setValuesSearchIndex: (tabId: TabId, index: string) => void
+ setValuesSearchQuery: (tabId: TabId, query: string) => void
+ setIsValuesSearchSelected: (tabId: TabId, isSelected: boolean) => void
+
searchHistory: string[]
addSearchHistory: (key: string) => void
}
@@ -158,6 +186,8 @@ const storeCreator: StateCreator = (set, get) => ({
id,
selectedKeys: [],
search: { key: "", type: undefined },
+ valuesSearch: { index: "", query: "" },
+ isValuesSearchSelected: false,
pinned: false,
}
@@ -366,6 +396,70 @@ const storeCreator: StateCreator = (set, get) => ({
})
},
+ setValuesSearch: (tabId, valuesSearch) => {
+ set((old) => {
+ const tabIndex = old.tabs.findIndex(([id]) => id === tabId)
+ if (tabIndex === -1) return old
+
+ const newTabs = [...old.tabs]
+ const [, tabData] = newTabs[tabIndex]
+ newTabs[tabIndex] = [tabId, { ...tabData, valuesSearch }]
+
+ return { ...old, tabs: newTabs }
+ })
+ },
+
+ setValuesSearchIndex: (tabId, index) => {
+ set((old) => {
+ const tabIndex = old.tabs.findIndex(([id]) => id === tabId)
+ if (tabIndex === -1) return old
+
+ const newTabs = [...old.tabs]
+ const [, tabData] = newTabs[tabIndex]
+ newTabs[tabIndex] = [
+ tabId,
+ {
+ ...tabData,
+ valuesSearch: { ...tabData.valuesSearch, index },
+ },
+ ]
+
+ return { ...old, tabs: newTabs }
+ })
+ },
+
+ setValuesSearchQuery: (tabId, query) => {
+ set((old) => {
+ const tabIndex = old.tabs.findIndex(([id]) => id === tabId)
+ if (tabIndex === -1) return old
+
+ const newTabs = [...old.tabs]
+ const [, tabData] = newTabs[tabIndex]
+ newTabs[tabIndex] = [
+ tabId,
+ {
+ ...tabData,
+ valuesSearch: { ...tabData.valuesSearch, query },
+ },
+ ]
+
+ return { ...old, tabs: newTabs }
+ })
+ },
+
+ setIsValuesSearchSelected: (tabId, isSelected) => {
+ set((old) => {
+ const tabIndex = old.tabs.findIndex(([id]) => id === tabId)
+ if (tabIndex === -1) return old
+
+ const newTabs = [...old.tabs]
+ const [, tabData] = newTabs[tabIndex]
+ newTabs[tabIndex] = [tabId, { ...tabData, isValuesSearchSelected: isSelected }]
+
+ return { ...old, tabs: newTabs }
+ })
+ },
+
searchHistory: [],
addSearchHistory: (key) => {
set((old) => ({ ...old, searchHistory: [key, ...old.searchHistory] }))
diff --git a/src/tab-provider.tsx b/src/tab-provider.tsx
index 9790e25..7a7c6fa 100644
--- a/src/tab-provider.tsx
+++ b/src/tab-provider.tsx
@@ -1,6 +1,6 @@
import { createContext, useContext, useMemo } from "react"
-import type { SearchFilter, SelectedItem } from "./store"
+import type { SearchFilter, SelectedItem, ValuesSearchFilter } from "./store"
import { useDatabrowserStore, type TabId } from "./store"
import type { DataType } from "./types"
@@ -28,6 +28,10 @@ export const useTab = () => {
setSearch,
setSearchKey,
setSearchType,
+ setValuesSearch,
+ setValuesSearchIndex,
+ setValuesSearchQuery,
+ setIsValuesSearchSelected,
} = useDatabrowserStore()
const tabId = useTabId()
const tabData = useMemo(() => tabs.find(([id]) => id === tabId)?.[1], [tabs, tabId])
@@ -42,6 +46,8 @@ export const useTab = () => {
selectedKeys: tabData.selectedKeys ?? [],
selectedListItem: tabData.selectedListItem,
search: tabData.search,
+ valuesSearch: tabData.valuesSearch,
+ isValuesSearchSelected: tabData.isValuesSearchSelected,
pinned: tabData.pinned,
setSelectedKey: (key: string | undefined) => setSelectedKey(tabId, key),
@@ -50,6 +56,11 @@ export const useTab = () => {
setSearch: (search: SearchFilter) => setSearch(tabId, search),
setSearchKey: (key: string) => setSearchKey(tabId, key),
setSearchType: (type: DataType | undefined) => setSearchType(tabId, type),
+ setValuesSearch: (search: ValuesSearchFilter) => setValuesSearch(tabId, search),
+ setValuesSearchIndex: (index: string) => setValuesSearchIndex(tabId, index),
+ setValuesSearchQuery: (query: string) => setValuesSearchQuery(tabId, query),
+ setIsValuesSearchSelected: (isSelected: boolean) =>
+ setIsValuesSearchSelected(tabId, isSelected),
}),
[selectedTab, tabs, tabId]
)
diff --git a/src/types/index.ts b/src/types/index.ts
index e13357c..17ce23f 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -1,4 +1,13 @@
-export const DATA_TYPES = ["string", "list", "hash", "set", "zset", "json", "stream"] as const
+export const DATA_TYPES = [
+ "string",
+ "list",
+ "hash",
+ "set",
+ "zset",
+ "json",
+ "stream",
+ "search",
+] as const
export const DATA_TYPE_NAMES = {
string: "String",
list: "List",
@@ -7,6 +16,7 @@ export const DATA_TYPE_NAMES = {
zset: "Sorted Set",
json: "JSON",
stream: "Stream",
+ search: "Search Index",
} as const
export type DataType = (typeof DATA_TYPES)[number]
|