Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(plugin): Added CommandPalette #2145

Closed
wants to merge 10 commits into from
49 changes: 49 additions & 0 deletions docs/3_COMMAND_PALETTE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Command Palette Actions Guide
ethan-davies marked this conversation as resolved.
Show resolved Hide resolved

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.
177 changes: 177 additions & 0 deletions src/plugins/commandPalette/commands.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { relaunch, showItemInFolder } from "@utils/native";
import { Alerts, GuildStore, NavigationRouter, Parser, SettingsRouter, Toasts, UserStore, React } from "@webpack/common";
import { openSimpleTextInput } from "./components/TextInput";
import { checkForUpdates, getRepo } from "@utils/updater";
import Plugins from "~plugins";
import { Settings } from "@api/Settings";
import { openMultipleChoice } from "./components/MultipleChoice";
import { Clipboard } from "@webpack/common";
import { showNotification } from "@api/Notifications";
import { PresenceStore, Tooltip } from "@webpack/common";
import { findStore } from "@webpack";
import { FluxDispatcher } from "@webpack/common";
import { ChannelStore } from "@webpack/common";
import { ChangeList } from "@utils/ChangeList";

export interface ButtonAction {
id: string;
label: string;
callback?: () => void;
registrar?: string;
}

export let actions: ButtonAction[] = [
{ id: 'openVencordSettings', label: 'Open Vencord tab', callback: async () => await SettingsRouter.open("VencordSettings"), registrar: "Vencord" },
{ id: 'openPluginSettings', label: 'Open Plugin tab', callback: () => SettingsRouter.open("VencordPlugins"), registrar: "Vencord" },
{ id: 'openThemesSettings', label: 'Open Themes tab', callback: () => SettingsRouter.open("VencordThemes"), registrar: "Vencord" },
{ id: 'openUpdaterSettings', label: 'Open Updater tab', callback: () => SettingsRouter.open("VencordUpdater"), registrar: "Vencord" },
{ id: 'openVencordCloudSettings', label: 'Open Cloud tab', callback: () => SettingsRouter.open("VencordCloud"), registrar: "Vencord" },
{ id: 'openBackupSettings', label: 'Open Backup & Restore tab', callback: () => SettingsRouter.open("VencordSettingsSync"), registrar: "Vencord" },
{ id: 'restartClient', label: 'Restart Client', callback: () => relaunch(), registrar: "Vencord" },
{ id: 'openQuickCSSFile', label: 'Open Quick CSS File', callback: () => VencordNative.quickCss.openEditor(), registrar: "Vencord" },
{ id: 'openSettingsFolder', label: 'Open Settings Folder', callback: async () => showItemInFolder(await VencordNative.settings.getSettingsDir()), registrar: "Vencord" },
{ id: 'openInGithub', label: 'Open in Github', callback: async () => VencordNative.native.openExternal(await getRepo()), registrar: "Vencord" },

{
id: 'openInBrowser', label: 'Open in Browser', callback: async () => {
const url = await openSimpleTextInput("Enter a URL");
const newUrl = url.replace(/(https?:\/\/)?([a-zA-Z0-9-]+)\.([a-zA-Z0-9-]+)/, "https://$2.$3");

try {
new URL(newUrl); // Throws if invalid
VencordNative.native.openExternal(newUrl);
} catch {
Toasts.show({
message: "Invalid URL",
type: Toasts.Type.FAILURE,
id: Toasts.genId(),
options: {
position: Toasts.Position.BOTTOM
}
});
}
}, registrar: "Vencord"
},

{
id: 'togglePlugin', label: 'Toggle Plugin', callback: async () => {
const plugins = Object.keys(Plugins);
let options: ButtonAction[] = [];

for (const plugin of plugins) {
options.push({
id: plugin,
label: plugin
});
}

const choice = await openMultipleChoice(options);

const enabled = await openMultipleChoice([
{ id: 'enable', label: 'Enable' },
{ id: 'disable', label: 'Disable' }
]);

if (choice && enabled) {
return togglePlugin(choice, enabled.id === 'enable');
}
}, registrar: "Vencord"
},

{
id: 'quickFetch', label: 'Quick Fetch', callback: async () => {
try {
const url = await openSimpleTextInput("Enter URL to fetch (GET only)");
const newUrl = url.replace(/(https?:\/\/)?([a-zA-Z0-9-]+)\.([a-zA-Z0-9-]+)/, "https://$2.$3");
const res = (await fetch(newUrl));
const text = await res.text();
Clipboard.copy(text);

Toasts.show({
message: "Copied response to clipboard!",
type: Toasts.Type.SUCCESS,
id: Toasts.genId(),
options: {
position: Toasts.Position.BOTTOM
}
});

} catch (e) {
Toasts.show({
message: "Issue fetching URL",
type: Toasts.Type.FAILURE,
id: Toasts.genId(),
options: {
position: Toasts.Position.BOTTOM
}
});
}
}, registrar: "Vencord"
},

{
id: 'checkForUpdates', label: 'Check for Updates', callback: async () => {
const isOutdated = await checkForUpdates();

if (isOutdated) {
setTimeout(() => showNotification({
title: "A Vencord update is available!",
body: "Click here to view the update",
permanent: true,
noPersist: true,
onClick() {
SettingsRouter.open("VencordUpdater");
}
}), 10_000);
} else {
Toasts.show({
message: "No updates available",
type: Toasts.Type.MESSAGE,
id: Toasts.genId(),
options: {
position: Toasts.Position.BOTTOM
}
});
}
}, registrar: "Vencord"
},

{
id: 'navToServer', label: 'Navigate to Server', callback: async () => {
const allServers = Object.values(GuildStore.getGuilds());
let options: ButtonAction[] = [];

for (const server of allServers) {
options.push({
id: server.id,
label: server.name
});
}

const choice = await openMultipleChoice(options);

if (choice) {
NavigationRouter.transitionToGuild(choice.id);
}
}, registrar: "Vencord"
}
];

function togglePlugin(plugin: ButtonAction, enabled: boolean) {

Settings.plugins[plugin.id].enabled = enabled;

Toasts.show({
message: `Successfully ${enabled ? 'enabled' : 'disabled'} ${plugin.id}`,
type: Toasts.Type.SUCCESS,
id: Toasts.genId(),
options: {
position: Toasts.Position.BOTTOM
}
});
}

export function registerAction(action: ButtonAction) {
actions.push(action);
}

124 changes: 124 additions & 0 deletions src/plugins/commandPalette/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { classNameFactory } from "@api/Styles";
import { Logger } from "@utils/Logger";
import { closeAllModals, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { React, TextInput, useState } from "@webpack/common";
import { useEffect } from "@webpack/common";
import { actions } from "../commands";
import { settings } from "..";

import "./styles.css";

const logger = new Logger("CommandPalette", "#e5c890");

export function CommandPalette({ modalProps }) {
const cl = classNameFactory("vc-command-palette-");
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const [startIndex, setStartIndex] = useState(0);

const allowMouse = settings.store.allowMouseControl;

const sortedActions = actions.slice().sort((a, b) => a.label.localeCompare(b.label));

const [queryEh, setQuery] = useState("");

const filteredActions = sortedActions.filter(
(action) => action.label.toLowerCase().includes(queryEh.toLowerCase())
);

const visibleActions = filteredActions.slice(startIndex, startIndex + 20);

const totalActions = filteredActions.length;

const handleWheel = (e: React.WheelEvent) => {
if (allowMouse && filteredActions.length > 20) {
if (e.deltaY > 0) {
setStartIndex((prev) => Math.min(prev + 2, filteredActions.length - 20));
} else {
setStartIndex((prev) => Math.max(prev - 2, 0));
}
}
};

const handleButtonClick = (actionId: string, index: number) => {
const selectedAction = filteredActions.find((action) => action.id === actionId);

if (selectedAction) {
logger.log(`${selectedAction.id}'s action was triggered.`);
}

closeAllModals();

selectedAction?.callback?.();
setFocusedIndex(index);
};

const handleKeyDown = (e: React.KeyboardEvent) => {
const currentIndex = focusedIndex !== null ? focusedIndex : -1;
let nextIndex;

switch (e.key) {
case 'ArrowUp':
e.preventDefault();
nextIndex = currentIndex > 0 ? currentIndex - 1 : visibleActions.length - 1;
setFocusedIndex(nextIndex);

if (currentIndex === 0 && totalActions > 20) {
setStartIndex((prev) => Math.max(prev - 1, 0));
setFocusedIndex(0);
}

break;
case 'ArrowDown':
e.preventDefault();
nextIndex = currentIndex < visibleActions.length - 1 ? currentIndex + 1 : 0;
setFocusedIndex(nextIndex);

if (currentIndex === visibleActions.length - 1 && totalActions > 20) {
setStartIndex((prev) => Math.min(prev + 1, filteredActions.length - 20));
setFocusedIndex(19);
}
break;
case 'Enter':
if (currentIndex !== null && currentIndex >= 0 && currentIndex < visibleActions.length) {
handleButtonClick(visibleActions[currentIndex].id, currentIndex);
}
break;
default:
break;
}
};

useEffect(() => {
setFocusedIndex(0);
setStartIndex(0);
}, [queryEh]);

return (
<ModalRoot className={cl("root")} {...modalProps} size={ModalSize.MEDIUM} onKeyDown={handleKeyDown} onWheel={handleWheel}>
<div>
<TextInput
value={queryEh}
onChange={(e) => setQuery(e)}
style={{ width: "100%", borderBottomLeftRadius: "0", borderBottomRightRadius: "0", paddingLeft: "0.9rem" }}
placeholder="Search the Command Palette"
/>
<div className={cl("option-container")}>
{visibleActions.map((action, index) => (
<button
key={action.id}
className={cl("option", { "key-hover": index === focusedIndex })}
onClick={() => { if (allowMouse) handleButtonClick(action.id, index); }}
onMouseMove={() => { if (allowMouse) setFocusedIndex(index); }}
style={allowMouse ? { cursor: "pointer" } : { cursor: "default" }}
>
{action.label}
{action.registrar && <span className={cl("registrar")}>{action.registrar}</span>}
</button>
))}
</div>
</div>
</ModalRoot>
);
}

export const openCommandPalette = () => openModal((modalProps) => <CommandPalette modalProps={modalProps} />);
Loading