Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/commands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,10 @@ This folder contains end-user and developer documentation for each registered sl
Some commands require API keys or tokens:

- [GitHub API Options](../options/github/README.md) – Personal Access Token for `//link ci` and other GitHub API features

## Developer notes

When adding a new slash command:

- Register it via `registerCommand(name, spec, metadata)` and provide `icon` + `description` metadata so it appears automatically in the `//` command list.
- Keep command-specific data loading in small helpers (e.g. cache loaders) to minimize boilerplate in `command.ts`.
35 changes: 35 additions & 0 deletions src/content/commands/command-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it, vi } from "vitest"
import { getOrLoadCommandCache, runNonBlocking } from "./command-helpers.ts"
import * as picker from "../picker/index.ts"

describe("command helpers", () => {
it("returns cached value when present", async () => {
vi.spyOn(picker, "getCommandCache").mockReturnValue("cached")
const setSpy = vi.spyOn(picker, "setCommandCache")

const loader = vi.fn().mockResolvedValue("loaded")
const value = await getOrLoadCommandCache("test:key", loader)

expect(value).toBe("cached")
expect(loader).not.toHaveBeenCalled()
expect(setSpy).not.toHaveBeenCalled()
})

it("loads and caches when value is missing", async () => {
vi.spyOn(picker, "getCommandCache").mockReturnValue(null)
const setSpy = vi.spyOn(picker, "setCommandCache")

const loader = vi.fn().mockResolvedValue("loaded")
const value = await getOrLoadCommandCache("test:key", loader)

expect(value).toBe("loaded")
expect(loader).toHaveBeenCalledOnce()
expect(setSpy).toHaveBeenCalledWith("test:key", "loaded")
})

it("ignores runNonBlocking errors", async () => {
runNonBlocking(Promise.reject(new Error("boom")))
await Promise.resolve()
expect(true).toBe(true)
})
})
24 changes: 24 additions & 0 deletions src/content/commands/command-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { getCommandCache, setCommandCache } from "../picker/index.ts"

/**
* Returns a cached command value or loads and stores it.
*/
export async function getOrLoadCommandCache<T>(key: string, loader: () => Promise<T>): Promise<T> {
const cached = getCommandCache<T>(key)
if (cached !== null) {
return cached
}

const loaded = await loader()
setCommandCache(key, loaded)
return loaded
}

/**
* Handles non-critical async side effects without surfacing errors.
*/
export function runNonBlocking(task: Promise<unknown>): void {
task.catch(() => {
// non-critical side effect; intentionally ignored
})
}
51 changes: 11 additions & 40 deletions src/content/commands/commands-list/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,52 +7,23 @@
*/

import type { PickerItem } from "../../types.ts"
import { registerCommand, type CommandSpec, listCommands } from "../registry.ts"
import {
registerCommand,
type CommandSpec,
listCommands,
getCommandMetadata,
type CommandMetadata,
} from "../registry.ts"
import { renderList, setSlashQueryInField } from "../../picker/index.ts"
import { COMMAND_PREFIX } from "../../../utils/command-prefix.ts"

type CommandMeta = {
icon: string
description: string
}

const commandMeta: Record<string, CommandMeta> = {
giphy: {
icon: "🎬",
description: "Search and insert animated GIFs",
},
emoji: {
icon: "😀",
description: "Search and insert emoji",
},
font: {
icon: "𝔄",
description: "Transform text into fancy unicode fonts",
},
mermaid: {
icon: "📊",
description: "Create diagrams and flowcharts",
},
mention: {
icon: "@",
description: "Mention a GitHub user",
},
now: {
icon: "🕐",
description: "Insert current date and time",
},
kbd: {
icon: "⌨️",
description: "Insert keyboard shortcut notation",
},
link: {
icon: "🔗",
description: "Insert formatted links",
},
const FALLBACK_COMMAND_META: CommandMetadata = {
icon: "📝",
description: "Insert content",
}

function makeCommandItem(name: string): PickerItem {
const meta = commandMeta[name] || { icon: "📝", description: "Insert content" }
const meta = getCommandMetadata(name) || FALLBACK_COMMAND_META
return {
id: name,
previewUrl: "",
Expand Down
18 changes: 8 additions & 10 deletions src/content/commands/emoji/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
*/

import { registerCommand, type CommandSpec } from "../registry.ts"
import { getOrLoadCommandCache, runNonBlocking } from "../command-helpers.ts"
import { createGridHandlers } from "../grid-handlers.ts"
import { getCommandCache, setCommandCache, insertTextAtCursor } from "../../picker/index.ts"
import { insertTextAtCursor } from "../../picker/index.ts"
import type { PickerItem } from "../../types.ts"
import { createSmallTile } from "../../../utils/tile-builder.ts"
import {
Expand Down Expand Up @@ -56,21 +57,15 @@ function insertEmoji(emoji: string): void {
if (!insertTextAtCursor(emoji + " ")) return

// Add to recently used (fire-and-forget, errors are non-critical)
addRecentEmoji(emoji).catch(() => {
// Silently ignore storage errors - not critical for UX
})
runNonBlocking(addRecentEmoji(emoji))
}

const emojiCommand: CommandSpec = {
preflight: async () => ({ showSetup: false }),

getEmptyState: async () => {
// Load recently used emojis if not cached
let recentEmojis = getCommandCache<string[]>(CACHE_RECENT_EMOJIS)
if (!recentEmojis) {
recentEmojis = await getRecentEmojis()
setCommandCache(CACHE_RECENT_EMOJIS, recentEmojis)
}
const recentEmojis = await getOrLoadCommandCache(CACHE_RECENT_EMOJIS, getRecentEmojis)

// If we have recent emojis, show them first
if (recentEmojis.length > 0) {
Expand Down Expand Up @@ -116,6 +111,9 @@ const emojiCommand: CommandSpec = {
}

// Register the command
registerCommand("emoji", emojiCommand)
registerCommand("emoji", emojiCommand, {
icon: "😀",
description: "Search and insert emoji",
})

export { emojiCommand, makeEmojiTile }
5 changes: 4 additions & 1 deletion src/content/commands/font/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,9 @@ const fontCommand: CommandSpec = {
}

// Register the command
registerCommand("font", fontCommand)
registerCommand("font", fontCommand, {
icon: "𝔄",
description: "Transform text into fancy unicode fonts",
})

export { fontCommand, FONT_OPTIONS }
5 changes: 4 additions & 1 deletion src/content/commands/giphy/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,9 @@ const giphyCommand: CommandSpec = {
}

// Register the command
registerCommand("giphy", giphyCommand)
registerCommand("giphy", giphyCommand, {
icon: "🎬",
description: "Search and insert animated GIFs",
})

export { giphyCommand }
5 changes: 4 additions & 1 deletion src/content/commands/kbd/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,10 @@ const kbdCommand: CommandSpec = {
}

// Register the command
registerCommand("kbd", kbdCommand)
registerCommand("kbd", kbdCommand, {
icon: "⌨️",
description: "Insert keyboard shortcut notation",
})

export {
kbdCommand,
Expand Down
5 changes: 4 additions & 1 deletion src/content/commands/link/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,10 @@ const linkCommand: CommandSpec = {
}

// Register the command
registerCommand("link", linkCommand)
registerCommand("link", linkCommand, {
icon: "🔗",
description: "Insert formatted links",
})

export {
linkCommand,
Expand Down
46 changes: 15 additions & 31 deletions src/content/commands/mention/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@

import { escapeForSvg } from "../../../utils/svg.ts"
import { registerCommand, type CommandSpec } from "../registry.ts"
import { getOrLoadCommandCache, runNonBlocking } from "../command-helpers.ts"
import { createGridHandlers } from "../grid-handlers.ts"
import { getCommandCache, setCommandCache, insertTextAtCursor } from "../../picker/index.ts"
import { insertTextAtCursor } from "../../picker/index.ts"
import type { PickerItem } from "../../types.ts"
import {
getAllParticipants,
Expand Down Expand Up @@ -108,28 +109,17 @@ function insertMention(username: string): void {
if (!insertTextAtCursor(`@${username} `)) return

// Add to recently used (fire-and-forget, errors are non-critical)
addRecentMention(username).catch(() => {
// Silently ignore storage errors - not critical for UX
})
runNonBlocking(addRecentMention(username))
}

const mentionCommand: CommandSpec = {
preflight: async () => ({ showSetup: false }),

getEmptyState: async () => {
// Get participants from page
let participants = getCommandCache<MentionItem[]>(CACHE_PARTICIPANTS)
if (!participants) {
participants = getAllParticipants()
setCommandCache(CACHE_PARTICIPANTS, participants)
}

// Load recently mentioned users if not cached
let recentMentions = getCommandCache<string[]>(CACHE_RECENT_MENTIONS)
if (!recentMentions) {
recentMentions = await getRecentMentions()
setCommandCache(CACHE_RECENT_MENTIONS, recentMentions)
}
const participants = await getOrLoadCommandCache(CACHE_PARTICIPANTS, async () =>
getAllParticipants()
)
const recentMentions = await getOrLoadCommandCache(CACHE_RECENT_MENTIONS, getRecentMentions)

// Add recent mentions that are not already in participants
const existingUsernames = new Set(participants.map((p) => p.username.toLowerCase()))
Expand All @@ -152,19 +142,10 @@ const mentionCommand: CommandSpec = {
},

getResults: async (query: string) => {
// Get cached participants
let participants = getCommandCache<MentionItem[]>(CACHE_PARTICIPANTS)
if (!participants) {
participants = getAllParticipants()
setCommandCache(CACHE_PARTICIPANTS, participants)
}

// Get recent mentions for search
let recentMentions = getCommandCache<string[]>(CACHE_RECENT_MENTIONS)
if (!recentMentions) {
recentMentions = await getRecentMentions()
setCommandCache(CACHE_RECENT_MENTIONS, recentMentions)
}
const participants = await getOrLoadCommandCache(CACHE_PARTICIPANTS, async () =>
getAllParticipants()
)
const recentMentions = await getOrLoadCommandCache(CACHE_RECENT_MENTIONS, getRecentMentions)

// Add recent mentions
const existingUsernames = new Set(participants.map((p) => p.username.toLowerCase()))
Expand All @@ -191,6 +172,9 @@ const mentionCommand: CommandSpec = {
}

// Register the command
registerCommand("mention", mentionCommand)
registerCommand("mention", mentionCommand, {
icon: "@",
description: "Mention a GitHub user",
})

export { mentionCommand, makeMentionTile }
5 changes: 4 additions & 1 deletion src/content/commands/mermaid/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ const mermaidCommand: CommandSpec = {
}

// Register the command
registerCommand("mermaid", mermaidCommand)
registerCommand("mermaid", mermaidCommand, {
icon: "📊",
description: "Create diagrams and flowcharts",
})

export { mermaidCommand, makeDiagramTile, DIAGRAM_TEMPLATES }
5 changes: 4 additions & 1 deletion src/content/commands/now/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,9 @@ const nowCommand: CommandSpec = {
}

// Register the command
registerCommand("now", nowCommand)
registerCommand("now", nowCommand, {
icon: "🕐",
description: "Insert current date and time",
})

export { nowCommand, DATE_OPTIONS, makeDateTile, getRelativeTime }
14 changes: 13 additions & 1 deletion src/content/commands/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/

import { describe, it, expect, beforeEach, vi } from "vitest"
import { registerCommand, getCommand, listCommands } from "./registry.ts"
import { registerCommand, getCommand, getCommandMetadata, listCommands } from "./registry.ts"
import type { CommandSpec } from "./registry.ts"

describe("command registry", () => {
Expand All @@ -22,6 +22,18 @@ describe("command registry", () => {
const cmd = getCommand("test")
expect(cmd).toBe(mockCommand)
})

it("registers command metadata", () => {
registerCommand("test-meta", mockCommand, {
icon: "🧪",
description: "test metadata",
})

expect(getCommandMetadata("test-meta")).toEqual({
icon: "🧪",
description: "test metadata",
})
})
})

describe("getCommand", () => {
Expand Down
16 changes: 15 additions & 1 deletion src/content/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export type SuggestionsResult = {
items: string[]
}

export type CommandMetadata = {
icon: string
description: string
}

export type CommandSpec = {
preflight: () => Promise<PreflightResult>
getEmptyState: () => Promise<EmptyStateResult>
Expand All @@ -54,9 +59,13 @@ export type CommandSpec = {
}

const commandRegistry: Record<string, CommandSpec> = {}
const commandMetadataRegistry: Partial<Record<string, CommandMetadata>> = {}

export function registerCommand(name: string, spec: CommandSpec): void {
export function registerCommand(name: string, spec: CommandSpec, metadata?: CommandMetadata): void {
commandRegistry[name] = spec
if (metadata) {
commandMetadataRegistry[name] = metadata
}
}

export function getCommand(name: string): CommandSpec | null {
Expand All @@ -66,6 +75,11 @@ export function getCommand(name: string): CommandSpec | null {
return commandRegistry[name] || null
}

export function getCommandMetadata(name: string): CommandMetadata | null {
if (name === null || name === undefined) return null
return commandMetadataRegistry[name] || null
}

export function listCommands(): string[] {
return Object.keys(commandRegistry).sort()
}