Skip to content

Commit fd23e53

Browse files
committed
feat: implement dynamic list for available commands and themes on configuration change
1 parent 7d834eb commit fd23e53

4 files changed

Lines changed: 296 additions & 18 deletions

File tree

src/commandsProvider.ts

Lines changed: 129 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as vscode from 'vscode';
2+
import { execMageforge, getMagentoRoot } from './magento';
23

34
export interface MageforgeCommand {
45
id: string;
@@ -95,28 +96,142 @@ export const MAGEFORGE_COMMANDS: MageforgeCommand[] = [
9596
},
9697
];
9798

99+
/**
100+
* Query the installed MageForge CLI for the commands it actually exposes.
101+
* Returns the raw command names (e.g. `mageforge:theme:build`).
102+
*/
103+
export async function getAvailableMageforgeCommands(magentoRoot: string): Promise<string[]> {
104+
const output = await execMageforge(magentoRoot, 'list', ['--raw', 'mageforge']);
105+
return output
106+
.split('\n')
107+
.map((line) => stripAnsi(line).trim())
108+
.filter((line) => line.length > 0)
109+
.map((line) => line.split(/\s+/)[0]);
110+
}
111+
98112
export class CommandsProvider implements vscode.TreeDataProvider<CommandTreeItem> {
113+
private readonly _onDidChangeTreeData = new vscode.EventEmitter<CommandTreeItem | undefined>();
114+
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
115+
116+
private availableCommands: Set<string> | undefined;
117+
private loadError: string | undefined;
118+
private loadingPromise: Promise<void> | undefined;
119+
120+
refresh(): void {
121+
this.availableCommands = undefined;
122+
this.loadError = undefined;
123+
this.loadingPromise = undefined;
124+
this._onDidChangeTreeData.fire(undefined);
125+
}
126+
99127
getTreeItem(element: CommandTreeItem): vscode.TreeItem {
100128
return element;
101129
}
102130

103-
getChildren(): CommandTreeItem[] {
104-
return MAGEFORGE_COMMANDS.map((cmd) => new CommandTreeItem(cmd));
131+
async getChildren(element?: CommandTreeItem): Promise<CommandTreeItem[]> {
132+
if (element) {
133+
return [];
134+
}
135+
136+
await this.ensureCommandsLoaded();
137+
138+
if (this.loadError) {
139+
return [new CommandTreeItem(undefined, this.loadError)];
140+
}
141+
142+
const commands = this.availableCommands
143+
? MAGEFORGE_COMMANDS.filter((cmd) => this.availableCommands!.has(cmd.cliCommand))
144+
: MAGEFORGE_COMMANDS;
145+
146+
return commands.map((cmd) => new CommandTreeItem(cmd));
147+
}
148+
149+
private async ensureCommandsLoaded(): Promise<void> {
150+
if (this.availableCommands !== undefined || this.loadError !== undefined) {
151+
return;
152+
}
153+
154+
if (this.loadingPromise) {
155+
await this.loadingPromise;
156+
return;
157+
}
158+
159+
this.loadingPromise = this.loadCommands().finally(() => {
160+
this.loadingPromise = undefined;
161+
});
162+
await this.loadingPromise;
163+
}
164+
165+
private async loadCommands(): Promise<void> {
166+
const root = getMagentoRoot();
167+
if (!root) {
168+
this.loadError = 'Open a Magento workspace to see available commands.';
169+
return;
170+
}
171+
172+
try {
173+
const available = await getAvailableMageforgeCommands(root);
174+
this.availableCommands = new Set(available);
175+
this.loadError = undefined;
176+
} catch (error) {
177+
const message = error instanceof Error ? error.message : String(error);
178+
this.availableCommands = undefined;
179+
this.loadError = formatLoadError(stripAnsi(message));
180+
}
105181
}
106182
}
107183

108184
export class CommandTreeItem extends vscode.TreeItem {
109-
constructor(public readonly mageforgeCommand: MageforgeCommand) {
110-
super(mageforgeCommand.label, vscode.TreeItemCollapsibleState.None);
111-
this.description = mageforgeCommand.description;
112-
this.tooltip = new vscode.MarkdownString(
113-
`**${mageforgeCommand.label}**\n\n\`${mageforgeCommand.description}\``,
114-
);
115-
this.iconPath = new vscode.ThemeIcon(mageforgeCommand.icon);
116-
this.command = {
117-
command: mageforgeCommand.id,
118-
title: mageforgeCommand.label,
119-
};
120-
this.contextValue = 'mageforgeCommand';
185+
constructor(
186+
public readonly mageforgeCommand: MageforgeCommand | undefined,
187+
label?: string,
188+
) {
189+
super(label ?? mageforgeCommand!.label, vscode.TreeItemCollapsibleState.None);
190+
191+
if (mageforgeCommand) {
192+
this.description = mageforgeCommand.description;
193+
this.tooltip = new vscode.MarkdownString(
194+
`**${mageforgeCommand.label}**\n\n\`${mageforgeCommand.description}\``,
195+
);
196+
this.iconPath = new vscode.ThemeIcon(mageforgeCommand.icon);
197+
this.command = {
198+
command: mageforgeCommand.id,
199+
title: mageforgeCommand.label,
200+
};
201+
this.contextValue = 'mageforgeCommand';
202+
} else {
203+
this.iconPath = new vscode.ThemeIcon('info');
204+
}
205+
}
206+
}
207+
208+
/** Strip ANSI escape sequences (colors, cursor movement) from console output. */
209+
function stripAnsi(text: string): string {
210+
return text.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '');
211+
}
212+
213+
/**
214+
* Convert a raw command failure into a user-friendly error message.
215+
* Long messages are truncated so they do not break the tree view layout.
216+
*/
217+
function formatLoadError(message: string): string {
218+
const normalized = message.toLowerCase();
219+
220+
if (
221+
normalized.includes('ddev') &&
222+
/not (running|started)|could not|failed|unable/i.test(message)
223+
) {
224+
return 'DDEV is not running. Start the project with `ddev start` and try again.';
225+
}
226+
if (normalized.includes('docker-compose') || normalized.includes('docker compose')) {
227+
return 'Docker Compose service unavailable. Check that containers are running.';
121228
}
229+
if (normalized.includes('lando')) {
230+
return 'Lando environment unavailable. Start the project with `lando start`.';
231+
}
232+
if (normalized.includes('command not found') || normalized.includes('no such file')) {
233+
return 'MageForge CLI not found. Run `composer require openforgeproject/mageforge`.';
234+
}
235+
236+
return message.length > 120 ? `${message.slice(0, 120)}…` : message;
122237
}

src/extension.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ export function activate(context: vscode.ExtensionContext) {
2828
vscode.window.createTreeView('mageforge.themes', { treeDataProvider: themesProvider }),
2929
);
3030

31+
// Refresh dynamic views when MageForge settings change.
32+
context.subscriptions.push(
33+
vscode.workspace.onDidChangeConfiguration((event) => {
34+
if (event.affectsConfiguration('mageforge')) {
35+
commandsProvider.refresh();
36+
themesProvider.refresh();
37+
}
38+
}),
39+
);
40+
3141
// Notify the user and open the changelog after the extension was updated.
3242
void showUpdateNotificationIfNeeded(context, changelogProvider);
3343

@@ -53,6 +63,9 @@ export function activate(context: vscode.ExtensionContext) {
5363
);
5464

5565
context.subscriptions.push(
66+
vscode.commands.registerCommand('mageforge.refreshCommands', () =>
67+
commandsProvider.refresh(),
68+
),
5669
vscode.commands.registerCommand('mageforge.refreshThemes', () => themesProvider.refresh()),
5770
vscode.commands.registerCommand(
5871
'mageforge.template.overrideFile',

src/test/extension.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ suite('Extension Integration Test Suite', () => {
2020

2121
assert.ok(mageforgeCommands.includes('mageforge.theme.build'));
2222
assert.ok(mageforgeCommands.includes('mageforge.theme.watch'));
23+
assert.ok(mageforgeCommands.includes('mageforge.refreshCommands'));
2324
assert.ok(mageforgeCommands.includes('mageforge.refreshThemes'));
2425
assert.ok(mageforgeCommands.includes('mageforge.showChangelog'));
2526
assert.ok(mageforgeCommands.includes('mageforge.updateMageforge'));
Lines changed: 153 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,31 @@
11
import * as assert from 'assert';
2-
import { CommandTreeItem, CommandsProvider, MAGEFORGE_COMMANDS } from '../../commandsProvider';
2+
import mockRequire = require('mock-require');
3+
import type { MageforgeCommand } from '../../commandsProvider';
4+
5+
type MockMagento = {
6+
getMagentoRoot: () => string | undefined;
7+
execMageforge: (root: string, command: string, args?: string[]) => Promise<string>;
8+
};
9+
10+
function loadCommandsProvider(magentoMock: MockMagento) {
11+
mockRequire('../../magento', magentoMock);
12+
return mockRequire.reRequire(
13+
'../../commandsProvider',
14+
) as typeof import('../../commandsProvider');
15+
}
316

417
suite('commandsProvider.ts unit tests', () => {
18+
teardown(() => {
19+
mockRequire.stop('../../magento');
20+
});
21+
522
suite('MAGEFORGE_COMMANDS', () => {
623
test('contains expected commands', () => {
24+
const { MAGEFORGE_COMMANDS } = loadCommandsProvider({
25+
getMagentoRoot: () => undefined,
26+
execMageforge: async () => '',
27+
});
28+
729
const ids = MAGEFORGE_COMMANDS.map((cmd) => cmd.id);
830
assert.ok(ids.includes('mageforge.theme.build'));
931
assert.ok(ids.includes('mageforge.theme.watch'));
@@ -12,28 +34,142 @@ suite('commandsProvider.ts unit tests', () => {
1234
});
1335

1436
test('theme commands accept themes', () => {
37+
const { MAGEFORGE_COMMANDS } = loadCommandsProvider({
38+
getMagentoRoot: () => undefined,
39+
execMageforge: async () => '',
40+
});
41+
1542
const themeCommands = MAGEFORGE_COMMANDS.filter((cmd) => cmd.acceptsThemes);
1643
const ids = themeCommands.map((cmd) => cmd.id);
1744
assert.deepStrictEqual(ids.sort(), ['mageforge.theme.build', 'mageforge.theme.watch']);
1845
});
1946

2047
test('watch command is marked as watch', () => {
48+
const { MAGEFORGE_COMMANDS } = loadCommandsProvider({
49+
getMagentoRoot: () => undefined,
50+
execMageforge: async () => '',
51+
});
52+
2153
const watch = MAGEFORGE_COMMANDS.find((cmd) => cmd.id === 'mageforge.theme.watch');
2254
assert.strictEqual(watch?.isWatch, true);
2355
});
2456
});
2557

58+
suite('getAvailableMageforgeCommands', () => {
59+
test('parses raw command list output', async () => {
60+
const { getAvailableMageforgeCommands } = loadCommandsProvider({
61+
getMagentoRoot: () => '/magento',
62+
execMageforge: async () =>
63+
'mageforge:theme:build Builds a theme\nmageforge:theme:list\nmageforge:system:version',
64+
});
65+
66+
const available = await getAvailableMageforgeCommands('/magento');
67+
68+
assert.deepStrictEqual(available.sort(), [
69+
'mageforge:system:version',
70+
'mageforge:theme:build',
71+
'mageforge:theme:list',
72+
]);
73+
});
74+
});
75+
2676
suite('CommandsProvider', () => {
27-
test('returns all commands as tree items', () => {
77+
test('shows only commands reported by the CLI', async () => {
78+
const { CommandsProvider, MAGEFORGE_COMMANDS } = loadCommandsProvider({
79+
getMagentoRoot: () => '/magento',
80+
execMageforge: async () =>
81+
'mageforge:theme:build\nmageforge:theme:watch\nmageforge:system:version',
82+
});
83+
84+
const provider = new CommandsProvider();
85+
const children = await provider.getChildren();
86+
87+
assert.strictEqual(children.length, 3);
88+
const ids = children.map((child) => child.mageforgeCommand?.id);
89+
assert.deepStrictEqual(ids.sort(), [
90+
'mageforge.system.version',
91+
'mageforge.theme.build',
92+
'mageforge.theme.watch',
93+
]);
94+
});
95+
96+
test('shows info message when no Magento root is found', async () => {
97+
const { CommandsProvider } = loadCommandsProvider({
98+
getMagentoRoot: () => undefined,
99+
execMageforge: async () => '',
100+
});
101+
102+
const provider = new CommandsProvider();
103+
const children = await provider.getChildren();
104+
105+
assert.strictEqual(children.length, 1);
106+
assert.strictEqual(children[0].mageforgeCommand, undefined);
107+
assert.ok(children[0].label?.toString().includes('Magento workspace'));
108+
});
109+
110+
test('shows user-friendly error when CLI call fails', async () => {
111+
const { CommandsProvider } = loadCommandsProvider({
112+
getMagentoRoot: () => '/magento',
113+
execMageforge: async () => {
114+
throw new Error('ddev is not running');
115+
},
116+
});
117+
118+
const provider = new CommandsProvider();
119+
const children = await provider.getChildren();
120+
121+
assert.strictEqual(children.length, 1);
122+
assert.strictEqual(children[0].mageforgeCommand, undefined);
123+
assert.ok(children[0].label?.toString().includes('DDEV'));
124+
});
125+
126+
test('caches available commands across multiple getChildren calls', async () => {
127+
let calls = 0;
128+
const { CommandsProvider } = loadCommandsProvider({
129+
getMagentoRoot: () => '/magento',
130+
execMageforge: async () => {
131+
calls++;
132+
return 'mageforge:theme:build';
133+
},
134+
});
135+
28136
const provider = new CommandsProvider();
29-
const children = provider.getChildren();
137+
await provider.getChildren();
138+
await provider.getChildren();
30139

31-
assert.strictEqual(children.length, MAGEFORGE_COMMANDS.length);
140+
assert.strictEqual(calls, 1);
141+
});
142+
143+
test('refresh clears cache and reloads commands', async () => {
144+
let calls = 0;
145+
const { CommandsProvider } = loadCommandsProvider({
146+
getMagentoRoot: () => '/magento',
147+
execMageforge: async () => {
148+
calls++;
149+
return calls === 1
150+
? 'mageforge:theme:build'
151+
: 'mageforge:theme:build\nmageforge:theme:watch';
152+
},
153+
});
154+
155+
const provider = new CommandsProvider();
156+
const first = await provider.getChildren();
157+
assert.strictEqual(first.length, 1);
158+
159+
provider.refresh();
160+
const second = await provider.getChildren();
161+
assert.strictEqual(second.length, 2);
162+
assert.strictEqual(calls, 2);
32163
});
33164
});
34165

35166
suite('CommandTreeItem', () => {
36167
test('sets label, description and command', () => {
168+
const { CommandTreeItem, MAGEFORGE_COMMANDS } = loadCommandsProvider({
169+
getMagentoRoot: () => undefined,
170+
execMageforge: async () => '',
171+
});
172+
37173
const cmd = MAGEFORGE_COMMANDS[0];
38174
const item = new CommandTreeItem(cmd);
39175

@@ -43,5 +179,18 @@ suite('commandsProvider.ts unit tests', () => {
43179
assert.strictEqual(item.command?.title, cmd.label);
44180
assert.strictEqual(item.contextValue, 'mageforgeCommand');
45181
});
182+
183+
test('renders info item without command', () => {
184+
const { CommandTreeItem } = loadCommandsProvider({
185+
getMagentoRoot: () => undefined,
186+
execMageforge: async () => '',
187+
});
188+
189+
const item = new CommandTreeItem(undefined, 'Info message');
190+
191+
assert.strictEqual(item.label, 'Info message');
192+
assert.strictEqual(item.command, undefined);
193+
assert.strictEqual(item.contextValue, undefined);
194+
});
46195
});
47196
});

0 commit comments

Comments
 (0)