feat(redis): add Redis command completion to query editor

- add completion module: command names, two-stage subcommands, and key names
- key-name completion is async with a per-db cache capped at 1000 entries, cleared on disconnect
- gate key completion by argument position: stop after a single-key command is filled, keep suggesting for variadic key-list commands
- reuse the diagnostics command table as the data source, showing group/arity/safety
This commit is contained in:
haipengno1 authored and GitHub committed 2026-06-16 22:03:48 +08:00
1 parent 2a59d3143e
commit 1e96937455
4 files changed
+444 -1

No files matched your search

@@ -38,6 +38,7 @@ import { isSchemaAware, isSingleDatabase } from "@/lib/databaseFeatureSupport";
import * as api from "@/lib/api";
import { areSqlSemanticDiagnosticsEqual, buildSqlParserErrorDiagnostic, buildSqlSemanticDiagnostics, shouldRunSqlSemanticDiagnostics, type SqlSemanticDiagnostic } from "@/lib/sqlSemanticDiagnostics";
import { buildRedisSyntaxDiagnostics, shouldRunRedisDiagnostics } from "@/lib/redisSyntaxDiagnostics";
import { buildRedisCompletionItemsFromContext, getRedisCompletionContext, getRedisCompletionResultValidFor, shouldAutoOpenRedisCompletion, takesKeyArgument, type RedisCompletionItem } from "@/lib/redisCompletion";
import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionItem, SqlCompletionObject } from "@/lib/sqlCompletion";
import type { DatabaseType, SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database";
@@ -874,7 +875,7 @@ function unregisterTableReferenceDropListener() {
let completionEpoch = 0;
let completionDebounceTimer: ReturnType<typeof setTimeout> | null = null;
type QueryCompletionItem = SqlCompletionItem | ElasticsearchCompletionItem;
type QueryCompletionItem = SqlCompletionItem | ElasticsearchCompletionItem | RedisCompletionItem;
function buildCompletionResult(items: QueryCompletionItem[], from: number, validFor?: RegExp) {
if (items.length === 0) return null;
@@ -969,6 +970,29 @@ async function provideElasticsearchCompletions(currentState: import("@codemirror
return buildCompletionResult(items, completionContext.from, getElasticsearchCompletionResultValidFor());
}
async function provideRedisCompletions(currentState: import("@codemirror/state").EditorState, position: number, explicit: boolean) {
if (!props.connectionId) return null;
const epoch = ++completionEpoch;
const fullDoc = currentState.doc.toString();
if (!explicit && !shouldAutoOpenRedisCompletion(fullDoc, position)) return null;
const completionContext = getRedisCompletionContext(fullDoc, position);
// Key-name completion needs a reliable db index; props.database may briefly be "" on
// the New Query path before the active db resolves, and only key-argument commands warrant it.
let keys: string[] = [];
if (completionContext.mode === "argument" && props.database && takesKeyArgument(completionContext.mainCommand)) {
try {
keys = await connectionStore.listRedisCompletionKeys(props.connectionId, props.database);
} catch {
keys = [];
}
}
if (epoch !== completionEpoch) return null;
const items = buildRedisCompletionItemsFromContext(completionContext, { keys });
return buildCompletionResult(items, completionContext.from, getRedisCompletionResultValidFor());
}
async function provideSqlCompletions(currentState: import("@codemirror/state").EditorState, position: number, explicit: boolean) {
if (imeCompositionActive || view.value?.compositionStarted || view.value?.composing) return null;
if (!props.connectionId) return null;
@@ -978,6 +1002,9 @@ async function provideSqlCompletions(currentState: import("@codemirror/state").E
return provideElasticsearchCompletions(currentState, position, explicit);
}
}
if (props.databaseType === "redis") {
return provideRedisCompletions(currentState, position, explicit);
}
const hasDatabase = props.database != null;
const epoch = ++completionEpoch;
+243
View File
@@ -0,0 +1,243 @@
/**
* Redis command autocompletion for the query editor. Mirrors the shape of
* `elasticsearchCompletion.ts` so the editor's completion pipeline (the single
* `autocompletion({ override })` in QueryEditor.vue) can dispatch to it.
*
* Data source: the static `REDIS_COMMAND_TABLE` (built for diagnostics). Keys
* are UPPER CASE command names; two-token subcommands are keyed as `"MAIN SUB"`
* (e.g. `"XGROUP CREATE"`). We split those into a main-name index + subcommand
* index here.
*/
import { REDIS_COMMAND_TABLE } from "@/lib/redisCommandTable";
import type { RedisCommandSpec } from "@/lib/redisCommandTable";
export interface RedisCompletionItem {
label: string;
type: "keyword" | "text"; // command/subcommand=keyword, key name=text
detail?: string; // single-line, e.g. "string · confirm"
info?: string; // multi-line: Group / Arity / Safety
apply?: string;
boost: number;
}
export interface RedisCompletionContext {
mode: "command" | "subcommand" | "argument";
prefix: string;
/** Absolute document offset where the completion starts. */
from: number;
/** Upper-cased main command already typed, when known. */
mainCommand?: string;
/** In argument mode: 0-based index of the argument position (after the command head). */
argumentIndex?: number;
}
export interface RedisCompletionInput {
keys?: string[];
}
// ---- Static indexes derived from the command table ----
interface MainCommandEntry {
name: string;
spec: RedisCommandSpec;
}
interface SubcommandEntry {
main: string;
sub: string;
spec: RedisCommandSpec;
}
const MAIN_COMMANDS: MainCommandEntry[] = [];
const SUBCOMMANDS: SubcommandEntry[] = [];
const SUBCOMMAND_MAINS = new Set<string>();
for (const [key, spec] of Object.entries(REDIS_COMMAND_TABLE)) {
const space = key.indexOf(" ");
if (space < 0) {
MAIN_COMMANDS.push({ name: key, spec });
} else {
const main = key.slice(0, space);
const sub = key.slice(space + 1);
SUBCOMMANDS.push({ main, sub, spec });
SUBCOMMAND_MAINS.add(main);
// A main command that only has subcommand forms still needs a main-name
// entry so the user can complete the main token first.
if (!REDIS_COMMAND_TABLE[main]) MAIN_COMMANDS.push({ name: main, spec });
}
}
// Groups whose first argument is a key name (enable key completion there).
const KEY_ARGUMENT_GROUPS = new Set(["string", "generic", "list", "hash", "set", "zset", "bitmap", "hyperloglog", "geo", "stream"]);
// Commands that accept a variadic list of keys (keep suggesting keys beyond the
// first argument slot). Most key commands take exactly one key; these keep going.
const MULTI_KEY_COMMANDS = new Set(["DEL", "UNLINK", "EXISTS", "TOUCH", "MGET"]);
// Boost tuning: common groups surface higher.
const GROUP_BOOST: Record<string, number> = {
string: 110,
generic: 108,
connection: 100,
server: 96,
};
function describeArity(arity: number): string {
if (arity > 0) {
const n = arity - 1;
return `exactly ${n} argument${n === 1 ? "" : "s"}`;
}
if (arity < 0) {
const n = -arity - 1;
return `at least ${n} argument${n === 1 ? "" : "s"}`;
}
return "variable arguments";
}
function matchesPrefix(value: string, prefix: string): boolean {
return value.toLowerCase().startsWith(prefix.toLowerCase());
}
function buildSpecDetail(spec: RedisCommandSpec): string {
return spec.safety === "allowed" ? spec.group : `${spec.group} · ${spec.safety}`;
}
function buildSpecInfo(spec: RedisCommandSpec, label: string): string {
return [`Command: ${label}`, `Group: ${spec.group}`, `Arity: ${describeArity(spec.arity)}`, `Safety: ${spec.safety}`].join("\n");
}
function boostFor(spec: RedisCommandSpec): number {
return GROUP_BOOST[spec.group] ?? 90;
}
// ---- Context parsing ----
export function getRedisCompletionContext(text: string, cursor: number): RedisCompletionContext {
const safeCursor = Math.max(0, Math.min(cursor, text.length));
const lineStart = text.lastIndexOf("\n", safeCursor - 1) + 1;
const beforeCursor = text.slice(lineStart, safeCursor);
// Tokenize the part before the cursor by whitespace.
const tokens = beforeCursor.trimStart().length === 0 ? [] : beforeCursor.trim().split(/\s+/);
const endsWithSpace = beforeCursor.length > 0 && /\s$/.test(beforeCursor);
// Current word being typed (no trailing space yet).
const currentWord = endsWithSpace ? "" : (tokens[tokens.length - 1] ?? "");
const wordStartFromEnd = currentWord.length;
const from = safeCursor - wordStartFromEnd;
const typedTokens = endsWithSpace ? tokens : tokens.slice(0, -1);
// No command yet (or typing the very first token).
if (typedTokens.length === 0) {
return { mode: "command", prefix: currentWord, from };
}
const main = typedTokens[0]!.toUpperCase();
// First token done + space → maybe a subcommand of a command that has them.
if (typedTokens.length === 1 && SUBCOMMAND_MAINS.has(main)) {
return { mode: "subcommand", prefix: currentWord, from, mainCommand: main };
}
// Past the command (and any subcommand slot) → an argument. Track which
// argument position the cursor is at so we only suggest key names for the
// first key argument (e.g. GET <key>, not after the key is filled in).
const commandHeadTokens = typedTokens.length >= 2 && REDIS_COMMAND_TABLE[`${main} ${typedTokens[1]!.toUpperCase()}`] ? 2 : 1;
const argumentIndex = Math.max(typedTokens.length - commandHeadTokens, 0);
return { mode: "argument", prefix: currentWord, from, mainCommand: main, argumentIndex };
}
// ---- Item builders ----
function commandItems(prefix: string): RedisCompletionItem[] {
const items = MAIN_COMMANDS.filter((entry) => matchesPrefix(entry.name, prefix)).map((entry) => ({
label: entry.name,
type: "keyword" as const,
detail: buildSpecDetail(entry.spec),
info: buildSpecInfo(entry.spec, entry.name),
boost: boostFor(entry.spec),
}));
return items.sort((a, b) => b.boost - a.boost);
}
function subcommandItems(main: string, prefix: string): RedisCompletionItem[] {
const items = SUBCOMMANDS.filter((entry) => entry.main === main && matchesPrefix(entry.sub, prefix)).map((entry) => ({
label: entry.sub,
type: "keyword" as const,
detail: buildSpecDetail(entry.spec),
info: buildSpecInfo(entry.spec, `${main} ${entry.sub}`),
boost: boostFor(entry.spec),
}));
return items.sort((a, b) => b.boost - a.boost);
}
function keyItems(prefix: string, keys: string[]): RedisCompletionItem[] {
if (!prefix) {
// No partial key typed yet: offer a bounded sample (sorted) so the menu isn't empty.
return keys.slice(0, 100).map((key) => ({
label: key,
type: "text" as const,
detail: "key",
boost: 60,
}));
}
return keys
.filter((key) => key.toLowerCase().includes(prefix.toLowerCase()))
.slice(0, 100)
.map((key) => ({
label: key,
type: "text" as const,
detail: "key",
boost: key.toLowerCase().startsWith(prefix.toLowerCase()) ? 70 : 55,
}));
}
export function buildRedisCompletionItemsFromContext(context: RedisCompletionContext, input: RedisCompletionInput = {}): RedisCompletionItem[] {
if (context.mode === "command") return commandItems(context.prefix);
if (context.mode === "subcommand" && context.mainCommand) {
return subcommandItems(context.mainCommand, context.prefix);
}
// argument mode: offer key names at the key-argument slot only. Most key
// commands take a single key (first slot); variadic key-list commands
// (DEL/UNLINK/EXISTS/...) keep suggesting at every slot.
if (context.mode === "argument" && takesKeyArgument(context.mainCommand) && shouldSuggestKeyAt(context.mainCommand, context.argumentIndex)) {
return keyItems(context.prefix, input.keys ?? []);
}
return [];
}
function shouldSuggestKeyAt(mainCommand: string | undefined, argumentIndex: number | undefined): boolean {
if (argumentIndex == null) return false;
if (mainCommand && MULTI_KEY_COMMANDS.has(mainCommand)) return true;
return argumentIndex === 0;
}
export function buildRedisCompletionItems(text: string, cursor: number, input: RedisCompletionInput = {}): RedisCompletionItem[] {
return buildRedisCompletionItemsFromContext(getRedisCompletionContext(text, cursor), input);
}
/** True when the main command's first argument is a key (by group heuristic). */
export function takesKeyArgument(mainCommand?: string): boolean {
if (!mainCommand) return false;
const spec = REDIS_COMMAND_TABLE[mainCommand];
if (spec) return KEY_ARGUMENT_GROUPS.has(spec.group);
// A main that only exists via subcommands (e.g. XGROUP, CONFIG): treat stream/cluster
// subcommand roots as key-taking only for the stream group, conservatively.
return false;
}
export function shouldAutoOpenRedisCompletion(text: string, cursor: number): boolean {
const previousChar = text[cursor - 1];
if (!previousChar) return false;
if (/[\n\r]/.test(previousChar)) return false;
// Open while typing command names or key names (letters/digits/_/:/./-).
if (/[\w:*.-]/.test(previousChar)) return true;
// Just typed a space after a command → open to suggest subcommands / keys.
if (/\s/.test(previousChar)) return true;
return false;
}
export function getRedisCompletionResultValidFor(): RegExp {
return /[\w:*.-]*$/;
}
@@ -108,6 +108,7 @@ export const useConnectionStore = defineStore("connection", () => {
const completionForeignKeysCache = ref<Record<string, ForeignKeyInfo[]>>({});
const completionDatabasesCache = ref<Record<string, string[]>>({});
const elasticsearchCompletionIndicesCache = ref<Record<string, string[]>>({});
const redisCompletionKeysCache = ref<Record<string, string[]>>({});
const schemaListCache = ref<Record<string, string[]>>({});
const sidebarSearchQuery = ref("");
const completionTableIndex = new Map<string, { touched: number; tables: SqlCompletionTable[] }>();
@@ -603,6 +604,9 @@ export const useConnectionStore = defineStore("connection", () => {
for (const key of Object.keys(elasticsearchCompletionIndicesCache.value)) {
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete elasticsearchCompletionIndicesCache.value[key];
}
for (const key of Object.keys(redisCompletionKeysCache.value)) {
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete redisCompletionKeysCache.value[key];
}
for (const key of completionTableIndex.keys()) {
if (key.startsWith(cachePrefix)) completionTableIndex.delete(key);
}
@@ -1970,6 +1974,27 @@ export const useConnectionStore = defineStore("connection", () => {
return elasticsearchCompletionIndicesCache.value[cacheKey];
}
// Upper bound on cached key names per db, to keep completion memory bounded
// (Redis can hold far more keys than we ever want resident for autocomplete).
const REDIS_COMPLETION_KEYS_MAX = 1000;
async function listRedisCompletionKeys(connectionId: string, database: string): Promise<string[]> {
if (!database) return [];
const cacheKey = `${connectionId}:${database}`;
const cached = redisCompletionKeysCache.value[cacheKey];
if (cached) return cached;
return withCompletionInFlight(`${cacheKey}:redis-keys`, async () => {
await ensureConnected(connectionId);
const pageSize = settingsStore.editorSettings.redisScanPageSize;
// Bounded multi-round SCAN: trade coverage for latency/memory safety.
const result = await api.redisScanKeysBatch(connectionId, Number(database), 0, "*", pageSize, 6);
const keys = result.keys.map((key) => key.key_display).slice(0, REDIS_COMPLETION_KEYS_MAX);
redisCompletionKeysCache.value[cacheKey] = keys;
evictOldestCacheEntries(redisCompletionKeysCache.value, COMPLETION_CACHE_MAX);
return keys;
});
}
async function listCompletionTables(connectionId: string, database: string, filter = "", limit?: number, schema?: string): Promise<SqlCompletionTable[]> {
const normalizedFilter = filter.trim().toLowerCase();
const relaxedFilter = relaxedCompletionTableFilter(normalizedFilter);
@@ -2765,6 +2790,7 @@ export const useConnectionStore = defineStore("connection", () => {
refreshCompletionSchemas,
refreshCompletionDatabases,
listElasticsearchCompletionIndices,
listRedisCompletionKeys,
exportConnectionsToFile,
readImportFile,
importConnectionsFromFile,