feat(redis): add command terminal autocomplete
This commit is contained in:
1 parent
80bd8dac06
commit
dbe3375739
13 files changed
+1865
-337
No files matched your search
@@ -3114,7 +3114,7 @@ function completionOptionForItem(item: QueryCompletionItem) {
|
||||
markCompletionAccepted(item);
|
||||
const replaceTo = "replaceClosingQuote" in item && item.replaceClosingQuote === view.state.sliceDoc(to, to + 1) ? to + 1 : to;
|
||||
const insert = appendSqlCompletionSpace(item.apply ?? item.label, {
|
||||
enabled: shouldInsertSqlCompletionSpace() && settingsStore.editorSettings.insertSpaceAfterCompletion,
|
||||
enabled: ("appendSpace" in item && item.appendSpace === true) || (shouldInsertSqlCompletionSpace() && settingsStore.editorSettings.insertSpaceAfterCompletion),
|
||||
itemType: item.type,
|
||||
nextCharacter: view.state.sliceDoc(replaceTo, replaceTo + 1),
|
||||
});
|
||||
@@ -3157,11 +3157,22 @@ async function provideRedisCompletions(currentState: import("@codemirror/state")
|
||||
const fullDoc = currentState.doc.toString();
|
||||
if (!explicit && !shouldAutoOpenRedisCompletion(fullDoc, position)) return null;
|
||||
|
||||
const completionContext = getRedisCompletionContext(fullDoc, position);
|
||||
let commands;
|
||||
try {
|
||||
commands = await connectionStore.listRedisCompletionCommandDocs(props.connectionId, props.database ?? "0");
|
||||
} catch {
|
||||
// Completion is deliberately instance-driven: do not substitute a bundled
|
||||
// command list when the server does not expose command metadata.
|
||||
return null;
|
||||
}
|
||||
if (epoch !== completionEpoch) return null;
|
||||
|
||||
const completionInput = { commands };
|
||||
const completionContext = getRedisCompletionContext(fullDoc, position, completionInput);
|
||||
// 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)) {
|
||||
if (completionContext.mode === "argument" && props.database && takesKeyArgument(completionContext.commandName, completionInput, completionContext.argumentIndex, completionContext.argumentValues)) {
|
||||
try {
|
||||
keys = await connectionStore.listRedisCompletionKeys(props.connectionId, props.database);
|
||||
} catch {
|
||||
@@ -3172,6 +3183,7 @@ async function provideRedisCompletions(currentState: import("@codemirror/state")
|
||||
|
||||
const items = buildRedisCompletionItemsFromContext(completionContext, {
|
||||
keys,
|
||||
commands,
|
||||
});
|
||||
if (items.length === 0) return null;
|
||||
// Use the built-in filter (the default) so typing narrows the list and moves
|
||||
|
||||
@@ -22,9 +22,13 @@ const mocks = vi.hoisted(() => ({
|
||||
redisCheckJsonModule: vi.fn(),
|
||||
redisDeleteKey: vi.fn(),
|
||||
redisDeleteKeys: vi.fn(),
|
||||
redisExecuteCommand: vi.fn(),
|
||||
saveHistory: vi.fn(),
|
||||
canBuildRedisFuzzyTree: vi.fn((loadedKeyCount: number) => loadedKeyCount <= 200_000),
|
||||
toast: vi.fn(),
|
||||
updateRedisDbKeyStats: vi.fn(),
|
||||
listRedisCompletionCommandDocs: vi.fn(),
|
||||
listRedisCompletionKeys: vi.fn(),
|
||||
redisScanPageSize: 100,
|
||||
infiniteScroll: false,
|
||||
queryResultMaxRowsEnabled: true,
|
||||
@@ -46,6 +50,8 @@ vi.mock("@/lib/backend/api", () => ({
|
||||
redisCheckJsonModule: mocks.redisCheckJsonModule,
|
||||
redisDeleteKey: mocks.redisDeleteKey,
|
||||
redisDeleteKeys: mocks.redisDeleteKeys,
|
||||
redisExecuteCommand: mocks.redisExecuteCommand,
|
||||
saveHistory: mocks.saveHistory,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/redis/redisKeyTree", async (importOriginal) => {
|
||||
@@ -58,6 +64,8 @@ vi.mock("@/stores/connectionStore", () => ({
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
getConfig: () => ({ name: "Redis", redis_key_separator: ":", redis_scan_page_size: mocks.redisScanPageSize }),
|
||||
updateRedisDbKeyStats: mocks.updateRedisDbKeyStats,
|
||||
listRedisCompletionCommandDocs: mocks.listRedisCompletionCommandDocs,
|
||||
listRedisCompletionKeys: mocks.listRedisCompletionKeys,
|
||||
invalidateCompletionCache: vi.fn(),
|
||||
refreshRedisDbKeyCounts: vi.fn(),
|
||||
}),
|
||||
@@ -346,6 +354,15 @@ function redisKeyInfo(keyType = "json") {
|
||||
return { key_display: KEY_NAME, key_raw: KEY_RAW, key_type: keyType, ttl: 90, size: 7, value_preview: "{}" };
|
||||
}
|
||||
|
||||
const completionCommands = [
|
||||
{ name: "GET", group: "string", arity: 2, keySpecs: [{ beginSearch: { type: "index" as const, index: 1 }, findKeys: { type: "range" as const, lastKey: 0, keyStep: 1, limit: 0 } }] },
|
||||
{ name: "GETEX", group: "string", arity: -2, keySpecs: [{ beginSearch: { type: "index" as const, index: 1 }, findKeys: { type: "range" as const, lastKey: 0, keyStep: 1, limit: 0 } }] },
|
||||
{ name: "GETSET", group: "string", arity: 3, keySpecs: [{ beginSearch: { type: "index" as const, index: 1 }, findKeys: { type: "range" as const, lastKey: 0, keyStep: 1, limit: 0 } }] },
|
||||
{ name: "PING", group: "connection", arity: -1, keySpecs: [] },
|
||||
{ name: "SET", group: "string", arity: -3, keySpecs: [{ beginSearch: { type: "index" as const, index: 1 }, findKeys: { type: "range" as const, lastKey: 0, keyStep: 1, limit: 0 } }] },
|
||||
{ name: "VGET", group: "string", arity: 2, summary: "Reads a vendor key.", keySpecs: [{ beginSearch: { type: "index" as const, index: 1 }, findKeys: { type: "range" as const, lastKey: 0, keyStep: 1, limit: 0 } }] },
|
||||
];
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
@@ -376,6 +393,10 @@ function resetApiMocks() {
|
||||
mocks.redisCheckJsonModule.mockResolvedValue(true);
|
||||
mocks.redisDeleteKey.mockResolvedValue(undefined);
|
||||
mocks.redisDeleteKeys.mockResolvedValue(0);
|
||||
mocks.redisExecuteCommand.mockResolvedValue({ value: "OK" });
|
||||
mocks.saveHistory.mockResolvedValue(undefined);
|
||||
mocks.listRedisCompletionCommandDocs.mockResolvedValue(completionCommands);
|
||||
mocks.listRedisCompletionKeys.mockResolvedValue(["user:1"]);
|
||||
mocks.canBuildRedisFuzzyTree.mockImplementation((loadedKeyCount: number) => loadedKeyCount <= 200_000);
|
||||
}
|
||||
|
||||
@@ -386,6 +407,7 @@ function mountBrowser() {
|
||||
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
return host;
|
||||
}
|
||||
|
||||
function mountScopedBrowser() {
|
||||
@@ -502,6 +524,21 @@ async function openCreateDialog() {
|
||||
await setInput('input[placeholder="redis.createKeyNamePlaceholder"]', KEY_NAME);
|
||||
}
|
||||
|
||||
async function openCommandPanel() {
|
||||
const trigger = document.querySelector<HTMLElement>('[value="command"]');
|
||||
expect(trigger, "redis.commandLine trigger").toBeDefined();
|
||||
trigger!.click();
|
||||
await settle();
|
||||
}
|
||||
|
||||
function commandCompletionLabels(): string[] {
|
||||
return Array.from(document.querySelectorAll<HTMLButtonElement>('[role="option"]')).map((item) => item.textContent?.trim() ?? "");
|
||||
}
|
||||
|
||||
async function setCommandInput(value: string) {
|
||||
await setInput("[data-redis-command-input]", value);
|
||||
}
|
||||
|
||||
async function fillCreateValue(type: CreateType) {
|
||||
if (type === "string") {
|
||||
const textarea = requiredElement<HTMLTextAreaElement>("textarea");
|
||||
@@ -597,6 +634,186 @@ describe("RedisKeyBrowser scope changes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("RedisKeyBrowser command completion", () => {
|
||||
it("uses the connected server's module command docs and accepts the selection with Tab", async () => {
|
||||
mountBrowser();
|
||||
await settle();
|
||||
await openCommandPanel();
|
||||
await setCommandInput("VGE");
|
||||
|
||||
expect(mocks.listRedisCompletionCommandDocs).toHaveBeenCalledWith("connection", "0");
|
||||
expect(commandCompletionLabels()).toContain("VGETReads a vendor key.string");
|
||||
|
||||
const input = requiredElement<HTMLInputElement>("[data-redis-command-input]");
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true }));
|
||||
await settle();
|
||||
expect(input.value).toBe("VGE");
|
||||
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
|
||||
await settle();
|
||||
expect(input.value).toBe("VGET ");
|
||||
});
|
||||
|
||||
it("completes known keys only at a documented key argument", async () => {
|
||||
mountBrowser();
|
||||
await settle();
|
||||
await openCommandPanel();
|
||||
await setCommandInput("VGET ");
|
||||
|
||||
expect(mocks.listRedisCompletionKeys).toHaveBeenCalledWith("connection", "0");
|
||||
expect(commandCompletionLabels()).toContain("user:1key");
|
||||
|
||||
const input = requiredElement<HTMLInputElement>("[data-redis-command-input]");
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
|
||||
await settle();
|
||||
expect(input.value).toBe("VGET user:1");
|
||||
});
|
||||
|
||||
it("replaces an incomplete quoted key with the executable completion text", async () => {
|
||||
mocks.listRedisCompletionKeys.mockResolvedValueOnce(["user name"]);
|
||||
mountBrowser();
|
||||
await settle();
|
||||
await openCommandPanel();
|
||||
await setCommandInput('VGET "user');
|
||||
|
||||
const input = requiredElement<HTMLInputElement>("[data-redis-command-input]");
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
|
||||
await settle();
|
||||
|
||||
expect(input.value).toBe('VGET "user name"');
|
||||
});
|
||||
|
||||
it("does not guess command candidates when server metadata is unavailable", async () => {
|
||||
mocks.listRedisCompletionCommandDocs.mockRejectedValueOnce(new Error("unknown subcommand 'DOCS'"));
|
||||
mountBrowser();
|
||||
await settle();
|
||||
await openCommandPanel();
|
||||
await setCommandInput("GE");
|
||||
|
||||
expect(commandCompletionLabels()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps one selected completion while pointer and keyboard move it", async () => {
|
||||
mountBrowser();
|
||||
await settle();
|
||||
await openCommandPanel();
|
||||
await setCommandInput("GE");
|
||||
|
||||
const options = Array.from(document.querySelectorAll<HTMLButtonElement>('[role="option"]'));
|
||||
const input = requiredElement<HTMLInputElement>("[data-redis-command-input]");
|
||||
const listbox = requiredElement<HTMLElement>('[role="listbox"]');
|
||||
expect(options.length).toBeGreaterThan(1);
|
||||
expect(options.filter((option) => option.getAttribute("aria-selected") === "true")).toEqual([options[0]]);
|
||||
expect(options.every((option) => !option.hasAttribute("title"))).toBe(true);
|
||||
expect(input.getAttribute("aria-controls")).toBe(listbox.id);
|
||||
expect(input.getAttribute("aria-activedescendant")).toBe(options[0]!.id);
|
||||
|
||||
options[1]!.dispatchEvent(new Event("pointerenter", { bubbles: true }));
|
||||
await settle();
|
||||
expect(options.filter((option) => option.getAttribute("aria-selected") === "true")).toEqual([options[1]]);
|
||||
expect(input.getAttribute("aria-activedescendant")).toBe(options[1]!.id);
|
||||
|
||||
listbox.scrollTop = 20;
|
||||
vi.spyOn(listbox, "getBoundingClientRect").mockReturnValue({ top: 100, bottom: 200 } as DOMRect);
|
||||
vi.spyOn(options[2]!, "getBoundingClientRect").mockReturnValue({ top: 180, bottom: 224 } as DOMRect);
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
|
||||
await settle();
|
||||
expect(options.filter((option) => option.getAttribute("aria-selected") === "true")).toEqual([options[2]]);
|
||||
expect(listbox.scrollTop).toBe(44);
|
||||
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
|
||||
await settle();
|
||||
expect(options.filter((option) => option.getAttribute("aria-selected") === "true")).toEqual([options[2]]);
|
||||
|
||||
vi.spyOn(options[1]!, "getBoundingClientRect").mockReturnValue({ top: 76, bottom: 120 } as DOMRect);
|
||||
vi.spyOn(options[0]!, "getBoundingClientRect").mockReturnValue({ top: 100, bottom: 144 } as DOMRect);
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }));
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }));
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }));
|
||||
await settle();
|
||||
expect(options.filter((option) => option.getAttribute("aria-selected") === "true")).toEqual([options[0]]);
|
||||
expect(listbox.scrollTop).toBe(20);
|
||||
});
|
||||
|
||||
it("accepts a documented argument keyword and advances to its value", async () => {
|
||||
mocks.listRedisCompletionCommandDocs.mockResolvedValueOnce([
|
||||
...completionCommands,
|
||||
{
|
||||
name: "XREAD",
|
||||
group: "stream",
|
||||
arity: -4,
|
||||
keySpecs: [],
|
||||
arguments: [
|
||||
{ name: "count", token: "COUNT", type: "integer", optional: true },
|
||||
{ name: "streams", token: "STREAMS", type: "block", arguments: [{ name: "key", type: "key", multiple: true }] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
mountBrowser();
|
||||
await settle();
|
||||
await openCommandPanel();
|
||||
await setCommandInput("XREAD C");
|
||||
|
||||
const input = requiredElement<HTMLInputElement>("[data-redis-command-input]");
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
|
||||
await settle();
|
||||
|
||||
expect(input.value).toBe("XREAD COUNT ");
|
||||
expect(commandCompletionLabels()).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts the selected completion before executing on Enter", async () => {
|
||||
mountBrowser();
|
||||
await settle();
|
||||
await openCommandPanel();
|
||||
await setCommandInput("SE");
|
||||
|
||||
const input = requiredElement<HTMLInputElement>("[data-redis-command-input]");
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
await settle();
|
||||
|
||||
expect(input.value).toBe("SET ");
|
||||
expect(commandCompletionLabels()).toContain("user:1key");
|
||||
});
|
||||
|
||||
it("waits for command metadata instead of sending a partial command on Enter", async () => {
|
||||
const docs = deferred<typeof completionCommands>();
|
||||
mocks.listRedisCompletionCommandDocs.mockReturnValueOnce(docs.promise);
|
||||
mountBrowser();
|
||||
await settle();
|
||||
await openCommandPanel();
|
||||
await setCommandInput("SE");
|
||||
|
||||
const input = requiredElement<HTMLInputElement>("[data-redis-command-input]");
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
await settle();
|
||||
|
||||
expect(input.value).toBe("SE");
|
||||
expect(mocks.redisExecuteCommand).not.toHaveBeenCalled();
|
||||
|
||||
docs.resolve(completionCommands);
|
||||
await settle();
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
await settle();
|
||||
expect(input.value).toBe("SET ");
|
||||
expect(mocks.redisExecuteCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("executes an exact command instead of completing it a second time", async () => {
|
||||
mountBrowser();
|
||||
await settle();
|
||||
await openCommandPanel();
|
||||
await setCommandInput("PING");
|
||||
|
||||
const input = requiredElement<HTMLInputElement>("[data-redis-command-input]");
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisExecuteCommand).toHaveBeenCalledWith("connection", 0, "PING", true);
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RedisKeyBrowser expiry creation", () => {
|
||||
it.each(["string", "hash", "list", "set", "zset", "stream", "json"] as const)("writes %s before applying one relative TTL", async (type) => {
|
||||
mountBrowser();
|
||||
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
import { classifyRedisCommandSafety } from "@/lib/redis/redisCommandSafety";
|
||||
import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable";
|
||||
import { isRedisClearScreenCommand, nextRedisCommandDb, redisKeyTextToRaw } from "@/lib/redis/redisCommandSession";
|
||||
import { buildRedisCompletionItemsFromContext, getRedisCompletionContext, takesKeyArgument, type RedisCompletionItem } from "@/lib/redis/redisCompletion";
|
||||
import type { RedisCommandDocumentation } from "@/lib/redis/redisCommandDocs";
|
||||
import { formatRedisConsoleValue, redisValuePreview, redisValueSize } from "@/lib/redis/redisValuePresentation";
|
||||
import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
@@ -119,6 +121,15 @@ const commandRunning = ref(false);
|
||||
const commandDb = ref(props.db);
|
||||
const commandHistory = ref<RedisCommandHistoryEntry[]>([]);
|
||||
const commandHistoryIndex = ref(-1);
|
||||
const commandCompletionItems = ref<RedisCompletionItem[]>([]);
|
||||
const commandCompletionSelectedIndex = ref(0);
|
||||
const commandCompletionLoading = ref(false);
|
||||
const commandCompletionListboxId = `redis-command-completions-${uuid()}`;
|
||||
const commandCompletionSelectedItem = computed(() => commandCompletionItems.value[commandCompletionSelectedIndex.value]);
|
||||
const commandCompletionActiveDescendant = computed(() => (commandCompletionSelectedItem.value ? `${commandCompletionListboxId}-option-${commandCompletionSelectedIndex.value}` : undefined));
|
||||
const commandDocumentationLoading = ref(false);
|
||||
const commandCompletionOpen = computed(() => commandDocumentationLoading.value || commandCompletionLoading.value || commandCompletionItems.value.length > 0);
|
||||
const commandDocumentation = shallowRef<RedisCommandDocumentation[]>([]);
|
||||
const activeSidePanel = ref<RedisSidePanel>("detail");
|
||||
const showCreateKeyDialog = ref(false);
|
||||
const creatingKey = ref(false);
|
||||
@@ -152,6 +163,10 @@ let redisDbFlushedListenerRegistered = false;
|
||||
let redisInfiniteScrollFrame = 0;
|
||||
const loadedKeyRaws = new Set<string>();
|
||||
let treeIndex: RedisKeyTreeIndex | null = null;
|
||||
const REDIS_COMMAND_COMPLETION_MENU_LIMIT = 12;
|
||||
let commandCompletionRequestId = 0;
|
||||
let commandDocumentationConnectionId: string | null = null;
|
||||
let commandDocumentationRequestId = 0;
|
||||
|
||||
const valueQuery = computed(() => searchPattern.value.trim());
|
||||
const isValueSearchMode = computed(() => searchMode.value === "value" || searchMode.value === "all");
|
||||
@@ -909,6 +924,7 @@ async function openCommandPanel() {
|
||||
activeSidePanel.value = "command";
|
||||
await nextTick();
|
||||
getCommandInput()?.focus();
|
||||
requestCommandDocumentation();
|
||||
}
|
||||
|
||||
function makeEntry(): CreateKeyEntry {
|
||||
@@ -1209,6 +1225,7 @@ async function createRedisKey() {
|
||||
|
||||
async function executeCommand() {
|
||||
const command = commandText.value.trim();
|
||||
dismissCommandCompletions();
|
||||
if (!command) {
|
||||
// 空命令显示提示但不记入历史
|
||||
appendCommandOutput({
|
||||
@@ -1345,6 +1362,147 @@ function getCommandInput(): HTMLInputElement | null {
|
||||
return rootRef.value?.querySelector<HTMLInputElement>("[data-redis-command-input]") ?? null;
|
||||
}
|
||||
|
||||
function resetCommandDocumentation() {
|
||||
commandDocumentationRequestId++;
|
||||
commandDocumentationConnectionId = null;
|
||||
commandDocumentationLoading.value = false;
|
||||
commandDocumentation.value = [];
|
||||
}
|
||||
|
||||
function requestCommandDocumentation() {
|
||||
if (commandDocumentationLoading.value || commandDocumentationConnectionId === props.connectionId) return;
|
||||
const requestId = ++commandDocumentationRequestId;
|
||||
const connectionId = props.connectionId;
|
||||
const database = String(commandDb.value);
|
||||
commandDocumentationLoading.value = true;
|
||||
void connectionStore
|
||||
.listRedisCompletionCommandDocs(connectionId, database)
|
||||
.then((docs) => {
|
||||
if (requestId !== commandDocumentationRequestId || connectionId !== props.connectionId) return;
|
||||
commandDocumentation.value = docs;
|
||||
commandDocumentationConnectionId = connectionId;
|
||||
if (commandText.value) void refreshCommandCompletions();
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestId !== commandDocumentationRequestId || connectionId !== props.connectionId) return;
|
||||
// Do not offer guessed commands when the instance's metadata is unavailable.
|
||||
commandDocumentation.value = [];
|
||||
commandDocumentationConnectionId = connectionId;
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestId === commandDocumentationRequestId) commandDocumentationLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function dismissCommandCompletions() {
|
||||
commandCompletionRequestId++;
|
||||
commandCompletionItems.value = [];
|
||||
commandCompletionSelectedIndex.value = 0;
|
||||
commandCompletionLoading.value = false;
|
||||
}
|
||||
|
||||
async function refreshCommandCompletions(options: { force?: boolean } = {}) {
|
||||
const input = getCommandInput();
|
||||
const text = commandText.value;
|
||||
if (!options.force && !text) {
|
||||
dismissCommandCompletions();
|
||||
return;
|
||||
}
|
||||
|
||||
const cursor = input?.selectionStart ?? text.length;
|
||||
requestCommandDocumentation();
|
||||
const completionInput = { commands: commandDocumentation.value };
|
||||
const context = getRedisCompletionContext(text, cursor, completionInput);
|
||||
const requestId = ++commandCompletionRequestId;
|
||||
commandCompletionItems.value = [];
|
||||
commandCompletionSelectedIndex.value = 0;
|
||||
|
||||
let keys: string[] = [];
|
||||
const needsKeys = context.mode === "argument" && takesKeyArgument(context.commandName, completionInput, context.argumentIndex, context.argumentValues);
|
||||
commandCompletionLoading.value = needsKeys;
|
||||
if (needsKeys) {
|
||||
try {
|
||||
keys = await connectionStore.listRedisCompletionKeys(props.connectionId, String(commandDb.value));
|
||||
} catch {
|
||||
keys = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (requestId !== commandCompletionRequestId) return;
|
||||
commandCompletionItems.value = buildRedisCompletionItemsFromContext(context, { keys, ...completionInput }).slice(0, REDIS_COMMAND_COMPLETION_MENU_LIMIT);
|
||||
commandCompletionLoading.value = false;
|
||||
}
|
||||
|
||||
function onCommandInput() {
|
||||
void refreshCommandCompletions();
|
||||
}
|
||||
|
||||
function onCommandInputClick() {
|
||||
void refreshCommandCompletions();
|
||||
}
|
||||
|
||||
function selectCommandCompletion(index: number) {
|
||||
if (index < 0 || index >= commandCompletionItems.value.length) return;
|
||||
commandCompletionSelectedIndex.value = index;
|
||||
void nextTick(() => {
|
||||
const listbox = document.getElementById(commandCompletionListboxId);
|
||||
const option = document.getElementById(`${commandCompletionListboxId}-option-${index}`);
|
||||
if (!listbox || !option) return;
|
||||
const listboxRect = listbox.getBoundingClientRect();
|
||||
const optionRect = option.getBoundingClientRect();
|
||||
if (optionRect.top < listboxRect.top) listbox.scrollTop -= listboxRect.top - optionRect.top;
|
||||
else if (optionRect.bottom > listboxRect.bottom) listbox.scrollTop += optionRect.bottom - listboxRect.bottom;
|
||||
});
|
||||
}
|
||||
|
||||
function moveCommandCompletionSelection(direction: 1 | -1): boolean {
|
||||
const count = commandCompletionItems.value.length;
|
||||
if (count === 0) return false;
|
||||
const nextIndex = Math.min(Math.max(commandCompletionSelectedIndex.value + direction, 0), count - 1);
|
||||
if (nextIndex !== commandCompletionSelectedIndex.value) selectCommandCompletion(nextIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
function commandCompletionInsertion(index = commandCompletionSelectedIndex.value) {
|
||||
const item = commandCompletionItems.value[index];
|
||||
const input = getCommandInput();
|
||||
if (!item || !input) return null;
|
||||
|
||||
const text = commandText.value;
|
||||
const context = getRedisCompletionContext(text, input.selectionStart ?? text.length, { commands: commandDocumentation.value });
|
||||
const from = context.from;
|
||||
const to = input.selectionEnd ?? text.length;
|
||||
const insert = item.apply ?? item.label;
|
||||
const commandHead = context.mode === "command" || context.mode === "subcommand";
|
||||
const appendSpace = (commandHead || item.appendSpace === true) && !/^\s/.test(text.slice(to));
|
||||
return { text, from, to, insert, replacement: `${insert}${appendSpace ? " " : ""}`, appendSpace, commandHead };
|
||||
}
|
||||
|
||||
function selectedCompletionMatchesInput(): boolean {
|
||||
const completion = commandCompletionInsertion();
|
||||
if (!completion) return false;
|
||||
const current = completion.text.slice(completion.from, completion.to);
|
||||
return completion.commandHead ? current.toUpperCase() === completion.insert.toUpperCase() : current === completion.insert;
|
||||
}
|
||||
|
||||
function acceptCommandCompletion(index = commandCompletionSelectedIndex.value): boolean {
|
||||
const completion = commandCompletionInsertion(index);
|
||||
if (!completion) return false;
|
||||
|
||||
commandText.value = `${completion.text.slice(0, completion.from)}${completion.replacement}${completion.text.slice(completion.to)}`;
|
||||
dismissCommandCompletions();
|
||||
|
||||
void nextTick(() => {
|
||||
const nextInput = getCommandInput();
|
||||
if (!nextInput) return;
|
||||
const cursor = completion.from + completion.replacement.length;
|
||||
nextInput.focus();
|
||||
nextInput.setSelectionRange(cursor, cursor);
|
||||
if (completion.appendSpace) void refreshCommandCompletions({ force: true });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function focusSearch(): boolean {
|
||||
if (activeSidePanel.value === "detail" && valueViewerRef.value?.focusSearch()) {
|
||||
return true;
|
||||
@@ -1425,6 +1583,38 @@ function onCommandAreaClick() {
|
||||
}
|
||||
|
||||
function onCommandInputKeydown(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.code === "Space") {
|
||||
event.preventDefault();
|
||||
void refreshCommandCompletions({ force: true });
|
||||
return;
|
||||
}
|
||||
if (event.key === "Tab" && !event.shiftKey && acceptCommandCompletion()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape" && commandCompletionOpen.value) {
|
||||
event.preventDefault();
|
||||
dismissCommandCompletions();
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp" && moveCommandCompletionSelection(-1)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown" && moveCommandCompletionSelection(1)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
// Do not execute a partial command before instance metadata can resolve it.
|
||||
if (event.key === "Enter" && commandDocumentationLoading.value) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" && !selectedCompletionMatchesInput() && acceptCommandCompletion()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// 上下键切换历史命令
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
@@ -1438,6 +1628,7 @@ function onCommandInputKeydown(event: KeyboardEvent) {
|
||||
commandHistoryIndex.value--;
|
||||
}
|
||||
commandText.value = commandHistory.value[commandHistoryIndex.value].command;
|
||||
dismissCommandCompletions();
|
||||
} else if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
if (commandHistoryIndex.value === -1) return;
|
||||
@@ -1451,9 +1642,10 @@ function onCommandInputKeydown(event: KeyboardEvent) {
|
||||
commandHistoryIndex.value = -1;
|
||||
commandText.value = "";
|
||||
}
|
||||
dismissCommandCompletions();
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
executeCommand();
|
||||
void executeCommand();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1499,6 +1691,7 @@ watch(
|
||||
// ContentArea remounts this browser for scope changes; keep embedded uses
|
||||
// in sync as well so an old scan cannot populate the new scope.
|
||||
commandDb.value = db;
|
||||
resetCommandDocumentation();
|
||||
resetLoadedKeys();
|
||||
try {
|
||||
await connectionStore.ensureConnected(connectionId);
|
||||
@@ -1737,17 +1930,53 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
|
||||
<form class="flex shrink-0 items-center gap-2 border-t border-white/10 bg-[#171b21] px-4 py-2" @submit.prevent="executeCommand">
|
||||
<span class="shrink-0 text-[#d7ba7d]">{{ commandPrompt }}</span>
|
||||
<input
|
||||
v-model="commandText"
|
||||
data-redis-command-input
|
||||
class="dbx-editor-font-family min-w-0 flex-1 border-0 bg-transparent p-0 text-[13px] text-slate-200 caret-[#d7ba7d] outline-none placeholder:text-slate-500"
|
||||
:class="{ 'opacity-50': commandRunning }"
|
||||
:readonly="commandRunning"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
@keydown="onCommandInputKeydown"
|
||||
/>
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<div v-if="commandCompletionOpen" class="absolute bottom-[calc(100%+0.5rem)] left-0 z-20 w-full overflow-hidden rounded-md border border-white/15 bg-[#20262f] py-1 shadow-xl">
|
||||
<div v-if="commandDocumentationLoading || commandCompletionLoading" class="flex items-center justify-center px-3 py-2 text-slate-400">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
</div>
|
||||
<div v-else :id="commandCompletionListboxId" role="listbox" aria-label="Redis command completions" class="max-h-60 overflow-y-auto">
|
||||
<button
|
||||
v-for="(item, index) in commandCompletionItems"
|
||||
:id="`${commandCompletionListboxId}-option-${index}`"
|
||||
:key="`${item.type}:${item.label}:${index}`"
|
||||
type="button"
|
||||
role="option"
|
||||
class="flex w-full items-center gap-3 px-3 py-1.5 text-left text-xs transition-colors"
|
||||
:class="commandCompletionSelectedIndex === index ? 'bg-[#2b3440] text-white' : 'text-slate-200 hover:text-white'"
|
||||
:aria-selected="commandCompletionSelectedIndex === index"
|
||||
:aria-description="item.info"
|
||||
@pointerenter="selectCommandCompletion(index)"
|
||||
@mousedown.prevent
|
||||
@click.stop="acceptCommandCompletion(index)"
|
||||
>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate font-mono">{{ item.label }}</span>
|
||||
<span v-if="item.summary" class="block truncate text-[11px] text-slate-400">{{ item.summary }}</span>
|
||||
</span>
|
||||
<span v-if="item.detail" class="shrink-0 text-slate-400">{{ item.detail }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="commandText"
|
||||
data-redis-command-input
|
||||
class="dbx-editor-font-family min-w-0 w-full border-0 bg-transparent p-0 text-[13px] text-slate-200 caret-[#d7ba7d] outline-none placeholder:text-slate-500"
|
||||
:class="{ 'opacity-50': commandRunning }"
|
||||
:readonly="commandRunning"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
aria-autocomplete="list"
|
||||
aria-haspopup="listbox"
|
||||
:aria-controls="commandCompletionOpen ? commandCompletionListboxId : undefined"
|
||||
:aria-activedescendant="commandCompletionOpen ? commandCompletionActiveDescendant : undefined"
|
||||
:aria-expanded="commandCompletionOpen"
|
||||
@click.stop="onCommandInputClick"
|
||||
@input="onCommandInput"
|
||||
@keydown="onCommandInputKeydown"
|
||||
/>
|
||||
</div>
|
||||
<Loader2 v-if="commandRunning" class="h-3.5 w-3.5 shrink-0 animate-spin text-slate-500" />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user