diff --git a/README.md b/README.md index 9d5dfaf..1799613 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,18 @@ The Activity Bar icon ![image](/icons/creeper_icon.png) will open the Minecraft ##### Minecraft Command Shortcuts Add shortcuts for your favorite Minecraft commands. +A project can also provide shortcuts by checking a `minecraft-debugger.json` file into the root of the workspace folder. They are shown above your own shortcuts and update when the file changes. Each entry is either a command string or an object with a `command` and an optional `label`. Use `commandShortcutFiles` to load more shortcuts from other files in the folder. Entries are paths or globs relative to `minecraft-debugger.json`. A `.json` file holds an array of shortcuts (or an object with `commandShortcuts`); any other file, such as `.mcfunction` or `.txt`, holds one command per line, ignoring blank lines and lines starting with `#`. Shortcuts from files are listed after the inline ones. The file is only loaded in trusted workspaces. + +```json +{ + "commandShortcuts": [ + "/time set day", + { "label": "Clear weather", "command": "/weather clear" } + ], + "commandShortcutFiles": ["tools/dev.mcfunction", "shortcuts/*.json"] +} +``` + ##### Script Profiler After setting a local path for saving captures, use `Start Profiler` and `Stop Profiler` to create performance captures of your actively running add-on. diff --git a/package.json b/package.json index c361f69..af27bdb 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,19 @@ "onCommand:extension.minecraft-js.getPort" ], "main": "./dist/extension.js", + "capabilities": { + "untrustedWorkspaces": { + "supported": "limited", + "description": "Project settings from minecraft-debugger.json are only loaded in trusted workspaces." + } + }, "contributes": { + "jsonValidation": [ + { + "fileMatch": "minecraft-debugger.json", + "url": "./schemas/minecraft-debugger.schema.json" + } + ], "viewsContainers": { "activitybar": [ { diff --git a/schemas/minecraft-debugger.schema.json b/schemas/minecraft-debugger.schema.json new file mode 100644 index 0000000..52accf3 --- /dev/null +++ b/schemas/minecraft-debugger.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Minecraft Debugger project settings", + "type": "object", + "properties": { + "commandShortcutFiles": { + "type": "array", + "description": "Paths or globs, relative to this file's folder, of files holding more command shortcuts. A .json file holds an array of shortcuts or an object with commandShortcuts; any other file holds one command per line, ignoring blank lines and lines starting with #.", + "items": { "type": "string" } + }, + "commandShortcuts": { + "type": "array", + "description": "Minecraft commands shown as shortcuts in the Minecraft Debugger home panel.", + "items": { + "oneOf": [ + { "type": "string", "description": "The command to run." }, + { + "type": "object", + "properties": { + "command": { "type": "string", "description": "The command to run." }, + "label": { "type": "string", "description": "Text shown instead of the command." } + }, + "required": ["command"] + } + ] + } + } + } +} diff --git a/src/extension.ts b/src/extension.ts index 21c43c5..fbda2cf 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,6 +11,7 @@ import { MinecraftDiagnosticsPanel } from './panels/minecraft-diagnostics'; import { ServerDebugAdapterFactory } from './server-debug-adapter-factory'; import { StatsProvider } from './stats/stats-provider'; import { ReplayStatsProvider } from './stats/replay-stats-provider'; +import { ProjectSettingsLoader } from './project-settings'; // called when extension is activated // @@ -18,8 +19,11 @@ export function activate(context: vscode.ExtensionContext): void { const liveStatsProvider = new StatsProvider('Live', 'minecraftDiagnosticsLive'); const eventEmitter: EventEmitter = new EventEmitter(); + const projectSettings = new ProjectSettingsLoader(); + context.subscriptions.push(projectSettings); + // home view - const homeViewProvider = new HomeViewProvider(context.extensionUri, eventEmitter); + const homeViewProvider = new HomeViewProvider(context.extensionUri, eventEmitter, projectSettings); context.subscriptions.push(vscode.window.registerWebviewViewProvider(HomeViewProvider.viewType, homeViewProvider)); // register a configuration provider for the 'minecraft-js' debug type diff --git a/src/panels/home-view-provider.ts b/src/panels/home-view-provider.ts index 137f334..993c4ae 100644 --- a/src/panels/home-view-provider.ts +++ b/src/panels/home-view-provider.ts @@ -7,17 +7,21 @@ import { getNonce } from '../utilities/getNonce'; import { getUri } from '../utilities/getUri'; import { EventEmitter } from 'stream'; import { MinecraftCapabilities } from '../session'; +import { ProjectSettingsLoader } from '../project-settings'; export class HomeViewProvider implements vscode.WebviewViewProvider { public static readonly viewType = 'minecraft-debugger-home-panel'; private readonly _extensionUri: vscode.Uri; private _eventEmitter: EventEmitter; + private _projectSettings: ProjectSettingsLoader; private _view?: vscode.WebviewView; - constructor(extensionUri: vscode.Uri, eventEmitter: EventEmitter) { + constructor(extensionUri: vscode.Uri, eventEmitter: EventEmitter, projectSettings: ProjectSettingsLoader) { this._extensionUri = extensionUri; this._eventEmitter = eventEmitter; + this._projectSettings = projectSettings; + this._projectSettings.onDidChange(() => this._postProjectSettings()); } public setDebuggerStatus( @@ -34,6 +38,13 @@ export class HomeViewProvider implements vscode.WebviewViewProvider { }); } + private _postProjectSettings() { + this._view?.webview.postMessage({ + type: 'project-settings', + commandShortcuts: this._projectSettings.settings.commandShortcuts, + }); + } + private _requestDebuggerStatus() { this._eventEmitter.emit('request-debugger-status'); } @@ -93,6 +104,7 @@ export class HomeViewProvider implements vscode.WebviewViewProvider { } case 'request-debugger-status': { this._requestDebuggerStatus(); + this._postProjectSettings(); break; } case 'browse-captures-base-path': { diff --git a/src/project-settings.test.ts b/src/project-settings.test.ts new file mode 100644 index 0000000..b9d2511 --- /dev/null +++ b/src/project-settings.test.ts @@ -0,0 +1,74 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +import { describe, it, expect, vi } from 'vitest'; +import { parseCommandShortcutFiles, parseCommandText, parseProjectSettings } from './project-settings'; + +vi.mock('vscode'); + +describe('parseProjectSettings', () => { + it('parses string and object entries', () => { + const result = parseProjectSettings({ + commandShortcuts: [ + '/time set day', + { label: 'Clear weather', command: '/weather clear' }, + { command: 'reload' }, + ], + }); + expect(result.commandShortcuts).toEqual([ + { command: '/time set day' }, + { label: 'Clear weather', command: '/weather clear' }, + { command: 'reload' }, + ]); + }); + + it('skips invalid entries', () => { + const result = parseProjectSettings({ + commandShortcuts: [1, null, '', ' ', {}, { label: 'x' }, { command: 5 }, ['a'], 'ok', { command: 'c', label: 3 }], + }); + expect(result.commandShortcuts).toEqual([{ command: 'ok' }, { command: 'c' }]); + }); + + it('returns empty for non-object roots, missing key, or non-array value', () => { + for (const input of [null, undefined, 'x', 5, [], {}, { commandShortcuts: 'x' }, { other: [] }]) { + expect(parseProjectSettings(input)).toEqual({ commandShortcuts: [] }); + } + }); + + it('accepts an array root', () => { + expect(parseProjectSettings(['a', { command: 'b', label: 'B' }, 3])).toEqual({ + commandShortcuts: [{ command: 'a' }, { label: 'B', command: 'b' }], + }); + }); + + it('ignores unknown keys', () => { + expect(parseProjectSettings({ future: true, commandShortcuts: ['a'] })).toEqual({ + commandShortcuts: [{ command: 'a' }], + }); + }); +}); + +describe('parseCommandShortcutFiles', () => { + it('keeps non-empty strings only', () => { + expect(parseCommandShortcutFiles({ commandShortcutFiles: ['a.json', '', 4, 'b/*.txt'] })).toEqual([ + 'a.json', + 'b/*.txt', + ]); + }); + + it('returns empty for missing or invalid values', () => { + for (const input of [null, undefined, [], {}, { commandShortcutFiles: 'a.json' }]) { + expect(parseCommandShortcutFiles(input)).toEqual([]); + } + }); +}); + +describe('parseCommandText', () => { + it('reads one command per line, skipping blanks and comments', () => { + const text = '# comment\r\n time set day \r\n\r\n/weather clear\n # indented comment\nsay hi\n'; + expect(parseCommandText(text)).toEqual([ + { command: 'time set day' }, + { command: '/weather clear' }, + { command: 'say hi' }, + ]); + }); +}); diff --git a/src/project-settings.ts b/src/project-settings.ts new file mode 100644 index 0000000..2b1ff12 --- /dev/null +++ b/src/project-settings.ts @@ -0,0 +1,164 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +import * as vscode from 'vscode'; +import * as path from 'path'; +import { EventEmitter } from 'stream'; + +export const PROJECT_SETTINGS_FILE_NAME = 'minecraft-debugger.json'; + +export interface CommandShortcut { + label?: string; + command: string; +} + +export interface ProjectSettings { + commandShortcuts: CommandShortcut[]; +} + +export function parseProjectSettings(json: unknown): ProjectSettings { + const commandShortcuts: CommandShortcut[] = []; + const entries = Array.isArray(json) ? json : (json as { commandShortcuts?: unknown } | null)?.commandShortcuts; + if (Array.isArray(entries)) { + for (const entry of entries) { + if (typeof entry === 'string' && entry.trim() !== '') { + commandShortcuts.push({ command: entry }); + } else if (typeof entry === 'object' && entry !== null) { + const { command, label } = entry as { command?: unknown; label?: unknown }; + if (typeof command === 'string' && command.trim() !== '') { + commandShortcuts.push(typeof label === 'string' && label !== '' ? { label, command } : { command }); + } + } + } + } + return { commandShortcuts }; +} + +export function parseCommandShortcutFiles(json: unknown): string[] { + const files = (json as { commandShortcutFiles?: unknown } | null)?.commandShortcutFiles; + return Array.isArray(files) ? files.filter((f): f is string => typeof f === 'string' && f.trim() !== '') : []; +} + +// one command per line, blank lines and '#' comments are skipped (mcfunction style) +export function parseCommandText(text: string): CommandShortcut[] { + return text + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line !== '' && !line.startsWith('#')) + .map(command => ({ command })); +} + +export class ProjectSettingsLoader implements vscode.Disposable { + private _settings: ProjectSettings = { commandShortcuts: [] }; + private _eventEmitter = new EventEmitter(); + private _disposables: vscode.Disposable[] = []; + private _watchers: vscode.Disposable[] = []; + private _patternWatchers: vscode.Disposable[] = []; + private _generation = 0; + + constructor() { + this._disposables.push( + vscode.workspace.onDidChangeWorkspaceFolders(() => this._watchAndReload()), + vscode.workspace.onDidGrantWorkspaceTrust(() => this._reload()) + ); + this._watchAndReload(); + } + + public get settings(): ProjectSettings { + return this._settings; + } + + public onDidChange(listener: (settings: ProjectSettings) => void): void { + this._eventEmitter.on('change', listener); + } + + public dispose(): void { + this._generation++; + this._watchers.forEach(w => w.dispose()); + this._patternWatchers.forEach(w => w.dispose()); + this._disposables.forEach(d => d.dispose()); + this._eventEmitter.removeAllListeners(); + } + + private _watch(folder: vscode.WorkspaceFolder, pattern: string): vscode.Disposable { + const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(folder, pattern)); + watcher.onDidCreate(() => this._reload()); + watcher.onDidChange(() => this._reload()); + watcher.onDidDelete(() => this._reload()); + return watcher; + } + + private _watchAndReload() { + this._watchers.forEach(w => w.dispose()); + this._watchers = (vscode.workspace.workspaceFolders ?? []).map(folder => + this._watch(folder, PROJECT_SETTINGS_FILE_NAME) + ); + this._reload(); + } + + private async _readText(uri: vscode.Uri): Promise { + try { + return Buffer.from(await vscode.workspace.fs.readFile(uri)).toString('utf8'); + } catch { + return undefined; + } + } + + private _parseFile(fsPath: string, text: string): CommandShortcut[] { + if (path.extname(fsPath).toLowerCase() !== '.json') { + return parseCommandText(text); + } + try { + return parseProjectSettings(JSON.parse(text)).commandShortcuts; + } catch { + vscode.window.showWarningMessage(`Invalid JSON in ${fsPath}, ignoring it.`); + return []; + } + } + + private async _reload() { + const generation = ++this._generation; + const commandShortcuts: CommandShortcut[] = []; + const patternWatchers: vscode.Disposable[] = []; + const seen = new Set(); + if (vscode.workspace.isTrusted) { + for (const folder of vscode.workspace.workspaceFolders ?? []) { + const mainPath = path.join(folder.uri.fsPath, PROJECT_SETTINGS_FILE_NAME); + seen.add(mainPath); + const text = await this._readText(vscode.Uri.file(mainPath)); + if (text === undefined) { + continue; // no settings file in this folder + } + let json: unknown; + try { + json = JSON.parse(text); + } catch { + vscode.window.showWarningMessage(`Invalid JSON in ${mainPath}, ignoring project settings.`); + continue; + } + commandShortcuts.push(...parseProjectSettings(json).commandShortcuts); + for (const pattern of parseCommandShortcutFiles(json)) { + patternWatchers.push(this._watch(folder, pattern)); + const uris = await vscode.workspace.findFiles(new vscode.RelativePattern(folder, pattern)); + for (const fsPath of uris.map(uri => uri.fsPath).sort()) { + if (seen.has(fsPath)) { + continue; + } + seen.add(fsPath); + const fileText = await this._readText(vscode.Uri.file(fsPath)); + if (fileText !== undefined) { + commandShortcuts.push(...this._parseFile(fsPath, fileText)); + } + } + } + } + } + if (generation !== this._generation) { + patternWatchers.forEach(w => w.dispose()); + return; + } + this._patternWatchers.forEach(w => w.dispose()); + this._patternWatchers = patternWatchers; + this._settings = { commandShortcuts }; + this._eventEmitter.emit('change', this._settings); + } +} diff --git a/webview-ui/src/home_panel/App.tsx b/webview-ui/src/home_panel/App.tsx index efee20d..e6cd072 100644 --- a/webview-ui/src/home_panel/App.tsx +++ b/webview-ui/src/home_panel/App.tsx @@ -1,7 +1,7 @@ // Copyright (C) Microsoft Corporation. All rights reserved. import { useEffect, useState } from 'react'; -import CommandSection from './controls/CommandSection'; +import CommandSection, { ProjectCommand } from './controls/CommandSection'; import { CommandButton, CommandHandlers, getCommandHandlers } from './handlers/CommandHandlers'; import GeneralSection from './controls/GeneralSection'; import ProfilerSection from './controls/ProfilerSection'; @@ -42,6 +42,7 @@ const App = () => { const [debuggerListening, setDebuggerListening] = useState(false); const [supportsCommands, setSupportsCommands] = useState(false); const [supportsProfiler, setSupportsProfiler] = useState(false); + const [projectCommands, setProjectCommands] = useState([]); const { commandButtons, setCommandButtons, onAddCommand, onDeleteCommand, onEditCommand }: CommandHandlers = getCommandHandlers(); @@ -108,6 +109,8 @@ const App = () => { setDebuggerListening(message.isListening); setSupportsCommands(message.supportsCommands); setSupportsProfiler(message.supportsProfiler); + } else if (message.type === 'project-settings') { + setProjectCommands(message.commandShortcuts); } }; @@ -130,6 +133,7 @@ const App = () => { void; onEditCommand: (id: string, event: React.ChangeEvent) => void; @@ -17,6 +23,7 @@ interface CommandSectionProps { const CommandSection: React.FC = ({ debuggerConnected, supportsCommands, + projectCommands, commandButtons, onAddCommand, onEditCommand, @@ -26,6 +33,19 @@ const CommandSection: React.FC = ({ return (

Minecraft Command Shortcuts

+ {projectCommands.length > 0 &&

From project (minecraft-debugger.json)

} + {projectCommands.map((projectCommand, index) => ( +
+ {projectCommand.label ?? projectCommand.command} + onRunCommand(projectCommand.command)} + disabled={!debuggerConnected || !supportsCommands} + > + Run + +
+ ))} Add Command Shortcut