From 477058b55dd1c118887c699b16bca2adff2c5d6d Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa <13642582+Fabrimat@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:13:38 +0200 Subject: [PATCH 1/2] feat: Load project settings from minecraft-debugger.json Projects can now ship a minecraft-debugger.json at the workspace root. The first supported setting is commandShortcuts, which are shown in the home panel above the user's own command shortcuts. The file is watched for changes and is only loaded in trusted workspaces. --- README.md | 11 ++ package.json | 12 +++ schemas/minecraft-debugger.schema.json | 24 +++++ src/extension.ts | 6 +- src/panels/home-view-provider.ts | 14 ++- src/project-settings.test.ts | 42 ++++++++ src/project-settings.ts | 100 ++++++++++++++++++ webview-ui/src/home_panel/App.tsx | 6 +- .../home_panel/controls/CommandSection.tsx | 20 ++++ 9 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 schemas/minecraft-debugger.schema.json create mode 100644 src/project-settings.test.ts create mode 100644 src/project-settings.ts diff --git a/README.md b/README.md index 9d5dfafb..2d03ba27 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,17 @@ 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`. The file is only loaded in trusted workspaces. + +```json +{ + "commandShortcuts": [ + "/time set day", + { "label": "Clear weather", "command": "/weather clear" } + ] +} +``` + ##### 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 c361f692..af27bdba 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 00000000..1a167e58 --- /dev/null +++ b/schemas/minecraft-debugger.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Minecraft Debugger project settings", + "type": "object", + "properties": { + "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 21c43c59..fbda2cff 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 137f334b..993c4aeb 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 00000000..e49114e1 --- /dev/null +++ b/src/project-settings.test.ts @@ -0,0 +1,42 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +import { describe, it, expect, vi } from 'vitest'; +import { 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('ignores unknown keys', () => { + expect(parseProjectSettings({ future: true, commandShortcuts: ['a'] })).toEqual({ + commandShortcuts: [{ command: 'a' }], + }); + }); +}); diff --git a/src/project-settings.ts b/src/project-settings.ts new file mode 100644 index 00000000..0cfe5515 --- /dev/null +++ b/src/project-settings.ts @@ -0,0 +1,100 @@ +// 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 = (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 class ProjectSettingsLoader implements vscode.Disposable { + private _settings: ProjectSettings = { commandShortcuts: [] }; + private _eventEmitter = new EventEmitter(); + private _disposables: vscode.Disposable[] = []; + private _watchers: vscode.Disposable[] = []; + + 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._watchers.forEach(w => w.dispose()); + this._disposables.forEach(d => d.dispose()); + this._eventEmitter.removeAllListeners(); + } + + private _watchAndReload() { + this._watchers.forEach(w => w.dispose()); + this._watchers = []; + for (const folder of vscode.workspace.workspaceFolders ?? []) { + const watcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(folder, PROJECT_SETTINGS_FILE_NAME) + ); + watcher.onDidCreate(() => this._reload()); + watcher.onDidChange(() => this._reload()); + watcher.onDidDelete(() => this._reload()); + this._watchers.push(watcher); + } + this._reload(); + } + + private async _reload() { + const commandShortcuts: CommandShortcut[] = []; + if (vscode.workspace.isTrusted) { + for (const folder of vscode.workspace.workspaceFolders ?? []) { + const uri = vscode.Uri.file(path.join(folder.uri.fsPath, PROJECT_SETTINGS_FILE_NAME)); + let text: string; + try { + text = Buffer.from(await vscode.workspace.fs.readFile(uri)).toString('utf8'); + } catch { + continue; // no settings file in this folder + } + try { + commandShortcuts.push(...parseProjectSettings(JSON.parse(text)).commandShortcuts); + } catch { + vscode.window.showWarningMessage(`Invalid JSON in ${uri.fsPath}, ignoring project settings.`); + } + } + } + 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 efee20de..e6cd0725 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 From 86c8c7b08a9a3f57d7a0d5ea784e7b84b9ca1045 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa <13642582+Fabrimat@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:23:50 +0200 Subject: [PATCH 2/2] feat: Support referencing command shortcut files from project settings minecraft-debugger.json can now list paths or globs in commandShortcutFiles. JSON files use the same shortcut format and other files are read as one command per line, skipping blank lines and # comments. Referenced files are watched and reloads no longer race each other. --- README.md | 5 +- schemas/minecraft-debugger.schema.json | 5 ++ src/project-settings.test.ts | 34 ++++++++- src/project-settings.ts | 100 ++++++++++++++++++++----- 4 files changed, 123 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 2d03ba27..1799613b 100644 --- a/README.md +++ b/README.md @@ -178,14 +178,15 @@ 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`. The file is only loaded in trusted workspaces. +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"] } ``` diff --git a/schemas/minecraft-debugger.schema.json b/schemas/minecraft-debugger.schema.json index 1a167e58..52accf38 100644 --- a/schemas/minecraft-debugger.schema.json +++ b/schemas/minecraft-debugger.schema.json @@ -3,6 +3,11 @@ "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.", diff --git a/src/project-settings.test.ts b/src/project-settings.test.ts index e49114e1..b9d25113 100644 --- a/src/project-settings.test.ts +++ b/src/project-settings.test.ts @@ -1,7 +1,7 @@ // Copyright (C) Microsoft Corporation. All rights reserved. import { describe, it, expect, vi } from 'vitest'; -import { parseProjectSettings } from './project-settings'; +import { parseCommandShortcutFiles, parseCommandText, parseProjectSettings } from './project-settings'; vi.mock('vscode'); @@ -34,9 +34,41 @@ describe('parseProjectSettings', () => { } }); + 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 index 0cfe5515..2b1ff122 100644 --- a/src/project-settings.ts +++ b/src/project-settings.ts @@ -17,7 +17,7 @@ export interface ProjectSettings { export function parseProjectSettings(json: unknown): ProjectSettings { const commandShortcuts: CommandShortcut[] = []; - const entries = (json as { commandShortcuts?: unknown } | null)?.commandShortcuts; + 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() !== '') { @@ -33,11 +33,27 @@ export function parseProjectSettings(json: unknown): ProjectSettings { 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( @@ -56,44 +72,92 @@ export class ProjectSettingsLoader implements vscode.Disposable { } 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 = []; - for (const folder of vscode.workspace.workspaceFolders ?? []) { - const watcher = vscode.workspace.createFileSystemWatcher( - new vscode.RelativePattern(folder, PROJECT_SETTINGS_FILE_NAME) - ); - watcher.onDidCreate(() => this._reload()); - watcher.onDidChange(() => this._reload()); - watcher.onDidDelete(() => this._reload()); - this._watchers.push(watcher); - } + 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 uri = vscode.Uri.file(path.join(folder.uri.fsPath, PROJECT_SETTINGS_FILE_NAME)); - let text: string; - try { - text = Buffer.from(await vscode.workspace.fs.readFile(uri)).toString('utf8'); - } catch { + 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 { - commandShortcuts.push(...parseProjectSettings(JSON.parse(text)).commandShortcuts); + json = JSON.parse(text); } catch { - vscode.window.showWarningMessage(`Invalid JSON in ${uri.fsPath}, ignoring project settings.`); + 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); }