silverbullet/web/hooks/slash_command.ts

186 lines
5.2 KiB
TypeScript
Raw Permalink Normal View History

2024-07-30 23:33:33 +08:00
import type { Hook, Manifest } from "$lib/plugos/types.ts";
import type { System } from "$lib/plugos/system.ts";
import type {
Completion,
CompletionContext,
CompletionResult,
} from "@codemirror/autocomplete";
2024-07-30 23:33:33 +08:00
import type { Client } from "../client.ts";
import { syntaxTree } from "@codemirror/language";
2024-07-30 23:33:33 +08:00
import type {
2024-02-29 22:23:05 +08:00
SlashCompletionOption,
SlashCompletions,
} from "../../plug-api/types.ts";
import { safeRun } from "$lib/async.ts";
2024-07-30 23:33:33 +08:00
import type { SlashCommandDef, SlashCommandHookT } from "$lib/manifest.ts";
import { parseCommand } from "$common/command.ts";
export type AppSlashCommand = {
slashCommand: SlashCommandDef;
run: () => Promise<void>;
};
const slashCommandRegexp = /([^\w:]|^)\/[\w#\-]*/;
export class SlashCommandHook implements Hook<SlashCommandHookT> {
slashCommands = new Map<string, AppSlashCommand>();
2023-07-14 22:56:20 +08:00
private editor: Client;
2023-07-14 22:56:20 +08:00
constructor(editor: Client) {
this.editor = editor;
}
buildAllCommands(system: System<SlashCommandHookT>) {
this.slashCommands.clear();
2022-10-16 01:02:56 +08:00
for (const plug of system.loadedPlugs.values()) {
for (
const [name, functionDef] of Object.entries(
plug.manifest!.functions,
)
) {
if (!functionDef.slashCommand) {
continue;
}
const cmd = functionDef.slashCommand;
this.slashCommands.set(cmd.name, {
slashCommand: cmd,
run: () => {
return plug.invoke(name, [cmd]);
},
});
}
}
if (this.editor.config?.shortcuts) {
// Add slash commands for shortcuts that configure them
for (const shortcut of this.editor.config.shortcuts) {
if (shortcut.slashCommand) {
const parsedCommand = parseCommand(shortcut.command);
this.slashCommands.set(shortcut.slashCommand, {
slashCommand: {
name: shortcut.slashCommand,
description: parsedCommand.alias || parsedCommand.name,
},
run: () => {
return this.editor.runCommandByName(
parsedCommand.name,
parsedCommand.args,
);
},
});
}
}
}
}
// Completer for CodeMirror
2023-11-06 16:14:16 +08:00
public async slashCommandCompleter(
ctx: CompletionContext,
2023-11-06 16:14:16 +08:00
): Promise<CompletionResult | null> {
2022-10-16 01:02:56 +08:00
const prefix = ctx.matchBefore(slashCommandRegexp);
if (!prefix) {
return null;
}
const prefixText = prefix.text;
2022-10-16 01:02:56 +08:00
const options: Completion[] = [];
2024-05-28 02:33:41 +08:00
// No slash commands in comment blocks (queries and such) or links
2022-10-16 01:02:56 +08:00
const currentNode = syntaxTree(ctx.state).resolveInner(ctx.pos);
2024-05-28 02:33:41 +08:00
if (
currentNode.type.name === "CommentBlock" ||
currentNode.type.name === "Link"
) {
return null;
}
2023-11-06 16:14:16 +08:00
2022-10-16 01:02:56 +08:00
for (const def of this.slashCommands.values()) {
options.push({
label: def.slashCommand.name,
detail: def.slashCommand.description,
boost: def.slashCommand.boost,
apply: () => {
// Delete slash command part
2023-07-27 17:41:44 +08:00
this.editor.editorView.dispatch({
changes: {
from: prefix!.from + prefixText.indexOf("/"),
to: ctx.pos,
insert: "",
},
});
// Replace with whatever the completion is
safeRun(async () => {
await def.run();
this.editor.focus();
});
},
});
}
2023-11-06 16:14:16 +08:00
const slashCompletions: CompletionResult | SlashCompletions | null =
await this.editor
.completeWithEvent(
ctx,
"slash:complete",
);
2023-11-06 16:14:16 +08:00
if (slashCompletions) {
for (
const slashCompletion of slashCompletions
.options as SlashCompletionOption[]
) {
2023-11-06 16:14:16 +08:00
options.push({
label: slashCompletion.label,
detail: slashCompletion.detail,
boost: slashCompletion.order && -slashCompletion.order,
2023-11-06 16:14:16 +08:00
apply: () => {
// Delete slash command part
this.editor.editorView.dispatch({
changes: {
from: prefix!.from + prefixText.indexOf("/"),
to: ctx.pos,
insert: "",
},
});
// Replace with whatever the completion is
safeRun(async () => {
await this.editor.clientSystem.system.invokeFunction(
slashCompletion.invoke,
[slashCompletion],
2023-11-06 16:14:16 +08:00
);
this.editor.focus();
});
},
});
}
}
return {
// + 1 because of the '/'
from: prefix.from + prefixText.indexOf("/") + 1,
options: options,
};
}
apply(system: System<SlashCommandHookT>): void {
this.buildAllCommands(system);
system.on({
plugLoaded: () => {
this.buildAllCommands(system);
},
});
}
validateManifest(manifest: Manifest<SlashCommandHookT>): string[] {
2022-10-16 01:02:56 +08:00
const errors = [];
for (const [name, functionDef] of Object.entries(manifest.functions)) {
if (!functionDef.slashCommand) {
continue;
}
const cmd = functionDef.slashCommand;
if (!cmd.name) {
errors.push(`Function ${name} has a command but no name`);
}
}
return [];
}
}