Add Command Palette & KeyboardNavigation (#450)

Congrats you did it, you learned patching slightly.
This commit is contained in:
Jake 2025-10-16 04:31:53 +01:00 • committed by GitHub
parent aba73babf1
commit fccee12ad9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 5471 additions and 111 deletions

View file

@ -0,0 +1,417 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./style.css";
import { classNameFactory } from "@api/Styles";
import { ModalRoot, ModalSize, openModal } from "@utils/modal";
import { React, TextInput, useCallback, useEffect, useMemo, useRef, useState } from "@webpack/common";
import { settings } from "./index";
import {
type CommandEntry,
getCategoryGroupLabel,
getCategoryPath,
getCategoryWeight,
getCommandSearchText,
getRecentRank,
getRegistryVersion,
listCommands,
subscribeRegistry
} from "./registry";
type PickerItem =
| { type: "section"; label: string; }
| { type: "command"; command: CommandEntry; };
const isCommandItem = (item: PickerItem | undefined): item is { type: "command"; command: CommandEntry; } =>
item?.type === "command";
const getFirstSelectableIndex = (items: PickerItem[]) =>
items.findIndex(isCommandItem);
const getNextSelectableIndex = (start: number, direction: 1 | -1, items: PickerItem[]) => {
if (items.length === 0) return -1;
let index = start;
for (let i = 0; i < items.length; i += 1) {
index = (index + direction + items.length) % items.length;
if (isCommandItem(items[index])) return index;
}
return getFirstSelectableIndex(items);
};
interface CommandPickerProps {
allowMultiple?: boolean;
initialQuery?: string;
initialSelectedIds?: string[];
filter?(command: CommandEntry): boolean;
onSelect?(command: CommandEntry): void;
onComplete?(commands: CommandEntry[], commandIds: string[]): void;
onClose?(): void;
}
export function openCommandPicker(props: CommandPickerProps) {
return openModal(modalProps => (
<CommandPickerModal modalProps={modalProps} {...props} />
));
}
function CommandPickerModal({ modalProps, allowMultiple = false, initialQuery = "", initialSelectedIds = [], filter: filterPredicate, onSelect, onComplete, onClose }: CommandPickerProps & { modalProps: any; }) {
const cl = classNameFactory("vc-command-palette-");
const [query, setQuery] = useState(initialQuery);
const [registryVersion, setRegistryVersion] = useState(() => getRegistryVersion());
const [selectedIndex, setSelectedIndex] = useState(-1);
const [visible, setVisible] = useState(false);
const showTags = settings.store.showTags ?? true;
const { visualStyle = "classic" } = settings.use(["visualStyle"]);
const styleClass = visualStyle === "polished" ? "style-polished" : "style-classic";
const normalizedInitialSelected = useMemo(() => Array.from(new Set(initialSelectedIds)), [initialSelectedIds]);
const [pickedIds, setPickedIds] = useState<string[]>(() => normalizedInitialSelected);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const pickedIdsRef = useRef<string[]>([]);
useEffect(() => subscribeRegistry(setRegistryVersion), []);
useEffect(() => {
const frame = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(frame);
}, []);
useEffect(() => {
pickedIdsRef.current = pickedIds;
}, [pickedIds]);
const commands = useMemo(() => listCommands(), [registryVersion]);
const commandMap = useMemo(() => new Map(commands.map(entry => [entry.id, entry])), [commands]);
const availableCommands = useMemo(() => {
if (!filterPredicate) return commands;
return commands.filter(filterPredicate);
}, [commands, filterPredicate]);
const trimmedQuery = query.trim();
const lowerQuery = trimmedQuery.toLowerCase();
const pickedSet = useMemo(() => new Set(pickedIds), [pickedIds]);
const { items, hasCommands } = useMemo(() => {
const sortByWeight = (a: CommandEntry, b: CommandEntry) => {
const weightDiff = getCategoryWeight(b.categoryId) - getCategoryWeight(a.categoryId);
if (weightDiff !== 0) return weightDiff;
return a.label.localeCompare(b.label);
};
let entries: CommandEntry[];
if (lowerQuery) {
const scoreCommand = (entry: CommandEntry) => {
let score = 0;
const labelLower = entry.label.toLowerCase();
if (labelLower === lowerQuery) score += 60;
else if (labelLower.startsWith(lowerQuery)) score += 45;
else if (labelLower.includes(lowerQuery)) score += 30;
const labelTokens = labelLower.split(/[^a-z0-9]+/);
if (labelTokens.includes(lowerQuery)) score += 20;
if (entry.keywords && entry.keywords.length) {
const keywordMatches = entry.keywords
.map(keyword => keyword.toLowerCase())
.filter(Boolean);
if (keywordMatches.includes(lowerQuery)) score += 35;
else if (keywordMatches.some(keyword => keyword.includes(lowerQuery))) score += 15;
}
const descriptionLower = entry.description?.toLowerCase() ?? "";
if (descriptionLower.includes(lowerQuery)) score += 10;
if (entry.tags && entry.tags.length) {
const tagMatches = entry.tags.map(tag => tag.toLowerCase());
if (tagMatches.includes(lowerQuery)) score += 25;
else if (tagMatches.some(tag => tag.includes(lowerQuery))) score += 10;
}
if (score === 0) score = 5;
score += getCategoryWeight(entry.categoryId);
const recentRank = getRecentRank(entry.id);
if (recentRank >= 0) {
score += Math.max(0, 30 - recentRank * 4);
}
return score;
};
const bestByGroup = new Map<string, { entry: CommandEntry; score: number; order: number; }>();
let orderCounter = 0;
for (const entry of availableCommands) {
if (entry.hiddenInSearch) continue;
const searchText = getCommandSearchText(entry.id);
if (!searchText.includes(lowerQuery)) continue;
const score = scoreCommand(entry);
const groupKey = entry.searchGroup ?? entry.id;
const existing = bestByGroup.get(groupKey);
if (!existing || score > existing.score || (score === existing.score && orderCounter < existing.order)) {
bestByGroup.set(groupKey, { entry, score, order: orderCounter });
}
orderCounter += 1;
}
entries = Array.from(bestByGroup.values())
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return a.order - b.order;
})
.map(item => item.entry);
} else {
entries = [...availableCommands].sort(sortByWeight);
}
const grouped = new Map<string, CommandEntry[]>();
const groupOrder: string[] = [];
for (const entry of entries) {
const label = getCategoryGroupLabel(entry.categoryId);
if (!grouped.has(label)) {
grouped.set(label, []);
groupOrder.push(label);
}
grouped.get(label)!.push(entry);
}
const built: PickerItem[] = [];
for (const label of groupOrder) {
const groupEntries = grouped.get(label);
if (!groupEntries || groupEntries.length === 0) continue;
built.push({ type: "section", label });
for (const entry of groupEntries) {
built.push({ type: "command", command: entry });
}
}
return { items: built, hasCommands: entries.length > 0 };
}, [availableCommands, lowerQuery]);
useEffect(() => {
if (!hasCommands) {
setSelectedIndex(-1);
return;
}
setSelectedIndex(prev => {
if (prev >= 0 && prev < items.length && isCommandItem(items[prev])) return prev;
return getFirstSelectableIndex(items);
});
}, [items, hasCommands]);
useEffect(() => {
if (selectedIndex < 0) return;
const node = itemRefs.current[selectedIndex];
node?.scrollIntoView({ block: "nearest" });
}, [selectedIndex, items]);
itemRefs.current = itemRefs.current.slice(0, items.length);
const togglePicked = (command: CommandEntry) => {
setPickedIds(prev => {
const exists = prev.includes(command.id);
if (exists) return prev.filter(id => id !== command.id);
return [...prev, command.id];
});
};
const handleSelect = (command: CommandEntry) => {
if (allowMultiple) {
togglePicked(command);
return;
}
onSelect?.(command);
modalProps.onClose?.();
};
const moveSelection = (direction: 1 | -1) => {
if (!hasCommands) return;
setSelectedIndex(prev => {
const start = prev >= 0 && prev < items.length && isCommandItem(items[prev])
? prev
: getFirstSelectableIndex(items);
if (start < 0) return -1;
return getNextSelectableIndex(start, direction, items);
});
};
const flushSelection = useCallback(() => {
if (!allowMultiple) return;
const uniqueIds = Array.from(new Set(pickedIdsRef.current));
const selectedCommands = uniqueIds
.map(id => commandMap.get(id))
.filter((entry): entry is CommandEntry => Boolean(entry));
if (onComplete) onComplete(selectedCommands, uniqueIds);
else selectedCommands.forEach(entry => onSelect?.(entry));
}, [allowMultiple, commandMap, onComplete, onSelect]);
useEffect(() => () => {
if (allowMultiple) flushSelection();
onClose?.();
}, [allowMultiple, flushSelection, onClose]);
const isEditableTarget = (target: EventTarget | null) => {
if (!(target instanceof HTMLElement)) return false;
return target.isContentEditable || target.tagName === "INPUT" || target.tagName === "TEXTAREA";
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!hasCommands) {
if (event.key === "Escape") {
event.preventDefault();
modalProps.onClose?.();
}
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
moveSelection(1);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
moveSelection(-1);
return;
}
if (event.key === "Enter") {
const selectedItem = selectedIndex >= 0 ? items[selectedIndex] : undefined;
if (allowMultiple) {
if (isCommandItem(selectedItem)) {
event.preventDefault();
togglePicked(selectedItem.command);
}
return;
}
const fallback = items.find(isCommandItem);
const target = isCommandItem(selectedItem) ? selectedItem.command : fallback?.command;
if (target) {
event.preventDefault();
handleSelect(target);
}
return;
}
if (
allowMultiple &&
event.key === " " &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey &&
!isEditableTarget(event.target)
) {
const selectedItem = selectedIndex >= 0 ? items[selectedIndex] : undefined;
if (isCommandItem(selectedItem)) {
event.preventDefault();
togglePicked(selectedItem.command);
}
return;
}
if (event.key === "Escape") {
event.preventDefault();
modalProps.onClose?.();
}
};
const headerText = allowMultiple ? "Add Commands" : "Choose Command";
return (
<ModalRoot
{...modalProps}
size={ModalSize.SMALL}
className={cl("root", styleClass, visible && "root-visible")}
>
<div className={cl("container")} onKeyDown={handleKeyDown}>
<div className={cl("header")}>{headerText}</div>
<div
className={cl("search")}
onMouseDown={event => {
event.preventDefault();
event.currentTarget.querySelector("input")?.focus({ preventScroll: true });
}}
>
<span className={cl("prompt")}>›</span>
<TextInput
autoFocus
value={query}
onChange={setQuery}
placeholder="Search commands"
/>
</div>
<div className={cl("list")}>
{!hasCommands && (
<div className={cl("empty")}>No commands found</div>
)}
{items.map((item, index) => {
if (item.type === "section") {
itemRefs.current[index] = null;
return (
<div key={`section-${index}`} className={cl("section")}>
{item.label}
</div>
);
}
const { command } = item;
const path = getCategoryPath(command.categoryId);
const categoryLabel = path
.filter(cat => cat.label && cat.id !== "quick-actions")
.map(cat => cat.label)
.join(" / ");
return (
<button
key={command.id}
type="button"
className={cl("item", {
selected: index === selectedIndex,
chosen: allowMultiple && pickedSet.has(command.id),
danger: command.danger
})}
onClick={() => handleSelect(command)}
onMouseEnter={() => setSelectedIndex(index)}
ref={element => {
itemRefs.current[index] = element;
}}
>
<div className={cl("item-content")}>
<div className={cl("text")}>
<div className={cl("label")}>{command.label}</div>
{command.description && (
<div className={cl("description")}>{command.description}</div>
)}
{categoryLabel && (
<div className={cl("meta")}>{categoryLabel}</div>
)}
{showTags && command.tags && command.tags.length > 0 && (
<div className={cl("tags")}>
{command.tags.map(tag => (
<span key={tag} className={cl("tag-chip")}>{tag}</span>
))}
</div>
)}
</div>
</div>
</button>
);
})}
</div>
</div>
</ModalRoot>
);
}

View file

@ -1,142 +1,157 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import { Devs } from "@utils/constants";
import { closeAllModals } from "@utils/modal";
import { EquicordDevs, IS_MAC } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { SettingsRouter, useState } from "@webpack/common";
import { registerAction } from "./commands";
import { openCommandPalette } from "./components/CommandPalette";
import { cleanupCommandPaletteRuntime, registerBuiltInCommands, wrapChatBarChildren } from "./registry";
import { CommandPaletteSettingsPanel } from "./settingsPanel";
import { openCommandPalette } from "./ui";
const cl = classNameFactory("vc-command-palette-");
let isRecordingGlobal: boolean = false;
const DEFAULT_KEYS = IS_MAC ? ["Meta", "Shift", "P"] : ["Control", "Shift", "P"];
const DEFAULT_HOTKEY = DEFAULT_KEYS.join("+");
let openScheduled = false;
export const settings = definePluginSettings({
hotkey: {
description: "The hotkey to open the command palette.",
type: OptionType.COMPONENT,
default: ["Control", "Shift", "P"],
component: () => {
const [isRecording, setIsRecording] = useState(false);
const recordKeybind = (setIsRecording: (value: boolean) => void) => {
const keys: Set<string> = new Set();
const keyLists: string[][] = [];
setIsRecording(true);
isRecordingGlobal = true;
const updateKeys = () => {
if (keys.size === 0 || !document.querySelector(`.${cl("key-recorder-button")}`)) {
const longestArray = keyLists.reduce((a, b) => a.length > b.length ? a : b);
if (longestArray.length > 0) {
settings.store.hotkey = longestArray.map(key => key.toLowerCase());
}
setIsRecording(false);
isRecordingGlobal = false;
document.removeEventListener("keydown", keydownListener);
document.removeEventListener("keyup", keyupListener);
}
keyLists.push(Array.from(keys));
};
const keydownListener = (e: KeyboardEvent) => {
const { key } = e;
if (!keys.has(key)) {
keys.add(key);
}
updateKeys();
};
const keyupListener = (e: KeyboardEvent) => {
keys.delete(e.key);
updateKeys();
};
document.addEventListener("keydown", keydownListener);
document.addEventListener("keyup", keyupListener);
};
return (
<>
<div className={cl("key-recorder-container")} onClick={() => recordKeybind(setIsRecording)}>
<div className={`${cl("key-recorder")} ${isRecording ? cl("recording") : ""}`}>
{settings.store.hotkey.map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(" + ")}
<button className={`${cl("key-recorder-button")} ${isRecording ? cl("recording-button") : ""}`} disabled={isRecording}>
{isRecording ? "Recording..." : "Record keybind"}
</button>
</div>
</div>
</>
);
}
description: "Hotkey used to open the command palette (format: Ctrl+Shift+P)",
type: OptionType.STRING,
default: DEFAULT_HOTKEY
},
allowMouseControl: {
description: "Allow the mouse to control the command palette.",
visualStyle: {
description: "Palette appearance",
type: OptionType.SELECT,
options: [
{ label: "Classic", value: "classic", default: true },
{ label: "Polished", value: "polished" }
]
},
showTags: {
description: "Display tag chips for commands",
type: OptionType.BOOLEAN,
default: true
},
enableTagFilter: {
description: "Show the tag filter bar",
type: OptionType.BOOLEAN,
default: true
},
customCommands: {
description: "Manage custom command palette entries",
type: OptionType.COMPONENT,
component: CommandPaletteSettingsPanel
}
});
function getConfiguredHotkey() {
const raw = settings.store.hotkey;
const hotkeyString = typeof raw === "string" ? raw.trim() : "";
if (!hotkeyString) return DEFAULT_KEYS;
const parts = hotkeyString
.split("+")
.map(part => part.trim())
.filter(Boolean);
return parts.length > 0 ? parts : DEFAULT_KEYS;
}
function matchesHotkey(e: KeyboardEvent) {
const current = getConfiguredHotkey().map(key => key.toLowerCase());
const pressed = e.key.toLowerCase();
let nonModifierMatched = false;
for (const key of current) {
switch (key) {
case "control":
case "ctrl":
if (!(e.ctrlKey || (IS_MAC && e.metaKey))) return false;
continue;
case "meta":
case "cmd":
case "command":
if (!e.metaKey) return false;
continue;
case "shift":
if (!e.shiftKey) return false;
continue;
case "alt":
case "option":
if (!e.altKey) return false;
continue;
default:
if (pressed !== key) return false;
nonModifierMatched = true;
}
}
return nonModifierMatched || current.every(key => ["control", "ctrl", "meta", "cmd", "command", "shift", "alt", "option"].includes(key));
}
function shouldIgnoreTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
const tag = target.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
}
function hotkeyUsesModifiers() {
const keys = getConfiguredHotkey();
return keys.some(key => {
const lower = key.toLowerCase();
return lower === "control"
|| lower === "ctrl"
|| lower === "meta"
|| lower === "cmd"
|| lower === "command"
|| lower === "alt"
|| lower === "option";
});
}
export default definePlugin({
name: "CommandPalette",
description: "Allows you to navigate the UI with a keyboard.",
authors: [Devs.Ethan],
description: "Quickly run actions through a searchable command palette",
authors: [EquicordDevs.justjxke],
settings,
patches: [
{
find: '"sticker")',
replacement: {
match: /(\.buttons,children:)(\i)/,
replace: "$1$self.wrapChatBarChildren($2)"
}
}
],
start() {
document.addEventListener("keydown", this.event);
registerAction({
id: "openDevSettings",
label: "Open Dev tab",
callback: () => SettingsRouter.open("EquicordPatchHelper"),
registrar: "Equicord"
});
registerBuiltInCommands();
window.addEventListener("keydown", this.handleKeydown);
},
stop() {
document.removeEventListener("keydown", this.event);
window.removeEventListener("keydown", this.handleKeydown);
openScheduled = false;
cleanupCommandPaletteRuntime();
},
handleKeydown(e: KeyboardEvent) {
if (!matchesHotkey(e)) return;
if (shouldIgnoreTarget(e.target) && !hotkeyUsesModifiers()) return;
e.preventDefault();
if (openScheduled) return;
openScheduled = true;
requestAnimationFrame(() => {
openScheduled = false;
openCommandPalette();
});
},
event(e: KeyboardEvent) {
enum Modifiers {
control = "ctrlKey",
shift = "shiftKey",
alt = "altKey",
meta = "metaKey"
}
const { hotkey } = settings.store;
const pressedKey = e.key.toLowerCase();
if (isRecordingGlobal) return;
for (let i = 0; i < hotkey.length; i++) {
const lowercasedRequiredKey = hotkey[i].toLowerCase();
if (lowercasedRequiredKey in Modifiers && !e[Modifiers[lowercasedRequiredKey]]) {
return;
}
if (!(lowercasedRequiredKey in Modifiers) && pressedKey !== lowercasedRequiredKey) {
return;
}
}
closeAllModals();
if (document.querySelector(`.${cl("root")}`)) return;
openCommandPalette();
}
wrapChatBarChildren,
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,531 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Switch } from "@components/Switch";
import { Button, Forms, React, Select, TextInput } from "@webpack/common";
import { openCommandPicker } from "./CommandPicker";
import {
type CustomCommandDefinition,
getCategoryPath,
getCommandById,
getCustomCommandsSnapshot,
getSettingsCommandMetaById,
getSettingsCommandMetaByRoute,
listAllTags,
listCategories,
normalizeTag,
saveCustomCommands,
subscribeCustomCommands,
subscribeRegistry
} from "./registry";
const ACTION_OPTIONS = [
{ label: "Run Command", value: "command" },
{ label: "Open Settings Page", value: "settings" },
{ label: "Open URL", value: "url" },
{ label: "Run Macro", value: "macro" }
] as const;
type ActionType = typeof ACTION_OPTIONS[number]["value"];
const ACTION_HELP_TEXT: Record<ActionType, string> = {
command: "Alias another palette command by ID. Use the picker if you’re unsure of the identifier.",
settings: "Jump directly to a Discord settings page. Choose a page from the picker.",
url: "Open a link. Use https:// links for best compatibility.",
macro: "Run a sequence of commands in order. Add steps via the picker."
};
type CategoryOption = { label: string; value: string; description?: string; };
function buildCategoryOptions(): CategoryOption[] {
const categories = listCategories();
const options: CategoryOption[] = categories.map(category => {
const path = getCategoryPath(category.id);
const label = path.length ? path.map(item => item.label).join(" › ") : category.label;
return {
label: `${label} (${category.id})`,
value: category.id,
description: category.description
} satisfies CategoryOption;
});
return [
{
label: "Auto (use default placement)",
value: ""
},
...options
];
}
function createDefaultCommand(): CustomCommandDefinition {
const baseId = `custom-${Math.random().toString(36).slice(2, 8)}`;
return {
id: baseId,
label: "New Command",
keywords: [],
tags: [],
categoryId: undefined,
description: "",
danger: false,
action: {
type: "command",
commandId: ""
}
};
}
function normalizeTagList(values: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const raw of values) {
const trimmed = raw.trim();
if (!trimmed) continue;
const slug = normalizeTag(trimmed);
if (!slug || seen.has(slug)) continue;
seen.add(slug);
result.push(trimmed);
}
return result;
}
function duplicateWithUpdates(command: CustomCommandDefinition, updates: Partial<CustomCommandDefinition>): CustomCommandDefinition {
const nextTags = updates.tags !== undefined ? normalizeTagList(updates.tags) : command.tags;
return {
...command,
...updates,
keywords: updates.keywords ?? command.keywords,
tags: nextTags,
action: updates.action ?? command.action
};
}
type TemplateConfig = {
label: string;
description: string;
apply(command: CustomCommandDefinition): CustomCommandDefinition;
};
const TEMPLATE_CONFIGS: TemplateConfig[] = [
{
label: "Alias",
description: "Mirror an existing command",
apply: command => duplicateWithUpdates(command, {
action: { type: "command", commandId: "" }
})
},
{
label: "Settings",
description: "Open Discord settings",
apply: command => duplicateWithUpdates(command, {
action: { type: "settings", route: "My Account" }
})
},
{
label: "Link",
description: "Open an external URL",
apply: command => duplicateWithUpdates(command, {
action: { type: "url", url: "https://", openExternal: true }
})
},
{
label: "Macro",
description: "Run multiple commands",
apply: command => duplicateWithUpdates(command, {
action: { type: "macro", steps: [] }
})
}
];
function parseListInput(value: string): string[] {
const parts = value
.split(",")
.map(item => item.trim());
return normalizeTagList(parts);
}
function renderActionFields(command: CustomCommandDefinition, onChange: (action: CustomCommandDefinition["action"]) => void) {
const { action } = command;
switch (action.type) {
case "command":
const trimmedId = action.commandId.trim();
let commandError: string | undefined;
if (!trimmedId) {
commandError = "Enter a command ID or choose one below.";
} else if (!getCommandById(trimmedId)) {
commandError = "No command matches this ID.";
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<TextInput
label="Target Command ID"
value={action.commandId}
placeholder="Existing command id"
error={commandError}
onChange={value => onChange({ type: "command", commandId: value })}
/>
<Button
size={Button.Sizes.SMALL}
onClick={() => openCommandPicker({
initialQuery: action.commandId,
onSelect: command => onChange({ type: "command", commandId: command.id })
})}
>
Choose Command…
</Button>
</div>
);
case "settings": {
const selectedSettings = action.route ? getSettingsCommandMetaByRoute(action.route) : undefined;
const currentSummary = selectedSettings
? `${selectedSettings.label} (${selectedSettings.route})`
: action.route || "No page selected";
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<Forms.FormText style={{ color: "var(--text-muted, #a5a6ab)" }}>
Current page: {currentSummary}
</Forms.FormText>
<Button
size={Button.Sizes.SMALL}
onClick={() => openCommandPicker({
initialSelectedIds: selectedSettings ? [selectedSettings.id] : undefined,
filter: entry => entry.categoryId === "discord-settings",
onSelect: command => {
const meta = getSettingsCommandMetaById(command.id);
onChange({ type: "settings", route: meta?.route ?? command.label });
}
})}
>
Choose Page…
</Button>
</div>
);
}
case "url": {
let urlError: string | undefined;
const trimmedUrl = action.url.trim();
if (trimmedUrl && !trimmedUrl.startsWith("http://") && !trimmedUrl.startsWith("https://")) {
urlError = "Use http:// or https:// links.";
} else if (trimmedUrl) {
try {
new URL(trimmedUrl);
} catch {
urlError = "Enter a valid URL.";
}
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<TextInput
label="URL"
value={action.url}
placeholder="https://example.com"
error={urlError}
onChange={value => onChange({ type: "url", url: value, openExternal: action.openExternal })}
/>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Switch
checked={Boolean(action.openExternal)}
onChange={value => onChange({ type: "url", url: action.url, openExternal: value })}
/>
<Forms.FormText>Open externally</Forms.FormText>
</div>
</div>
);
}
case "macro":
const invalidSteps = action.steps.filter(step => !getCommandById(step));
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<TextInput
label="Command Sequence"
value={action.steps.join(", ")}
placeholder="command-a, command-b"
onChange={value => onChange({ type: "macro", steps: parseListInput(value) })}
/>
<Button
size={Button.Sizes.SMALL}
onClick={() => openCommandPicker({
allowMultiple: true,
initialSelectedIds: action.steps,
onComplete: (commands, ids) => {
const nextIds = ids ?? commands.map(command => command.id);
onChange({ type: "macro", steps: nextIds });
}
})}
>
Add Step…
</Button>
{action.steps.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{action.steps.map((step, index) => (
<Button
key={`${step}-${index}`}
size={Button.Sizes.MIN}
onClick={() => {
const next = [...action.steps];
next.splice(index, 1);
onChange({ type: "macro", steps: next });
}}
look={Button.Looks.LINK}
>
{step} ✕
</Button>
))}
</div>
)}
{invalidSteps.length > 0 && (
<Forms.FormText style={{ color: "var(--status-danger-text, #f04747)" }}>
Unknown command IDs: {invalidSteps.join(", ")}
</Forms.FormText>
)}
</div>
);
default:
return null;
}
}
export function CommandPaletteSettingsPanel() {
const [commands, setCommands] = React.useState<CustomCommandDefinition[]>(() => getCustomCommandsSnapshot());
const [tagMeta, setTagMeta] = React.useState(() => listAllTags());
const [categoryOptions, setCategoryOptions] = React.useState(() => buildCategoryOptions());
const [advancedOpen, setAdvancedOpen] = React.useState<Record<string, boolean>>({});
const [collapsed, setCollapsed] = React.useState<Record<string, boolean>>({});
React.useEffect(() => {
return subscribeCustomCommands(next => setCommands(next));
}, []);
React.useEffect(() => {
setTagMeta(listAllTags());
setCategoryOptions(buildCategoryOptions());
return subscribeRegistry(() => {
setTagMeta(listAllTags());
setCategoryOptions(buildCategoryOptions());
});
}, []);
const updateCommands = React.useCallback((next: CustomCommandDefinition[]) => {
setCommands(next);
void saveCustomCommands(next);
}, []);
const handleAdd = () => {
updateCommands([...commands, createDefaultCommand()]);
};
const handleRemove = (id: string) => {
updateCommands(commands.filter(command => command.id !== id));
};
const handleUpdate = (id: string, updater: (command: CustomCommandDefinition) => CustomCommandDefinition) => {
updateCommands(commands.map(command => (command.id === id ? updater(command) : command)));
};
React.useEffect(() => {
setAdvancedOpen(prev => {
const next = { ...prev };
for (const key of Object.keys(next)) {
if (!commands.some(command => command.id === key)) {
delete next[key];
}
}
return next;
});
setCollapsed(prev => {
const next = { ...prev };
for (const key of Object.keys(next)) {
if (!commands.some(command => command.id === key)) {
delete next[key];
}
}
return next;
});
}, [commands]);
return (
<section>
<Forms.FormTitle tag="h4" style={{ color: "var(--text-normal, #dcddde)" }}>Custom Commands</Forms.FormTitle>
<Forms.FormText className="vc-cp-settings-help">
{/* Hank, don't abbreviate cyberpunk hank */}
1) Name the command · 2) Add optional description/keywords/tags/category · 3) Choose an action and fill its details (IDs should match existing palette commands for aliases and macros).
</Forms.FormText>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{commands.map(command => {
const manualAdvanced = advancedOpen[command.id];
const hasAdvancedContent = Boolean(command.description?.trim()?.length || (command.keywords?.length ?? 0) || (command.tags?.length ?? 0) || command.categoryId || command.danger);
const showAdvanced = manualAdvanced ?? hasAdvancedContent;
const categoryId = command.categoryId ?? "";
const categoryPath = categoryId ? getCategoryPath(categoryId) : [];
const categoryLabel = categoryId
? (categoryPath.length ? categoryPath.map(item => item.label).join(" › ") : categoryId)
: "Auto (default)";
const isCollapsed = collapsed[command.id] ?? false;
return (
<div key={command.id} style={{ padding: 14, borderRadius: 8, background: "var(--background-primary)", border: "1px solid var(--background-tertiary)", display: "flex", flexDirection: "column", gap: 12 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Forms.FormTitle tag="h5" style={{ color: "var(--text-normal, #dcddde)" }}>{command.label || "Untitled Command"}</Forms.FormTitle>
<Button
size={Button.Sizes.MIN}
look={Button.Looks.LINK}
onClick={() => setCollapsed(prev => ({ ...prev, [command.id]: !isCollapsed }))}
>
{isCollapsed ? "Expand" : "Collapse"}
</Button>
</div>
{!isCollapsed && (
<>
<TextInput
label="Label"
value={command.label}
placeholder="Display name"
onChange={value => handleUpdate(command.id, prev => duplicateWithUpdates(prev, { label: value }))}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
<Select
options={ACTION_OPTIONS}
isSelected={value => value === command.action.type}
select={value => {
const nextType = value as ActionType;
switch (nextType) {
case "command":
handleUpdate(command.id, prev => TEMPLATE_CONFIGS[0].apply(prev));
break;
case "settings":
handleUpdate(command.id, prev => TEMPLATE_CONFIGS[1].apply(prev));
break;
case "url":
handleUpdate(command.id, prev => TEMPLATE_CONFIGS[2].apply(prev));
break;
case "macro":
handleUpdate(command.id, prev => TEMPLATE_CONFIGS[3].apply(prev));
break;
default:
break;
}
}}
serialize={option => option.value}
/>
</div>
<Forms.FormText style={{ color: "var(--text-muted, #a5a6ab)", fontSize: 12 }}>
{ACTION_HELP_TEXT[command.action.type]}
</Forms.FormText>
</div>
{renderActionFields(command, action => handleUpdate(command.id, prev => duplicateWithUpdates(prev, { action })))}
<Button
size={Button.Sizes.SMALL}
look={Button.Looks.LINK}
onClick={() => setAdvancedOpen(prev => ({ ...prev, [command.id]: !(showAdvanced ?? false) }))}
>
{showAdvanced ? "Hide advanced options" : "Show advanced options"}
</Button>
{showAdvanced && (
<div style={{ display: "flex", flexDirection: "column", gap: 12, background: "var(--background-secondary)", borderRadius: 8, padding: 12 }}>
<TextInput
label="Description"
value={command.description ?? ""}
placeholder="Optional subtitle"
onChange={value => handleUpdate(command.id, prev => duplicateWithUpdates(prev, { description: value }))}
/>
<TextInput
label="Keywords"
value={(command.keywords ?? []).join(", ")}
placeholder="Comma-separated keywords"
onChange={value => handleUpdate(command.id, prev => duplicateWithUpdates(prev, { keywords: parseListInput(value) }))}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<TextInput
label="Tags"
value={(command.tags ?? []).join(", ")}
placeholder="Comma-separated tags"
onChange={value => handleUpdate(command.id, prev => duplicateWithUpdates(prev, { tags: parseListInput(value) }))}
/>
{tagMeta.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<Forms.FormText style={{ color: "var(--text-muted, #a5a6ab)", fontSize: 12 }}>
Suggestions
</Forms.FormText>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{tagMeta
.filter(meta => !(command.tags ?? []).some(tag => normalizeTag(tag) === meta.id))
.slice(0, 12)
.map(meta => (
<Button
key={meta.id}
size={Button.Sizes.MIN}
look={Button.Looks.LINK}
onClick={() => handleUpdate(command.id, prev => duplicateWithUpdates(prev, {
tags: normalizeTagList([...(prev.tags ?? []), meta.label])
}))}
>
{meta.label}
</Button>
))}
</div>
</div>
)}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<Select
options={categoryOptions}
isSelected={value => value === categoryId}
select={value => handleUpdate(command.id, prev => duplicateWithUpdates(prev, { categoryId: value || undefined }))}
serialize={option => option.value}
/>
<Forms.FormText style={{ color: "var(--text-muted, #a5a6ab)", fontSize: 12 }}>
Choose where this command appears in the palette.
</Forms.FormText>
{categoryId && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
<span style={{ display: "inline-flex", alignItems: "center", gap: 6, borderRadius: 999, padding: "2px 10px", fontSize: 12, background: "var(--background-tertiary)", color: "var(--text-normal, #dcddde)" }}>
{categoryLabel}
<span style={{ color: "var(--text-muted, #a5a6ab)" }}>· {categoryId}</span>
</span>
</div>
)}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Switch
checked={Boolean(command.danger)}
onChange={value => handleUpdate(command.id, prev => duplicateWithUpdates(prev, { danger: value }))}
/>
<Forms.FormText>Show as dangerous</Forms.FormText>
</div>
</div>
)}
</>
)}
<Button
color={Button.Colors.RED}
size={Button.Sizes.SMALL}
onClick={() => handleRemove(command.id)}
>
Remove Command
</Button>
</div>
);
})}
</div>
<Button
color={Button.Colors.BRAND}
style={{ marginTop: 16 }}
onClick={handleAdd}
>
Add Command
</Button>
</section>
);
}

View file

@ -0,0 +1,686 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
/* cl = vc-command-palette- */
/* stylelint-disable no-descending-specificity */
/* shared layout */
.vc-command-palette-root {
width: 540px;
border-radius: 12px;
color: var(--text-normal, #dcddde);
font-family: var(--font-primary);
overflow: hidden;
opacity: 0;
transform: translate3d(0, 12px, 0) scale(0.98);
transition: opacity 120ms ease-out, transform 120ms ease-out;
will-change: transform, opacity;
}
.vc-command-palette-root-visible {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
.vc-command-palette-container {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
}
.vc-command-palette-header {
display: flex;
flex-direction: column;
gap: 8px;
}
.vc-command-palette-header-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.vc-command-palette-title {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.vc-command-palette-header-trail {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
max-width: 70%;
}
.vc-command-palette-header-trail-path {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.vc-command-palette-back {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 999px;
border: 1px solid transparent;
cursor: pointer;
font-size: 14px;
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease, transform 0.18s ease;
}
.vc-command-palette-search {
display: flex;
align-items: center;
gap: 8px;
border-radius: 8px;
padding: 0 12px;
border: 1px solid transparent;
}
.vc-command-palette-prompt {
font-size: 18px;
font-weight: 600;
}
.vc-command-palette-search input {
flex: 1;
background: transparent;
color: var(--text-normal);
font-size: 16px;
padding: 12px 0;
outline: none;
box-shadow: none;
width: 464px;
border: none !important
}
.vc-command-picker-search input {
width: 100%;
}
.vc-command-palette-search input::placeholder {
color: var(--text-muted);
}
.vc-command-palette-tag-filter-wrapper {
display: flex;
flex-direction: column;
gap: 6px;
padding: 0 4px;
}
.vc-command-palette-tag-filter {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.vc-command-palette-tag-button {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid transparent;
font-size: 12px;
cursor: pointer;
transition: transform 0.15s ease, background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.vc-command-palette-tag-label {
font-weight: 500;
}
.vc-command-palette-tag-count,
.vc-command-palette-tag-hotkey {
font-size: 11px;
}
.vc-command-palette-tag-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.vc-command-palette-tag-status {
font-size: 12px;
color: var(--text-muted);
padding: 0 6px;
}
.vc-command-palette-list {
display: flex;
flex-direction: column;
max-height: 460px;
overflow-y: auto;
border-radius: 8px;
scrollbar-width: none;
}
.vc-command-palette-section {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
pointer-events: none;
}
.vc-command-palette-item {
position: relative;
display: flex;
border: none;
background: transparent;
text-align: left;
font-family: inherit;
cursor: pointer;
outline: none;
color: var(--text-normal);
}
.vc-command-palette-item:focus-visible {
outline: none;
}
.vc-command-palette-item-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 12px;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid transparent;
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.vc-command-palette-item.vc-command-palette-category .vc-command-palette-category-indicator {
font-size: 18px;
}
.vc-command-palette-label {
font-weight: 600;
}
.vc-command-palette-label-danger {
color: inherit;
}
.vc-command-palette-text {
display: flex;
flex-direction: column;
gap: 4px;
}
.vc-command-palette-description {
font-size: 13px;
color: var(--text-muted);
}
.vc-command-palette-meta {
font-size: 12px;
color: var(--text-muted);
}
.vc-command-palette-tags {
margin-top: 6px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.vc-command-palette-tag-chip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
border: 1px solid transparent;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.vc-command-palette-pin {
font-size: 16px;
color: rgb(255 255 255 / 32%);
cursor: pointer;
user-select: none;
transition: color 0.18s ease, transform 0.18s ease;
}
.vc-command-palette-pin:hover {
color: var(--brand-experiment, #5865f2);
transform: scale(1.05);
}
.vc-command-palette-pin-active {
color: var(--brand-experiment, #5865f2);
}
.vc-command-palette-empty {
padding: 48px 0;
text-align: center;
color: var(--text-muted);
font-size: 14px;
}
/* polished theme (so like v2) */
.vc-command-palette-style-polished.vc-command-palette-root {
background: linear-gradient(140deg, rgb(31 33 36 / 94%), rgb(16 17 20 / 92%));
border: 1px solid rgb(255 255 255 / 5%);
box-shadow: 0 38px 90px rgb(0 0 0 / 55%);
backdrop-filter: blur(22px) saturate(115%);
border-radius: 14px;
}
.vc-command-palette-style-polished .vc-command-palette-container {
gap: 10px;
padding: 14px;
}
.vc-command-palette-style-polished .vc-command-palette-header {
gap: 10px;
padding: 12px;
border-radius: 12px;
background: rgb(49 51 56 / 58%);
border: 1px solid rgb(255 255 255 / 6%);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 6%);
backdrop-filter: blur(16px);
}
.vc-command-palette-style-polished .vc-command-palette-title {
letter-spacing: 0.12em;
color: var(--text-muted, #a5a6ab);
}
.vc-command-palette-style-polished .vc-command-palette-header-trail {
padding: 2px 10px 2px 6px;
border-radius: 999px;
background: rgb(255 255 255 / 8%);
border: 1px solid rgb(255 255 255 / 10%);
color: var(--text-muted, #a5a6ab);
}
.vc-command-palette-style-polished .vc-command-palette-back {
width: 22px;
height: 22px;
background: rgb(32 34 37 / 85%);
border: 1px solid transparent;
color: var(--text-normal, #dcddde);
}
.vc-command-palette-style-polished .vc-command-palette-back:hover,
.vc-command-palette-style-polished .vc-command-palette-back:focus-visible {
background: rgb(255 255 255 / 18%);
box-shadow: 0 6px 14px rgb(0 0 0 / 28%);
transform: translateY(-1px);
}
.vc-command-palette-style-polished .vc-command-palette-search {
gap: 10px;
padding: 0 14px;
border-radius: 10px;
background: rgb(20 21 24 / 92%);
border: 1px solid rgb(255 255 255 / 6%);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 8%);
}
.vc-command-palette-style-polished .vc-command-palette-prompt {
color: var(--text-muted, #a5a6ab);
}
.vc-command-palette-style-polished .vc-command-palette-tag-button {
--tag-chip-color: rgba(88 101 242 / 100%);
--tag-chip-glow: rgba(88 101 242 / 24%);
padding: 5px 12px;
border: 1px solid rgb(255 255 255 / 12%);
background: var(--tag-chip-glow, rgb(255 255 255 / 6%));
color: var(--tag-chip-color);
backdrop-filter: blur(10px);
}
.vc-command-palette-style-polished .vc-command-palette-tag-button:hover,
.vc-command-palette-style-polished .vc-command-palette-tag-button:focus-visible {
transform: translateY(-1px);
border-color: rgb(255 255 255 / 18%);
box-shadow: 0 8px 18px rgb(0 0 0 / 20%);
}
.vc-command-palette-style-polished .vc-command-palette-tag-button.vc-command-palette-active {
background: linear-gradient(135deg, rgb(255 255 255 / 18%), rgb(255 255 255 / 4%)), var(--tag-chip-glow, rgba(255 255 255 / 14%));
border-color: var(--tag-chip-color);
box-shadow: inset 0 0 0 1px var(--tag-chip-color), 0 10px 24px var(--tag-chip-glow);
}
.vc-command-palette-style-polished .vc-command-palette-tag-count,
.vc-command-palette-style-polished .vc-command-palette-tag-hotkey {
color: var(--tag-chip-color);
opacity: 0.75;
}
.vc-command-palette-style-polished .vc-command-palette-tag-button.vc-command-palette-active .vc-command-palette-tag-count,
.vc-command-palette-style-polished .vc-command-palette-tag-button.vc-command-palette-active .vc-command-palette-tag-hotkey {
color: var(--tag-chip-color);
opacity: 1;
}
.vc-command-palette-style-polished .vc-command-palette-tag-indicator {
background: var(--tag-chip-color, rgba(88 101 242 / 100%));
box-shadow: 0 0 0 2px var(--tag-chip-glow, rgba(88 101 242 / 28%));
}
.vc-command-palette-style-polished .vc-command-palette-list {
gap: 6px;
padding: 8px;
border-radius: 12px;
border: 1px solid rgb(255 255 255 / 6%);
background: rgb(14 15 18 / 82%);
backdrop-filter: blur(12px);
}
.vc-command-palette-style-polished .vc-command-palette-section {
padding: 6px 10px;
color: rgb(255 255 255 / 38%);
}
.vc-command-palette-style-polished .vc-command-palette-item-content {
border-radius: 10px;
background: linear-gradient(135deg, rgb(255 255 255 / 5%), rgb(255 255 255 / 2%));
padding: 12px 14px;
}
.vc-command-palette-style-polished .vc-command-palette-item.vc-command-palette-category .vc-command-palette-item-content {
background: linear-gradient(135deg, rgb(255 255 255 / 6%), rgb(255 255 255 / 2%));
border-color: rgb(255 255 255 / 12%);
}
.vc-command-palette-style-polished .vc-command-palette-item.vc-command-palette-category .vc-command-palette-category-indicator {
color: rgb(255 255 255 / 45%);
}
.vc-command-palette-style-polished .vc-command-palette-item.vc-command-palette-pinned .vc-command-palette-item-content {
background: linear-gradient(120deg, rgba(var(--brand-experiment-500-rgb, 88 101 242) / 18%), rgba(var(--brand-experiment-500-rgb, 88 101 242) / 6%));
border-color: rgba(var(--brand-experiment-500-rgb, 88 101 242) / 38%);
box-shadow: 0 10px 26px rgba(var(--brand-experiment-500-rgb, 88 101 242) / 22%);
}
.vc-command-palette-style-polished .vc-command-palette-item.vc-command-palette-selected .vc-command-palette-item-content {
background: linear-gradient(100deg, rgba(88 101 242 / 28%), rgba(88 101 242 / 10%));
border-color: rgba(88 101 242 / 55%);
box-shadow: 0 12px 30px rgba(88 101 242 / 28%), 0 0 0 1px rgba(88 101 242 / 55%);
transform: translate3d(0, -1px, 0);
}
.vc-command-palette-style-polished .vc-command-palette-item.vc-command-palette-chosen .vc-command-palette-item-content {
background: linear-gradient(100deg, rgba(67 181 129 / 30%), rgba(67 181 129 / 10%));
border-color: rgba(67 181 129 / 50%);
box-shadow: 0 12px 30px rgba(67 181 129 / 25%), 0 0 0 1px rgba(67 181 129 / 50%);
}
.vc-command-palette-style-polished .vc-command-palette-item.vc-command-palette-selected.vc-command-palette-chosen .vc-command-palette-item-content {
background: linear-gradient(100deg, rgba(67 181 129 / 38%), rgba(67 181 129 / 16%));
border-color: rgba(67 181 129 / 60%);
box-shadow: 0 12px 30px rgba(67 181 129 / 32%), 0 0 0 1px rgba(67 181 129 / 60%);
}
.vc-command-palette-style-polished .vc-command-palette-item.vc-command-palette-danger .vc-command-palette-item-content {
background: linear-gradient(120deg, rgba(237 66 69 / 18%), rgba(237 66 69 / 6%));
border-color: rgba(237 66 69 / 38%);
box-shadow: 0 10px 26px rgba(237 66 69 / 22%);
}
.vc-command-palette-style-polished .vc-command-palette-item.vc-command-palette-selected.vc-command-palette-danger .vc-command-palette-item-content {
background: linear-gradient(100deg, rgba(237 66 69 / 32%), rgba(237 66 69 / 12%));
border-color: rgba(237 66 69 / 62%);
box-shadow: 0 12px 30px rgba(237 66 69 / 32%), 0 0 0 1px rgba(237 66 69 / 62%);
}
.vc-command-palette-style-polished .vc-command-palette-item.vc-command-palette-danger:is(:hover, :focus-visible) .vc-command-palette-item-content {
background: linear-gradient(120deg, rgba(237 66 69 / 22%), rgba(237 66 69 / 10%));
border-color: rgba(237 66 69 / 45%);
box-shadow: 0 12px 30px rgba(237 66 69 / 28%);
}
.vc-command-palette-style-polished .vc-command-palette-item:not(:is(.vc-command-palette-danger, .vc-command-palette-chosen, .vc-command-palette-selected)):hover .vc-command-palette-item-content,
.vc-command-palette-style-polished .vc-command-palette-item:not(:is(.vc-command-palette-danger, .vc-command-palette-chosen, .vc-command-palette-selected)):focus-visible .vc-command-palette-item-content {
border-color: rgb(255 255 255 / 14%);
background: linear-gradient(135deg, rgb(255 255 255 / 8%), rgb(255 255 255 / 3%));
box-shadow: 0 8px 22px rgb(0 0 0 / 22%);
}
.vc-command-palette-style-polished .vc-command-palette-item.vc-command-palette-pinned:is(:hover, :focus-visible) .vc-command-palette-item-content {
background: linear-gradient(120deg, rgba(var(--brand-experiment-500-rgb, 88 101 242) / 22%), rgba(var(--brand-experiment-500-rgb, 88 101 242) / 10%));
border-color: rgba(var(--brand-experiment-500-rgb, 88 101 242) / 45%);
box-shadow: 0 12px 30px rgba(var(--brand-experiment-500-rgb, 88 101 242) / 28%);
}
.vc-command-palette-style-polished .vc-command-palette-tag-chip {
--tag-chip-color: rgba(88 101 242 / 100%);
--tag-chip-glow: rgba(88 101 242 / 24%);
border: 1px solid rgba(255 255 255 / 12%);
background: var(--tag-chip-glow, rgba(255 255 255 / 8%));
color: var(--tag-chip-color);
font-weight: 600;
backdrop-filter: blur(6px);
}
.vc-command-palette-style-polished .vc-command-palette-item.vc-command-palette-selected .vc-command-palette-tag-chip,
.vc-command-palette-style-polished .vc-command-palette-item.vc-command-palette-chosen .vc-command-palette-tag-chip {
border-color: rgba(255 255 255 / 28%);
background: linear-gradient(135deg, rgba(255 255 255 / 18%), rgba(255 255 255 / 4%)), var(--tag-chip-glow, rgba(255 255 255 / 12%));
}
/* classic theme (v1) */
.vc-command-palette-style-classic.vc-command-palette-root {
background: rgb(32 34 37 / 96%);
border: 1px solid rgb(79 84 92 / 40%);
box-shadow: 0 0 32px rgb(0 0 0 / 45%);
backdrop-filter: blur(18px);
}
.vc-command-palette-style-classic .vc-command-palette-header {
background: rgb(46 48 54 / 68%);
border-radius: 8px;
border: 1px solid rgb(79 84 92 / 40%);
padding: 10px 12px;
}
.vc-command-palette-style-classic .vc-command-palette-title {
color: var(--text-muted, #a5a6ab);
}
.vc-command-palette-style-classic .vc-command-palette-header-trail {
color: var(--text-muted, #a5a6ab);
}
.vc-command-palette-style-classic .vc-command-palette-back {
background: rgb(54 57 63);
border-color: rgb(30 33 36);
color: var(--text-normal);
}
.vc-command-palette-style-classic .vc-command-palette-back:hover,
.vc-command-palette-style-classic .vc-command-palette-back:focus-visible {
background: rgb(64 68 75);
border-color: rgb(90 92 98 / 50%);
transform: translateY(-1px);
}
.vc-command-palette-style-classic .vc-command-palette-search {
background: rgb(46 48 54 / 75%);
border-color: rgb(79 84 92 / 45%);
}
.vc-command-palette-style-classic .vc-command-palette-prompt {
color: var(--text-muted, #a5a6ab);
}
.vc-command-palette-style-classic .vc-command-palette-tag-button {
background: rgb(255 255 255 / 5%);
border-color: rgb(255 255 255 / 8%);
color: var(--text-muted);
}
.vc-command-palette-style-classic .vc-command-palette-tag-button:hover,
.vc-command-palette-style-classic .vc-command-palette-tag-button:focus-visible {
background: rgb(255 255 255 / 8%);
border-color: rgb(255 255 255 / 16%);
transform: translateY(-1px);
}
.vc-command-palette-style-classic .vc-command-palette-tag-button.vc-command-palette-active {
background: rgba(var(--brand-experiment-500-rgb, 88 101 242) / 18%);
border-color: rgba(var(--brand-experiment-500-rgb, 88 101 242) / 65%);
color: var(--text-normal);
}
.vc-command-palette-style-classic .vc-command-palette-tag-count,
.vc-command-palette-style-classic .vc-command-palette-tag-hotkey {
color: var(--text-muted);
}
.vc-command-palette-style-classic .vc-command-palette-tag-button.vc-command-palette-active .vc-command-palette-tag-count,
.vc-command-palette-style-classic .vc-command-palette-tag-button.vc-command-palette-active .vc-command-palette-tag-hotkey {
color: var(--text-normal);
}
.vc-command-palette-style-classic .vc-command-palette-tag-indicator {
background: rgba(var(--brand-experiment-500-rgb, 88 101 242) / 100%);
box-shadow: 0 0 0 2px rgba(var(--brand-experiment-500-rgb, 88 101 242) / 28%);
}
.vc-command-palette-style-classic .vc-command-palette-list {
border: 1px solid rgb(79 84 92 / 35%);
background: rgb(17 18 20 / 75%);
padding: 0;
}
.vc-command-palette-style-classic .vc-command-palette-section {
padding: 12px 16px 6px;
color: var(--text-muted, #a5a6ab);
}
.vc-command-palette-style-classic .vc-command-palette-item::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: transparent;
transition: background 0.18s ease, box-shadow 0.18s ease;
}
.vc-command-palette-style-classic .vc-command-palette-item-content {
position: relative;
border-radius: 0;
padding: 14px 16px;
border-left: 3px solid transparent;
transition: color 0.18s ease;
z-index: 1;
}
.vc-command-palette-style-classic .vc-command-palette-item + .vc-command-palette-item .vc-command-palette-item-content,
.vc-command-palette-style-classic .vc-command-palette-section + .vc-command-palette-item .vc-command-palette-item-content {
border-top: 1px solid rgb(79 84 92 / 35%);
}
.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-category .vc-command-palette-item-content {
background: rgb(255 255 255 / 3%);
border: none;
}
.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-category .vc-command-palette-category-indicator {
color: var(--text-muted, #a5a6ab);
}
.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-pinned::before {
background: rgba(var(--brand-experiment-500-rgb, 88 101 242) / 16%);
box-shadow: inset 3px 0 0 rgba(var(--brand-experiment-500-rgb, 88 101 242) / 80%);
}
.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-selected::before {
background: rgba(var(--brand-experiment-500-rgb, 88 101 242) / 18%);
box-shadow: inset 3px 0 0 rgba(var(--brand-experiment-500-rgb, 88 101 242) / 100%);
}
.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-selected .vc-command-palette-item-content {
transform: translate3d(0, -1px, 0);
}
.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-chosen::before {
background: rgba(67 181 129 / 24%);
box-shadow: inset 3px 0 0 rgba(67 181 129 / 90%);
}
.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-danger::before {
background: rgba(237 66 69 / 16%);
box-shadow: inset 3px 0 0 rgba(237 66 69 / 80%);
}
.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-danger:is(:hover, :focus-visible)::before {
background: rgba(237 66 69 / 22%);
box-shadow: inset 3px 0 0 rgba(237 66 69 / 90%), inset 0 1px 0 rgba(255 255 255 / 6%), inset 0 -1px 0 rgba(0 0 0 / 25%);
}
.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-danger:is(:hover, :focus-visible) .vc-command-palette-item-content {
color: var(--text-normal, #dcddde);
}
.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-selected.vc-command-palette-chosen::before {
background: rgba(67 181 129 / 32%);
}
.vc-command-palette-style-classic .vc-command-palette-item:not(:is(.vc-command-palette-danger, .vc-command-palette-chosen, .vc-command-palette-selected)):hover::before,
.vc-command-palette-style-classic .vc-command-palette-item:not(:is(.vc-command-palette-danger, .vc-command-palette-chosen, .vc-command-palette-selected)):focus-visible::before {
background: rgb(52 60 108 / 96%);
box-shadow: inset 3px 0 0 rgba(var(--brand-experiment-500-rgb, 88 101 242) / 100%), inset 0 1px 0 rgba(255 255 255 / 6%), inset 0 -1px 0 rgba(0 0 0 / 25%);
}
.vc-command-palette-style-classic .vc-command-palette-item:not(:is(.vc-command-palette-danger, .vc-command-palette-chosen, .vc-command-palette-selected)):hover .vc-command-palette-item-content,
.vc-command-palette-style-classic .vc-command-palette-item:not(:is(.vc-command-palette-danger, .vc-command-palette-chosen, .vc-command-palette-selected)):focus-visible .vc-command-palette-item-content {
color: var(--text-normal);
}
.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-pinned:is(:hover, :focus-visible)::before {
background: rgba(var(--brand-experiment-500-rgb, 88 101 242) / 22%);
box-shadow: inset 3px 0 0 rgba(var(--brand-experiment-500-rgb, 88 101 242) / 90%);
}
.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-pinned:is(:hover, :focus-visible) .vc-command-palette-item-content {
color: var(--text-normal, #dcddde);
}
.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-selected.vc-command-palette-danger .vc-command-palette-item-content {
background: rgba(237 66 69 / 26%);
}
.vc-command-palette-style-classic .vc-command-palette-tag-chip {
background: rgb(255 255 255 / 6%);
border-color: rgb(255 255 255 / 12%);
color: var(--text-muted);
}
.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-selected .vc-command-palette-tag-chip,
.vc-command-palette-style-classic .vc-command-palette-item.vc-command-palette-chosen .vc-command-palette-tag-chip {
color: var(--text-normal);
border-color: rgb(255 255 255 / 22%);
background: rgb(255 255 255 / 12%);
}
/* stylelint-enable no-descending-specificity */
/* fuck you css */

View file

@ -0,0 +1,787 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./style.css";
import { classNameFactory } from "@api/Styles";
import { IS_LINUX, IS_MAC, IS_WINDOWS } from "@utils/constants";
import { closeAllModals, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { React, TextInput, useCallback, useEffect, useMemo, useRef, useState } from "@webpack/common";
import type { CSSProperties } from "react";
import { settings } from "./index";
import {
type CommandCategory,
type CommandEntry,
type CommandTagMeta,
DEFAULT_CATEGORY_ID,
executeCommand,
getCategoryGroupLabel,
getCategoryPath,
getCategoryWeight,
getCommandSearchText,
getCommandTagIds,
getRecentRank,
getRegistryVersion,
listAllTags,
listChildCategories,
listCommands,
listCommandsByCategory,
listCommandsInTree,
normalizeTag,
refreshAllContextProviders,
subscribePinned,
subscribeRegistry,
togglePinned
} from "./registry";
const cl = classNameFactory("vc-command-palette-");
const DEFAULT_TAG_COLOR = {
dot: "rgba(var(--brand-experiment-500-rgb, 88 101 242) / 100%)",
glow: "rgba(var(--brand-experiment-500-rgb, 88 101 242) / 35%)"
};
const TAG_COLOR_MAP: Record<string, { dot: string; glow: string; }> = {
danger: {
dot: "var(--status-danger-background, #ed4245)",
glow: "rgba(237, 66, 69, 0.4)"
},
warning: {
dot: "var(--status-warning-background, #faa61a)",
glow: "rgba(250, 166, 26, 0.4)"
},
custom: {
dot: "rgba(186, 104, 200, 1)",
glow: "rgba(186, 104, 200, 0.35)"
},
core: {
dot: "rgba(78, 201, 176, 1)",
glow: "rgba(78, 201, 176, 0.35)"
},
navigation: {
dot: "rgba(59, 130, 246, 1)",
glow: "rgba(59, 130, 246, 0.35)"
},
utility: {
dot: "rgba(255, 140, 0, 1)",
glow: "rgba(255, 140, 0, 0.35)"
},
developer: {
dot: "rgba(147, 112, 219, 1)",
glow: "rgba(147, 112, 219, 0.35)"
},
customization: {
dot: "rgba(244, 114, 182, 1)",
glow: "rgba(244, 114, 182, 0.35)"
},
plugins: {
dot: "rgba(56, 189, 248, 1)",
glow: "rgba(56, 189, 248, 0.35)"
},
session: {
dot: "rgba(0, 200, 255, 1)",
glow: "rgba(0, 200, 255, 0.35)"
},
context: {
dot: "rgba(0, 168, 120, 1)",
glow: "rgba(0, 168, 120, 0.35)"
}
};
function getTagColors(tagSlug: string) {
return TAG_COLOR_MAP[tagSlug] ?? DEFAULT_TAG_COLOR;
}
function getTagIndicatorStyle(tagSlug: string): CSSProperties {
const entry = getTagColors(tagSlug);
return {
background: entry.dot,
boxShadow: `0 0 0 2px ${entry.glow}`
};
}
function getTagColorStyle(tagSlug: string): CSSProperties {
const entry = getTagColors(tagSlug);
return {
"--tag-chip-color": entry.dot,
"--tag-chip-glow": entry.glow
} as CSSProperties;
}
type CommandItem = {
type: "command";
command: CommandEntry;
pinned: boolean;
};
type PaletteItem =
| { type: "category"; category: CommandCategory; }
| { type: "section"; label: string; }
| CommandItem;
export function CommandPalette({ modalProps }) {
const [query, setQuery] = useState("");
const [categoryStack, setCategoryStack] = useState<CommandCategory[]>([]);
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [registryVersion, setRegistryVersion] = useState(() => getRegistryVersion());
const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set());
const [visible, setVisible] = useState(false);
const [activeTagIds, setActiveTagIds] = useState<string[]>([]);
const { visualStyle = "classic" } = settings.use(["visualStyle"]);
const styleClass = visualStyle === "polished" ? "style-polished" : "style-classic";
const showTags = settings.store.showTags ?? true;
const tagFilterEnabled = showTags && (settings.store.enableTagFilter ?? true);
const listRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const isSelectableItem = (item?: PaletteItem) => Boolean(item && item.type !== "section");
const getFirstSelectableIndex = (list: PaletteItem[]) => {
for (let i = 0; i < list.length; i += 1) {
if (isSelectableItem(list[i])) return i;
}
return -1;
};
const getNextSelectableIndex = (start: number, direction: 1 | -1, list: PaletteItem[]) => {
if (list.length === 0) return -1;
if (!list.some(item => isSelectableItem(item))) return -1;
let index = start;
for (let i = 0; i < list.length; i += 1) {
index = (index + direction + list.length) % list.length;
if (isSelectableItem(list[index])) return index;
}
return getFirstSelectableIndex(list);
};
useEffect(() => {
const unsubscribe = subscribePinned(ids => {
setPinnedIds(new Set(ids));
});
return unsubscribe;
}, []);
useEffect(() => subscribeRegistry(setRegistryVersion), []);
useEffect(() => {
const frame = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(frame);
}, []);
const allTags = useMemo<CommandTagMeta[]>(() => listAllTags(), [registryVersion]);
useEffect(() => {
const available = new Set(allTags.map(tag => tag.id));
setActiveTagIds(prev => {
if (available.size === 0) {
return prev.length ? [] : prev;
}
const filtered = prev.filter(id => available.has(id));
return filtered.length === prev.length ? prev : filtered;
});
}, [allTags]);
const hotkeyTags = useMemo(() => allTags.slice(0, 9), [allTags]);
const tagLabelMap = useMemo(() => new Map(allTags.map(tag => [tag.id, tag.label])), [allTags]);
const activeTagSet = useMemo(() => new Set(activeTagIds), [activeTagIds]);
const activeTagLabels = useMemo(
() => activeTagIds
.map(id => tagLabelMap.get(id) ?? id)
.filter((label, index, arr) => arr.indexOf(label) === index),
[activeTagIds, tagLabelMap]
);
const toggleTag = useCallback((tagId: string) => {
setActiveTagIds(prev => {
if (prev.includes(tagId)) {
return prev.filter(id => id !== tagId);
}
return [...prev, tagId];
});
}, []);
const trimmedQuery = query.trim();
const isSearching = trimmedQuery.length > 0;
const currentCategory = categoryStack[categoryStack.length - 1] ?? null;
const allCommands = useMemo(() => listCommands(), [registryVersion]);
const commandMap = useMemo(() => new Map(allCommands.map(entry => [entry.id, entry])), [allCommands]);
const activeTagKey = useMemo(() => activeTagIds.slice().sort().join("|"), [activeTagIds]);
useEffect(() => {
refreshAllContextProviders();
}, [activeTagKey, tagFilterEnabled]);
const matchingCommandIds = useMemo(() => {
if (!tagFilterEnabled || activeTagIds.length === 0) return null;
const required = new Set(activeTagIds);
const matches = new Set<string>();
for (const entry of allCommands) {
const tags = getCommandTagIds(entry.id);
if (!tags.length) continue;
let ok = true;
for (const tagId of required) {
if (!tags.includes(tagId)) {
ok = false;
break;
}
}
if (ok) matches.add(entry.id);
}
return matches;
}, [allCommands, tagFilterEnabled, activeTagKey]);
const matchesActiveTags = useCallback((entry: CommandEntry) => {
if (!tagFilterEnabled) return true;
if (!matchingCommandIds) return true;
return matchingCommandIds.has(entry.id);
}, [tagFilterEnabled, matchingCommandIds]);
const childCategories = useMemo(() => {
const parentId = currentCategory?.id ?? null;
const categories = listChildCategories(parentId);
if (isSearching) return [];
if (matchingCommandIds) {
return categories.filter(category => {
const entries = listCommandsInTree(category.id);
return entries.some(entry => matchingCommandIds.has(entry.id));
});
}
return categories;
}, [isSearching, currentCategory, registryVersion, matchingCommandIds]);
const pinnedCommands = useMemo(() => {
if (pinnedIds.size === 0) return [];
const ordered: CommandEntry[] = [];
for (const id of pinnedIds) {
const entry = commandMap.get(id);
if (entry) ordered.push(entry);
}
return ordered;
}, [pinnedIds, commandMap]);
const pinnedForView = useMemo(() => {
if (isSearching) return [];
let base = pinnedCommands;
if (currentCategory) {
const inTree = new Set(listCommandsInTree(currentCategory.id).map(entry => entry.id));
base = base.filter(entry => inTree.has(entry.id));
}
if (matchingCommandIds) {
base = base.filter(entry => matchingCommandIds.has(entry.id));
}
return base;
}, [pinnedCommands, currentCategory, isSearching, registryVersion, matchingCommandIds]);
const visibleCommands = useMemo(() => {
const lower = trimmedQuery.toLowerCase();
const scoreCommand = (entry: CommandEntry, isPinned: boolean) => {
const labelLower = entry.label.toLowerCase();
let score = 0;
if (labelLower === lower) score += 60;
else if (labelLower.startsWith(lower)) score += 45;
else if (labelLower.includes(lower)) score += 30;
const labelTokens = labelLower.split(/[^a-z0-9]+/);
if (labelTokens.includes(lower)) score += 20;
if (entry.keywords && entry.keywords.length) {
const keywordMatches = entry.keywords
.map(keyword => keyword.toLowerCase())
.filter(Boolean);
if (keywordMatches.includes(lower)) score += 35;
else if (keywordMatches.some(keyword => keyword.includes(lower))) score += 15;
}
const descriptionLower = entry.description?.toLowerCase() ?? "";
if (descriptionLower.includes(lower)) score += 10;
if (entry.tags && entry.tags.length) {
const tagMatches = entry.tags.map(tag => tag.toLowerCase());
if (tagMatches.includes(lower)) score += 25;
else if (tagMatches.some(tag => tag.includes(lower))) score += 10;
}
if (score === 0) score = 5;
score += getCategoryWeight(entry.categoryId);
if (isPinned) score += 50;
const recentRank = getRecentRank(entry.id);
if (recentRank >= 0) {
score += Math.max(0, 30 - recentRank * 4);
}
return score;
};
if (lower) {
const source = currentCategory
? listCommandsInTree(currentCategory.id)
: allCommands;
let order = 0;
const bestByGroup = new Map<string, { entry: CommandEntry; score: number; order: number; }>();
for (const entry of source) {
const isPinned = pinnedIds.has(entry.id);
if (!isPinned && entry.hiddenInSearch) continue;
if (!matchesActiveTags(entry)) continue;
const searchText = getCommandSearchText(entry.id);
if (!searchText.includes(lower)) continue;
const score = scoreCommand(entry, isPinned);
const groupKey = entry.searchGroup ?? entry.id;
const existing = bestByGroup.get(groupKey);
if (!existing || score > existing.score || (score === existing.score && order < existing.order)) {
bestByGroup.set(groupKey, { entry, score, order });
}
order += 1;
}
return Array.from(bestByGroup.values())
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return a.order - b.order;
})
.map(item => item.entry);
}
const sortByPinned = (entries: CommandEntry[]) =>
[...entries].sort((a, b) => {
const pinnedA = pinnedIds.has(a.id) ? 1 : 0;
const pinnedB = pinnedIds.has(b.id) ? 1 : 0;
if (pinnedA !== pinnedB) return pinnedB - pinnedA;
const dangerA = a.danger ? 1 : 0;
const dangerB = b.danger ? 1 : 0;
if (dangerA !== dangerB) return dangerA - dangerB;
return a.label.localeCompare(b.label);
});
let base: CommandEntry[] = [];
if (currentCategory && childCategories.length === 0) {
base = listCommandsByCategory(currentCategory.id);
} else if (!currentCategory && childCategories.length === 0) {
base = listCommandsByCategory(DEFAULT_CATEGORY_ID);
}
if (matchingCommandIds) {
base = base.filter(entry => matchingCommandIds.has(entry.id));
}
return sortByPinned(base);
}, [
trimmedQuery,
currentCategory,
childCategories,
pinnedIds,
registryVersion,
allCommands,
matchesActiveTags,
matchingCommandIds
]);
const items = useMemo<PaletteItem[]>(() => {
if (isSearching) {
const grouped = new Map<string, CommandItem[]>();
const order: string[] = [];
for (const entry of visibleCommands) {
const label = getCategoryGroupLabel(entry.categoryId);
if (!grouped.has(label)) {
grouped.set(label, []);
order.push(label);
}
grouped.get(label)!.push({
type: "command",
command: entry,
pinned: pinnedIds.has(entry.id)
});
}
const result: PaletteItem[] = [];
for (const label of order) {
const commands = grouped.get(label);
if (!commands || commands.length === 0) continue;
result.push({ type: "section", label });
result.push(...commands);
}
return result;
}
const pinnedItems: CommandItem[] = pinnedForView.map(command => ({
type: "command",
command,
pinned: true
}));
const categoryItems: PaletteItem[] = childCategories.map(category => ({ type: "category", category }));
const commandItems: CommandItem[] = visibleCommands
.filter(entry => !pinnedIds.has(entry.id))
.map(entry => ({ type: "command", command: entry, pinned: pinnedIds.has(entry.id) }));
return [...pinnedItems, ...categoryItems, ...commandItems];
}, [childCategories, visibleCommands, pinnedForView, pinnedIds, isSearching, showTags]);
const getItemKey = useCallback((item: PaletteItem): string => {
switch (item.type) {
case "category":
return `category:${item.category.id}`;
case "section":
return `section:${item.label}`;
case "command":
return `command:${item.command.id}`;
default:
return "unknown";
}
}, []);
useEffect(() => {
if (items.length === 0) {
if (selectedIndex !== -1) setSelectedIndex(-1);
if (selectedKey !== null) setSelectedKey(null);
return;
}
if (selectedKey) {
const targetIndex = items.findIndex(item => isSelectableItem(item) && getItemKey(item) === selectedKey);
if (targetIndex !== -1) {
if (targetIndex !== selectedIndex) setSelectedIndex(targetIndex);
return;
}
if (selectedIndex !== -1) setSelectedIndex(-1);
setSelectedKey(null);
return;
}
if (selectedIndex >= items.length || (selectedIndex >= 0 && !isSelectableItem(items[selectedIndex]))) {
if (selectedIndex !== -1) setSelectedIndex(-1);
}
if (selectedKey !== null) setSelectedKey(null);
}, [items, selectedKey, selectedIndex, getItemKey]);
useEffect(() => {
if (trimmedQuery.length > 0) {
const first = getFirstSelectableIndex(items);
if (first !== selectedIndex) setSelectedIndex(first);
const key = first >= 0 ? getItemKey(items[first]) : null;
if (key !== selectedKey) setSelectedKey(key);
}
}, [trimmedQuery, items, selectedIndex, selectedKey, getItemKey]);
useEffect(() => {
if (selectedIndex < 0) return;
const node = itemRefs.current[selectedIndex];
if (node) node.scrollIntoView({ block: "nearest" });
}, [selectedIndex, items]);
const handleSelectCategory = (category: CommandCategory) => {
setCategoryStack(stack => [...stack, category]);
setSelectedIndex(-1);
setSelectedKey(null);
};
const handleBack = () => {
setCategoryStack(stack => stack.slice(0, -1));
setSelectedIndex(-1);
setSelectedKey(null);
};
const runCommand = async (entry: CommandEntry) => {
closeAllModals();
await executeCommand(entry);
};
const handleActivateItem = async (item: PaletteItem | undefined) => {
if (!item || item.type === "section") return;
if (item.type === "category") {
handleSelectCategory(item.category);
} else {
await runCommand(item.command);
}
};
itemRefs.current = itemRefs.current.slice(0, items.length);
const breadcrumbLabel = categoryStack.map(cat => cat.label).join(" / ");
const showBreadcrumb = !isSearching && categoryStack.length > 0;
return (
<ModalRoot {...modalProps} className={cl("root", styleClass, visible && "root-visible")}
size={ModalSize.SMALL}
>
<div
className={cl("container")}
onKeyDown={async e => {
if (showTags && tagFilterEnabled && e.altKey && !e.ctrlKey && !e.metaKey) {
let digit = -1;
if (e.code?.startsWith("Digit")) {
digit = Number(e.code.slice(5));
} else if (/^[0-9]$/.test(e.key)) {
digit = Number(e.key);
}
if (digit >= 1 && digit <= hotkeyTags.length) {
e.preventDefault();
const target = hotkeyTags[digit - 1];
if (target) toggleTag(target.id);
return;
}
if (digit === 0) {
e.preventDefault();
setActiveTagIds(prev => (prev.length ? [] : prev));
return;
}
}
if (e.key === "ArrowDown" && items.length > 0) {
e.preventDefault();
const next = getNextSelectableIndex(selectedIndex, 1, items);
if (next !== -1) {
if (next !== selectedIndex) setSelectedIndex(next);
const key = getItemKey(items[next]);
if (key !== selectedKey) setSelectedKey(key);
}
return;
}
if (e.key === "ArrowUp" && items.length > 0) {
e.preventDefault();
const next = getNextSelectableIndex(selectedIndex, -1, items);
if (next !== -1) {
if (next !== selectedIndex) setSelectedIndex(next);
const key = getItemKey(items[next]);
if (key !== selectedKey) setSelectedKey(key);
}
return;
}
if (e.key === "ArrowLeft" && categoryStack.length > 0) {
e.preventDefault();
handleBack();
return;
}
if (e.key === "ArrowRight") {
const item = items[selectedIndex];
if (isSelectableItem(item)) {
e.preventDefault();
await handleActivateItem(item);
}
return;
}
if (e.key === "Enter") {
const fallback = visibleCommands[0]
? ({ type: "command", command: visibleCommands[0], pinned: pinnedIds.has(visibleCommands[0].id) } satisfies CommandItem)
: undefined;
const item = items[selectedIndex] ?? fallback;
if (item && isSelectableItem(item)) {
e.preventDefault();
await handleActivateItem(item);
}
}
}}
>
<div className={cl("header")}>
<div className={cl("header-top")}>
<span className={cl("title")}>Command Palette</span>
{showBreadcrumb && (
<div className={cl("header-trail")}
onMouseDown={e => e.preventDefault()}
>
<button
className={cl("back")}
onClick={handleBack}
type="button"
>
‹
</button>
<span className={cl("header-trail-path")}>{breadcrumbLabel}</span>
</div>
)}
</div>
<div className={cl("search")}
onMouseDown={e => {
// prevent focus outline from showing on click
e.preventDefault();
e.currentTarget.querySelector("input")?.focus({ preventScroll: true });
}}
>
<span className={cl("prompt")}>›</span>
<TextInput
autoFocus
value={query}
onChange={value => setQuery(value)}
placeholder="Type a command"
/>
</div>
</div>
{showTags && tagFilterEnabled && allTags.length > 0 && (
<div className={cl("tag-filter-wrapper")}
onMouseDown={e => e.preventDefault()}
>
<div className={cl("tag-filter")}
>
{allTags.map(tag => {
const isActive = activeTagSet.has(tag.id);
const hotkeyIndex = hotkeyTags.findIndex(h => h.id === tag.id);
const tagSlug = normalizeTag(tag.label ?? tag.id);
const colorStyle = getTagColorStyle(tagSlug || tag.id);
const indicatorStyle = getTagIndicatorStyle(tagSlug || tag.id);
const hotkeyPrefix = IS_MAC ? "⌥" : IS_WINDOWS ? "Alt" : IS_LINUX ? "Alt" : "Alt";
return (
<button
key={tag.id}
className={cl("tag-button", { active: isActive })}
onClick={() => toggleTag(tag.id)}
type="button"
style={colorStyle}
>
{isActive && <span className={cl("tag-indicator")} aria-hidden="true" style={indicatorStyle} />}
<span className={cl("tag-label")}>{tag.label}</span>
<span className={cl("tag-count")}>{tag.count}</span>
{hotkeyIndex !== -1 && (
<span className={cl("tag-hotkey")}>{`${hotkeyPrefix}+${hotkeyIndex + 1}`}</span>
)}
</button>
);
})}
</div>
{activeTagLabels.length > 0 && (
<div className={cl("tag-status")}>
Filtering by {activeTagLabels.join(", ")}
</div>
)}
</div>
)}
<div className={cl("list")} ref={listRef}>
{items.length === 0 && (
<div className={cl("empty")}>No commands found</div>
)}
{items.map((item, index) => {
const isSelected = index === selectedIndex;
if (item.type === "section") {
itemRefs.current[index] = null;
return (
<div
key={`section-${index}`}
className={cl("section")}
>
{item.label}
</div>
);
}
if (item.type === "category") {
return (
<button
key={`category-${item.category.id}`}
className={cl("item", "category", { selected: isSelected })}
onClick={() => handleSelectCategory(item.category)}
type="button"
ref={el => {
itemRefs.current[index] = el;
}}
>
<div className={cl("item-content")}>
<div className={cl("text")}>
<div className={cl("label")}>{item.category.label}</div>
{item.category.description && (
<div className={cl("description")}>{item.category.description}</div>
)}
</div>
<span className={cl("category-indicator")}>›</span>
</div>
</button>
);
}
const { command, pinned } = item;
const path = getCategoryPath(command.categoryId);
const pathLabel = path
.filter(cat => cat.id !== DEFAULT_CATEGORY_ID)
.map(cat => cat.label)
.join(" / ");
const metaParts: string[] = [];
if (command.shortcut) metaParts.push(command.shortcut);
if (pinned) metaParts.push("Pinned");
if (pathLabel) metaParts.push(pathLabel);
const metaText = metaParts.join(" · ");
return (
<button
key={command.id}
className={cl("item", { selected: isSelected, danger: command.danger, pinned })}
onClick={async () => {
await runCommand(command);
}}
type="button"
ref={el => {
itemRefs.current[index] = el;
}}
>
<div className={cl("item-content")}>
<div className={cl("text")}>
<div className={cl("label", command.danger && "label-danger")}>{command.label}</div>
{command.description && (
<div className={cl("description")}>{command.description}</div>
)}
{metaText && (
<div className={cl("meta")}>{metaText}</div>
)}
{showTags && command.tags && command.tags.length > 0 && (
<div className={cl("tags")}>
{command.tags.map(tag => {
const slug = normalizeTag(tag) || tag;
const tagStyle = getTagColorStyle(slug);
return (
<span key={tag} className={cl("tag-chip")} style={tagStyle}>{tag}</span>
);
})}
</div>
)}
</div>
<span
className={cl("pin", pinned && "pin-active")}
onClick={async e => {
e.stopPropagation();
await togglePinned(command.id);
const key = getItemKey(item);
if (key !== selectedKey) setSelectedKey(key);
}}
role="button"
aria-label={pinned ? "Unpin command" : "Pin command"}
title={pinned ? "Unpin command" : "Pin command"}
>
{pinned ? "★" : "☆"}
</span>
</div>
</button>
);
})}
</div>
</div>
</ModalRoot>
);
}
export function openCommandPalette() {
openModal(modalProps => <CommandPalette modalProps={modalProps} />);
}

View file

@ -0,0 +1,49 @@
# Command Palette Actions Guide
Welcome to the Command Palette Actions Guide! This guide serves to inform you on how to implement your own actions to the CommandPalette plugin. To do so you have two options: hardcoding or utilizing the `registerAction` function. This guide will focus on using the function as its best practice and is what should be used in most situations.
> [!IMPORTANT]
> While both methods hardcoding and using `registerAction` offer similar implementations, it's recommended to refrain from Hardcoding unless you solely plan on using this locally.
### Implementing Multiple Choice Modals
```ts
registerAction({
id: 'multipleChoiceCommand',
label: 'Multiple Choice',
callback: async () => {
// Open a modal with multiple choices
const choice = await openMultipleChoice([
{ id: 'test1', label: 'Test 1' },
{ id: 'test2', label: 'Test 2' },
]);
// Log the selected choice with its label and ID
console.log(`Selected ${choice.label} with the ID ${choice.id}`);
},
});
```
- **ID**: A unique identifier for the command/action, ensuring uniqueness across specific options in that modal instance.
- **Label**: The text displayed in the command palette for user recognition.
- **Callback (optional)**: The function executed when the command is triggered.
Inside the callback, the `openMultipleChoice` function opens a modal with a list of choices. Users can select an option, and upon choosing, a `ButtonAction` type is returned. The user's choice is then logged for reference.
### Implementing String Input Modals
```ts
registerAction({
id: 'stringInputCommand',
label: 'String Input',
callback: async () => {
// Open a modal with a text input
const text = await openSimpleTextInput();
// Log the inputted text to console
console.log(`They typed: ${text}`);
},
});
```
When the `stringInputCommand` is triggered, a modal with a simple text input field appears. Users can input text, and the entered string is returned. In this case, we log their input to console.

View file

@ -0,0 +1,142 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import { Devs } from "@utils/constants";
import { closeAllModals } from "@utils/modal";
import definePlugin, { OptionType } from "@utils/types";
import { SettingsRouter, useState } from "@webpack/common";
import { registerAction } from "./commands";
import { openCommandPalette } from "./components/CommandPalette";
const cl = classNameFactory("vc-command-palette-");
let isRecordingGlobal: boolean = false;
export const settings = definePluginSettings({
hotkey: {
description: "The hotkey to open the command palette.",
type: OptionType.COMPONENT,
default: ["Control", "Shift", "P"],
component: () => {
const [isRecording, setIsRecording] = useState(false);
const recordKeybind = (setIsRecording: (value: boolean) => void) => {
const keys: Set<string> = new Set();
const keyLists: string[][] = [];
setIsRecording(true);
isRecordingGlobal = true;
const updateKeys = () => {
if (keys.size === 0 || !document.querySelector(`.${cl("key-recorder-button")}`)) {
const longestArray = keyLists.reduce((a, b) => a.length > b.length ? a : b);
if (longestArray.length > 0) {
settings.store.hotkey = longestArray.map(key => key.toLowerCase());
}
setIsRecording(false);
isRecordingGlobal = false;
document.removeEventListener("keydown", keydownListener);
document.removeEventListener("keyup", keyupListener);
}
keyLists.push(Array.from(keys));
};
const keydownListener = (e: KeyboardEvent) => {
const { key } = e;
if (!keys.has(key)) {
keys.add(key);
}
updateKeys();
};
const keyupListener = (e: KeyboardEvent) => {
keys.delete(e.key);
updateKeys();
};
document.addEventListener("keydown", keydownListener);
document.addEventListener("keyup", keyupListener);
};
return (
<>
<div className={cl("key-recorder-container")} onClick={() => recordKeybind(setIsRecording)}>
<div className={`${cl("key-recorder")} ${isRecording ? cl("recording") : ""}`}>
{settings.store.hotkey.map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(" + ")}
<button className={`${cl("key-recorder-button")} ${isRecording ? cl("recording-button") : ""}`} disabled={isRecording}>
{isRecording ? "Recording..." : "Record keybind"}
</button>
</div>
</div>
</>
);
}
},
allowMouseControl: {
description: "Allow the mouse to control the command palette.",
type: OptionType.BOOLEAN,
default: true
}
});
export default definePlugin({
name: "KeyboardNavigation",
description: "Allows you to navigate the UI with a keyboard.",
authors: [Devs.Ethan],
settings,
start() {
document.addEventListener("keydown", this.event);
registerAction({
id: "openDevSettings",
label: "Open Dev tab",
callback: () => SettingsRouter.open("EquicordPatchHelper"),
registrar: "Equicord"
});
},
stop() {
document.removeEventListener("keydown", this.event);
},
event(e: KeyboardEvent) {
enum Modifiers {
control = "ctrlKey",
shift = "shiftKey",
alt = "altKey",
meta = "metaKey"
}
const { hotkey } = settings.store;
const pressedKey = e.key.toLowerCase();
if (isRecordingGlobal) return;
for (let i = 0; i < hotkey.length; i++) {
const lowercasedRequiredKey = hotkey[i].toLowerCase();
if (lowercasedRequiredKey in Modifiers && !e[Modifiers[lowercasedRequiredKey]]) {
return;
}
if (!(lowercasedRequiredKey in Modifiers) && pressedKey !== lowercasedRequiredKey) {
return;
}
}
closeAllModals();
if (document.querySelector(`.${cl("root")}`)) return;
openCommandPalette();
}
});