@vsceasy/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/LICENSE +21 -0
- package/README.md +474 -0
- package/dist/bin/cli.d.ts +1 -0
- package/dist/bin/cli.js +9044 -0
- package/dist/cli.d.ts +3 -0
- package/dist/commands/command/add.d.ts +3 -0
- package/dist/commands/components/add.d.ts +3 -0
- package/dist/commands/create.d.ts +3 -0
- package/dist/commands/crud/add.d.ts +3 -0
- package/dist/commands/db/init.d.ts +3 -0
- package/dist/commands/doctor.d.ts +3 -0
- package/dist/commands/groups.d.ts +16 -0
- package/dist/commands/helper/add.d.ts +3 -0
- package/dist/commands/job/add.d.ts +3 -0
- package/dist/commands/menu/add.d.ts +3 -0
- package/dist/commands/menu/edit.d.ts +3 -0
- package/dist/commands/model/add.d.ts +3 -0
- package/dist/commands/panel/add.d.ts +3 -0
- package/dist/commands/publish/init.d.ts +3 -0
- package/dist/commands/rpc/add.d.ts +3 -0
- package/dist/commands/statusBar/add.d.ts +3 -0
- package/dist/commands/subpanel/add.d.ts +3 -0
- package/dist/commands/test/setup.d.ts +3 -0
- package/dist/commands/treeView/add.d.ts +3 -0
- package/dist/commands/upgrade.d.ts +3 -0
- package/dist/commands/wizard.d.ts +3 -0
- package/dist/data/codicons.d.ts +9 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3169 -0
- package/dist/lib/command/add.d.ts +31 -0
- package/dist/lib/components/add.d.ts +20 -0
- package/dist/lib/config.d.ts +10 -0
- package/dist/lib/crud/add.d.ts +19 -0
- package/dist/lib/crud/crudConfig.d.ts +37 -0
- package/dist/lib/crud/parseModel.d.ts +33 -0
- package/dist/lib/db/init.d.ts +16 -0
- package/dist/lib/db/wire.d.ts +10 -0
- package/dist/lib/doctor.d.ts +30 -0
- package/dist/lib/findProject.d.ts +10 -0
- package/dist/lib/helper/add.d.ts +14 -0
- package/dist/lib/iconPicker.d.ts +7 -0
- package/dist/lib/index.d.ts +46 -0
- package/dist/lib/interactive.d.ts +30 -0
- package/dist/lib/job/add.d.ts +24 -0
- package/dist/lib/menu/add.d.ts +13 -0
- package/dist/lib/menu/edit.d.ts +39 -0
- package/dist/lib/menuTree.d.ts +33 -0
- package/dist/lib/model/add.d.ts +27 -0
- package/dist/lib/model/parseFields.d.ts +14 -0
- package/dist/lib/panel/add.d.ts +29 -0
- package/dist/lib/publish/init.d.ts +12 -0
- package/dist/lib/rpc/add.d.ts +22 -0
- package/dist/lib/scaffold.d.ts +13 -0
- package/dist/lib/statusBar/add.d.ts +33 -0
- package/dist/lib/subpanel/add.d.ts +20 -0
- package/dist/lib/testSetup/index.d.ts +10 -0
- package/dist/lib/treeView/add.d.ts +13 -0
- package/dist/lib/upgrade.d.ts +22 -0
- package/dist/lib/validate.d.ts +14 -0
- package/dist/lib/wizard/run.d.ts +13 -0
- package/package.json +67 -0
- package/templates/_generators/command/command.ts.tpl +8 -0
- package/templates/_generators/components/Button.tsx.tpl +12 -0
- package/templates/_generators/components/Card.tsx.tpl +22 -0
- package/templates/_generators/components/Field.tsx.tpl +20 -0
- package/templates/_generators/components/Input.tsx.tpl +10 -0
- package/templates/_generators/components/List.tsx.tpl +29 -0
- package/templates/_generators/components/components.css.tpl +66 -0
- package/templates/_generators/components/index.ts.tpl +10 -0
- package/templates/_generators/crud/formApp.tsx.tpl +83 -0
- package/templates/_generators/crud/formNav.ts.tpl +19 -0
- package/templates/_generators/crud/formPanel.ts.tpl +32 -0
- package/templates/_generators/crud/listApp.tsx.tpl +84 -0
- package/templates/_generators/crud/listPanel.ts.tpl +30 -0
- package/templates/_generators/crud/main.tsx.tpl +6 -0
- package/templates/_generators/crud/service.ts.tpl +27 -0
- package/templates/_generators/helper/cache.ts.tpl +117 -0
- package/templates/_generators/helper/config.ts.tpl +36 -0
- package/templates/_generators/helper/db.ts.tpl +322 -0
- package/templates/_generators/helper/notifications.ts.tpl +45 -0
- package/templates/_generators/helper/secrets.ts.tpl +36 -0
- package/templates/_generators/helper/state.ts.tpl +44 -0
- package/templates/_generators/job/job.ts.tpl +10 -0
- package/templates/_generators/menu/menu.ts.tpl +21 -0
- package/templates/_generators/model/model.ts.tpl +17 -0
- package/templates/_generators/panel/App.tsx.tpl +10 -0
- package/templates/_generators/panel/main.tsx.tpl +6 -0
- package/templates/_generators/panel/panel.ts.tpl +5 -0
- package/templates/_generators/panel/templates/dashboard/App.tsx.tpl +41 -0
- package/templates/_generators/panel/templates/form/App.tsx.tpl +44 -0
- package/templates/_generators/panel/templates/list/App.tsx.tpl +40 -0
- package/templates/_generators/publish/CHANGELOG.md.tpl +8 -0
- package/templates/_generators/publish/README.md.tpl +23 -0
- package/templates/_generators/statusBar/statusBar.ts.tpl +7 -0
- package/templates/_generators/subpanel/App.tsx.tpl +10 -0
- package/templates/_generators/subpanel/main.tsx.tpl +6 -0
- package/templates/_generators/subpanel/subpanel.ts.tpl +6 -0
- package/templates/_generators/test/_helpers.ts.tpl +120 -0
- package/templates/_generators/test/sample.test.ts.tpl +38 -0
- package/templates/_generators/test/vitest.config.ts.tpl +23 -0
- package/templates/_generators/test/vscode.stub.ts.tpl +109 -0
- package/templates/_generators/treeView/treeView.ts.tpl +16 -0
- package/templates/react/.vscode/launch.json +34 -0
- package/templates/react/.vscode/tasks.json +32 -0
- package/templates/react/.vscodeignore +8 -0
- package/templates/react/README.md +50 -0
- package/templates/react/package.json +54 -0
- package/templates/react/scripts/gen.ts +395 -0
- package/templates/react/src/commands/hello.ts +6 -0
- package/templates/react/src/extension/extension.ts +5 -0
- package/templates/react/src/panels/dashboard.ts +21 -0
- package/templates/react/src/shared/api.ts +7 -0
- package/templates/react/src/shared/vsceasy/bootstrap.ts +657 -0
- package/templates/react/src/shared/vsceasy/client.ts +8 -0
- package/templates/react/src/shared/vsceasy/codiconNames.ts +196 -0
- package/templates/react/src/shared/vsceasy/define.ts +269 -0
- package/templates/react/src/shared/vsceasy/index.ts +13 -0
- package/templates/react/src/shared/vsceasy/rpc.ts +214 -0
- package/templates/react/src/webview/panels/dashboard/App.tsx +31 -0
- package/templates/react/src/webview/panels/dashboard/main.tsx +6 -0
- package/templates/react/src/webview/styles.css +33 -0
- package/templates/react/tsconfig.json +17 -0
- package/templates/react/vite.config.ts +42 -0
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
import * as vscode from 'vscode';
|
|
2
|
+
import type { PanelDef, CommandDef, MenuDef, MenuItem, StatusBarDef, StatusBarMenuItem, SubpanelDef, TreeViewDef, TreeNode, JobDef, JobSchedule } from './define';
|
|
3
|
+
import { createRpcServer, webviewTransport } from './rpc';
|
|
4
|
+
|
|
5
|
+
export interface Registry {
|
|
6
|
+
panels: Record<string, PanelDef>;
|
|
7
|
+
commands: Record<string, CommandDef>;
|
|
8
|
+
menus?: Record<string, MenuDef>;
|
|
9
|
+
statusBars?: Record<string, StatusBarDef>;
|
|
10
|
+
subpanels?: Record<string, SubpanelDef>;
|
|
11
|
+
treeViews?: Record<string, TreeViewDef>;
|
|
12
|
+
jobs?: Record<string, JobDef>;
|
|
13
|
+
/** Command prefix from package.json (e.g. "myExt"). */
|
|
14
|
+
prefix: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const openPanels = new Map<string, vscode.WebviewPanel>();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Hook fired with the `ExtensionContext`. Use to wire `initDb(context)`,
|
|
21
|
+
* `initSecrets(context)`, `initState(context)`, etc. Return value ignored;
|
|
22
|
+
* may be sync or async (awaited in order).
|
|
23
|
+
*
|
|
24
|
+
* Signature uses `...rest: any[]` so any 1- or 2-arg helper (including ones
|
|
25
|
+
* that declare a typed second parameter like `initDb(ctx, opts?)`) assigns
|
|
26
|
+
* cleanly. The bootstrap runtime always passes the `vscode` namespace as the
|
|
27
|
+
* second arg — helpers free to ignore it or declare their own type.
|
|
28
|
+
*/
|
|
29
|
+
export type ActivateHook = (
|
|
30
|
+
context: vscode.ExtensionContext,
|
|
31
|
+
...rest: any[]
|
|
32
|
+
) => unknown | Promise<unknown>;
|
|
33
|
+
|
|
34
|
+
export interface BootstrapOptions {
|
|
35
|
+
/**
|
|
36
|
+
* Hooks that receive the `ExtensionContext` on activate, before any panel /
|
|
37
|
+
* command / job is registered. Use to wire `initDb(context)`, `initSecrets(context)`,
|
|
38
|
+
* `initState(context)`, telemetry, etc.
|
|
39
|
+
*/
|
|
40
|
+
onActivate?: ActivateHook[];
|
|
41
|
+
/** Symmetric: runs in reverse on deactivate. */
|
|
42
|
+
onDeactivate?: ActivateHook[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function bootstrap(registry: Registry, options: BootstrapOptions = {}) {
|
|
46
|
+
return async function activate(context: vscode.ExtensionContext) {
|
|
47
|
+
for (const hook of options.onActivate ?? []) {
|
|
48
|
+
await hook(context, vscode);
|
|
49
|
+
}
|
|
50
|
+
for (const [id, def] of Object.entries(registry.commands)) {
|
|
51
|
+
const cmd = `${registry.prefix}.${def.id ?? id}`;
|
|
52
|
+
context.subscriptions.push(
|
|
53
|
+
vscode.commands.registerCommand(cmd, (...args) => def.run(vscode, context, ...args)),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const [id, def] of Object.entries(registry.panels)) {
|
|
58
|
+
if (def.command !== false) {
|
|
59
|
+
const cmd = `${registry.prefix}.open${capitalize(def.id ?? id)}`;
|
|
60
|
+
context.subscriptions.push(
|
|
61
|
+
vscode.commands.registerCommand(cmd, () => openPanel(context, registry.prefix, id, def)),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (registry.menus) {
|
|
67
|
+
for (const [id, def] of Object.entries(registry.menus)) {
|
|
68
|
+
registerMenu(context, registry, id, def);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (registry.statusBars) {
|
|
73
|
+
for (const [id, def] of Object.entries(registry.statusBars)) {
|
|
74
|
+
registerStatusBar(context, registry, id, def);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (registry.subpanels) {
|
|
79
|
+
for (const [id, def] of Object.entries(registry.subpanels)) {
|
|
80
|
+
registerSubpanel(context, registry, id, def);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (registry.treeViews) {
|
|
85
|
+
for (const [id, def] of Object.entries(registry.treeViews)) {
|
|
86
|
+
registerTreeView(context, registry, id, def);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (registry.jobs) {
|
|
91
|
+
for (const [id, def] of Object.entries(registry.jobs)) {
|
|
92
|
+
registerJob(context, registry, id, def);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Register deactivate hooks as disposables — fire in reverse order on shutdown.
|
|
97
|
+
for (const hook of [...(options.onDeactivate ?? [])].reverse()) {
|
|
98
|
+
context.subscriptions.push({
|
|
99
|
+
dispose: () => {
|
|
100
|
+
void hook(context, vscode);
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- Jobs (recurring / event-triggered) ---
|
|
108
|
+
|
|
109
|
+
function registerJob(
|
|
110
|
+
context: vscode.ExtensionContext,
|
|
111
|
+
registry: Registry,
|
|
112
|
+
id: string,
|
|
113
|
+
def: JobDef,
|
|
114
|
+
) {
|
|
115
|
+
const jobId = def.id ?? id;
|
|
116
|
+
const lastRunKey = `vsceasy.job.${jobId}.lastRun`;
|
|
117
|
+
|
|
118
|
+
const exec = async (reason: string) => {
|
|
119
|
+
if (def.minIntervalMs) {
|
|
120
|
+
const last = (context.globalState.get<number>(lastRunKey) ?? 0);
|
|
121
|
+
if (Date.now() - last < def.minIntervalMs) return;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
await def.run(vscode, context);
|
|
125
|
+
await context.globalState.update(lastRunKey, Date.now());
|
|
126
|
+
} catch (err) {
|
|
127
|
+
console.error(`[vsceasy job:${jobId}] (${reason}) failed:`, err);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const sched = def.schedule;
|
|
132
|
+
if ('every' in sched) {
|
|
133
|
+
const ms = parseDuration(sched.every);
|
|
134
|
+
if (ms <= 0) throw new Error(`Job "${jobId}": invalid every=${sched.every}`);
|
|
135
|
+
if (sched.runOnStart !== false) void exec('startup');
|
|
136
|
+
const handle = setInterval(() => void exec('interval'), ms);
|
|
137
|
+
context.subscriptions.push({ dispose: () => clearInterval(handle) });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if ('dailyAt' in sched) {
|
|
141
|
+
const [hStr, mStr] = sched.dailyAt.split(':');
|
|
142
|
+
const h = Number(hStr);
|
|
143
|
+
const m = Number(mStr ?? '0');
|
|
144
|
+
if (!Number.isFinite(h) || !Number.isFinite(m)) {
|
|
145
|
+
throw new Error(`Job "${jobId}": invalid dailyAt=${sched.dailyAt} (expected "HH:MM")`);
|
|
146
|
+
}
|
|
147
|
+
let timer: NodeJS.Timeout | undefined;
|
|
148
|
+
const scheduleNext = () => {
|
|
149
|
+
const next = new Date();
|
|
150
|
+
next.setHours(h, m, 0, 0);
|
|
151
|
+
if (next.getTime() <= Date.now()) next.setDate(next.getDate() + 1);
|
|
152
|
+
timer = setTimeout(async () => {
|
|
153
|
+
await exec('dailyAt');
|
|
154
|
+
scheduleNext();
|
|
155
|
+
}, next.getTime() - Date.now());
|
|
156
|
+
};
|
|
157
|
+
scheduleNext();
|
|
158
|
+
context.subscriptions.push({ dispose: () => { if (timer) clearTimeout(timer); } });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if ('on' in sched) {
|
|
162
|
+
let sub: vscode.Disposable;
|
|
163
|
+
switch (sched.on) {
|
|
164
|
+
case 'startup':
|
|
165
|
+
void exec('startup');
|
|
166
|
+
return;
|
|
167
|
+
case 'saveDocument':
|
|
168
|
+
sub = vscode.workspace.onDidSaveTextDocument(() => void exec('saveDocument'));
|
|
169
|
+
break;
|
|
170
|
+
case 'openDocument':
|
|
171
|
+
sub = vscode.workspace.onDidOpenTextDocument(() => void exec('openDocument'));
|
|
172
|
+
break;
|
|
173
|
+
case 'changeActiveEditor':
|
|
174
|
+
sub = vscode.window.onDidChangeActiveTextEditor(() => void exec('changeActiveEditor'));
|
|
175
|
+
break;
|
|
176
|
+
case 'changeConfig':
|
|
177
|
+
sub = vscode.workspace.onDidChangeConfiguration(() => void exec('changeConfig'));
|
|
178
|
+
break;
|
|
179
|
+
default:
|
|
180
|
+
throw new Error(`Job "${jobId}": unknown on=${(sched as { on: string }).on}`);
|
|
181
|
+
}
|
|
182
|
+
context.subscriptions.push(sub);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if ('onFile' in sched) {
|
|
186
|
+
const watcher = vscode.workspace.createFileSystemWatcher(sched.onFile);
|
|
187
|
+
watcher.onDidChange(() => void exec('onFile:change'));
|
|
188
|
+
watcher.onDidCreate(() => void exec('onFile:create'));
|
|
189
|
+
watcher.onDidDelete(() => void exec('onFile:delete'));
|
|
190
|
+
context.subscriptions.push(watcher);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const DURATION_RE = /^(\d+)\s*(ms|s|m|h|d)?$/;
|
|
196
|
+
|
|
197
|
+
function parseDuration(input: string | number): number {
|
|
198
|
+
if (typeof input === 'number') return input;
|
|
199
|
+
const m = DURATION_RE.exec(input.trim());
|
|
200
|
+
if (!m) return -1;
|
|
201
|
+
const n = Number(m[1]);
|
|
202
|
+
switch (m[2] ?? 'ms') {
|
|
203
|
+
case 'ms': return n;
|
|
204
|
+
case 's': return n * 1000;
|
|
205
|
+
case 'm': return n * 60_000;
|
|
206
|
+
case 'h': return n * 3_600_000;
|
|
207
|
+
case 'd': return n * 86_400_000;
|
|
208
|
+
default: return -1;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// --- Tree Views (data-driven) ---
|
|
213
|
+
|
|
214
|
+
function registerTreeView(
|
|
215
|
+
context: vscode.ExtensionContext,
|
|
216
|
+
registry: Registry,
|
|
217
|
+
id: string,
|
|
218
|
+
def: TreeViewDef,
|
|
219
|
+
) {
|
|
220
|
+
const viewId = `${registry.prefix}-${def.menu}-${def.id ?? id}`;
|
|
221
|
+
const provider = new DataTreeProvider(def, context);
|
|
222
|
+
const view = vscode.window.createTreeView(viewId, {
|
|
223
|
+
treeDataProvider: provider,
|
|
224
|
+
showCollapseAll: def.showCollapseAll !== false,
|
|
225
|
+
});
|
|
226
|
+
context.subscriptions.push(view);
|
|
227
|
+
|
|
228
|
+
const refreshCmd = `${registry.prefix}._tree.${def.id ?? id}.refresh`;
|
|
229
|
+
context.subscriptions.push(
|
|
230
|
+
vscode.commands.registerCommand(refreshCmd, () => provider.refresh()),
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const dispatchCmd = `${registry.prefix}._tree.${def.id ?? id}.run`;
|
|
234
|
+
context.subscriptions.push(
|
|
235
|
+
vscode.commands.registerCommand(dispatchCmd, async (node: TreeNode) => {
|
|
236
|
+
if (node.run) return node.run(vscode, context);
|
|
237
|
+
if (node.panel) {
|
|
238
|
+
const p = registry.panels[node.panel];
|
|
239
|
+
if (p) return openPanel(context, registry.prefix, node.panel, p);
|
|
240
|
+
}
|
|
241
|
+
if (node.command) {
|
|
242
|
+
const c = registry.commands[node.command];
|
|
243
|
+
if (c) return c.run(vscode, context);
|
|
244
|
+
}
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
provider.setDispatchCommand(dispatchCmd);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
class DataTreeProvider implements vscode.TreeDataProvider<TreeNode> {
|
|
251
|
+
private _onDidChange = new vscode.EventEmitter<TreeNode | undefined>();
|
|
252
|
+
readonly onDidChangeTreeData = this._onDidChange.event;
|
|
253
|
+
private dispatchCmd = '';
|
|
254
|
+
|
|
255
|
+
constructor(private readonly def: TreeViewDef, private readonly context: vscode.ExtensionContext) {}
|
|
256
|
+
|
|
257
|
+
setDispatchCommand(cmd: string) {
|
|
258
|
+
this.dispatchCmd = cmd;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
refresh() {
|
|
262
|
+
this._onDidChange.fire(undefined);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
getTreeItem(node: TreeNode): vscode.TreeItem {
|
|
266
|
+
const hasChildren = !!node.children?.length;
|
|
267
|
+
const state = hasChildren || node.children === undefined
|
|
268
|
+
? node.collapsed === 'expanded'
|
|
269
|
+
? vscode.TreeItemCollapsibleState.Expanded
|
|
270
|
+
: vscode.TreeItemCollapsibleState.Collapsed
|
|
271
|
+
: vscode.TreeItemCollapsibleState.None;
|
|
272
|
+
const item = new vscode.TreeItem(node.label, state);
|
|
273
|
+
item.id = node.id;
|
|
274
|
+
item.tooltip = node.tooltip;
|
|
275
|
+
item.description = node.description;
|
|
276
|
+
item.contextValue = node.contextValue;
|
|
277
|
+
item.iconPath = resolveIcon(this.context, node.icon);
|
|
278
|
+
if (this.dispatchCmd && (node.run || node.panel || node.command)) {
|
|
279
|
+
item.command = { command: this.dispatchCmd, title: node.label, arguments: [node] };
|
|
280
|
+
}
|
|
281
|
+
return item;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async getChildren(node?: TreeNode): Promise<TreeNode[]> {
|
|
285
|
+
if (node?.children) return node.children;
|
|
286
|
+
return Promise.resolve(this.def.getChildren(node, vscode, this.context));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// --- Webview Views (sidebar inline) ---
|
|
291
|
+
|
|
292
|
+
function registerSubpanel(
|
|
293
|
+
context: vscode.ExtensionContext,
|
|
294
|
+
registry: Registry,
|
|
295
|
+
id: string,
|
|
296
|
+
def: SubpanelDef,
|
|
297
|
+
) {
|
|
298
|
+
// Must match the view id gen.ts writes into package.json#views.<container>.
|
|
299
|
+
const viewId = `${registry.prefix}-${def.menu}-${def.id ?? id}`;
|
|
300
|
+
const provider: vscode.WebviewViewProvider = {
|
|
301
|
+
resolveWebviewView(view) {
|
|
302
|
+
view.webview.options = {
|
|
303
|
+
enableScripts: true,
|
|
304
|
+
localResourceRoots: [vscode.Uri.joinPath(context.extensionUri, 'dist', 'webview')],
|
|
305
|
+
};
|
|
306
|
+
const ui = def.ui ?? `subpanels/${def.id ?? id}`;
|
|
307
|
+
view.webview.html = renderHtml(view.webview, context, ui, def.title);
|
|
308
|
+
if (def.rpc) {
|
|
309
|
+
const handlers = def.rpc(vscode, context);
|
|
310
|
+
const server = createRpcServer(webviewTransport(view.webview), handlers);
|
|
311
|
+
view.onDidDispose(() => server.dispose());
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
context.subscriptions.push(
|
|
316
|
+
vscode.window.registerWebviewViewProvider(viewId, provider, {
|
|
317
|
+
webviewOptions: { retainContextWhenHidden: def.retainContext ?? true },
|
|
318
|
+
}),
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// --- Status bar items ---
|
|
323
|
+
|
|
324
|
+
function registerStatusBar(
|
|
325
|
+
context: vscode.ExtensionContext,
|
|
326
|
+
registry: Registry,
|
|
327
|
+
id: string,
|
|
328
|
+
def: StatusBarDef,
|
|
329
|
+
) {
|
|
330
|
+
const alignment = def.alignment === 'right'
|
|
331
|
+
? vscode.StatusBarAlignment.Right
|
|
332
|
+
: vscode.StatusBarAlignment.Left;
|
|
333
|
+
const item = vscode.window.createStatusBarItem(alignment, def.priority ?? 100);
|
|
334
|
+
item.text = def.icon ? `$(${def.icon}) ${def.text}` : def.text;
|
|
335
|
+
|
|
336
|
+
// Tooltip: markdown takes precedence over plain string
|
|
337
|
+
if (def.tooltipMarkdown) {
|
|
338
|
+
const md = new vscode.MarkdownString(def.tooltipMarkdown, true);
|
|
339
|
+
md.supportHtml = true;
|
|
340
|
+
md.isTrusted = true;
|
|
341
|
+
item.tooltip = md;
|
|
342
|
+
} else if (def.tooltip) {
|
|
343
|
+
item.tooltip = def.tooltip;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Click behaviour priority: menu > panel > command
|
|
347
|
+
if (def.menu && def.menu.length > 0) {
|
|
348
|
+
const dispatchCmd = `${registry.prefix}._statusBar.${id}.click`;
|
|
349
|
+
context.subscriptions.push(
|
|
350
|
+
vscode.commands.registerCommand(dispatchCmd, () => openStatusBarMenu(context, registry, def.menu!)),
|
|
351
|
+
);
|
|
352
|
+
item.command = dispatchCmd;
|
|
353
|
+
} else if (def.panel) {
|
|
354
|
+
const panelDef = registry.panels[def.panel];
|
|
355
|
+
if (panelDef) {
|
|
356
|
+
const suffix = capitalize(panelDef.id ?? def.panel);
|
|
357
|
+
item.command = `${registry.prefix}.open${suffix}`;
|
|
358
|
+
} else {
|
|
359
|
+
console.warn(`[vsceasy] statusBar "${id}" references unknown panel "${def.panel}"`);
|
|
360
|
+
}
|
|
361
|
+
} else if (def.command) {
|
|
362
|
+
item.command = registry.commands[def.command]
|
|
363
|
+
? `${registry.prefix}.${registry.commands[def.command].id ?? def.command}`
|
|
364
|
+
: def.command;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (def.backgroundColor) {
|
|
368
|
+
item.backgroundColor = new vscode.ThemeColor(def.backgroundColor);
|
|
369
|
+
}
|
|
370
|
+
item.show();
|
|
371
|
+
context.subscriptions.push(item);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function openStatusBarMenu(
|
|
375
|
+
context: vscode.ExtensionContext,
|
|
376
|
+
registry: Registry,
|
|
377
|
+
items: StatusBarMenuItem[],
|
|
378
|
+
) {
|
|
379
|
+
type QP = vscode.QuickPickItem & { __item: StatusBarMenuItem };
|
|
380
|
+
const picks: QP[] = items.map((it) => ({
|
|
381
|
+
label: it.label,
|
|
382
|
+
description: it.description,
|
|
383
|
+
detail: it.detail,
|
|
384
|
+
__item: it,
|
|
385
|
+
}));
|
|
386
|
+
const selected = await vscode.window.showQuickPick(picks, { placeHolder: 'Choose action' });
|
|
387
|
+
if (!selected) return;
|
|
388
|
+
const it = selected.__item;
|
|
389
|
+
if (it.url) {
|
|
390
|
+
await vscode.env.openExternal(vscode.Uri.parse(it.url));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (it.panel) {
|
|
394
|
+
const panelDef = registry.panels[it.panel];
|
|
395
|
+
if (panelDef) {
|
|
396
|
+
const cmd = `${registry.prefix}.open${capitalize(panelDef.id ?? it.panel)}`;
|
|
397
|
+
await vscode.commands.executeCommand(cmd);
|
|
398
|
+
}
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (it.command) {
|
|
402
|
+
const cmd = registry.commands[it.command]
|
|
403
|
+
? `${registry.prefix}.${registry.commands[it.command].id ?? it.command}`
|
|
404
|
+
: it.command;
|
|
405
|
+
await vscode.commands.executeCommand(cmd);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// --- Menus ---
|
|
410
|
+
|
|
411
|
+
function registerMenu(
|
|
412
|
+
context: vscode.ExtensionContext,
|
|
413
|
+
registry: Registry,
|
|
414
|
+
id: string,
|
|
415
|
+
def: MenuDef,
|
|
416
|
+
) {
|
|
417
|
+
// Must match the id gen.ts writes into package.json#viewsContainers/views.
|
|
418
|
+
// VS Code disallows '.' in view ids, so we use '-' as separator.
|
|
419
|
+
const viewId = `${registry.prefix}-${def.id ?? id}`;
|
|
420
|
+
const provider = new MenuTreeDataProvider(def.items, context);
|
|
421
|
+
const view = vscode.window.createTreeView(viewId, {
|
|
422
|
+
treeDataProvider: provider,
|
|
423
|
+
showCollapseAll: true,
|
|
424
|
+
});
|
|
425
|
+
context.subscriptions.push(view);
|
|
426
|
+
|
|
427
|
+
// Single dispatch command per menu — passes the item through arguments[0] of contributes.commands.
|
|
428
|
+
const dispatchCmd = `${registry.prefix}._menu.${def.id ?? id}.run`;
|
|
429
|
+
context.subscriptions.push(
|
|
430
|
+
vscode.commands.registerCommand(dispatchCmd, (item: MenuItem) =>
|
|
431
|
+
dispatchMenuItem(context, registry, item),
|
|
432
|
+
),
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
provider.setDispatchCommand(dispatchCmd);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function dispatchMenuItem(
|
|
439
|
+
context: vscode.ExtensionContext,
|
|
440
|
+
registry: Registry,
|
|
441
|
+
item: MenuItem,
|
|
442
|
+
) {
|
|
443
|
+
if (item.url) {
|
|
444
|
+
await vscode.env.openExternal(vscode.Uri.parse(item.url));
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (item.panel) {
|
|
448
|
+
const panel = registry.panels[item.panel];
|
|
449
|
+
if (!panel) {
|
|
450
|
+
vscode.window.showErrorMessage(`Menu item references unknown panel: ${item.panel}`);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
openPanel(context, registry.prefix, item.panel, panel);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (item.command) {
|
|
457
|
+
const cmd = registry.commands[item.command];
|
|
458
|
+
if (!cmd) {
|
|
459
|
+
vscode.window.showErrorMessage(`Menu item references unknown command: ${item.command}`);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
await cmd.run(vscode, context);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (item.run) {
|
|
466
|
+
await item.run(vscode, context);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
class MenuTreeDataProvider implements vscode.TreeDataProvider<MenuItem> {
|
|
472
|
+
private _onDidChange = new vscode.EventEmitter<MenuItem | undefined>();
|
|
473
|
+
readonly onDidChangeTreeData = this._onDidChange.event;
|
|
474
|
+
private dispatchCmd = '';
|
|
475
|
+
|
|
476
|
+
constructor(private readonly items: MenuItem[], private readonly context: vscode.ExtensionContext) {}
|
|
477
|
+
|
|
478
|
+
setDispatchCommand(cmd: string) {
|
|
479
|
+
this.dispatchCmd = cmd;
|
|
480
|
+
this._onDidChange.fire(undefined);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
getTreeItem(item: MenuItem): vscode.TreeItem {
|
|
484
|
+
const hasChildren = !!item.children?.length;
|
|
485
|
+
const collapsibleState = hasChildren
|
|
486
|
+
? item.collapsed === 'collapsed'
|
|
487
|
+
? vscode.TreeItemCollapsibleState.Collapsed
|
|
488
|
+
: vscode.TreeItemCollapsibleState.Expanded
|
|
489
|
+
: vscode.TreeItemCollapsibleState.None;
|
|
490
|
+
const node = new vscode.TreeItem(item.label, collapsibleState);
|
|
491
|
+
node.tooltip = item.description ?? item.label;
|
|
492
|
+
node.description = item.description;
|
|
493
|
+
node.iconPath = resolveIcon(this.context, item.icon);
|
|
494
|
+
if (!hasChildren && this.dispatchCmd) {
|
|
495
|
+
node.command = {
|
|
496
|
+
command: this.dispatchCmd,
|
|
497
|
+
title: item.label,
|
|
498
|
+
arguments: [item],
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
return node;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
getChildren(item?: MenuItem): MenuItem[] {
|
|
505
|
+
if (!item) return this.items;
|
|
506
|
+
return item.children ?? [];
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function resolveIcon(
|
|
511
|
+
context: vscode.ExtensionContext,
|
|
512
|
+
icon: MenuItem['icon'],
|
|
513
|
+
): vscode.TreeItem['iconPath'] {
|
|
514
|
+
if (!icon) return undefined;
|
|
515
|
+
if (typeof icon === 'string') return new vscode.ThemeIcon(icon);
|
|
516
|
+
const path = require('path') as typeof import('path');
|
|
517
|
+
const toUri = (p: string) =>
|
|
518
|
+
path.isAbsolute(p) ? vscode.Uri.file(p) : vscode.Uri.joinPath(context.extensionUri, p);
|
|
519
|
+
if ('path' in icon) return toUri(icon.path);
|
|
520
|
+
return { light: toUri(icon.light), dark: toUri(icon.dark) };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function openPanel(context: vscode.ExtensionContext, prefix: string, id: string, def: PanelDef) {
|
|
524
|
+
const key = `${prefix}.${def.id ?? id}`;
|
|
525
|
+
const existing = openPanels.get(key);
|
|
526
|
+
const column = resolveColumn(def.column);
|
|
527
|
+
if (existing) {
|
|
528
|
+
existing.reveal(column);
|
|
529
|
+
return existing;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const panel = vscode.window.createWebviewPanel(
|
|
533
|
+
key,
|
|
534
|
+
def.title,
|
|
535
|
+
column,
|
|
536
|
+
{
|
|
537
|
+
enableScripts: true,
|
|
538
|
+
retainContextWhenHidden: def.retainContext ?? true,
|
|
539
|
+
localResourceRoots: [vscode.Uri.joinPath(context.extensionUri, 'dist', 'webview')],
|
|
540
|
+
},
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
const ui = def.ui ?? `panels/${def.id ?? id}`;
|
|
544
|
+
panel.webview.html = renderHtml(panel.webview, context, ui, def.title);
|
|
545
|
+
|
|
546
|
+
if (def.rpc) {
|
|
547
|
+
const handlers = def.rpc(vscode, context);
|
|
548
|
+
const server = createRpcServer(webviewTransport(panel.webview), handlers);
|
|
549
|
+
panel.onDidDispose(() => server.dispose());
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
openPanels.set(key, panel);
|
|
553
|
+
panel.onDidDispose(() => openPanels.delete(key));
|
|
554
|
+
return panel;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
interface ViteManifestEntry {
|
|
558
|
+
file: string;
|
|
559
|
+
css?: string[];
|
|
560
|
+
assets?: string[];
|
|
561
|
+
imports?: string[];
|
|
562
|
+
}
|
|
563
|
+
type ViteManifest = Record<string, ViteManifestEntry>;
|
|
564
|
+
|
|
565
|
+
let cachedManifest: { mtime: number; data: ViteManifest } | null = null;
|
|
566
|
+
|
|
567
|
+
function loadManifest(extensionUri: vscode.Uri): ViteManifest | null {
|
|
568
|
+
const fs = require('fs') as typeof import('fs');
|
|
569
|
+
const path = require('path') as typeof import('path');
|
|
570
|
+
// Vite manifest can land at either `manifest.json` (new) or `.vite/manifest.json` (default).
|
|
571
|
+
const webviewRoot = vscode.Uri.joinPath(extensionUri, 'dist', 'webview').fsPath;
|
|
572
|
+
for (const rel of ['manifest.json', '.vite/manifest.json']) {
|
|
573
|
+
const p = path.join(webviewRoot, rel);
|
|
574
|
+
if (!fs.existsSync(p)) continue;
|
|
575
|
+
const mtime = fs.statSync(p).mtimeMs;
|
|
576
|
+
if (cachedManifest?.mtime === mtime) return cachedManifest.data;
|
|
577
|
+
cachedManifest = { mtime, data: JSON.parse(fs.readFileSync(p, 'utf8')) };
|
|
578
|
+
return cachedManifest.data;
|
|
579
|
+
}
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function resolveAssets(extensionUri: vscode.Uri, ui: string): { js: string[]; css: string[] } {
|
|
584
|
+
const manifest = loadManifest(extensionUri);
|
|
585
|
+
if (!manifest) {
|
|
586
|
+
// Fallback to convention: <ui>/index.js + <ui>/index.css
|
|
587
|
+
return { js: [`${ui}/index.js`], css: [`${ui}/index.css`] };
|
|
588
|
+
}
|
|
589
|
+
// Manifest keys for HTML entries look like `<ui>/index.html`.
|
|
590
|
+
const key = `${ui}/index.html`;
|
|
591
|
+
const entry = manifest[key];
|
|
592
|
+
if (!entry) return { js: [`${ui}/index.js`], css: [] };
|
|
593
|
+
const js = [entry.file];
|
|
594
|
+
const css = [...(entry.css ?? [])];
|
|
595
|
+
// Recursively pull CSS from imported chunks.
|
|
596
|
+
const seen = new Set<string>();
|
|
597
|
+
const walk = (imp: string) => {
|
|
598
|
+
if (seen.has(imp)) return;
|
|
599
|
+
seen.add(imp);
|
|
600
|
+
const e = manifest[imp];
|
|
601
|
+
if (!e) return;
|
|
602
|
+
if (e.css) css.push(...e.css);
|
|
603
|
+
e.imports?.forEach(walk);
|
|
604
|
+
};
|
|
605
|
+
entry.imports?.forEach(walk);
|
|
606
|
+
return { js, css };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function renderHtml(webview: vscode.Webview, context: vscode.ExtensionContext, ui: string, title: string): string {
|
|
610
|
+
const root = vscode.Uri.joinPath(context.extensionUri, 'dist', 'webview');
|
|
611
|
+
const { js, css } = resolveAssets(context.extensionUri, ui);
|
|
612
|
+
const toUri = (rel: string) =>
|
|
613
|
+
webview.asWebviewUri(vscode.Uri.joinPath(root, ...rel.split('/'))).toString();
|
|
614
|
+
const scriptTags = js
|
|
615
|
+
.map((f) => `<script type="module" nonce="{{NONCE}}" src="${toUri(f)}"></script>`)
|
|
616
|
+
.join('\n ');
|
|
617
|
+
const styleTags = css.map((f) => `<link rel="stylesheet" href="${toUri(f)}" />`).join('\n ');
|
|
618
|
+
const nonce = Array.from({ length: 16 }, () => Math.random().toString(36)[2]).join('');
|
|
619
|
+
const csp = [
|
|
620
|
+
`default-src 'none'`,
|
|
621
|
+
`style-src ${webview.cspSource} 'unsafe-inline'`,
|
|
622
|
+
`script-src 'nonce-${nonce}'`,
|
|
623
|
+
`img-src ${webview.cspSource} https: data:`,
|
|
624
|
+
`font-src ${webview.cspSource}`,
|
|
625
|
+
].join('; ');
|
|
626
|
+
|
|
627
|
+
return `<!DOCTYPE html>
|
|
628
|
+
<html lang="en">
|
|
629
|
+
<head>
|
|
630
|
+
<meta charset="UTF-8" />
|
|
631
|
+
<meta http-equiv="Content-Security-Policy" content="${csp}" />
|
|
632
|
+
${styleTags}
|
|
633
|
+
<title>${escapeHtml(title)}</title>
|
|
634
|
+
</head>
|
|
635
|
+
<body><div id="root"></div>
|
|
636
|
+
${scriptTags.replace(/\{\{NONCE\}\}/g, nonce)}
|
|
637
|
+
</body>
|
|
638
|
+
</html>`;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function resolveColumn(c: PanelDef['column']): vscode.ViewColumn {
|
|
642
|
+
switch (c) {
|
|
643
|
+
case 'beside': return vscode.ViewColumn.Beside;
|
|
644
|
+
case 'one': return vscode.ViewColumn.One;
|
|
645
|
+
case 'two': return vscode.ViewColumn.Two;
|
|
646
|
+
case 'three': return vscode.ViewColumn.Three;
|
|
647
|
+
default: return vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.One;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function capitalize(s: string): string {
|
|
652
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function escapeHtml(s: string): string {
|
|
656
|
+
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!));
|
|
657
|
+
}
|