/* * Vencord, a Discord client mod * Copyright (c) 2025 Vendicated and contributors * SPDX-License-Identifier: GPL-3.0-or-later */ import { Button, TextButton } from "@components/Button"; import { Heading } from "@components/Heading"; import { Paragraph } from "@components/Paragraph"; import { Switch } from "@components/Switch"; import { 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 = { 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(); 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 { 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 (
onChange({ type: "command", commandId: value })} />
); case "settings": { const selectedSettings = action.route ? getSettingsCommandMetaByRoute(action.route) : undefined; const currentSummary = selectedSettings ? `${selectedSettings.label} (${selectedSettings.route})` : action.route || "No page selected"; return (
Current page: {currentSummary}
); } 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 (
onChange({ type: "url", url: value, openExternal: action.openExternal })} />
onChange({ type: "url", url: action.url, openExternal: value })} /> Open externally
); } case "macro": const invalidSteps = action.steps.filter(step => !getCommandById(step)); return (
onChange({ type: "macro", steps: parseListInput(value) })} /> {action.steps.length > 0 && (
{action.steps.map((step, index) => ( { const next = [...action.steps]; next.splice(index, 1); onChange({ type: "macro", steps: next }); }} > {step} ✕ ))}
)} {invalidSteps.length > 0 && ( Unknown command IDs: {invalidSteps.join(", ")} )}
); default: return null; } } export function CommandPaletteSettingsPanel() { const [commands, setCommands] = React.useState(() => getCustomCommandsSnapshot()); const [tagMeta, setTagMeta] = React.useState(() => listAllTags()); const [categoryOptions, setCategoryOptions] = React.useState(() => buildCategoryOptions()); const [advancedOpen, setAdvancedOpen] = React.useState>({}); const [collapsed, setCollapsed] = React.useState>({}); 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 (
Custom Commands {/* 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).
{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 (
{command.label || "Untitled Command"} setCollapsed(prev => ({ ...prev, [command.id]: !isCollapsed }))} > {isCollapsed ? "Expand" : "Collapse"}
{!isCollapsed && ( <> handleUpdate(command.id, prev => duplicateWithUpdates(prev, { label: value }))} />
value === categoryId} select={value => handleUpdate(command.id, prev => duplicateWithUpdates(prev, { categoryId: value || undefined }))} serialize={option => option.value} /> Choose where this command appears in the palette. {categoryId && (
{categoryLabel} · {categoryId}
)}
handleUpdate(command.id, prev => duplicateWithUpdates(prev, { danger: value }))} /> Show as dangerous
)} )}
); })}
); }