@vsceasy/cli 0.1.6 → 0.1.8
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 +11 -0
- package/README.md +8 -2
- package/dist/bin/cli.js +382 -32
- package/dist/commands/groups.d.ts +1 -0
- package/dist/commands/store/add.d.ts +3 -0
- package/dist/index.js +244 -24
- package/dist/lib/store/add.d.ts +14 -0
- package/dist/lib/templatesData.d.ts +1 -1
- package/package.json +1 -1
- package/templates/_generators/crud/formApp.tsx.tpl +25 -10
- package/templates/_generators/helper/db.ts.tpl +35 -1
- package/templates/_generators/store/store.ts.tpl +23 -0
- package/templates/react/src/shared/vsceasy/bootstrap.ts +19 -6
- package/templates/react/src/shared/vsceasy/client.ts +27 -0
- package/templates/react/src/shared/vsceasy/define.ts +34 -4
- package/templates/react/src/shared/vsceasy/index.ts +4 -1
- package/templates/react/src/shared/vsceasy/store.ts +73 -0
package/dist/index.js
CHANGED
|
@@ -2238,7 +2238,7 @@ var init_upgrade = __esm(() => {
|
|
|
2238
2238
|
});
|
|
2239
2239
|
|
|
2240
2240
|
// src/lib/templatesData.ts
|
|
2241
|
-
var TEMPLATES_VERSION = "0.1.
|
|
2241
|
+
var TEMPLATES_VERSION = "0.1.8", TEMPLATE_FILES;
|
|
2242
2242
|
var init_templatesData = __esm(() => {
|
|
2243
2243
|
TEMPLATE_FILES = {
|
|
2244
2244
|
"_generators/command/command.ts.tpl": `import { defineCommand } from '../shared/vsceasy';
|
|
@@ -2437,21 +2437,35 @@ type FormState = Partial<{{Name}}>;
|
|
|
2437
2437
|
|
|
2438
2438
|
const emptyForm: FormState = {{emptyFormLiteral}};
|
|
2439
2439
|
|
|
2440
|
+
// \`<input type="date">\` only accepts a \`yyyy-MM-dd\` value. Stored dates may be
|
|
2441
|
+
// ISO strings or Date objects, so normalize before binding to the input.
|
|
2442
|
+
function toDateInput(v: unknown): string {
|
|
2443
|
+
if (v == null || v === '') return '';
|
|
2444
|
+
const d = v instanceof Date ? v : new Date(v as string);
|
|
2445
|
+
return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10);
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2440
2448
|
export function App() {
|
|
2441
2449
|
const [form, setForm] = useState<FormState>(emptyForm);
|
|
2442
2450
|
const [editingId, setEditingId] = useState<{{Name}}['{{primaryKey}}'] | null>(null);
|
|
2443
2451
|
const [error, setError] = useState<string | null>(null);
|
|
2444
2452
|
const [saving, setSaving] = useState(false);
|
|
2445
2453
|
|
|
2446
|
-
const load = useCallback(async () => {
|
|
2447
|
-
// The list stashes
|
|
2448
|
-
// clears it after handing it over)
|
|
2454
|
+
const load = useCallback(async (initial: boolean) => {
|
|
2455
|
+
// The list stashes a row id before revealing this panel. Pull it (the host
|
|
2456
|
+
// clears it after handing it over).
|
|
2449
2457
|
const id = await api.pendingId();
|
|
2450
2458
|
if (id == null || id === '') {
|
|
2451
|
-
|
|
2452
|
-
|
|
2459
|
+
// No row was requested. On the first mount, start with an empty "new" form.
|
|
2460
|
+
// On later reveals (focus/visibility), DON'T reset — that would wipe a form
|
|
2461
|
+
// the user is busy filling in. Just leave the current state as-is.
|
|
2462
|
+
if (initial) {
|
|
2463
|
+
setForm(emptyForm);
|
|
2464
|
+
setEditingId(null);
|
|
2465
|
+
}
|
|
2453
2466
|
return;
|
|
2454
2467
|
}
|
|
2468
|
+
// The list asked to edit a specific row — load it, replacing the current form.
|
|
2455
2469
|
const row = await api.get(id);
|
|
2456
2470
|
if (row) {
|
|
2457
2471
|
setForm(row);
|
|
@@ -2460,11 +2474,12 @@ export function App() {
|
|
|
2460
2474
|
}, []);
|
|
2461
2475
|
|
|
2462
2476
|
useEffect(() => {
|
|
2463
|
-
void load();
|
|
2464
|
-
// Webviews retain state when hidden, so re-
|
|
2465
|
-
//
|
|
2466
|
-
|
|
2467
|
-
const
|
|
2477
|
+
void load(true);
|
|
2478
|
+
// Webviews retain state when hidden, so re-check on reveal: the list may have
|
|
2479
|
+
// asked to edit a different row. When nothing is pending, \`load\` leaves the
|
|
2480
|
+
// in-progress form untouched (see above).
|
|
2481
|
+
const onFocus = () => { void load(false); };
|
|
2482
|
+
const onVisible = () => { if (document.visibilityState === 'visible') void load(false); };
|
|
2468
2483
|
window.addEventListener('focus', onFocus);
|
|
2469
2484
|
document.addEventListener('visibilitychange', onVisible);
|
|
2470
2485
|
return () => {
|
|
@@ -2935,6 +2950,35 @@ export interface Repository<T> {
|
|
|
2935
2950
|
clear(): Promise<void>;
|
|
2936
2951
|
}
|
|
2937
2952
|
|
|
2953
|
+
// ── Change notifications (reactivity) ────────────────────────────────────────
|
|
2954
|
+
|
|
2955
|
+
/**
|
|
2956
|
+
* Per-entity change listeners. Any mutation (insert/upsert/update/delete/...) on
|
|
2957
|
+
* an entity fires its listeners with the entity name. This is the host-side hook
|
|
2958
|
+
* the \`watch()\` helper builds on to push updates to webviews.
|
|
2959
|
+
*/
|
|
2960
|
+
const entityListeners = new Map<string, Set<(entity: string) => void>>();
|
|
2961
|
+
|
|
2962
|
+
function emitChange(entity: string): void {
|
|
2963
|
+
entityListeners.get(entity)?.forEach((cb) => {
|
|
2964
|
+
try { cb(entity); } catch { /* a bad listener must not break a write */ }
|
|
2965
|
+
});
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
/**
|
|
2969
|
+
* Subscribe to changes on an entity. Returns an unsubscribe function.
|
|
2970
|
+
*
|
|
2971
|
+
* watchEntity(Todos, () => server.emit('todos:changed'));
|
|
2972
|
+
*
|
|
2973
|
+
* Prefer the higher-level \`watch()\` helper, which wires this to an RPC emit.
|
|
2974
|
+
*/
|
|
2975
|
+
export function watchEntity<T>(entity: Entity<T>, cb: (entity: string) => void): () => void {
|
|
2976
|
+
let set = entityListeners.get(entity.name);
|
|
2977
|
+
if (!set) entityListeners.set(entity.name, (set = new Set()));
|
|
2978
|
+
set.add(cb);
|
|
2979
|
+
return () => { set!.delete(cb); };
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2938
2982
|
// ── Provider interface (future-proof for sqlite/etc.) ────────────────────────
|
|
2939
2983
|
|
|
2940
2984
|
export interface Provider {
|
|
@@ -3105,6 +3149,7 @@ function makeDb(provider: Provider): Db {
|
|
|
3105
3149
|
}
|
|
3106
3150
|
rows.push(row);
|
|
3107
3151
|
await save(rows);
|
|
3152
|
+
emitChange(entity.name);
|
|
3108
3153
|
return row;
|
|
3109
3154
|
},
|
|
3110
3155
|
async upsert(row) {
|
|
@@ -3112,6 +3157,7 @@ function makeDb(provider: Provider): Db {
|
|
|
3112
3157
|
const i = rows.findIndex((r) => r[pk] === row[pk]);
|
|
3113
3158
|
if (i >= 0) rows[i] = row; else rows.push(row);
|
|
3114
3159
|
await save(rows);
|
|
3160
|
+
emitChange(entity.name);
|
|
3115
3161
|
return row;
|
|
3116
3162
|
},
|
|
3117
3163
|
async update(id, patch) {
|
|
@@ -3120,6 +3166,7 @@ function makeDb(provider: Provider): Db {
|
|
|
3120
3166
|
if (i < 0) return null;
|
|
3121
3167
|
rows[i] = { ...rows[i], ...patch };
|
|
3122
3168
|
await save(rows);
|
|
3169
|
+
emitChange(entity.name);
|
|
3123
3170
|
return rows[i];
|
|
3124
3171
|
},
|
|
3125
3172
|
async delete(id) {
|
|
@@ -3128,17 +3175,19 @@ function makeDb(provider: Provider): Db {
|
|
|
3128
3175
|
const next = rows.filter((r) => r[pk] !== id);
|
|
3129
3176
|
if (next.length === before) return false;
|
|
3130
3177
|
await save(next);
|
|
3178
|
+
emitChange(entity.name);
|
|
3131
3179
|
return true;
|
|
3132
3180
|
},
|
|
3133
3181
|
async deleteMany(where) {
|
|
3134
3182
|
const rows = await load();
|
|
3135
3183
|
const next = rows.filter((r) => !match(r, where));
|
|
3136
3184
|
const removed = rows.length - next.length;
|
|
3137
|
-
if (removed > 0) await save(next);
|
|
3185
|
+
if (removed > 0) { await save(next); emitChange(entity.name); }
|
|
3138
3186
|
return removed;
|
|
3139
3187
|
},
|
|
3140
3188
|
async clear() {
|
|
3141
3189
|
await save([]);
|
|
3190
|
+
emitChange(entity.name);
|
|
3142
3191
|
},
|
|
3143
3192
|
};
|
|
3144
3193
|
};
|
|
@@ -3563,6 +3612,30 @@ export default defineStatusBar({
|
|
|
3563
3612
|
alignment: '{{alignment}}',
|
|
3564
3613
|
priority: {{priority}},{{commandLine}}{{panelLine}}
|
|
3565
3614
|
});
|
|
3615
|
+
`,
|
|
3616
|
+
"_generators/store/store.ts.tpl": `import { defineStore } from '../shared/vsceasy';
|
|
3617
|
+
|
|
3618
|
+
/**
|
|
3619
|
+
* Reactive store: a single observable value. Mutate it with \`.set()\` / \`.update()\`
|
|
3620
|
+
* and anything subscribed reacts.
|
|
3621
|
+
*
|
|
3622
|
+
* import { {{name}} } from '../stores/{{name}}';
|
|
3623
|
+
* {{name}}.set({{example}});
|
|
3624
|
+
* {{name}}.update((v) => v);
|
|
3625
|
+
*
|
|
3626
|
+
* To push changes to a webview, watch it on the host and emit over RPC:
|
|
3627
|
+
*
|
|
3628
|
+
* // inside a panel/subpanel rpc():
|
|
3629
|
+
* import { watch } from '../shared/vsceasy';
|
|
3630
|
+
* import { {{name}} } from '../stores/{{name}}';
|
|
3631
|
+
* watch({{name}}, () => server.emit('{{name}}:changed', {{name}}.get()));
|
|
3632
|
+
*
|
|
3633
|
+
* Then listen in the webview:
|
|
3634
|
+
*
|
|
3635
|
+
* import { listen } from '../shared/vsceasy/client';
|
|
3636
|
+
* listen(api, '{{name}}:changed', (v) => render(v));
|
|
3637
|
+
*/
|
|
3638
|
+
export const {{name}} = defineStore<{{type}}>({{initial}});
|
|
3566
3639
|
`,
|
|
3567
3640
|
"_generators/subpanel/App.tsx.tpl": `import React from 'react';
|
|
3568
3641
|
{{apiBlock}}
|
|
@@ -4759,6 +4832,13 @@ function registerTreeView(
|
|
|
4759
4832
|
vscode.commands.registerCommand(refreshCmd, () => provider.refresh()),
|
|
4760
4833
|
);
|
|
4761
4834
|
|
|
4835
|
+
// Keep the tree live: let the def subscribe to a data source and re-run
|
|
4836
|
+
// getChildren on change. The unsubscribe is disposed on deactivate.
|
|
4837
|
+
if (def.watch) {
|
|
4838
|
+
const off = def.watch(() => provider.refresh(), vscode, context);
|
|
4839
|
+
if (off) context.subscriptions.push({ dispose: off });
|
|
4840
|
+
}
|
|
4841
|
+
|
|
4762
4842
|
const dispatchCmd = \`\${registry.prefix}._tree.\${def.id ?? id}.run\`;
|
|
4763
4843
|
context.subscriptions.push(
|
|
4764
4844
|
vscode.commands.registerCommand(dispatchCmd, async (node: TreeNode) => {
|
|
@@ -4835,9 +4915,11 @@ function registerSubpanel(
|
|
|
4835
4915
|
const ui = def.ui ?? \`subpanels/\${def.id ?? id}\`;
|
|
4836
4916
|
view.webview.html = renderHtml(view.webview, context, ui, def.title);
|
|
4837
4917
|
if (def.rpc) {
|
|
4838
|
-
|
|
4839
|
-
const
|
|
4840
|
-
|
|
4918
|
+
let server: ReturnType<typeof createRpcServer> | undefined;
|
|
4919
|
+
const emit = (topic: string, payload?: unknown) => server?.emit(topic, payload);
|
|
4920
|
+
const handlers = def.rpc(vscode, context, emit);
|
|
4921
|
+
server = createRpcServer(webviewTransport(view.webview), handlers);
|
|
4922
|
+
view.onDidDispose(() => server!.dispose());
|
|
4841
4923
|
}
|
|
4842
4924
|
},
|
|
4843
4925
|
};
|
|
@@ -5073,9 +5155,13 @@ function openPanel(context: vscode.ExtensionContext, prefix: string, id: string,
|
|
|
5073
5155
|
panel.webview.html = renderHtml(panel.webview, context, ui, def.title);
|
|
5074
5156
|
|
|
5075
5157
|
if (def.rpc) {
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
5158
|
+
// \`emit\` is wired lazily: the rpc factory may call watch()/watchEntity()
|
|
5159
|
+
// immediately, but the server (and thus the real emit) only exists after.
|
|
5160
|
+
let server: ReturnType<typeof createRpcServer> | undefined;
|
|
5161
|
+
const emit = (topic: string, payload?: unknown) => server?.emit(topic, payload);
|
|
5162
|
+
const handlers = def.rpc(vscode, context, emit);
|
|
5163
|
+
server = createRpcServer(webviewTransport(panel.webview), handlers);
|
|
5164
|
+
panel.onDidDispose(() => server!.dispose());
|
|
5079
5165
|
}
|
|
5080
5166
|
|
|
5081
5167
|
openPanels.set(key, panel);
|
|
@@ -5193,6 +5279,33 @@ export {
|
|
|
5193
5279
|
webviewState,
|
|
5194
5280
|
} from './rpc';
|
|
5195
5281
|
export type { RpcClient, Handlers, Transport, RpcClientOptions, WebviewApi } from './rpc';
|
|
5282
|
+
|
|
5283
|
+
export { defineStore } from './store';
|
|
5284
|
+
export type { Store } from './store';
|
|
5285
|
+
|
|
5286
|
+
import type { RpcClient, Handlers } from './rpc';
|
|
5287
|
+
|
|
5288
|
+
/**
|
|
5289
|
+
* Listen for a change pushed from the host over the RPC event channel, and run a
|
|
5290
|
+
* callback when it arrives. This is the webview side of the reactivity model —
|
|
5291
|
+
* the host emits with \`server.emit(topic, …)\` (usually wired via \`watch()\` /
|
|
5292
|
+
* \`watchEntity()\`), and your visual element reacts here. Returns an unsubscribe
|
|
5293
|
+
* function; call it on unmount.
|
|
5294
|
+
*
|
|
5295
|
+
* const api = connectWebview<TodoStatsViewApi>();
|
|
5296
|
+
* // re-read + re-render whenever todos change on the host:
|
|
5297
|
+
* const off = listen(api, 'todos:changed', () => refresh());
|
|
5298
|
+
*
|
|
5299
|
+
* It's a thin, named wrapper over \`api.on(topic, handler)\` so the place you
|
|
5300
|
+
* listen reads clearly in the UI code. The payload (if any) is passed through.
|
|
5301
|
+
*/
|
|
5302
|
+
export function listen<H extends Handlers>(
|
|
5303
|
+
api: RpcClient<H>,
|
|
5304
|
+
topic: string,
|
|
5305
|
+
handler: (payload?: unknown) => void,
|
|
5306
|
+
): () => void {
|
|
5307
|
+
return api.on(topic, handler);
|
|
5308
|
+
}
|
|
5196
5309
|
`,
|
|
5197
5310
|
"react/src/shared/vsceasy/codiconNames.ts": `/**
|
|
5198
5311
|
* AUTO-GENERATED by scripts/genCodiconTypes.ts — do not edit.
|
|
@@ -5395,6 +5508,18 @@ export type CodiconName =
|
|
|
5395
5508
|
import type { Handlers } from './rpc';
|
|
5396
5509
|
import type { CodiconName } from './codiconNames';
|
|
5397
5510
|
|
|
5511
|
+
/**
|
|
5512
|
+
* Push a change to this webview over the RPC event channel. The webview reacts
|
|
5513
|
+
* with \`listen(api, topic, …)\`. Wire it to a data source with \`watch()\` /
|
|
5514
|
+
* \`watchEntity()\` inside \`rpc()\`:
|
|
5515
|
+
*
|
|
5516
|
+
* rpc: (vscode, ctx, emit) => {
|
|
5517
|
+
* watchEntity(Todos, () => emit('todos:changed'));
|
|
5518
|
+
* return { stats: () => … };
|
|
5519
|
+
* }
|
|
5520
|
+
*/
|
|
5521
|
+
export type RpcEmit = (topic: string, payload?: unknown) => void;
|
|
5522
|
+
|
|
5398
5523
|
export interface PanelDef<H extends Handlers = Handlers> {
|
|
5399
5524
|
/** Stable id. Default: file basename. Used as command suffix and webview key. */
|
|
5400
5525
|
id?: string;
|
|
@@ -5406,8 +5531,11 @@ export interface PanelDef<H extends Handlers = Handlers> {
|
|
|
5406
5531
|
column?: 'active' | 'beside' | 'one' | 'two' | 'three';
|
|
5407
5532
|
/** Keep DOM alive when hidden. Default: true. */
|
|
5408
5533
|
retainContext?: boolean;
|
|
5409
|
-
/**
|
|
5410
|
-
|
|
5534
|
+
/**
|
|
5535
|
+
* RPC handlers — receives the vscode namespace, the extension context, and
|
|
5536
|
+
* \`emit\` for pushing change events to this webview (see {@link RpcEmit}).
|
|
5537
|
+
*/
|
|
5538
|
+
rpc?: (vscode: typeof import('vscode'), ctx: vscode.ExtensionContext, emit: RpcEmit) => H;
|
|
5411
5539
|
/** Optional command palette entry that opens this panel. Default: true. */
|
|
5412
5540
|
command?:
|
|
5413
5541
|
| boolean
|
|
@@ -5518,8 +5646,11 @@ export interface SubpanelDef<H extends Handlers = Handlers> {
|
|
|
5518
5646
|
ui?: string;
|
|
5519
5647
|
/** Keep DOM alive when hidden. Default: true. */
|
|
5520
5648
|
retainContext?: boolean;
|
|
5521
|
-
/**
|
|
5522
|
-
|
|
5649
|
+
/**
|
|
5650
|
+
* RPC handlers — receives the vscode namespace, the extension context, and
|
|
5651
|
+
* \`emit\` for pushing change events to this webview (see {@link RpcEmit}).
|
|
5652
|
+
*/
|
|
5653
|
+
rpc?: (vscode: typeof import('vscode'), ctx: vscode.ExtensionContext, emit: RpcEmit) => H;
|
|
5523
5654
|
}
|
|
5524
5655
|
|
|
5525
5656
|
export function defineSubpanel<H extends Handlers = Handlers>(def: SubpanelDef<H>): SubpanelDef<H> {
|
|
@@ -5621,6 +5752,18 @@ export interface TreeViewDef {
|
|
|
5621
5752
|
vscode: typeof import('vscode'),
|
|
5622
5753
|
ctx: vscode.ExtensionContext,
|
|
5623
5754
|
) => TreeNode[] | Promise<TreeNode[]>;
|
|
5755
|
+
/**
|
|
5756
|
+
* Keep the tree live. Receives \`refresh\` — call it (directly or as a callback)
|
|
5757
|
+
* to re-run \`getChildren\`. Subscribe to a data source here so the tree updates
|
|
5758
|
+
* itself; return an unsubscribe to clean up.
|
|
5759
|
+
*
|
|
5760
|
+
* watch: (refresh) => watchEntity(Todos, refresh),
|
|
5761
|
+
*/
|
|
5762
|
+
watch?: (
|
|
5763
|
+
refresh: () => void,
|
|
5764
|
+
vscode: typeof import('vscode'),
|
|
5765
|
+
ctx: vscode.ExtensionContext,
|
|
5766
|
+
) => (() => void) | void;
|
|
5624
5767
|
}
|
|
5625
5768
|
|
|
5626
5769
|
export function defineTreeView(def: TreeViewDef): TreeViewDef {
|
|
@@ -5662,7 +5805,7 @@ export function defineJob(def: JobDef): JobDef {
|
|
|
5662
5805
|
}
|
|
5663
5806
|
`,
|
|
5664
5807
|
"react/src/shared/vsceasy/index.ts": `export { definePanel, defineCommand, defineMenu, defineStatusBar, defineSubpanel, defineTreeView, defineJob } from './define';
|
|
5665
|
-
export type { PanelDef, CommandDef, MenuDef, MenuItem, MenuIcon, StatusBarDef, StatusBarMenuItem, KeybindingDef, SubpanelDef, TreeViewDef, TreeNode, JobDef, JobSchedule, CodiconName } from './define';
|
|
5808
|
+
export type { PanelDef, CommandDef, MenuDef, MenuItem, MenuIcon, StatusBarDef, StatusBarMenuItem, KeybindingDef, SubpanelDef, TreeViewDef, TreeNode, JobDef, JobSchedule, CodiconName, RpcEmit } from './define';
|
|
5666
5809
|
export { bootstrap } from './bootstrap';
|
|
5667
5810
|
export type { Registry, BootstrapOptions, ActivateHook } from './bootstrap';
|
|
5668
5811
|
export {
|
|
@@ -5674,6 +5817,9 @@ export {
|
|
|
5674
5817
|
webviewState,
|
|
5675
5818
|
} from './rpc';
|
|
5676
5819
|
export type { Transport, RpcClient, Handlers, RpcClientOptions, WebviewApi } from './rpc';
|
|
5820
|
+
export { defineStore, watch } from './store';
|
|
5821
|
+
export type { Store, Watchable } from './store';
|
|
5822
|
+
export { listen } from './client';
|
|
5677
5823
|
`,
|
|
5678
5824
|
"react/src/shared/vsceasy/rpc.ts": `// Typed RPC bridge — webview <-> extension.
|
|
5679
5825
|
// Used by both sides. Transport-agnostic core + thin adapters.
|
|
@@ -5889,6 +6035,80 @@ export function webviewState<T>(defaults: T): {
|
|
|
5889
6035
|
},
|
|
5890
6036
|
};
|
|
5891
6037
|
}
|
|
6038
|
+
`,
|
|
6039
|
+
"react/src/shared/vsceasy/store.ts": `/**
|
|
6040
|
+
* Reactive stores — a tiny, framework-agnostic observable value.
|
|
6041
|
+
*
|
|
6042
|
+
* A store holds one value and notifies subscribers when it changes. It's the
|
|
6043
|
+
* non-ORM half of the reactivity model: use it for arbitrary state (a counter,
|
|
6044
|
+
* a flag, a selection) that a visual element should track.
|
|
6045
|
+
*
|
|
6046
|
+
* const counter = defineStore(0);
|
|
6047
|
+
* counter.subscribe((v) => console.log('now', v));
|
|
6048
|
+
* counter.set(1); // logs: now 1
|
|
6049
|
+
* counter.update((n) => n + 1);
|
|
6050
|
+
*
|
|
6051
|
+
* Pair it with \`watch()\` on the host to push changes to a webview, and
|
|
6052
|
+
* \`listen()\` on the webview to react — see those helpers below and in client.ts.
|
|
6053
|
+
*/
|
|
6054
|
+
export interface Store<T> {
|
|
6055
|
+
/** Read the current value. */
|
|
6056
|
+
get(): T;
|
|
6057
|
+
/** Replace the value and notify subscribers (no-op if \`Object.is\`-equal). */
|
|
6058
|
+
set(next: T): void;
|
|
6059
|
+
/** Derive the next value from the current one, then \`set\` it. */
|
|
6060
|
+
update(fn: (current: T) => T): void;
|
|
6061
|
+
/** Subscribe to changes. Returns an unsubscribe function. */
|
|
6062
|
+
subscribe(cb: (value: T) => void): () => void;
|
|
6063
|
+
}
|
|
6064
|
+
|
|
6065
|
+
export function defineStore<T>(initial: T): Store<T> {
|
|
6066
|
+
let value = initial;
|
|
6067
|
+
const subs = new Set<(value: T) => void>();
|
|
6068
|
+
|
|
6069
|
+
return {
|
|
6070
|
+
get: () => value,
|
|
6071
|
+
set(next) {
|
|
6072
|
+
if (Object.is(value, next)) return;
|
|
6073
|
+
value = next;
|
|
6074
|
+
subs.forEach((cb) => {
|
|
6075
|
+
try { cb(value); } catch { /* a bad subscriber must not break a set */ }
|
|
6076
|
+
});
|
|
6077
|
+
},
|
|
6078
|
+
update(fn) {
|
|
6079
|
+
this.set(fn(value));
|
|
6080
|
+
},
|
|
6081
|
+
subscribe(cb) {
|
|
6082
|
+
subs.add(cb);
|
|
6083
|
+
return () => { subs.delete(cb); };
|
|
6084
|
+
},
|
|
6085
|
+
};
|
|
6086
|
+
}
|
|
6087
|
+
|
|
6088
|
+
/**
|
|
6089
|
+
* Anything that can be watched: it exposes a \`subscribe(cb)\` returning an
|
|
6090
|
+
* unsubscribe function. Stores satisfy this directly. (ORM entities are watched
|
|
6091
|
+
* with \`watchEntity\` from your generated \`db.ts\`, which has the same shape.)
|
|
6092
|
+
*/
|
|
6093
|
+
export interface Watchable {
|
|
6094
|
+
subscribe(cb: (...args: any[]) => void): () => void;
|
|
6095
|
+
}
|
|
6096
|
+
|
|
6097
|
+
/**
|
|
6098
|
+
* Bridge a watchable source to a side-effect — typically an RPC emit that pushes
|
|
6099
|
+
* the change to a subscribed webview. Runs \`effect\` on every change. Returns an
|
|
6100
|
+
* unsubscribe function; register it on the panel's \`ctx.subscriptions\` (wrapped
|
|
6101
|
+
* in \`{ dispose }\`) so it's cleaned up when the extension deactivates.
|
|
6102
|
+
*
|
|
6103
|
+
* // host side, in a panel's rpc():
|
|
6104
|
+
* watch(badgeCount, () => server.emit('badge:changed'));
|
|
6105
|
+
*
|
|
6106
|
+
* For ORM entities, use \`watchEntity(Todos, () => server.emit('todos:changed'))\`
|
|
6107
|
+
* from your generated db.ts — same idea, same return.
|
|
6108
|
+
*/
|
|
6109
|
+
export function watch(source: Watchable, effect: () => void): () => void {
|
|
6110
|
+
return source.subscribe(() => effect());
|
|
6111
|
+
}
|
|
5892
6112
|
`,
|
|
5893
6113
|
"react/src/webview/panels/dashboard/App.tsx": `import React, { useEffect, useState } from 'react';
|
|
5894
6114
|
import { connectWebview } from '../../../shared/vsceasy/client';
|
|
@@ -6723,7 +6943,7 @@ function renderInput(field, override) {
|
|
|
6723
6943
|
case "boolean":
|
|
6724
6944
|
return wrap(` <input type="checkbox" checked={!!form.${name}} onChange={(e) => onChange('${name}', e.target.checked as any)} />`);
|
|
6725
6945
|
case "date":
|
|
6726
|
-
return wrap(` <input type="date"${required} value={(form.${name}
|
|
6946
|
+
return wrap(` <input type="date"${required} value={toDateInput(form.${name})} onChange={(e) => onChange('${name}', e.target.value as any)} />`);
|
|
6727
6947
|
case "select": {
|
|
6728
6948
|
const opts = (spec.options ?? []).map((o) => ` <option value=${JSON.stringify(o)}>${escapeJsx(o)}</option>`).join(`
|
|
6729
6949
|
`);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type StoreType = 'number' | 'string' | 'boolean' | 'json';
|
|
2
|
+
export interface AddStoreOptions {
|
|
3
|
+
name: string;
|
|
4
|
+
/** Value type. Default: 'number'. */
|
|
5
|
+
type?: StoreType;
|
|
6
|
+
/** Raw initial value expression. Defaults per type (0 / '' / false / null). */
|
|
7
|
+
initial?: string;
|
|
8
|
+
projectRoot: string;
|
|
9
|
+
templatesRoot: string;
|
|
10
|
+
}
|
|
11
|
+
export interface AddStoreResult {
|
|
12
|
+
created: string[];
|
|
13
|
+
}
|
|
14
|
+
export declare function addStore(opts: AddStoreOptions): AddStoreResult;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const TEMPLATES_VERSION = "0.1.
|
|
1
|
+
export declare const TEMPLATES_VERSION = "0.1.8";
|
|
2
2
|
export declare const TEMPLATE_FILES: Record<string, string>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vsceasy/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Build VS Code extensions fast — React UI + typed RPC bridge between extension and webview + file-based routing for panels, commands, menus, tree views, and subpanels.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -9,21 +9,35 @@ type FormState = Partial<{{Name}}>;
|
|
|
9
9
|
|
|
10
10
|
const emptyForm: FormState = {{emptyFormLiteral}};
|
|
11
11
|
|
|
12
|
+
// `<input type="date">` only accepts a `yyyy-MM-dd` value. Stored dates may be
|
|
13
|
+
// ISO strings or Date objects, so normalize before binding to the input.
|
|
14
|
+
function toDateInput(v: unknown): string {
|
|
15
|
+
if (v == null || v === '') return '';
|
|
16
|
+
const d = v instanceof Date ? v : new Date(v as string);
|
|
17
|
+
return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10);
|
|
18
|
+
}
|
|
19
|
+
|
|
12
20
|
export function App() {
|
|
13
21
|
const [form, setForm] = useState<FormState>(emptyForm);
|
|
14
22
|
const [editingId, setEditingId] = useState<{{Name}}['{{primaryKey}}'] | null>(null);
|
|
15
23
|
const [error, setError] = useState<string | null>(null);
|
|
16
24
|
const [saving, setSaving] = useState(false);
|
|
17
25
|
|
|
18
|
-
const load = useCallback(async () => {
|
|
19
|
-
// The list stashes
|
|
20
|
-
// clears it after handing it over)
|
|
26
|
+
const load = useCallback(async (initial: boolean) => {
|
|
27
|
+
// The list stashes a row id before revealing this panel. Pull it (the host
|
|
28
|
+
// clears it after handing it over).
|
|
21
29
|
const id = await api.pendingId();
|
|
22
30
|
if (id == null || id === '') {
|
|
23
|
-
|
|
24
|
-
|
|
31
|
+
// No row was requested. On the first mount, start with an empty "new" form.
|
|
32
|
+
// On later reveals (focus/visibility), DON'T reset — that would wipe a form
|
|
33
|
+
// the user is busy filling in. Just leave the current state as-is.
|
|
34
|
+
if (initial) {
|
|
35
|
+
setForm(emptyForm);
|
|
36
|
+
setEditingId(null);
|
|
37
|
+
}
|
|
25
38
|
return;
|
|
26
39
|
}
|
|
40
|
+
// The list asked to edit a specific row — load it, replacing the current form.
|
|
27
41
|
const row = await api.get(id);
|
|
28
42
|
if (row) {
|
|
29
43
|
setForm(row);
|
|
@@ -32,11 +46,12 @@ export function App() {
|
|
|
32
46
|
}, []);
|
|
33
47
|
|
|
34
48
|
useEffect(() => {
|
|
35
|
-
void load();
|
|
36
|
-
// Webviews retain state when hidden, so re-
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
const
|
|
49
|
+
void load(true);
|
|
50
|
+
// Webviews retain state when hidden, so re-check on reveal: the list may have
|
|
51
|
+
// asked to edit a different row. When nothing is pending, `load` leaves the
|
|
52
|
+
// in-progress form untouched (see above).
|
|
53
|
+
const onFocus = () => { void load(false); };
|
|
54
|
+
const onVisible = () => { if (document.visibilityState === 'visible') void load(false); };
|
|
40
55
|
window.addEventListener('focus', onFocus);
|
|
41
56
|
document.addEventListener('visibilitychange', onVisible);
|
|
42
57
|
return () => {
|
|
@@ -64,6 +64,35 @@ export interface Repository<T> {
|
|
|
64
64
|
clear(): Promise<void>;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// ── Change notifications (reactivity) ────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Per-entity change listeners. Any mutation (insert/upsert/update/delete/...) on
|
|
71
|
+
* an entity fires its listeners with the entity name. This is the host-side hook
|
|
72
|
+
* the `watch()` helper builds on to push updates to webviews.
|
|
73
|
+
*/
|
|
74
|
+
const entityListeners = new Map<string, Set<(entity: string) => void>>();
|
|
75
|
+
|
|
76
|
+
function emitChange(entity: string): void {
|
|
77
|
+
entityListeners.get(entity)?.forEach((cb) => {
|
|
78
|
+
try { cb(entity); } catch { /* a bad listener must not break a write */ }
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Subscribe to changes on an entity. Returns an unsubscribe function.
|
|
84
|
+
*
|
|
85
|
+
* watchEntity(Todos, () => server.emit('todos:changed'));
|
|
86
|
+
*
|
|
87
|
+
* Prefer the higher-level `watch()` helper, which wires this to an RPC emit.
|
|
88
|
+
*/
|
|
89
|
+
export function watchEntity<T>(entity: Entity<T>, cb: (entity: string) => void): () => void {
|
|
90
|
+
let set = entityListeners.get(entity.name);
|
|
91
|
+
if (!set) entityListeners.set(entity.name, (set = new Set()));
|
|
92
|
+
set.add(cb);
|
|
93
|
+
return () => { set!.delete(cb); };
|
|
94
|
+
}
|
|
95
|
+
|
|
67
96
|
// ── Provider interface (future-proof for sqlite/etc.) ────────────────────────
|
|
68
97
|
|
|
69
98
|
export interface Provider {
|
|
@@ -234,6 +263,7 @@ function makeDb(provider: Provider): Db {
|
|
|
234
263
|
}
|
|
235
264
|
rows.push(row);
|
|
236
265
|
await save(rows);
|
|
266
|
+
emitChange(entity.name);
|
|
237
267
|
return row;
|
|
238
268
|
},
|
|
239
269
|
async upsert(row) {
|
|
@@ -241,6 +271,7 @@ function makeDb(provider: Provider): Db {
|
|
|
241
271
|
const i = rows.findIndex((r) => r[pk] === row[pk]);
|
|
242
272
|
if (i >= 0) rows[i] = row; else rows.push(row);
|
|
243
273
|
await save(rows);
|
|
274
|
+
emitChange(entity.name);
|
|
244
275
|
return row;
|
|
245
276
|
},
|
|
246
277
|
async update(id, patch) {
|
|
@@ -249,6 +280,7 @@ function makeDb(provider: Provider): Db {
|
|
|
249
280
|
if (i < 0) return null;
|
|
250
281
|
rows[i] = { ...rows[i], ...patch };
|
|
251
282
|
await save(rows);
|
|
283
|
+
emitChange(entity.name);
|
|
252
284
|
return rows[i];
|
|
253
285
|
},
|
|
254
286
|
async delete(id) {
|
|
@@ -257,17 +289,19 @@ function makeDb(provider: Provider): Db {
|
|
|
257
289
|
const next = rows.filter((r) => r[pk] !== id);
|
|
258
290
|
if (next.length === before) return false;
|
|
259
291
|
await save(next);
|
|
292
|
+
emitChange(entity.name);
|
|
260
293
|
return true;
|
|
261
294
|
},
|
|
262
295
|
async deleteMany(where) {
|
|
263
296
|
const rows = await load();
|
|
264
297
|
const next = rows.filter((r) => !match(r, where));
|
|
265
298
|
const removed = rows.length - next.length;
|
|
266
|
-
if (removed > 0) await save(next);
|
|
299
|
+
if (removed > 0) { await save(next); emitChange(entity.name); }
|
|
267
300
|
return removed;
|
|
268
301
|
},
|
|
269
302
|
async clear() {
|
|
270
303
|
await save([]);
|
|
304
|
+
emitChange(entity.name);
|
|
271
305
|
},
|
|
272
306
|
};
|
|
273
307
|
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { defineStore } from '../shared/vsceasy';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reactive store: a single observable value. Mutate it with `.set()` / `.update()`
|
|
5
|
+
* and anything subscribed reacts.
|
|
6
|
+
*
|
|
7
|
+
* import { {{name}} } from '../stores/{{name}}';
|
|
8
|
+
* {{name}}.set({{example}});
|
|
9
|
+
* {{name}}.update((v) => v);
|
|
10
|
+
*
|
|
11
|
+
* To push changes to a webview, watch it on the host and emit over RPC:
|
|
12
|
+
*
|
|
13
|
+
* // inside a panel/subpanel rpc():
|
|
14
|
+
* import { watch } from '../shared/vsceasy';
|
|
15
|
+
* import { {{name}} } from '../stores/{{name}}';
|
|
16
|
+
* watch({{name}}, () => server.emit('{{name}}:changed', {{name}}.get()));
|
|
17
|
+
*
|
|
18
|
+
* Then listen in the webview:
|
|
19
|
+
*
|
|
20
|
+
* import { listen } from '../shared/vsceasy/client';
|
|
21
|
+
* listen(api, '{{name}}:changed', (v) => render(v));
|
|
22
|
+
*/
|
|
23
|
+
export const {{name}} = defineStore<{{type}}>({{initial}});
|
|
@@ -230,6 +230,13 @@ function registerTreeView(
|
|
|
230
230
|
vscode.commands.registerCommand(refreshCmd, () => provider.refresh()),
|
|
231
231
|
);
|
|
232
232
|
|
|
233
|
+
// Keep the tree live: let the def subscribe to a data source and re-run
|
|
234
|
+
// getChildren on change. The unsubscribe is disposed on deactivate.
|
|
235
|
+
if (def.watch) {
|
|
236
|
+
const off = def.watch(() => provider.refresh(), vscode, context);
|
|
237
|
+
if (off) context.subscriptions.push({ dispose: off });
|
|
238
|
+
}
|
|
239
|
+
|
|
233
240
|
const dispatchCmd = `${registry.prefix}._tree.${def.id ?? id}.run`;
|
|
234
241
|
context.subscriptions.push(
|
|
235
242
|
vscode.commands.registerCommand(dispatchCmd, async (node: TreeNode) => {
|
|
@@ -306,9 +313,11 @@ function registerSubpanel(
|
|
|
306
313
|
const ui = def.ui ?? `subpanels/${def.id ?? id}`;
|
|
307
314
|
view.webview.html = renderHtml(view.webview, context, ui, def.title);
|
|
308
315
|
if (def.rpc) {
|
|
309
|
-
|
|
310
|
-
const
|
|
311
|
-
|
|
316
|
+
let server: ReturnType<typeof createRpcServer> | undefined;
|
|
317
|
+
const emit = (topic: string, payload?: unknown) => server?.emit(topic, payload);
|
|
318
|
+
const handlers = def.rpc(vscode, context, emit);
|
|
319
|
+
server = createRpcServer(webviewTransport(view.webview), handlers);
|
|
320
|
+
view.onDidDispose(() => server!.dispose());
|
|
312
321
|
}
|
|
313
322
|
},
|
|
314
323
|
};
|
|
@@ -544,9 +553,13 @@ function openPanel(context: vscode.ExtensionContext, prefix: string, id: string,
|
|
|
544
553
|
panel.webview.html = renderHtml(panel.webview, context, ui, def.title);
|
|
545
554
|
|
|
546
555
|
if (def.rpc) {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
556
|
+
// `emit` is wired lazily: the rpc factory may call watch()/watchEntity()
|
|
557
|
+
// immediately, but the server (and thus the real emit) only exists after.
|
|
558
|
+
let server: ReturnType<typeof createRpcServer> | undefined;
|
|
559
|
+
const emit = (topic: string, payload?: unknown) => server?.emit(topic, payload);
|
|
560
|
+
const handlers = def.rpc(vscode, context, emit);
|
|
561
|
+
server = createRpcServer(webviewTransport(panel.webview), handlers);
|
|
562
|
+
panel.onDidDispose(() => server!.dispose());
|
|
550
563
|
}
|
|
551
564
|
|
|
552
565
|
openPanels.set(key, panel);
|