From 420957b05238cb5b649c1733837072deaaf638fe Mon Sep 17 00:00:00 2001 From: Allen Helton Date: Thu, 15 Jan 2026 15:53:14 -0600 Subject: [PATCH 1/5] Adding autocomplete feature for commands --- .../useIsConnected.test.tsx | 2 +- .../__tests__/useValkeyAutocomplete.test.ts | 314 ++++ .../useValkeyConnectionNavigation.test.tsx | 2 +- .../useWebSocketNavigation.test.tsx | 2 +- .../valkey-command-matching.filtering.test.ts | 159 ++ .../__tests__/valkey-command-matching.test.ts | 85 ++ .../__tests__/valkey-commands.catalog.test.ts | 44 + .../valkey-commands.validation.test.ts | 154 ++ apps/frontend/src/components/Settings.tsx | 2 +- .../components/send-command/SendCommand.tsx | 190 ++- .../components/ui/autocomplete-dropdown.tsx | 235 +++ apps/frontend/src/css/index.css | 17 +- apps/frontend/src/data/valkey-commands.json | 1334 +++++++++++++++++ apps/frontend/src/hooks/hooks.ts | 3 + .../src/hooks/useValkeyAutocomplete.ts | 243 +++ apps/frontend/src/types/valkey-commands.ts | 23 + .../src/utils/valkey-command-matching.ts | 200 +++ 17 files changed, 2989 insertions(+), 20 deletions(-) rename apps/frontend/src/{hooks => __tests__}/useIsConnected.test.tsx (98%) create mode 100644 apps/frontend/src/__tests__/useValkeyAutocomplete.test.ts rename apps/frontend/src/{hooks => __tests__}/useValkeyConnectionNavigation.test.tsx (99%) rename apps/frontend/src/{hooks => __tests__}/useWebSocketNavigation.test.tsx (98%) create mode 100644 apps/frontend/src/__tests__/valkey-command-matching.filtering.test.ts create mode 100644 apps/frontend/src/__tests__/valkey-command-matching.test.ts create mode 100644 apps/frontend/src/__tests__/valkey-commands.catalog.test.ts create mode 100644 apps/frontend/src/__tests__/valkey-commands.validation.test.ts create mode 100644 apps/frontend/src/components/ui/autocomplete-dropdown.tsx create mode 100644 apps/frontend/src/data/valkey-commands.json create mode 100644 apps/frontend/src/hooks/useValkeyAutocomplete.ts create mode 100644 apps/frontend/src/types/valkey-commands.ts create mode 100644 apps/frontend/src/utils/valkey-command-matching.ts diff --git a/apps/frontend/src/hooks/useIsConnected.test.tsx b/apps/frontend/src/__tests__/useIsConnected.test.tsx similarity index 98% rename from apps/frontend/src/hooks/useIsConnected.test.tsx rename to apps/frontend/src/__tests__/useIsConnected.test.tsx index 38753aa5..07a6dc7e 100644 --- a/apps/frontend/src/hooks/useIsConnected.test.tsx +++ b/apps/frontend/src/__tests__/useIsConnected.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { renderHook } from "@testing-library/react" import { Provider } from "react-redux" import { CONNECTED, CONNECTING, DISCONNECTED, ERROR } from "@common/src/constants" -import useIsConnected from "./useIsConnected" +import useIsConnected from "../hooks/useIsConnected" import { setupTestStore } from "@/test/utils/test-utils" import { mockConnectionState } from "@/test/utils/mocks" import { standaloneConnectFulfilled } from "@/state/valkey-features/connection/connectionSlice" diff --git a/apps/frontend/src/__tests__/useValkeyAutocomplete.test.ts b/apps/frontend/src/__tests__/useValkeyAutocomplete.test.ts new file mode 100644 index 00000000..179d98b3 --- /dev/null +++ b/apps/frontend/src/__tests__/useValkeyAutocomplete.test.ts @@ -0,0 +1,314 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { renderHook, act } from "@testing-library/react" +import { useValkeyAutocomplete } from "../hooks/useValkeyAutocomplete" +import * as commandMatching from "@/utils/valkey-command-matching" + +vi.mock("@/utils/valkey-command-matching", () => ({ + matchCommands: vi.fn(), +})) + +const mockMatchCommands = vi.mocked(commandMatching.matchCommands) + +describe("useValkeyAutocomplete", () => { + beforeEach(() => { + vi.clearAllMocks() + mockMatchCommands.mockReturnValue([]) + }) + + it("should initialize with empty state", () => { + const { result } = renderHook(() => useValkeyAutocomplete()) + + expect(result.current.state.suggestions).toEqual([]) + expect(result.current.state.selectedIndex).toBe(0) + expect(result.current.state.isVisible).toBe(false) + expect(result.current.state.isLoading).toBe(false) + }) + + it("should update query and show suggestions", async () => { + mockMatchCommands.mockReturnValue([ + { + command: { + name: "GET", + syntax: "GET key", + category: "string", + description: "Get the value of a key", + parameters: [{ name: "key", type: "key", required: true, placeholder: "key" }], + }, + score: 1, + matchType: "prefix", + highlightRanges: [[0, 3]], + }, + ]) + + const { result } = renderHook(() => useValkeyAutocomplete({ debounceMs: 10 })) + + act(() => { + result.current.actions.updateQuery("GET") + }) + + // Wait for debounce + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + }) + + expect(result.current.state.suggestions).toHaveLength(1) + expect(result.current.state.suggestions[0].command.name).toBe("GET") + expect(result.current.state.isVisible).toBe(true) + }) + + it("should hide dropdown for empty query", async () => { + const { result } = renderHook(() => useValkeyAutocomplete({ debounceMs: 10 })) + + act(() => { + result.current.actions.updateQuery("") + }) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + }) + + expect(result.current.state.isVisible).toBe(false) + }) + + it("should navigate through suggestions", async () => { + mockMatchCommands.mockReturnValue([ + { + command: { + name: "GET", + syntax: "GET key", + category: "string", + description: "Get the value of a key", + parameters: [{ name: "key", type: "key", required: true, placeholder: "key" }], + }, + score: 1, + matchType: "prefix", + highlightRanges: [[0, 3]], + }, + ]) + + const { result } = renderHook(() => useValkeyAutocomplete({ debounceMs: 10 })) + + act(() => { + result.current.actions.updateQuery("GET") + }) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + }) + + expect(result.current.state.selectedIndex).toBe(0) + + act(() => { + result.current.actions.navigateDown() + }) + + // Should wrap around to 0 since there's only 1 suggestion + expect(result.current.state.selectedIndex).toBe(0) + + act(() => { + result.current.actions.navigateUp() + }) + + // Should wrap around to 0 since there's only 1 suggestion + expect(result.current.state.selectedIndex).toBe(0) + }) + + it("should hide dropdown when hide action is called", async () => { + mockMatchCommands.mockReturnValue([ + { + command: { + name: "GET", + syntax: "GET key", + category: "string", + description: "Get the value of a key", + parameters: [{ name: "key", type: "key", required: true, placeholder: "key" }], + }, + score: 1, + matchType: "prefix", + highlightRanges: [[0, 3]], + }, + ]) + + const { result } = renderHook(() => useValkeyAutocomplete({ debounceMs: 10 })) + + act(() => { + result.current.actions.updateQuery("GET") + }) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + }) + + expect(result.current.state.isVisible).toBe(true) + + act(() => { + result.current.actions.hide() + }) + + expect(result.current.state.isVisible).toBe(false) + }) + + it("should respect minQueryLength option", async () => { + const { result } = renderHook(() => useValkeyAutocomplete({ + debounceMs: 10, + minQueryLength: 2, + })) + + act(() => { + result.current.actions.updateQuery("G") + }) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + }) + + expect(result.current.state.isVisible).toBe(false) + + mockMatchCommands.mockReturnValue([ + { + command: { + name: "GET", + syntax: "GET key", + category: "string", + description: "Get the value of a key", + parameters: [{ name: "key", type: "key", required: true, placeholder: "key" }], + }, + score: 1, + matchType: "prefix", + highlightRanges: [[0, 2]], + }, + ]) + + act(() => { + result.current.actions.updateQuery("GE") + }) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + }) + + expect(result.current.state.isVisible).toBe(true) + }) + + it("should insert command into textarea", () => { + const { result } = renderHook(() => useValkeyAutocomplete()) + + // Mock textarea element + const mockTextarea = { + value: "G", + selectionStart: 1, + setSelectionRange: vi.fn(), + dispatchEvent: vi.fn(), + focus: vi.fn(), + } as any + + const mockRef = { current: mockTextarea } + + const command = { + name: "GET", + syntax: "GET key", + category: "string", + description: "Get the value of a key", + parameters: [{ name: "key", type: "key", required: true, placeholder: "key" }], + } + + act(() => { + result.current.actions.insertCommand(command, mockRef) + }) + + expect(mockTextarea.value).toBe("GET key") + expect(mockTextarea.setSelectionRange).toHaveBeenCalledWith(4, 4) // Position after "GET " + expect(mockTextarea.focus).toHaveBeenCalled() + }) + + it("should preserve existing arguments when inserting command", () => { + const { result } = renderHook(() => useValkeyAutocomplete()) + + // Mock textarea with existing arguments + const mockTextarea = { + value: "G mykey", + selectionStart: 1, + setSelectionRange: vi.fn(), + dispatchEvent: vi.fn(), + focus: vi.fn(), + } as any + + const mockRef = { current: mockTextarea } + + const command = { + name: "GET", + syntax: "GET key", + category: "string", + description: "Get the value of a key", + parameters: [{ name: "key", type: "key", required: true, placeholder: "key" }], + } + + act(() => { + result.current.actions.insertCommand(command, mockRef) + }) + + expect(mockTextarea.value).toBe("GET mykey") + expect(mockTextarea.setSelectionRange).toHaveBeenCalledWith(9, 9) // Position at end of arguments + expect(mockTextarea.focus).toHaveBeenCalled() + }) + + it("should handle command insertion with no parameters", () => { + const { result } = renderHook(() => useValkeyAutocomplete()) + + const mockTextarea = { + value: "PIN", + selectionStart: 3, + setSelectionRange: vi.fn(), + dispatchEvent: vi.fn(), + focus: vi.fn(), + } as any + + const mockRef = { current: mockTextarea } + + const command = { + name: "PING", + syntax: "PING", + category: "connection", + description: "Ping the server", + parameters: [], + } + + act(() => { + result.current.actions.insertCommand(command, mockRef) + }) + + expect(mockTextarea.value).toBe("PING") + expect(mockTextarea.setSelectionRange).toHaveBeenCalledWith(4, 4) // Position at end of command + expect(mockTextarea.focus).toHaveBeenCalled() + }) + + it("should handle multi-line command insertion", () => { + const { result } = renderHook(() => useValkeyAutocomplete()) + + const mockTextarea = { + value: "SET key1 value1\nG", + selectionStart: 17, // Position after 'G' on second line + setSelectionRange: vi.fn(), + dispatchEvent: vi.fn(), + focus: vi.fn(), + } as any + + const mockRef = { current: mockTextarea } + + const command = { + name: "GET", + syntax: "GET key", + category: "string", + description: "Get the value of a key", + parameters: [{ name: "key", type: "key", required: true, placeholder: "key" }], + } + + act(() => { + result.current.actions.insertCommand(command, mockRef) + }) + + expect(mockTextarea.value).toBe("SET key1 value1\nGET key") + expect(mockTextarea.setSelectionRange).toHaveBeenCalledWith(20, 20) // Position after "GET " on second line + expect(mockTextarea.focus).toHaveBeenCalled() + }) +}) diff --git a/apps/frontend/src/hooks/useValkeyConnectionNavigation.test.tsx b/apps/frontend/src/__tests__/useValkeyConnectionNavigation.test.tsx similarity index 99% rename from apps/frontend/src/hooks/useValkeyConnectionNavigation.test.tsx rename to apps/frontend/src/__tests__/useValkeyConnectionNavigation.test.tsx index cebb779b..a5387b0c 100644 --- a/apps/frontend/src/hooks/useValkeyConnectionNavigation.test.tsx +++ b/apps/frontend/src/__tests__/useValkeyConnectionNavigation.test.tsx @@ -3,7 +3,7 @@ import { renderHook } from "@testing-library/react" import { Provider } from "react-redux" import { BrowserRouter } from "react-router" import { CONNECTED, CONNECTING, ERROR } from "@common/src/constants" -import { useValkeyConnectionNavigation } from "./useValkeyConnectionNavigation" +import { useValkeyConnectionNavigation } from "../hooks/useValkeyConnectionNavigation" import { setupTestStore } from "@/test/utils/test-utils" import { mockConnectionState } from "@/test/utils/mocks" diff --git a/apps/frontend/src/hooks/useWebSocketNavigation.test.tsx b/apps/frontend/src/__tests__/useWebSocketNavigation.test.tsx similarity index 98% rename from apps/frontend/src/hooks/useWebSocketNavigation.test.tsx rename to apps/frontend/src/__tests__/useWebSocketNavigation.test.tsx index f11c67c9..c215506b 100644 --- a/apps/frontend/src/hooks/useWebSocketNavigation.test.tsx +++ b/apps/frontend/src/__tests__/useWebSocketNavigation.test.tsx @@ -3,7 +3,7 @@ import { renderHook } from "@testing-library/react" import { Provider } from "react-redux" import { BrowserRouter } from "react-router" import { CONNECTED, CONNECTING, ERROR } from "@common/src/constants" -import { useWebSocketNavigation } from "./useWebSocketNavigation" +import { useWebSocketNavigation } from "../hooks/useWebSocketNavigation" import { setupTestStore } from "@/test/utils/test-utils" import { mockWebSocketState } from "@/test/utils/mocks" diff --git a/apps/frontend/src/__tests__/valkey-command-matching.filtering.test.ts b/apps/frontend/src/__tests__/valkey-command-matching.filtering.test.ts new file mode 100644 index 00000000..7d48c505 --- /dev/null +++ b/apps/frontend/src/__tests__/valkey-command-matching.filtering.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from "vitest" +import { + getCommands, + searchCommands, + getCommandsByTier, + matchCommands +} from "../utils/valkey-command-matching" + +describe("Valkey Command Filtering", () => { + describe("getCommands", () => { + it("should return only default and remediation commands when adminMode is false", () => { + const commands = getCommands({ adminMode: false }) + + commands.forEach((command) => { + expect(["default", "remediation"]).toContain(command.tier) + }) + }) + + it("should return all commands including admin when adminMode is true", () => { + const allCommands = getCommands({ adminMode: true }) + const nonAdminCommands = getCommands({ adminMode: false }) + + expect(allCommands.length).toBeGreaterThan(nonAdminCommands.length) + + const hasAdminCommands = allCommands.some((c) => c.tier === "admin") + expect(hasAdminCommands).toBe(true) + }) + + it("should default to adminMode false when no options provided", () => { + const commands = getCommands() + + commands.forEach((command) => { + expect(["default", "remediation"]).toContain(command.tier) + }) + }) + }) + + describe("searchCommands", () => { + it("should not return admin commands when adminMode is false", () => { + const results = searchCommands("FLUSH", { adminMode: false }) + + results.forEach((result) => { + expect(result.command.tier).not.toBe("admin") + }) + }) + + it("should return admin commands when adminMode is true", () => { + const results = searchCommands("FLUSH", { adminMode: true }) + + const hasFlushDb = results.some((r) => r.command.name === "FLUSHDB") + const hasFlushAll = results.some((r) => r.command.name === "FLUSHALL") + + expect(hasFlushDb || hasFlushAll).toBe(true) + }) + + it("should respect maxResults parameter", () => { + const results = searchCommands("S", { maxResults: 5 }) + + expect(results.length).toBeLessThanOrEqual(5) + }) + + it("should return empty array for empty query", () => { + const results = searchCommands("", { adminMode: true }) + + expect(results).toEqual([]) + }) + }) + + describe("matchCommands", () => { + it("should filter by adminMode parameter", () => { + const withoutAdmin = matchCommands("CONFIG", 10, false) + const withAdmin = matchCommands("CONFIG", 10, true) + + expect(withAdmin.length).toBeGreaterThanOrEqual(withoutAdmin.length) + }) + + it("should prioritize prefix matches", () => { + const results = matchCommands("GET", 10, false) + + if (results.length > 0) { + expect(results[0].command.name).toBe("GET") + expect(results[0].matchType).toBe("prefix") + } + }) + + it("should return contains matches", () => { + const results = matchCommands("SCAN", 10, false) + + const hasScan = results.some((r) => r.command.name === "SCAN") + const hasHscan = results.some((r) => r.command.name === "HSCAN") + const hasSscan = results.some((r) => r.command.name === "SSCAN") + const hasZscan = results.some((r) => r.command.name === "ZSCAN") + + expect(hasScan || hasHscan || hasSscan || hasZscan).toBe(true) + }) + }) + + describe("getCommandsByTier", () => { + it("should return only default tier commands", () => { + const commands = getCommandsByTier("default") + + commands.forEach((command) => { + expect(command.tier).toBe("default") + }) + + expect(commands.length).toBeGreaterThan(0) + }) + + it("should return only remediation tier commands", () => { + const commands = getCommandsByTier("remediation") + + commands.forEach((command) => { + expect(command.tier).toBe("remediation") + }) + + expect(commands.length).toBeGreaterThan(0) + }) + + it("should return only admin tier commands", () => { + const commands = getCommandsByTier("admin") + + commands.forEach((command) => { + expect(command.tier).toBe("admin") + }) + + expect(commands.length).toBeGreaterThan(0) + }) + + it("should have FLUSHDB and FLUSHALL in admin tier", () => { + const adminCommands = getCommandsByTier("admin") + + const hasFlushDb = adminCommands.some((c) => c.name === "FLUSHDB") + const hasFlushAll = adminCommands.some((c) => c.name === "FLUSHALL") + + expect(hasFlushDb).toBe(true) + expect(hasFlushAll).toBe(true) + }) + + it("should have inspection commands in default tier", () => { + const defaultCommands = getCommandsByTier("default") + + const hasGet = defaultCommands.some((c) => c.name === "GET") + const hasScan = defaultCommands.some((c) => c.name === "SCAN") + const hasType = defaultCommands.some((c) => c.name === "TYPE") + + expect(hasGet).toBe(true) + expect(hasScan).toBe(true) + expect(hasType).toBe(true) + }) + + it("should have UNLINK in remediation tier", () => { + const remediationCommands = getCommandsByTier("remediation") + + const hasUnlink = remediationCommands.some((c) => c.name === "UNLINK") + + expect(hasUnlink).toBe(true) + }) + }) +}) diff --git a/apps/frontend/src/__tests__/valkey-command-matching.test.ts b/apps/frontend/src/__tests__/valkey-command-matching.test.ts new file mode 100644 index 00000000..301bfc32 --- /dev/null +++ b/apps/frontend/src/__tests__/valkey-command-matching.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest" +import { matchCommands, getAllCommands, getCategories } from "../utils/valkey-command-matching" + +describe("Valkey Command Matching", () => { + it("should return empty array for empty query", () => { + const results = matchCommands("") + expect(results).toEqual([]) + }) + + it("should return empty array for whitespace query", () => { + const results = matchCommands(" ") + expect(results).toEqual([]) + }) + + it("should find exact prefix matches", () => { + const results = matchCommands("GET") + expect(results.length).toBeGreaterThan(0) + expect(results[0].command.name).toBe("GET") + expect(results[0].matchType).toBe("prefix") + }) + + it("should prioritize prefix matches over contains matches", () => { + const results = matchCommands("SET") + const setCommand = results.find((r) => r.command.name === "SET") + const hsetCommand = results.find((r) => r.command.name === "HSET") + + expect(setCommand).toBeDefined() + expect(hsetCommand).toBeDefined() + + if (setCommand && hsetCommand) { + const setIndex = results.indexOf(setCommand) + const hsetIndex = results.indexOf(hsetCommand) + expect(setIndex).toBeLessThan(hsetIndex) + } + }) + + it("should limit results to maxResults parameter", () => { + const results = matchCommands("S", 5) + expect(results.length).toBeLessThanOrEqual(5) + }) + + it("should find contains matches", () => { + const results = matchCommands("PUSH") + expect(results.length).toBeGreaterThan(0) + + const lpushResult = results.find((r) => r.command.name === "LPUSH") + const rpushResult = results.find((r) => r.command.name === "RPUSH") + + expect(lpushResult).toBeDefined() + expect(rpushResult).toBeDefined() + expect(lpushResult?.matchType).toBe("contains") + expect(rpushResult?.matchType).toBe("contains") + }) + + it("should return all commands", () => { + const commands = getAllCommands() + expect(commands.length).toBeGreaterThan(0) + expect(commands[0]).toHaveProperty("name") + expect(commands[0]).toHaveProperty("syntax") + expect(commands[0]).toHaveProperty("category") + expect(commands[0]).toHaveProperty("description") + expect(commands[0]).toHaveProperty("parameters") + }) + + it("should return available categories", () => { + const categories = getCategories() + expect(categories.length).toBeGreaterThan(0) + expect(categories).toContain("string") + expect(categories).toContain("hash") + expect(categories).toContain("list") + }) + + it("should handle case insensitive matching", () => { + const results = matchCommands("get") + expect(results.length).toBeGreaterThan(0) + expect(results[0].command.name).toBe("GET") + }) + + it("should include highlight ranges for matches", () => { + const results = matchCommands("GET") + expect(results[0].highlightRanges).toBeDefined() + expect(results[0].highlightRanges.length).toBeGreaterThan(0) + expect(results[0].highlightRanges[0]).toEqual([0, 3]) + }) +}) diff --git a/apps/frontend/src/__tests__/valkey-commands.catalog.test.ts b/apps/frontend/src/__tests__/valkey-commands.catalog.test.ts new file mode 100644 index 00000000..73879931 --- /dev/null +++ b/apps/frontend/src/__tests__/valkey-commands.catalog.test.ts @@ -0,0 +1,44 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect } from "vitest" +import valkeyCommands from "../data/valkey-commands.json" + +describe("Valkey Command Catalog Snapshot", () => { + it("should match the expected command catalog structure", () => { + expect(valkeyCommands).toMatchSnapshot() + }) + + it("should have expected number of commands per tier", () => { + const commandsByTier = { + default: valkeyCommands.filter((c: any) => c.tier === "default").length, + remediation: valkeyCommands.filter((c: any) => c.tier === "remediation").length, + admin: valkeyCommands.filter((c: any) => c.tier === "admin").length, + } + + expect(commandsByTier).toMatchSnapshot() + }) + + it("should have expected command names per tier", () => { + const commandNamesByTier = { + default: valkeyCommands + .filter((c: any) => c.tier === "default") + .map((c: any) => c.name) + .sort(), + remediation: valkeyCommands + .filter((c: any) => c.tier === "remediation") + .map((c: any) => c.name) + .sort(), + admin: valkeyCommands + .filter((c: any) => c.tier === "admin") + .map((c: any) => c.name) + .sort(), + } + + expect(commandNamesByTier).toMatchSnapshot() + }) + + it("should have expected categories", () => { + const categories = [...new Set(valkeyCommands.map((c: any) => c.category))].sort() + + expect(categories).toMatchSnapshot() + }) +}) diff --git a/apps/frontend/src/__tests__/valkey-commands.validation.test.ts b/apps/frontend/src/__tests__/valkey-commands.validation.test.ts new file mode 100644 index 00000000..ea1ed4ba --- /dev/null +++ b/apps/frontend/src/__tests__/valkey-commands.validation.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect } from "vitest" +import valkeyCommands from "../data/valkey-commands.json" +import type { ValkeyCommand } from "@/types/valkey-commands" + +describe("Valkey Command Database Validation", () => { + const commands = valkeyCommands as ValkeyCommand[] + + it("should have all required fields for every command", () => { + commands.forEach((command) => { + expect(command).toHaveProperty("name") + expect(command).toHaveProperty("syntax") + expect(command).toHaveProperty("category") + expect(command).toHaveProperty("description") + expect(command).toHaveProperty("parameters") + expect(command).toHaveProperty("tier") + + expect(typeof command.name).toBe("string") + expect(typeof command.syntax).toBe("string") + expect(typeof command.category).toBe("string") + expect(typeof command.description).toBe("string") + expect(Array.isArray(command.parameters)).toBe(true) + expect(["default", "remediation", "admin"]).toContain(command.tier) + }) + }) + + it("should have non-empty name, syntax, and description", () => { + commands.forEach((command) => { + expect(command.name.length).toBeGreaterThan(0) + expect(command.syntax.length).toBeGreaterThan(0) + expect(command.description.length).toBeGreaterThan(0) + }) + }) + + it("should have valid categories", () => { + const validCategories = [ + "connection", + "server", + "generic", + "string", + "hash", + "list", + "set", + "sorted_set", + "scan", + "admin", + ] + + commands.forEach((command) => { + expect(validCategories).toContain(command.category) + }) + }) + + it("should have required parameters present in syntax", () => { + commands.forEach((command) => { + const requiredParams = command.parameters.filter((p) => p.required) + + requiredParams.forEach((param) => { + // Check if either the parameter name or a generic version is in the syntax + const paramName = param.name + const paramInSyntax = command.syntax.includes(paramName) || + command.syntax.includes(param.placeholder || param.name) + if (!paramInSyntax) { + console.log(`Command: ${command.name}, Missing param: ${param.name} (placeholder: ${param.placeholder}), Syntax: ${command.syntax}`) + } + expect(paramInSyntax).toBe(true) + }) + }) + }) + + it("should show repeatable parameters with [...] in syntax", () => { + commands.forEach((command) => { + const repeatableParams = command.parameters.filter((p) => p.repeatable) + + repeatableParams.forEach((param) => { + const placeholder = param.placeholder || param.name + const hasRepeatableSyntax = + command.syntax.includes(`${placeholder} [${placeholder} ...]`) || + command.syntax.includes(`[${placeholder} ...]`) + + expect(hasRepeatableSyntax).toBe(true) + }) + }) + }) + + it("should have valid parameter types", () => { + const validTypes = ["key", "value", "option", "number", "pattern", "cursor"] + + commands.forEach((command) => { + command.parameters.forEach((param) => { + expect(validTypes).toContain(param.type) + }) + }) + }) + + it("should have proper tier distribution", () => { + const defaultCommands = commands.filter((c) => c.tier === "default") + const remediationCommands = commands.filter((c) => c.tier === "remediation") + const adminCommands = commands.filter((c) => c.tier === "admin") + + // Should have commands in each tier + expect(defaultCommands.length).toBeGreaterThan(0) + expect(remediationCommands.length).toBeGreaterThan(0) + expect(adminCommands.length).toBeGreaterThan(0) + + // Default tier should have the most commands (inspection/exploration) + expect(defaultCommands.length).toBeGreaterThanOrEqual(remediationCommands.length) + }) + + it("should have dangerous commands in admin tier", () => { + const dangerousCommands = ["FLUSHDB", "FLUSHALL", "CONFIG SET"] + + dangerousCommands.forEach((cmdName) => { + const command = commands.find((c) => c.name === cmdName) + if (command) { + expect(command.tier).toBe("admin") + } + }) + }) + + it("should have UNLINK in remediation tier (safer than DEL)", () => { + const unlink = commands.find((c) => c.name === "UNLINK") + expect(unlink).toBeDefined() + expect(unlink?.tier).toBe("remediation") + }) + + it("should have inspection commands in default tier", () => { + const inspectionCommands = ["GET", "HGET", "LRANGE", "SMEMBERS", "ZRANGE", "TYPE", "TTL", "SCAN"] + + inspectionCommands.forEach((cmdName) => { + const command = commands.find((c) => c.name === cmdName) + if (command) { + expect(command.tier).toBe("default") + } + }) + }) + + it("should have unique command names", () => { + const names = commands.map((c) => c.name) + const uniqueNames = new Set(names) + expect(names.length).toBe(uniqueNames.size) + }) + + it("should have uppercase command names", () => { + commands.forEach((command) => { + expect(command.name).toBe(command.name.toUpperCase()) + }) + }) + + it("should have syntax starting with command name", () => { + commands.forEach((command) => { + expect(command.syntax.startsWith(command.name)).toBe(true) + }) + }) +}) diff --git a/apps/frontend/src/components/Settings.tsx b/apps/frontend/src/components/Settings.tsx index b01cb5ea..cb8b74d5 100644 --- a/apps/frontend/src/components/Settings.tsx +++ b/apps/frontend/src/components/Settings.tsx @@ -22,7 +22,7 @@ export default function Settings() { setMonitorEnabled(config.monitoring.monitorEnabled) setMonitorDuration(config.monitoring.monitorDuration) } - }, [config?.monitoring?.monitorEnabled, config?.monitoring?.monitorDuration]) + }, [config?.monitoring, config?.monitoring?.monitorEnabled, config?.monitoring?.monitorDuration]) const hasChanges = config?.monitoring && diff --git a/apps/frontend/src/components/send-command/SendCommand.tsx b/apps/frontend/src/components/send-command/SendCommand.tsx index e262377c..1ba7e645 100644 --- a/apps/frontend/src/components/send-command/SendCommand.tsx +++ b/apps/frontend/src/components/send-command/SendCommand.tsx @@ -14,6 +14,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import DiffCommands from "@/components/send-command/DiffCommands.tsx" import Response from "@/components/send-command/Response.tsx" import { useAppDispatch } from "@/hooks/hooks.ts" +import { useValkeyAutocomplete } from "@/hooks/useValkeyAutocomplete.ts" +import { AutocompleteDropdown } from "@/components/ui/autocomplete-dropdown.tsx" export function SendCommand() { const dispatch = useAppDispatch() @@ -28,13 +30,131 @@ export function SendCommand() { const allCommands = useSelector(selectAllCommands(id as string)) || [] const { error, response } = useSelector(getNth(commandIndex, id as string)) as CommandMetadata + // Initialize autocomplete hook + const autocomplete = useValkeyAutocomplete({ + maxSuggestions: 10, + debounceMs: 50, + minQueryLength: 1, + }) + + const textareaRef = useRef(null as HTMLTextAreaElement) + + const insertSelectedCommand = (command: ValkeyCommand) => { + const textarea = textareaRef.current + if (!textarea) return + + const currentValue = textarea.value + const cursorPosition = textarea.selectionStart || 0 + const lineStart = currentValue.lastIndexOf("\n", cursorPosition - 1) + 1 + + let commandStart = lineStart + while (commandStart < currentValue.length && /\s/.test(currentValue[commandStart])) { + commandStart++ + } + + let commandEnd = commandStart + while (commandEnd < currentValue.length && + /\S/.test(currentValue[commandEnd]) && + currentValue[commandEnd] !== "\n") { + commandEnd++ + } + + let existingArgs = "" + let argsStart = commandEnd + + while (argsStart < currentValue.length && currentValue[argsStart] === " ") { + argsStart++ + } + + let lineEnd = currentValue.indexOf("\n", argsStart) + if (lineEnd === -1) lineEnd = currentValue.length + + if (argsStart < lineEnd) { + existingArgs = currentValue.substring(argsStart, lineEnd) + } + + let commandText = command.name + if (existingArgs.trim()) { + commandText += ` ${existingArgs}` + } + + const beforeCommand = currentValue.substring(0, commandStart) + const afterLine = currentValue.substring(lineEnd) + const newValue = beforeCommand + commandText + afterLine + + setText(newValue) + autocomplete.actions.hide() + + setTimeout(() => { + const newCursorPosition = commandStart + command.name.length + (existingArgs.trim() ? 1 + existingArgs.length : 0) + textarea.setSelectionRange(newCursorPosition, newCursorPosition) + textarea.focus() + }, 0) + } + const onSubmit = (command?: string) => { dispatch(sendRequested({ command: command || text, connectionId: id })) setCommandIndex(length) setText("") + // Hide autocomplete when submitting + autocomplete.actions.hide() } const onKeyDown = (e: React.KeyboardEvent) => { + if (autocomplete.state.isVisible) { + switch (e.key) { + case "ArrowDown": + e.preventDefault() + autocomplete.actions.navigateDown() + return + case "ArrowUp": + e.preventDefault() + autocomplete.actions.navigateUp() + return + case "Home": + // Only handle Home for autocomplete if Ctrl is not pressed + if (!e.ctrlKey) { + e.preventDefault() + autocomplete.actions.navigateToFirst() + return + } + break + case "End": + // Only handle End for autocomplete if Ctrl is not pressed + if (!e.ctrlKey) { + e.preventDefault() + autocomplete.actions.navigateToLast() + return + } + break + case "Enter": + if (autocomplete.state.suggestions.length > 0) { + e.preventDefault() + const selectedCommand = autocomplete.state.suggestions[autocomplete.state.selectedIndex]?.command + if (selectedCommand) { + insertSelectedCommand(selectedCommand) + return + } + } + break + case "Tab": + if (autocomplete.state.suggestions.length > 0) { + e.preventDefault() + const selectedCommand = autocomplete.state.suggestions[autocomplete.state.selectedIndex]?.command + if (selectedCommand) { + insertSelectedCommand(selectedCommand) + return + } + } + break + case "Escape": + e.preventDefault() + autocomplete.actions.hide() + return + } + } + + // Original keyboard handling for non-autocomplete cases if (e.key === "Enter") { e.preventDefault() if (text.trim().length > 0) { @@ -43,17 +163,36 @@ export function SendCommand() { } else if (e.key === "Escape") { e.preventDefault() setText("") + autocomplete.actions.hide() } } + const onTextChange = (e: React.ChangeEvent) => { + const newText = e.target.value + setText(newText) + + // Update autocomplete query based on current cursor position + const cursorPosition = e.target.selectionStart || 0 + const textBeforeCursor = newText.substring(0, cursorPosition) + + // Find the current line + const lineStart = textBeforeCursor.lastIndexOf("\n") + 1 + const currentLine = textBeforeCursor.substring(lineStart) + + // Extract the command part (first word) for autocomplete + const commandMatch = currentLine.match(/^\s*(\S*)/) + const commandPart = commandMatch ? commandMatch[1] : "" + + // Update autocomplete query + autocomplete.actions.updateQuery(commandPart) + } + const canDiff = (index) => { // can diff only the same command, i.e. info vs info const currentCommand = allCommands[commandIndex] const targetCommand = allCommands[index] return currentCommand.command.toLowerCase() === targetCommand.command.toLowerCase() } - const textareaRef = useRef(null as HTMLTextAreaElement) - return ( -
-