diff --git a/apps/client/src/services/command_registry.ts b/apps/client/src/services/command_registry.ts new file mode 100644 index 000000000..3042636a7 --- /dev/null +++ b/apps/client/src/services/command_registry.ts @@ -0,0 +1,337 @@ +import appContext, { type CommandNames } from "../components/app_context.js"; + +export interface CommandDefinition { + id: string; + name: string; + description?: string; + icon?: string; + shortcut?: string; + commandName?: CommandNames; + handler?: () => void | Promise; + aliases?: string[]; +} + +class CommandRegistry { + private commands: Map = new Map(); + private aliases: Map = new Map(); + + constructor() { + this.registerDefaultCommands(); + } + + private registerDefaultCommands() { + // Navigation & UI Commands + this.register({ + id: "toggle-zen-mode", + name: "Toggle Zen Mode", + description: "Enter/exit distraction-free mode", + icon: "bx bx-fullscreen", + shortcut: "F9", + commandName: "toggleZenMode" + }); + + this.register({ + id: "toggle-left-pane", + name: "Toggle Left Pane", + description: "Show/hide the note tree sidebar", + icon: "bx bx-sidebar", + handler: () => appContext.triggerCommand("toggleLeftPane") + }); + + this.register({ + id: "show-options", + name: "Show Options", + description: "Open settings/preferences", + icon: "bx bx-cog", + commandName: "showOptions", + aliases: ["settings", "preferences"] + }); + + this.register({ + id: "show-help", + name: "Show Help", + description: "Open help documentation", + icon: "bx bx-help-circle", + handler: () => appContext.triggerCommand("showHelp") + }); + + this.register({ + id: "collapse-tree", + name: "Collapse Tree", + description: "Collapse all tree nodes", + icon: "bx bx-collapse", + shortcut: "Alt+C", + handler: () => appContext.triggerCommand("collapseTree") + }); + + // Note Operations + this.register({ + id: "create-note-into", + name: "Create New Note", + description: "Create a new child note", + icon: "bx bx-plus", + shortcut: "CommandOrControl+P", + commandName: "createNoteInto", + aliases: ["new note", "add note"] + }); + + this.register({ + id: "create-sql-console", + name: "Create SQL Console", + description: "Create a new SQL console note", + icon: "bx bx-data", + handler: () => appContext.triggerCommand("showSQLConsole") + }); + + this.register({ + id: "create-ai-chat", + name: "Create AI Chat", + description: "Create a new AI chat note", + icon: "bx bx-bot", + commandName: "createAiChat" + }); + + this.register({ + id: "clone-notes-to", + name: "Clone Note", + description: "Clone current note to another location", + icon: "bx bx-copy", + shortcut: "CommandOrControl+Shift+C", + commandName: "cloneNotesTo" + }); + + this.register({ + id: "delete-notes", + name: "Delete Note", + description: "Delete current note", + icon: "bx bx-trash", + shortcut: "Delete", + commandName: "deleteNotes" + }); + + this.register({ + id: "export-note", + name: "Export Note", + description: "Export current note", + icon: "bx bx-export", + handler: () => { + const notePath = appContext.tabManager.getActiveContextNotePath(); + if (notePath) { + appContext.triggerCommand("showExportDialog", { + notePath, + defaultType: "single" + }); + } + } + }); + + this.register({ + id: "show-note-source", + name: "Show Note Source", + description: "View note in source mode", + icon: "bx bx-code", + handler: () => appContext.triggerCommand("showNoteSource") + }); + + this.register({ + id: "show-attachments", + name: "Show Attachments", + description: "View note attachments", + icon: "bx bx-paperclip", + handler: () => appContext.triggerCommand("showAttachments") + }); + + // Session & Security + this.register({ + id: "enter-protected-session", + name: "Enter Protected Session", + description: "Enter password-protected mode", + icon: "bx bx-lock", + commandName: "enterProtectedSession" + }); + + this.register({ + id: "leave-protected-session", + name: "Leave Protected Session", + description: "Exit protected mode", + icon: "bx bx-lock-open", + commandName: "leaveProtectedSession" + }); + + // Search & Organization + this.register({ + id: "search-notes", + name: "Search Notes", + description: "Open advanced search", + icon: "bx bx-search", + shortcut: "CommandOrControl+Shift+F", + handler: () => appContext.triggerCommand("searchNotes", {}) + }); + + this.register({ + id: "search-in-subtree", + name: "Search in Subtree", + description: "Search within current subtree", + icon: "bx bx-search-alt", + shortcut: "CommandOrControl+Shift+S", + handler: () => { + const notePath = appContext.tabManager.getActiveContextNotePath(); + if (notePath) { + appContext.triggerCommand("searchInSubtree", { notePath }); + } + } + }); + + this.register({ + id: "show-search-history", + name: "Show Search History", + description: "View previous searches", + icon: "bx bx-history", + handler: () => appContext.triggerCommand("showSearchHistory") + }); + + this.register({ + id: "sort-child-notes", + name: "Sort Child Notes", + description: "Sort notes alphabetically", + icon: "bx bx-sort", + shortcut: "Alt+S", + commandName: "sortChildNotes" + }); + + // Developer Tools + this.register({ + id: "show-backend-log", + name: "Show Backend Log", + description: "View server logs", + icon: "bx bx-terminal", + handler: () => appContext.triggerCommand("showBackendLog") + }); + + this.register({ + id: "run-active-note", + name: "Run Active Note", + description: "Execute current note as script", + icon: "bx bx-play", + commandName: "runActiveNote" + }); + + // Recent Changes + this.register({ + id: "show-recent-changes", + name: "Show Recent Changes", + description: "View recently modified notes", + icon: "bx bx-time", + handler: () => appContext.triggerCommand("showRecentChanges", { ancestorNoteId: "root" }) + }); + + // Additional useful commands + this.register({ + id: "open-new-tab", + name: "Open New Tab", + description: "Open a new tab", + icon: "bx bx-tab", + shortcut: "CommandOrControl+T", + commandName: "openNewTab" + }); + + this.register({ + id: "close-active-tab", + name: "Close Active Tab", + description: "Close the current tab", + icon: "bx bx-x", + shortcut: "CommandOrControl+W", + commandName: "closeActiveTab" + }); + + this.register({ + id: "show-launch-bar", + name: "Show Launch Bar", + description: "Open the launch bar subtree", + icon: "bx bx-grid-alt", + handler: () => appContext.triggerCommand("showLaunchBarSubtree") + }); + } + + register(command: CommandDefinition) { + this.commands.set(command.id, command); + + // Register aliases + if (command.aliases) { + for (const alias of command.aliases) { + this.aliases.set(alias.toLowerCase(), command.id); + } + } + } + + getCommand(id: string): CommandDefinition | undefined { + return this.commands.get(id); + } + + getAllCommands(): CommandDefinition[] { + return Array.from(this.commands.values()); + } + + searchCommands(query: string): CommandDefinition[] { + const normalizedQuery = query.toLowerCase(); + const results: { command: CommandDefinition; score: number }[] = []; + + for (const command of this.commands.values()) { + let score = 0; + + // Exact match on name + if (command.name.toLowerCase() === normalizedQuery) { + score = 100; + } + // Name starts with query + else if (command.name.toLowerCase().startsWith(normalizedQuery)) { + score = 80; + } + // Name contains query + else if (command.name.toLowerCase().includes(normalizedQuery)) { + score = 60; + } + // Description contains query + else if (command.description?.toLowerCase().includes(normalizedQuery)) { + score = 40; + } + // Check aliases + else if (command.aliases?.some(alias => alias.toLowerCase().includes(normalizedQuery))) { + score = 50; + } + + if (score > 0) { + results.push({ command, score }); + } + } + + // Sort by score (highest first) and then by name + results.sort((a, b) => { + if (a.score !== b.score) { + return b.score - a.score; + } + return a.command.name.localeCompare(b.command.name); + }); + + return results.map(r => r.command); + } + + async executeCommand(commandId: string) { + const command = this.getCommand(commandId); + if (!command) { + console.error(`Command not found: ${commandId}`); + return; + } + + if (command.handler) { + await command.handler(); + } else if (command.commandName) { + appContext.triggerCommand(command.commandName); + } else { + console.error(`Command ${commandId} has no handler or commandName`); + } + } +} + +const commandRegistry = new CommandRegistry(); +export default commandRegistry; \ No newline at end of file diff --git a/apps/client/src/services/note_autocomplete.ts b/apps/client/src/services/note_autocomplete.ts index 4cfc614f0..cf05be465 100644 --- a/apps/client/src/services/note_autocomplete.ts +++ b/apps/client/src/services/note_autocomplete.ts @@ -3,6 +3,7 @@ import appContext from "../components/app_context.js"; import noteCreateService from "./note_create.js"; import froca from "./froca.js"; import { t } from "./i18n.js"; +import commandRegistry from "./command_registry.js"; import type { MentionFeedObjectItem } from "@triliumnext/ckeditor5"; // this key needs to have this value, so it's hit by the tooltip @@ -29,9 +30,12 @@ export interface Suggestion { notePathTitle?: string; notePath?: string; highlightedNotePathTitle?: string; - action?: string | "create-note" | "search-notes" | "external-link"; + action?: string | "create-note" | "search-notes" | "external-link" | "command"; parentNoteId?: string; icon?: string; + commandId?: string; + commandDescription?: string; + commandShortcut?: string; } interface Options { @@ -44,6 +48,8 @@ interface Options { hideGoToSelectedNoteButton?: boolean; /** If set, hides all right-side buttons in the autocomplete dropdown */ hideAllButtons?: boolean; + /** If set, enables command palette mode */ + isCommandPalette?: boolean; } async function autocompleteSourceForCKEditor(queryText: string) { @@ -73,6 +79,41 @@ async function autocompleteSourceForCKEditor(queryText: string) { } async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void, options: Options = {}) { + // Check if we're in command mode + if (options.isCommandPalette && term.startsWith(">")) { + const commandQuery = term.substring(1).trim(); + + if (commandQuery.length === 0) { + // Show all commands if no query + const allCommands = commandRegistry.getAllCommands(); + const commandSuggestions: Suggestion[] = allCommands.map(cmd => ({ + action: "command", + commandId: cmd.id, + noteTitle: cmd.name, + highlightedNotePathTitle: cmd.name, + commandDescription: cmd.description, + commandShortcut: cmd.shortcut, + icon: cmd.icon + })); + cb(commandSuggestions); + return; + } + + // Search commands + const matchedCommands = commandRegistry.searchCommands(commandQuery); + const commandSuggestions: Suggestion[] = matchedCommands.map(cmd => ({ + action: "command", + commandId: cmd.id, + noteTitle: cmd.name, + highlightedNotePathTitle: cmd.name, + commandDescription: cmd.description, + commandShortcut: cmd.shortcut, + icon: cmd.icon + })); + cb(commandSuggestions); + return; + } + const fastSearch = options.fastSearch === false ? false : true; if (fastSearch === false) { if (term.trim().length === 0) { @@ -270,7 +311,23 @@ function initNoteAutocomplete($el: JQuery, options?: Options) { }, displayKey: "notePathTitle", templates: { - suggestion: (suggestion) => ` ${suggestion.highlightedNotePathTitle}` + suggestion: (suggestion) => { + if (suggestion.action === "command") { + let html = `
`; + html += `
`; + html += ` ${suggestion.highlightedNotePathTitle}`; + if (suggestion.commandShortcut) { + html += ` ${suggestion.commandShortcut}`; + } + html += `
`; + if (suggestion.commandDescription) { + html += `${suggestion.commandDescription}`; + } + html += '
'; + return html; + } + return ` ${suggestion.highlightedNotePathTitle}`; + } }, // we can't cache identical searches because notes can be created / renamed, new recent notes can be added cache: false @@ -280,6 +337,12 @@ function initNoteAutocomplete($el: JQuery, options?: Options) { // TODO: Types fail due to "autocomplete:selected" not being registered in type definitions. ($el as any).on("autocomplete:selected", async (event: Event, suggestion: Suggestion) => { + if (suggestion.action === "command") { + $el.autocomplete("close"); + $el.trigger("autocomplete:commandselected", [suggestion]); + return; + } + if (suggestion.action === "external-link") { $el.setSelectedNotePath(null); $el.setSelectedExternalLink(suggestion.externalLink); diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css index 2296166f2..d5742feb6 100644 --- a/apps/client/src/stylesheets/style.css +++ b/apps/client/src/stylesheets/style.css @@ -1780,6 +1780,37 @@ textarea { padding: 1rem; } +/* Command palette styling */ +.jump-to-note-dialog .aa-suggestion .command-suggestion { + margin: -0.5rem -1rem; + padding: 0.75rem 1rem; + border-left: 3px solid var(--main-border-color); +} + +.jump-to-note-dialog .aa-cursor .command-suggestion, +.jump-to-note-dialog .aa-suggestion:hover .command-suggestion { + border-left-color: var(--link-color); + background-color: var(--hover-background-color); +} + +.jump-to-note-dialog .command-suggestion kbd { + padding: 0.2rem 0.4rem; + font-size: 0.8em; + background-color: var(--button-background-color); + border: 1px solid var(--main-border-color); + border-radius: 3px; + margin-left: 0.5rem; + position: absolute; + right: 1rem; + top: 0.75rem; +} + +.jump-to-note-dialog .command-suggestion small { + display: block; + margin-top: 0.25rem; + line-height: 1.3; +} + .empty-table-placeholder { text-align: center; color: var(--muted-text-color); diff --git a/apps/client/src/widgets/dialogs/jump_to_note.ts b/apps/client/src/widgets/dialogs/jump_to_note.ts index 98ee6e0e4..17a78902a 100644 --- a/apps/client/src/widgets/dialogs/jump_to_note.ts +++ b/apps/client/src/widgets/dialogs/jump_to_note.ts @@ -6,13 +6,14 @@ import BasicWidget from "../basic_widget.js"; import shortcutService from "../../services/shortcuts.js"; import { Modal } from "bootstrap"; import { openDialog } from "../../services/dialog.js"; +import commandRegistry from "../../services/command_registry.js"; const TPL = /*html*/`