Skip to content
Open
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
12 changes: 12 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
29 changes: 29 additions & 0 deletions schemas/minecraft-debugger.schema.json
Original file line number Diff line number Diff line change
@@ -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"]
}
]
}
}
}
}
6 changes: 5 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,19 @@ 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
//
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
Expand Down
14 changes: 13 additions & 1 deletion src/panels/home-view-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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');
}
Expand Down Expand Up @@ -93,6 +104,7 @@ export class HomeViewProvider implements vscode.WebviewViewProvider {
}
case 'request-debugger-status': {
this._requestDebuggerStatus();
this._postProjectSettings();
break;
}
case 'browse-captures-base-path': {
Expand Down
74 changes: 74 additions & 0 deletions src/project-settings.test.ts
Original file line number Diff line number Diff line change
@@ -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' },
]);
});
});
164 changes: 164 additions & 0 deletions src/project-settings.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> {
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<string>();
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);
}
}
Loading
Loading