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
3 changes: 3 additions & 0 deletions .vscodeignore
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
.vscode/**
.vscode-test/**
build/**
src/**
.gitignore
.mcdev.json
**/tsconfig.json
**/*.map
**/*.ts
out/**/*.test.js
node_modules/**
!node_modules/jsonc-parser/**
!node_modules/fflate/**
!node_modules/@vscode/**
!bin/**
webview/**
Expand Down
Binary file modified bin/native/windows/x64/mcdk.exe
Binary file not shown.
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -342,10 +342,11 @@
"native:build": "cmake --build native/tracy-bridge/build/x64-msvc-release",
"native:test": "ctest --test-dir native/tracy-bridge/build/x64-msvc-release --output-on-failure",
"native:install": "cmake --install native/tracy-bridge/build/x64-msvc-release",
"test:host-bridge": "npm run compile && node --test out/hostBridge/protocol.test.js out/hostBridge/uiDebugger.test.js out/hostBridge/latestOperationQueue.test.js out/hostBridge/pythonProfiler.test.js out/hostBridge/pythonProfilerReport.test.js out/hostBridge/pythonMemoryProfiler.test.js out/hostBridge/pythonMemoryProfilerReport.test.js out/hostBridge/nativeProfilerPortDiscovery.test.js out/hostBridge/nativeProfilerCapture.test.js out/hostBridge/nativeProfilerReport.test.js out/utils/mcdevDirectory.test.js",
"test:host-bridge": "npm run compile && node --test out/activationGate.test.js out/hostBridge/protocol.test.js out/hostBridge/uiDebugger.test.js out/hostBridge/latestOperationQueue.test.js out/hostBridge/pythonProfiler.test.js out/hostBridge/pythonProfilerReport.test.js out/hostBridge/pythonMemoryProfiler.test.js out/hostBridge/pythonMemoryProfilerReport.test.js out/hostBridge/nativeProfilerPortDiscovery.test.js out/hostBridge/nativeProfilerCapture.test.js out/hostBridge/nativeProfilerReport.test.js out/projectOperations/documentGate.test.js out/projectOperations/previewDocuments.test.js out/projectOperations/previewStore.test.js out/projectOperations/protocol.test.js out/projectOperations/targetResolver.test.js out/projectOperations/webviewProtocol.test.js out/skins/customSkinLibrary.test.js out/skins/vanillaSkins.test.js out/utils/mcdevDirectory.test.js",
"lint": "eslint src --ext ts"
},
"dependencies": {
"fflate": "^0.8.3",
"jsonc-parser": "^3.2.0"
},
"devDependencies": {
Expand Down
28 changes: 28 additions & 0 deletions src/activationGate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import * as assert from 'node:assert/strict';
import { test } from 'node:test';
import { resolveExtensionFeatureGate } from './activationGate';

test('shows project operations for a workspace not recognized by the legacy Add-on heuristic', () => {
assert.deepEqual(resolveExtensionFeatureGate(true, false, false), {
gameFeaturesEnabled: false,
sidebarVisible: true
});
});

test('preserves the existing Add-on and explicit game-feature gates', () => {
assert.deepEqual(resolveExtensionFeatureGate(true, false, true), {
gameFeaturesEnabled: true,
sidebarVisible: true
});
assert.deepEqual(resolveExtensionFeatureGate(false, true, false), {
gameFeaturesEnabled: true,
sidebarVisible: true
});
});

test('keeps the sidebar reachable without a workspace so it can show the actionable prompt', () => {
assert.deepEqual(resolveExtensionFeatureGate(false, false, false), {
gameFeaturesEnabled: false,
sidebarVisible: true
});
});
28 changes: 28 additions & 0 deletions src/activationGate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export interface ExtensionFeatureGate {
/** Existing game launch/debug affordances and keybindings. */
readonly gameFeaturesEnabled: boolean;
/** Configuration/project-operations sidebar registration and visibility. */
readonly sidebarVisible: boolean;
}

/**
* Keep the legacy game-feature gate independent from project inspection.
*
* The C++ project inspector supports more layouts than the old lightweight
* Add-on heuristic (for example script packs and gameplay maps), and the
* sidebar also owns the actionable prompt shown before a workspace is opened.
* It therefore remains visible independently of the game-feature gate.
*/
export function resolveExtensionFeatureGate(
_hasWorkspace: boolean,
userEnabled: boolean,
legacyAddonDetected: boolean
): ExtensionFeatureGate {
const gameFeaturesEnabled = userEnabled || legacyAddonDetected;
return {
gameFeaturesEnabled,
// The sidebar itself contains the actionable no-workspace state and must
// remain reachable even before a folder is opened.
sidebarVisible: true
};
}
78 changes: 70 additions & 8 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import { dynamicLibraryManager } from './native/dynamicLibraryManager';
import { GameDebuggerPanel, HostBridgeManager, PreparedHostBridgeLaunch } from './hostBridge';
import { shutdownAllNativeProfilerCaptures } from './hostBridge/nativeProfilerCapture';
import { McdevConfigStore } from './config';
import { ProjectOperationsService } from './projectOperations';
import {
MCDK_PREVIEW_SCHEME,
McdevPreviewDocumentProvider
} from './projectOperations/previewDocumentProvider';
import { resolveExtensionFeatureGate } from './activationGate';
import {
McDevToolsDebugConfigurationProvider,
McdbgDebugConfigurationProvider,
Expand All @@ -17,13 +23,31 @@ let extensionContext: vscode.ExtensionContext;
let mcdevConfigStore: McdevConfigStore | undefined;
let hostBridgeManager: HostBridgeManager | undefined;
let gameDebuggerPanel: GameDebuggerPanel | undefined;
let projectOperationsService: ProjectOperationsService | undefined;
let previewDocumentProvider: McdevPreviewDocumentProvider | undefined;

export async function activate(context: vscode.ExtensionContext): Promise<void> {
console.log('Minecraft ModPC Debug 插件已激活');
extensionContext = context;

mcdevConfigStore = new McdevConfigStore();
context.subscriptions.push(mcdevConfigStore);
projectOperationsService = new ProjectOperationsService({
executablePath: path.join(
context.extensionPath,
'bin',
'native',
'windows',
'x64',
'mcdk.exe'
)
});
context.subscriptions.push(projectOperationsService);
previewDocumentProvider = new McdevPreviewDocumentProvider(projectOperationsService);
context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider(
MCDK_PREVIEW_SCHEME,
previewDocumentProvider
));
hostBridgeManager = await HostBridgeManager.create(context, mcdevConfigStore);
context.subscriptions.push(hostBridgeManager);
gameDebuggerPanel = new GameDebuggerPanel(context.extensionUri, hostBridgeManager);
Expand All @@ -37,15 +61,33 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
const config = vscode.workspace.getConfiguration('mcdev-tools');
const userEnabled = config.get<boolean>('enable', false);
const isAddon = workspaceFolder ? isMinecraftAddonWorkspace(workspaceFolder) : false;
const pluginEnabled = userEnabled || isAddon;
const featureGate = resolveExtensionFeatureGate(
workspaceFolder !== undefined,
userEnabled,
isAddon
);

// 设置上下文
vscode.commands.executeCommand('setContext', 'mcdev-tools:enabled', pluginEnabled);
vscode.commands.executeCommand('setContext', 'mcdev-tools:showSidebar', pluginEnabled);
// Preserve the legacy game/debug feature gate while allowing mcdk's broader
// project inspector to decide whether every open workspace is a valid project.
vscode.commands.executeCommand(
'setContext',
'mcdev-tools:enabled',
featureGate.gameFeaturesEnabled
);
vscode.commands.executeCommand(
'setContext',
'mcdev-tools:showSidebar',
featureGate.sidebarVisible
);

// 只有启用时才注册侧边栏提供器
if (pluginEnabled) {
const sidebarProvider = new McDevToolsSidebarProvider(context.extensionUri, mcdevConfigStore);
if (featureGate.sidebarVisible) {
const sidebarProvider = new McDevToolsSidebarProvider(
context.extensionUri,
mcdevConfigStore,
projectOperationsService,
previewDocumentProvider,
context.workspaceState
);
const sidebarDisp = vscode.window.registerWebviewViewProvider('mcdev-tools.sidebar', sidebarProvider);
context.subscriptions.push(sidebarProvider, sidebarDisp);
console.log('McDevToolsSidebarProvider 已注册');
Expand Down Expand Up @@ -182,7 +224,19 @@ async function showSidebarPanel(context: vscode.ExtensionContext): Promise<void>
if (!mcdevConfigStore) {
throw new Error('.mcdev.json configuration store is not initialized');
}
const provider = new McDevToolsSidebarProvider(context.extensionUri, mcdevConfigStore);
if (!projectOperationsService) {
throw new Error('Project operations service is not initialized');
}
if (!previewDocumentProvider) {
throw new Error('Project preview document provider is not initialized');
}
const provider = new McDevToolsSidebarProvider(
context.extensionUri,
mcdevConfigStore,
projectOperationsService,
previewDocumentProvider,
context.workspaceState
);
provider.resolveWebviewPanel(panel);
context.subscriptions.push(provider);
}
Expand Down Expand Up @@ -267,9 +321,12 @@ export async function deactivate(): Promise<void> {
const panel = gameDebuggerPanel;
const bridgeManager = hostBridgeManager;
const configStore = mcdevConfigStore;
const projectOperations = projectOperationsService;
gameDebuggerPanel = undefined;
hostBridgeManager = undefined;
mcdevConfigStore = undefined;
projectOperationsService = undefined;
previewDocumentProvider = undefined;

try {
ptvsd.cleanupAllSessions();
Expand All @@ -290,6 +347,11 @@ export async function deactivate(): Promise<void> {
} catch (error) {
console.error('Failed to dispose the .mcdev.json configuration store', error);
}
try {
projectOperations?.dispose();
} catch (error) {
console.error('Failed to dispose the project operations service', error);
}
} finally {
try {
await shutdownAllNativeProfilerCaptures(extensionContext.extensionPath);
Expand Down
54 changes: 54 additions & 0 deletions src/projectOperations/documentGate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as assert from 'node:assert/strict';
import * as path from 'node:path';
import { test } from 'node:test';
import {
dirtyProjectDocumentPaths,
dirtyWorkspaceDocumentPaths,
isContainedPath
} from './documentGate';

test('finds every dirty file document in the first workspace root', () => {
const root = path.resolve('test-data', 'work', 'addon');
const expected = [
path.join(root, '.mcdev.json'),
path.join(root, 'BP', 'manifest.json'),
path.join(root, 'scripts', 'main.py')
].sort((left, right) => left.localeCompare(right));
assert.deepEqual(dirtyWorkspaceDocumentPaths(root, [
{ scheme: 'file', fsPath: path.join(root, 'BP', 'manifest.json'), isDirty: true },
{ scheme: 'file', fsPath: path.join(root, 'scripts', 'main.py'), isDirty: true },
{ scheme: 'file', fsPath: path.join(root, '.mcdev.json'), isDirty: true },
{ scheme: 'file', fsPath: path.join(root, 'RP', 'manifest.json'), isDirty: false }
]), expected);
});

test('ignores other workspace roots, sibling paths, untitled documents, and clean files', () => {
const root = path.resolve('test-data', 'work', 'addon');
assert.deepEqual(dirtyWorkspaceDocumentPaths(root, [
{ scheme: 'file', fsPath: path.join(`${root}-other`, 'manifest.json'), isDirty: true },
{ scheme: 'file', fsPath: path.resolve('test-data', 'second-root', 'manifest.json'), isDirty: true },
{ scheme: 'untitled', fsPath: path.join(root, 'new.py'), isDirty: true },
{ scheme: 'file', fsPath: path.join(root, 'manifest.json'), isDirty: false }
]), []);
});

test('path containment does not accept the workspace directory itself or a prefix sibling', () => {
const root = path.resolve('test-data', 'work', 'addon');
assert.equal(isContainedPath(root, root), false);
assert.equal(isContainedPath(root, path.join(root, 'manifest.json')), true);
assert.equal(isContainedPath(root, path.join(`${root}-old`, 'manifest.json')), false);
});

test('also gates dirty documents in a selected external Mod target', () => {
const workspace = path.resolve('test-data', 'work', 'addon');
const external = path.resolve('test-data', 'external', 'selected-mod');
const externalFile = path.join(external, 'scripts', 'main.py');
assert.deepEqual(dirtyProjectDocumentPaths(workspace, external, [
{ scheme: 'file', fsPath: externalFile, isDirty: true },
{
scheme: 'file',
fsPath: path.resolve('test-data', 'external', 'other-mod', 'main.py'),
isDirty: true
}
]), [externalFile]);
});
51 changes: 51 additions & 0 deletions src/projectOperations/documentGate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import * as path from 'path';

export interface TextDocumentState {
readonly scheme: string;
readonly fsPath: string;
readonly isDirty: boolean;
}

/** Returns dirty, on-disk text documents lexically contained by the first workspace root. */
export function dirtyWorkspaceDocumentPaths(
workspaceRoot: string,
documents: readonly TextDocumentState[]
): string[] {
return dirtyDocumentPathsInRoots([workspaceRoot], documents);
}

/** Includes a selected target even when included_mod_dirs points outside the workspace. */
export function dirtyProjectDocumentPaths(
workspaceRoot: string,
targetRoot: string,
documents: readonly TextDocumentState[]
): string[] {
return dirtyDocumentPathsInRoots([workspaceRoot, targetRoot], documents);
}

export function dirtyDocumentPathsInRoots(
roots: readonly string[],
documents: readonly TextDocumentState[]
): string[] {
const resolvedRoots = [...new Set(roots.map(root => path.resolve(root)))];
const dirtyPaths = new Set<string>();
for (const document of documents) {
if (document.scheme !== 'file' || !document.isDirty || !document.fsPath) {
continue;
}
const documentPath = path.resolve(document.fsPath);
if (!resolvedRoots.some(root => isContainedPath(root, documentPath))) {
continue;
}
dirtyPaths.add(documentPath);
}
return [...dirtyPaths].sort((left, right) => left.localeCompare(right));
}

export function isContainedPath(root: string, candidate: string): boolean {
const relative = path.relative(root, candidate);
return relative !== ''
&& relative !== '..'
&& !relative.startsWith(`..${path.sep}`)
&& !path.isAbsolute(relative);
}
8 changes: 8 additions & 0 deletions src/projectOperations/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export * from './documentGate';
export * from './previewStore';
export * from './previewDocuments';
export * from './protocol';
export * from './service';
export * from './targetResolver';
export * from './types';
export * from './webviewProtocol';
36 changes: 36 additions & 0 deletions src/projectOperations/previewDocumentProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as vscode from 'vscode';
import { ProjectPreviewSession } from './previewStore';
import { ProjectOperationsService } from './service';
import {
MCDK_PREVIEW_SCHEME,
PreviewDocumentSide,
previewDocumentContent,
previewDocumentUriParts
} from './previewDocuments';

export { MCDK_PREVIEW_SCHEME } from './previewDocuments';

/** Read-only virtual documents backed only by the bounded in-memory preview store. */
export class McdevPreviewDocumentProvider implements vscode.TextDocumentContentProvider {
constructor(private readonly service: ProjectOperationsService) {}

public provideTextDocumentContent(uri: vscode.Uri): string {
const content = previewDocumentContent(uri, this.service);
if (content === undefined) {
throw vscode.FileSystemError.FileNotFound(uri);
}
return content;
}

public uriFor(
session: ProjectPreviewSession,
fileIndex: number,
side: PreviewDocumentSide
): vscode.Uri {
try {
return vscode.Uri.from(previewDocumentUriParts(session, fileIndex, side));
} catch {
throw vscode.FileSystemError.FileNotFound(`Preview file ${fileIndex}`);
}
}
}
Loading