diff --git a/common/manifest.ts b/common/manifest.ts index f54a6a4..a9716d8 100644 --- a/common/manifest.ts +++ b/common/manifest.ts @@ -4,8 +4,10 @@ import { CronHookT } from "../plugos/hooks/node_cron"; import { EventHookT } from "../plugos/hooks/event"; import { CommandHookT } from "../webapp/hooks/command"; import { SlashCommandHookT } from "../webapp/hooks/slash_command"; +import { CompleterHookT } from "../webapp/hooks/completer"; export type SilverBulletHooks = CommandHookT & + CompleterHookT & SlashCommandHookT & EndpointHookT & CronHookT & diff --git a/plugos/environments/sandbox_worker.ts b/plugos/environments/sandbox_worker.ts index 1650b83..acdb8bb 100644 --- a/plugos/environments/sandbox_worker.ts +++ b/plugos/environments/sandbox_worker.ts @@ -44,8 +44,7 @@ return fn["default"].apply(null, arguments);`; self.addEventListener("message", (event: { data: WorkerMessage }) => { safeRun(async () => { - let messageEvent = event; - let data = messageEvent.data; + let data = event.data; switch (data.type) { case "load": loadedFunctions.set(data.name!, new Function(wrapScript(data.code!))); diff --git a/plugos/hooks/endpoint.ts b/plugos/hooks/endpoint.ts index 3d7ff7f..04d662d 100644 --- a/plugos/hooks/endpoint.ts +++ b/plugos/hooks/endpoint.ts @@ -27,7 +27,7 @@ export type EndPointDef = { export class EndpointHook implements Hook { private app: Express; - private prefix: string; + readonly prefix: string; constructor(app: Express, prefix: string) { this.app = app; diff --git a/plugos/hooks/event.ts b/plugos/hooks/event.ts index 8d20ee3..fb9424e 100644 --- a/plugos/hooks/event.ts +++ b/plugos/hooks/event.ts @@ -1,5 +1,6 @@ import { Hook, Manifest } from "../types"; import { System } from "../system"; +import { safeRun } from "../util"; // System events: // - plug:load (plugName: string) @@ -11,11 +12,11 @@ export type EventHookT = { export class EventHook implements Hook { private system?: System; - async dispatchEvent(eventName: string, data?: any): Promise { + async dispatchEvent(eventName: string, data?: any): Promise { if (!this.system) { throw new Error("Event hook is not initialized"); } - let promises: Promise[] = []; + let promises: Promise[] = []; for (const plug of this.system.loadedPlugs.values()) { for (const [name, functionDef] of Object.entries( plug.manifest!.functions @@ -28,14 +29,16 @@ export class EventHook implements Hook { } } } - return Promise.all(promises); + await Promise.all(promises); } apply(system: System): void { this.system = system; this.system.on({ plugLoaded: (name) => { - this.dispatchEvent("plug:load", name); + safeRun(async () => { + await this.dispatchEvent("plug:load", name); + }); }, }); } diff --git a/plugs/core/core.plug.yaml b/plugs/core/core.plug.yaml index de4e9f0..e38219f 100644 --- a/plugs/core/core.plug.yaml +++ b/plugs/core/core.plug.yaml @@ -29,9 +29,8 @@ functions: command: name: "Page: Rename" pageComplete: - path: "./navigate.ts:pageComplete" - events: - - editor:complete + path: "./page.ts:pageComplete" + isCompleter: true linkNavigate: path: "./navigate.ts:linkNavigate" command: diff --git a/plugs/core/navigate.ts b/plugs/core/navigate.ts index 3e0b01a..35890ec 100644 --- a/plugs/core/navigate.ts +++ b/plugs/core/navigate.ts @@ -38,18 +38,3 @@ export async function clickNavigate(event: ClickEvent) { await navigate(syntaxNode); } } - -export async function pageComplete() { - let prefix = await syscall("editor.matchBefore", "\\[\\[[\\w\\s]*"); - if (!prefix) { - return null; - } - let allPages = await syscall("space.listPages"); - return { - from: prefix.from + 2, - options: allPages.map((pageMeta: any) => ({ - label: pageMeta.name, - type: "page", - })), - }; -} diff --git a/plugs/core/page.ts b/plugs/core/page.ts index 3a54f33..54437d1 100644 --- a/plugs/core/page.ts +++ b/plugs/core/page.ts @@ -110,6 +110,22 @@ export async function reindexCommand() { await syscall("editor.flashNotification", "Reindexing done"); } +// Completion +export async function pageComplete() { + let prefix = await syscall("editor.matchBefore", "\\[\\[[\\w\\s]*"); + if (!prefix) { + return null; + } + let allPages = await syscall("space.listPages"); + return { + from: prefix.from + 2, + options: allPages.map((pageMeta: any) => ({ + label: pageMeta.name, + type: "page", + })), + }; +} + // Server functions export async function reindexSpace() { console.log("Clearing page index..."); diff --git a/plugs/core/word_count_command.ts b/plugs/core/word_count_command.ts index 9780f25..23ed52f 100644 --- a/plugs/core/word_count_command.ts +++ b/plugs/core/word_count_command.ts @@ -1,7 +1,7 @@ import { syscall } from "../lib/syscall"; function countWords(str: string): number { - var matches = str.match(/[\w\d\'\'-]+/gi); + const matches = str.match(/[\w\d\'-]+/gi); return matches ? matches.length : 0; } diff --git a/server/server.ts b/server/server.ts index 28d503d..1e18a82 100755 --- a/server/server.ts +++ b/server/server.ts @@ -22,7 +22,7 @@ let args = yargs(hideBin(process.argv)) if (!args._.length) { console.error("Usage: silverbullet "); - process.exit(1); + process.exit(1); } const pagesPath = args._[0] as string; @@ -58,11 +58,11 @@ expressServer ); await plugLoader.loadPlugs(); plugLoader.watcher(); - system.registerSyscalls("shell", ["shell"], shellSyscalls(pagesPath)); - system.addHook(new NodeCronHook()); - server.listen(port, () => { - console.log(`Server listening on port ${port}`); - }); + system.registerSyscalls("shell", ["shell"], shellSyscalls(pagesPath)); + system.addHook(new NodeCronHook()); + server.listen(port, () => { + console.log(`Server listening on port ${port}`); + }); }) .catch((e) => { console.error(e); diff --git a/webapp/app_event.ts b/webapp/app_event.ts index 984430a..baaa240 100644 --- a/webapp/app_event.ts +++ b/webapp/app_event.ts @@ -14,5 +14,5 @@ export type IndexEvent = { }; export interface AppEventDispatcher { - dispatchAppEvent(name: AppEvent, data?: any): Promise; + dispatchAppEvent(name: AppEvent, data?: any): Promise; } diff --git a/webapp/components/filter.tsx b/webapp/components/filter.tsx index 4cfc211..a20a13a 100644 --- a/webapp/components/filter.tsx +++ b/webapp/components/filter.tsx @@ -2,11 +2,11 @@ import React, { useEffect, useRef, useState } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { IconDefinition } from "@fortawesome/free-solid-svg-icons"; -export interface Option { +export type Option = { name: string; orderId?: number; hint?: string; -} +}; function magicSorter(a: Option, b: Option): number { if (a.orderId && b.orderId) { @@ -15,6 +15,42 @@ function magicSorter(a: Option, b: Option): number { return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1; } +function escapeRegExp(str: string): string { + return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); +} + +function fuzzyFilter(pattern: string, options: Option[]): Option[] { + let closeMatchRegex = escapeRegExp(pattern); + closeMatchRegex = closeMatchRegex.split(/\s+/).join(".*?"); + closeMatchRegex = closeMatchRegex.replace(/\\\//g, ".*?\\/.*?"); + const distantMatchRegex = escapeRegExp(pattern).split("").join(".*?"); + const r1 = new RegExp(closeMatchRegex, "i"); + const r2 = new RegExp(distantMatchRegex, "i"); + let matches = []; + if (!pattern) { + return options; + } + for (let option of options) { + let m = r1.exec(option.name); + if (m) { + matches.push({ + ...option, + orderId: 100000 - (options.length - m[0].length - m.index), + }); + } else { + // Let's try the distant matcher + var m2 = r2.exec(option.name); + if (m2) { + matches.push({ + ...option, + orderId: 10000 - (options.length - m2[0].length - m2.index), + }); + } + } + } + return matches; +} + export function FilterList({ placeholder, options, @@ -51,12 +87,7 @@ export function FilterList({ if (searchPhrase) { let foundExactMatch = false; - let results = options.filter((option) => { - if (option.name.toLowerCase() === searchPhrase) { - foundExactMatch = true; - } - return option.name.toLowerCase().indexOf(searchPhrase) !== -1; - }); + let results = fuzzyFilter(searchPhrase, options); results = results.sort(magicSorter); if (allowNew && !foundExactMatch) { results.push({ diff --git a/webapp/constant.ts b/webapp/constant.ts index e66d599..2d26452 100644 --- a/webapp/constant.ts +++ b/webapp/constant.ts @@ -1 +1 @@ -export const pageLinkRegex = /\[\[([\w\s\/\:,\.@\-]+)\]\]/; +export const pageLinkRegex = /\[\[([\w\s\/:,\.@\-]+)\]\]/; diff --git a/webapp/editor.tsx b/webapp/editor.tsx index 35c60e2..13ea006 100644 --- a/webapp/editor.tsx +++ b/webapp/editor.tsx @@ -1,8 +1,4 @@ -import { - autocompletion, - completionKeymap, - CompletionResult, -} from "@codemirror/autocomplete"; +import { autocompletion, completionKeymap } from "@codemirror/autocomplete"; import { closeBrackets, closeBracketsKeymap } from "@codemirror/closebrackets"; import { indentWithTab, standardKeymap } from "@codemirror/commands"; import { history, historyKeymap } from "@codemirror/history"; @@ -47,6 +43,7 @@ import { systemSyscalls } from "./syscalls/system"; import { Panel } from "./components/panel"; import { CommandHook } from "./hooks/command"; import { SlashCommandHook } from "./hooks/slash_command"; +import { CompleterHook } from "./hooks/completer"; class PageState { scrollTop: number; @@ -60,15 +57,17 @@ class PageState { export class Editor implements AppEventDispatcher { private system = new System("client"); + readonly commandHook: CommandHook; + readonly slashCommandHook: SlashCommandHook; + readonly completerHook: CompleterHook; + openPages = new Map(); - commandHook: CommandHook; editorView?: EditorView; viewState: AppViewState; viewDispatch: React.Dispatch; space: Space; pageNavigator: PathPageNavigator; eventHook: EventHook; - private slashCommandHook: SlashCommandHook; constructor(space: Space, parent: Element) { this.space = space; @@ -95,6 +94,10 @@ export class Editor implements AppEventDispatcher { this.slashCommandHook = new SlashCommandHook(this); this.system.addHook(this.slashCommandHook); + // Completer hook + this.completerHook = new CompleterHook(); + this.system.addHook(this.completerHook); + this.render(parent); this.editorView = new EditorView({ state: this.createEditorState( @@ -192,7 +195,7 @@ export class Editor implements AppEventDispatcher { }, 2000); } - async dispatchAppEvent(name: AppEvent, data?: any): Promise { + async dispatchAppEvent(name: AppEvent, data?: any): Promise { return this.eventHook.dispatchEvent(name, data); } @@ -236,7 +239,7 @@ export class Editor implements AppEventDispatcher { }), autocompletion({ override: [ - this.plugCompleter.bind(this), + this.completerHook.plugCompleter.bind(this.completerHook), this.slashCommandHook.slashCommandCompleter.bind( this.slashCommandHook ), @@ -330,19 +333,6 @@ export class Editor implements AppEventDispatcher { }); } - async plugCompleter(): Promise { - let allCompletionResults = await this.dispatchAppEvent("editor:complete"); - if (allCompletionResults.length === 1) { - return allCompletionResults[0]; - } else if (allCompletionResults.length > 1) { - console.error( - "Got completion results from multiple sources, cannot deal with that", - allCompletionResults - ); - } - return null; - } - focus() { this.editorView!.focus(); } diff --git a/webapp/hooks/completer.ts b/webapp/hooks/completer.ts new file mode 100644 index 0000000..9268877 --- /dev/null +++ b/webapp/hooks/completer.ts @@ -0,0 +1,46 @@ +import { Hook, Manifest } from "../../plugos/types"; +import { System } from "../../plugos/system"; +import { CompletionResult } from "@codemirror/autocomplete"; + +export type CompleterHookT = { + isCompleter?: boolean; +}; + +export class CompleterHook implements Hook { + private system?: System; + + public async plugCompleter(): Promise { + let completerPromises = []; + // TODO: Can be optimized (cache all functions) + for (const plug of this.system!.loadedPlugs.values()) { + if (!plug.manifest) { + continue; + } + for (const [functionName, functionDef] of Object.entries( + plug.manifest.functions + )) { + if (functionDef.isCompleter) { + completerPromises.push(plug.invoke(functionName, [])); + } + } + } + let allCompletionResults = await Promise.all(completerPromises); + if (allCompletionResults.length === 1) { + return allCompletionResults[0]; + } else if (allCompletionResults.length > 1) { + console.error( + "Got completion results from multiple sources, cannot deal with that", + allCompletionResults + ); + } + return null; + } + + apply(system: System): void { + this.system = system; + } + + validateManifest(manifest: Manifest): string[] { + return []; + } +} diff --git a/webapp/util.ts b/webapp/util.ts index de77473..1e73f0d 100644 --- a/webapp/util.ts +++ b/webapp/util.ts @@ -1,5 +1,5 @@ export function countWords(str: string): number { - var matches = str.match(/[\w\d\'\'-]+/gi); + const matches = str.match(/[\w\d\'-]+/gi); return matches ? matches.length : 0; }