@vsceasy/cli 0.1.7 → 0.1.9
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 +10 -0
- package/README.md +10 -0
- package/dist/bin/cli.js +543 -72
- package/dist/commands/groups.d.ts +1 -0
- package/dist/commands/store/add.d.ts +3 -0
- package/dist/index.js +327 -26
- package/dist/lib/crud/parseModel.d.ts +12 -0
- package/dist/lib/model/add.d.ts +18 -0
- package/dist/lib/model/parseFields.d.ts +9 -3
- 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 +2 -0
- package/templates/_generators/crud/formPanel.ts.tpl +2 -2
- package/templates/_generators/helper/db.ts.tpl +35 -1
- package/templates/_generators/model/model.ts.tpl +1 -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.9", TEMPLATE_FILES;
|
|
2242
2242
|
var init_templatesData = __esm(() => {
|
|
2243
2243
|
TEMPLATE_FILES = {
|
|
2244
2244
|
"_generators/command/command.ts.tpl": `import { defineCommand } from '../shared/vsceasy';
|
|
@@ -2450,6 +2450,7 @@ export function App() {
|
|
|
2450
2450
|
const [editingId, setEditingId] = useState<{{Name}}['{{primaryKey}}'] | null>(null);
|
|
2451
2451
|
const [error, setError] = useState<string | null>(null);
|
|
2452
2452
|
const [saving, setSaving] = useState(false);
|
|
2453
|
+
{{relationOptionsState}}
|
|
2453
2454
|
|
|
2454
2455
|
const load = useCallback(async (initial: boolean) => {
|
|
2455
2456
|
// The list stashes a row id before revealing this panel. Pull it (the host
|
|
@@ -2487,6 +2488,7 @@ export function App() {
|
|
|
2487
2488
|
document.removeEventListener('visibilitychange', onVisible);
|
|
2488
2489
|
};
|
|
2489
2490
|
}, [load]);
|
|
2491
|
+
{{relationOptionsLoad}}
|
|
2490
2492
|
|
|
2491
2493
|
const onChange = <K extends keyof FormState>(k: K, v: FormState[K]) => {
|
|
2492
2494
|
setForm((f) => ({ ...f, [k]: v }));
|
|
@@ -2550,7 +2552,7 @@ import { {{Name}}Service } from '../services/{{Name}}Service';
|
|
|
2550
2552
|
import { takePending{{Name}}Id } from '../services/{{name}}FormNav';
|
|
2551
2553
|
import type { {{Name}}FormApi } from '../shared/api';
|
|
2552
2554
|
import type { {{Name}} } from '../models/{{Name}}';
|
|
2553
|
-
|
|
2555
|
+
{{relationImports}}
|
|
2554
2556
|
export default definePanel<{{Name}}FormApi>({
|
|
2555
2557
|
title: '{{title}}',
|
|
2556
2558
|
column: 'beside',
|
|
@@ -2572,7 +2574,7 @@ export default definePanel<{{Name}}FormApi>({
|
|
|
2572
2574
|
void vscode.commands.executeCommand('{{prefix}}.open{{Plural}}List');
|
|
2573
2575
|
return saved;
|
|
2574
2576
|
},
|
|
2575
|
-
async cancel() {
|
|
2577
|
+
{{relationOptionsHandler}} async cancel() {
|
|
2576
2578
|
// No-op — webview closes itself.
|
|
2577
2579
|
},
|
|
2578
2580
|
}),
|
|
@@ -2950,6 +2952,35 @@ export interface Repository<T> {
|
|
|
2950
2952
|
clear(): Promise<void>;
|
|
2951
2953
|
}
|
|
2952
2954
|
|
|
2955
|
+
// ── Change notifications (reactivity) ────────────────────────────────────────
|
|
2956
|
+
|
|
2957
|
+
/**
|
|
2958
|
+
* Per-entity change listeners. Any mutation (insert/upsert/update/delete/...) on
|
|
2959
|
+
* an entity fires its listeners with the entity name. This is the host-side hook
|
|
2960
|
+
* the \`watch()\` helper builds on to push updates to webviews.
|
|
2961
|
+
*/
|
|
2962
|
+
const entityListeners = new Map<string, Set<(entity: string) => void>>();
|
|
2963
|
+
|
|
2964
|
+
function emitChange(entity: string): void {
|
|
2965
|
+
entityListeners.get(entity)?.forEach((cb) => {
|
|
2966
|
+
try { cb(entity); } catch { /* a bad listener must not break a write */ }
|
|
2967
|
+
});
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
/**
|
|
2971
|
+
* Subscribe to changes on an entity. Returns an unsubscribe function.
|
|
2972
|
+
*
|
|
2973
|
+
* watchEntity(Todos, () => server.emit('todos:changed'));
|
|
2974
|
+
*
|
|
2975
|
+
* Prefer the higher-level \`watch()\` helper, which wires this to an RPC emit.
|
|
2976
|
+
*/
|
|
2977
|
+
export function watchEntity<T>(entity: Entity<T>, cb: (entity: string) => void): () => void {
|
|
2978
|
+
let set = entityListeners.get(entity.name);
|
|
2979
|
+
if (!set) entityListeners.set(entity.name, (set = new Set()));
|
|
2980
|
+
set.add(cb);
|
|
2981
|
+
return () => { set!.delete(cb); };
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2953
2984
|
// ── Provider interface (future-proof for sqlite/etc.) ────────────────────────
|
|
2954
2985
|
|
|
2955
2986
|
export interface Provider {
|
|
@@ -3120,6 +3151,7 @@ function makeDb(provider: Provider): Db {
|
|
|
3120
3151
|
}
|
|
3121
3152
|
rows.push(row);
|
|
3122
3153
|
await save(rows);
|
|
3154
|
+
emitChange(entity.name);
|
|
3123
3155
|
return row;
|
|
3124
3156
|
},
|
|
3125
3157
|
async upsert(row) {
|
|
@@ -3127,6 +3159,7 @@ function makeDb(provider: Provider): Db {
|
|
|
3127
3159
|
const i = rows.findIndex((r) => r[pk] === row[pk]);
|
|
3128
3160
|
if (i >= 0) rows[i] = row; else rows.push(row);
|
|
3129
3161
|
await save(rows);
|
|
3162
|
+
emitChange(entity.name);
|
|
3130
3163
|
return row;
|
|
3131
3164
|
},
|
|
3132
3165
|
async update(id, patch) {
|
|
@@ -3135,6 +3168,7 @@ function makeDb(provider: Provider): Db {
|
|
|
3135
3168
|
if (i < 0) return null;
|
|
3136
3169
|
rows[i] = { ...rows[i], ...patch };
|
|
3137
3170
|
await save(rows);
|
|
3171
|
+
emitChange(entity.name);
|
|
3138
3172
|
return rows[i];
|
|
3139
3173
|
},
|
|
3140
3174
|
async delete(id) {
|
|
@@ -3143,17 +3177,19 @@ function makeDb(provider: Provider): Db {
|
|
|
3143
3177
|
const next = rows.filter((r) => r[pk] !== id);
|
|
3144
3178
|
if (next.length === before) return false;
|
|
3145
3179
|
await save(next);
|
|
3180
|
+
emitChange(entity.name);
|
|
3146
3181
|
return true;
|
|
3147
3182
|
},
|
|
3148
3183
|
async deleteMany(where) {
|
|
3149
3184
|
const rows = await load();
|
|
3150
3185
|
const next = rows.filter((r) => !match(r, where));
|
|
3151
3186
|
const removed = rows.length - next.length;
|
|
3152
|
-
if (removed > 0) await save(next);
|
|
3187
|
+
if (removed > 0) { await save(next); emitChange(entity.name); }
|
|
3153
3188
|
return removed;
|
|
3154
3189
|
},
|
|
3155
3190
|
async clear() {
|
|
3156
3191
|
await save([]);
|
|
3192
|
+
emitChange(entity.name);
|
|
3157
3193
|
},
|
|
3158
3194
|
};
|
|
3159
3195
|
};
|
|
@@ -3384,7 +3420,7 @@ export const {{Plural}} = defineEntity<{{Name}}>('{{collection}}', {
|
|
|
3384
3420
|
* import { {{Plural}}Repo } from '../models/{{Name}}';
|
|
3385
3421
|
* await {{Plural}}Repo().insert({ ... });
|
|
3386
3422
|
*/
|
|
3387
|
-
export const {{Plural}}Repo = () => db()({{Plural}});
|
|
3423
|
+
export const {{Plural}}Repo = () => db()({{Plural}});{{relationsBlock}}
|
|
3388
3424
|
`,
|
|
3389
3425
|
"_generators/panel/App.tsx.tpl": `import React from 'react';
|
|
3390
3426
|
{{apiBlock}}
|
|
@@ -3578,6 +3614,30 @@ export default defineStatusBar({
|
|
|
3578
3614
|
alignment: '{{alignment}}',
|
|
3579
3615
|
priority: {{priority}},{{commandLine}}{{panelLine}}
|
|
3580
3616
|
});
|
|
3617
|
+
`,
|
|
3618
|
+
"_generators/store/store.ts.tpl": `import { defineStore } from '../shared/vsceasy';
|
|
3619
|
+
|
|
3620
|
+
/**
|
|
3621
|
+
* Reactive store: a single observable value. Mutate it with \`.set()\` / \`.update()\`
|
|
3622
|
+
* and anything subscribed reacts.
|
|
3623
|
+
*
|
|
3624
|
+
* import { {{name}} } from '../stores/{{name}}';
|
|
3625
|
+
* {{name}}.set({{example}});
|
|
3626
|
+
* {{name}}.update((v) => v);
|
|
3627
|
+
*
|
|
3628
|
+
* To push changes to a webview, watch it on the host and emit over RPC:
|
|
3629
|
+
*
|
|
3630
|
+
* // inside a panel/subpanel rpc():
|
|
3631
|
+
* import { watch } from '../shared/vsceasy';
|
|
3632
|
+
* import { {{name}} } from '../stores/{{name}}';
|
|
3633
|
+
* watch({{name}}, () => server.emit('{{name}}:changed', {{name}}.get()));
|
|
3634
|
+
*
|
|
3635
|
+
* Then listen in the webview:
|
|
3636
|
+
*
|
|
3637
|
+
* import { listen } from '../shared/vsceasy/client';
|
|
3638
|
+
* listen(api, '{{name}}:changed', (v) => render(v));
|
|
3639
|
+
*/
|
|
3640
|
+
export const {{name}} = defineStore<{{type}}>({{initial}});
|
|
3581
3641
|
`,
|
|
3582
3642
|
"_generators/subpanel/App.tsx.tpl": `import React from 'react';
|
|
3583
3643
|
{{apiBlock}}
|
|
@@ -4774,6 +4834,13 @@ function registerTreeView(
|
|
|
4774
4834
|
vscode.commands.registerCommand(refreshCmd, () => provider.refresh()),
|
|
4775
4835
|
);
|
|
4776
4836
|
|
|
4837
|
+
// Keep the tree live: let the def subscribe to a data source and re-run
|
|
4838
|
+
// getChildren on change. The unsubscribe is disposed on deactivate.
|
|
4839
|
+
if (def.watch) {
|
|
4840
|
+
const off = def.watch(() => provider.refresh(), vscode, context);
|
|
4841
|
+
if (off) context.subscriptions.push({ dispose: off });
|
|
4842
|
+
}
|
|
4843
|
+
|
|
4777
4844
|
const dispatchCmd = \`\${registry.prefix}._tree.\${def.id ?? id}.run\`;
|
|
4778
4845
|
context.subscriptions.push(
|
|
4779
4846
|
vscode.commands.registerCommand(dispatchCmd, async (node: TreeNode) => {
|
|
@@ -4850,9 +4917,11 @@ function registerSubpanel(
|
|
|
4850
4917
|
const ui = def.ui ?? \`subpanels/\${def.id ?? id}\`;
|
|
4851
4918
|
view.webview.html = renderHtml(view.webview, context, ui, def.title);
|
|
4852
4919
|
if (def.rpc) {
|
|
4853
|
-
|
|
4854
|
-
const
|
|
4855
|
-
|
|
4920
|
+
let server: ReturnType<typeof createRpcServer> | undefined;
|
|
4921
|
+
const emit = (topic: string, payload?: unknown) => server?.emit(topic, payload);
|
|
4922
|
+
const handlers = def.rpc(vscode, context, emit);
|
|
4923
|
+
server = createRpcServer(webviewTransport(view.webview), handlers);
|
|
4924
|
+
view.onDidDispose(() => server!.dispose());
|
|
4856
4925
|
}
|
|
4857
4926
|
},
|
|
4858
4927
|
};
|
|
@@ -5088,9 +5157,13 @@ function openPanel(context: vscode.ExtensionContext, prefix: string, id: string,
|
|
|
5088
5157
|
panel.webview.html = renderHtml(panel.webview, context, ui, def.title);
|
|
5089
5158
|
|
|
5090
5159
|
if (def.rpc) {
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5160
|
+
// \`emit\` is wired lazily: the rpc factory may call watch()/watchEntity()
|
|
5161
|
+
// immediately, but the server (and thus the real emit) only exists after.
|
|
5162
|
+
let server: ReturnType<typeof createRpcServer> | undefined;
|
|
5163
|
+
const emit = (topic: string, payload?: unknown) => server?.emit(topic, payload);
|
|
5164
|
+
const handlers = def.rpc(vscode, context, emit);
|
|
5165
|
+
server = createRpcServer(webviewTransport(panel.webview), handlers);
|
|
5166
|
+
panel.onDidDispose(() => server!.dispose());
|
|
5094
5167
|
}
|
|
5095
5168
|
|
|
5096
5169
|
openPanels.set(key, panel);
|
|
@@ -5208,6 +5281,33 @@ export {
|
|
|
5208
5281
|
webviewState,
|
|
5209
5282
|
} from './rpc';
|
|
5210
5283
|
export type { RpcClient, Handlers, Transport, RpcClientOptions, WebviewApi } from './rpc';
|
|
5284
|
+
|
|
5285
|
+
export { defineStore } from './store';
|
|
5286
|
+
export type { Store } from './store';
|
|
5287
|
+
|
|
5288
|
+
import type { RpcClient, Handlers } from './rpc';
|
|
5289
|
+
|
|
5290
|
+
/**
|
|
5291
|
+
* Listen for a change pushed from the host over the RPC event channel, and run a
|
|
5292
|
+
* callback when it arrives. This is the webview side of the reactivity model —
|
|
5293
|
+
* the host emits with \`server.emit(topic, …)\` (usually wired via \`watch()\` /
|
|
5294
|
+
* \`watchEntity()\`), and your visual element reacts here. Returns an unsubscribe
|
|
5295
|
+
* function; call it on unmount.
|
|
5296
|
+
*
|
|
5297
|
+
* const api = connectWebview<TodoStatsViewApi>();
|
|
5298
|
+
* // re-read + re-render whenever todos change on the host:
|
|
5299
|
+
* const off = listen(api, 'todos:changed', () => refresh());
|
|
5300
|
+
*
|
|
5301
|
+
* It's a thin, named wrapper over \`api.on(topic, handler)\` so the place you
|
|
5302
|
+
* listen reads clearly in the UI code. The payload (if any) is passed through.
|
|
5303
|
+
*/
|
|
5304
|
+
export function listen<H extends Handlers>(
|
|
5305
|
+
api: RpcClient<H>,
|
|
5306
|
+
topic: string,
|
|
5307
|
+
handler: (payload?: unknown) => void,
|
|
5308
|
+
): () => void {
|
|
5309
|
+
return api.on(topic, handler);
|
|
5310
|
+
}
|
|
5211
5311
|
`,
|
|
5212
5312
|
"react/src/shared/vsceasy/codiconNames.ts": `/**
|
|
5213
5313
|
* AUTO-GENERATED by scripts/genCodiconTypes.ts — do not edit.
|
|
@@ -5410,6 +5510,18 @@ export type CodiconName =
|
|
|
5410
5510
|
import type { Handlers } from './rpc';
|
|
5411
5511
|
import type { CodiconName } from './codiconNames';
|
|
5412
5512
|
|
|
5513
|
+
/**
|
|
5514
|
+
* Push a change to this webview over the RPC event channel. The webview reacts
|
|
5515
|
+
* with \`listen(api, topic, …)\`. Wire it to a data source with \`watch()\` /
|
|
5516
|
+
* \`watchEntity()\` inside \`rpc()\`:
|
|
5517
|
+
*
|
|
5518
|
+
* rpc: (vscode, ctx, emit) => {
|
|
5519
|
+
* watchEntity(Todos, () => emit('todos:changed'));
|
|
5520
|
+
* return { stats: () => … };
|
|
5521
|
+
* }
|
|
5522
|
+
*/
|
|
5523
|
+
export type RpcEmit = (topic: string, payload?: unknown) => void;
|
|
5524
|
+
|
|
5413
5525
|
export interface PanelDef<H extends Handlers = Handlers> {
|
|
5414
5526
|
/** Stable id. Default: file basename. Used as command suffix and webview key. */
|
|
5415
5527
|
id?: string;
|
|
@@ -5421,8 +5533,11 @@ export interface PanelDef<H extends Handlers = Handlers> {
|
|
|
5421
5533
|
column?: 'active' | 'beside' | 'one' | 'two' | 'three';
|
|
5422
5534
|
/** Keep DOM alive when hidden. Default: true. */
|
|
5423
5535
|
retainContext?: boolean;
|
|
5424
|
-
/**
|
|
5425
|
-
|
|
5536
|
+
/**
|
|
5537
|
+
* RPC handlers — receives the vscode namespace, the extension context, and
|
|
5538
|
+
* \`emit\` for pushing change events to this webview (see {@link RpcEmit}).
|
|
5539
|
+
*/
|
|
5540
|
+
rpc?: (vscode: typeof import('vscode'), ctx: vscode.ExtensionContext, emit: RpcEmit) => H;
|
|
5426
5541
|
/** Optional command palette entry that opens this panel. Default: true. */
|
|
5427
5542
|
command?:
|
|
5428
5543
|
| boolean
|
|
@@ -5533,8 +5648,11 @@ export interface SubpanelDef<H extends Handlers = Handlers> {
|
|
|
5533
5648
|
ui?: string;
|
|
5534
5649
|
/** Keep DOM alive when hidden. Default: true. */
|
|
5535
5650
|
retainContext?: boolean;
|
|
5536
|
-
/**
|
|
5537
|
-
|
|
5651
|
+
/**
|
|
5652
|
+
* RPC handlers — receives the vscode namespace, the extension context, and
|
|
5653
|
+
* \`emit\` for pushing change events to this webview (see {@link RpcEmit}).
|
|
5654
|
+
*/
|
|
5655
|
+
rpc?: (vscode: typeof import('vscode'), ctx: vscode.ExtensionContext, emit: RpcEmit) => H;
|
|
5538
5656
|
}
|
|
5539
5657
|
|
|
5540
5658
|
export function defineSubpanel<H extends Handlers = Handlers>(def: SubpanelDef<H>): SubpanelDef<H> {
|
|
@@ -5636,6 +5754,18 @@ export interface TreeViewDef {
|
|
|
5636
5754
|
vscode: typeof import('vscode'),
|
|
5637
5755
|
ctx: vscode.ExtensionContext,
|
|
5638
5756
|
) => TreeNode[] | Promise<TreeNode[]>;
|
|
5757
|
+
/**
|
|
5758
|
+
* Keep the tree live. Receives \`refresh\` — call it (directly or as a callback)
|
|
5759
|
+
* to re-run \`getChildren\`. Subscribe to a data source here so the tree updates
|
|
5760
|
+
* itself; return an unsubscribe to clean up.
|
|
5761
|
+
*
|
|
5762
|
+
* watch: (refresh) => watchEntity(Todos, refresh),
|
|
5763
|
+
*/
|
|
5764
|
+
watch?: (
|
|
5765
|
+
refresh: () => void,
|
|
5766
|
+
vscode: typeof import('vscode'),
|
|
5767
|
+
ctx: vscode.ExtensionContext,
|
|
5768
|
+
) => (() => void) | void;
|
|
5639
5769
|
}
|
|
5640
5770
|
|
|
5641
5771
|
export function defineTreeView(def: TreeViewDef): TreeViewDef {
|
|
@@ -5677,7 +5807,7 @@ export function defineJob(def: JobDef): JobDef {
|
|
|
5677
5807
|
}
|
|
5678
5808
|
`,
|
|
5679
5809
|
"react/src/shared/vsceasy/index.ts": `export { definePanel, defineCommand, defineMenu, defineStatusBar, defineSubpanel, defineTreeView, defineJob } from './define';
|
|
5680
|
-
export type { PanelDef, CommandDef, MenuDef, MenuItem, MenuIcon, StatusBarDef, StatusBarMenuItem, KeybindingDef, SubpanelDef, TreeViewDef, TreeNode, JobDef, JobSchedule, CodiconName } from './define';
|
|
5810
|
+
export type { PanelDef, CommandDef, MenuDef, MenuItem, MenuIcon, StatusBarDef, StatusBarMenuItem, KeybindingDef, SubpanelDef, TreeViewDef, TreeNode, JobDef, JobSchedule, CodiconName, RpcEmit } from './define';
|
|
5681
5811
|
export { bootstrap } from './bootstrap';
|
|
5682
5812
|
export type { Registry, BootstrapOptions, ActivateHook } from './bootstrap';
|
|
5683
5813
|
export {
|
|
@@ -5689,6 +5819,9 @@ export {
|
|
|
5689
5819
|
webviewState,
|
|
5690
5820
|
} from './rpc';
|
|
5691
5821
|
export type { Transport, RpcClient, Handlers, RpcClientOptions, WebviewApi } from './rpc';
|
|
5822
|
+
export { defineStore, watch } from './store';
|
|
5823
|
+
export type { Store, Watchable } from './store';
|
|
5824
|
+
export { listen } from './client';
|
|
5692
5825
|
`,
|
|
5693
5826
|
"react/src/shared/vsceasy/rpc.ts": `// Typed RPC bridge — webview <-> extension.
|
|
5694
5827
|
// Used by both sides. Transport-agnostic core + thin adapters.
|
|
@@ -5904,6 +6037,80 @@ export function webviewState<T>(defaults: T): {
|
|
|
5904
6037
|
},
|
|
5905
6038
|
};
|
|
5906
6039
|
}
|
|
6040
|
+
`,
|
|
6041
|
+
"react/src/shared/vsceasy/store.ts": `/**
|
|
6042
|
+
* Reactive stores — a tiny, framework-agnostic observable value.
|
|
6043
|
+
*
|
|
6044
|
+
* A store holds one value and notifies subscribers when it changes. It's the
|
|
6045
|
+
* non-ORM half of the reactivity model: use it for arbitrary state (a counter,
|
|
6046
|
+
* a flag, a selection) that a visual element should track.
|
|
6047
|
+
*
|
|
6048
|
+
* const counter = defineStore(0);
|
|
6049
|
+
* counter.subscribe((v) => console.log('now', v));
|
|
6050
|
+
* counter.set(1); // logs: now 1
|
|
6051
|
+
* counter.update((n) => n + 1);
|
|
6052
|
+
*
|
|
6053
|
+
* Pair it with \`watch()\` on the host to push changes to a webview, and
|
|
6054
|
+
* \`listen()\` on the webview to react — see those helpers below and in client.ts.
|
|
6055
|
+
*/
|
|
6056
|
+
export interface Store<T> {
|
|
6057
|
+
/** Read the current value. */
|
|
6058
|
+
get(): T;
|
|
6059
|
+
/** Replace the value and notify subscribers (no-op if \`Object.is\`-equal). */
|
|
6060
|
+
set(next: T): void;
|
|
6061
|
+
/** Derive the next value from the current one, then \`set\` it. */
|
|
6062
|
+
update(fn: (current: T) => T): void;
|
|
6063
|
+
/** Subscribe to changes. Returns an unsubscribe function. */
|
|
6064
|
+
subscribe(cb: (value: T) => void): () => void;
|
|
6065
|
+
}
|
|
6066
|
+
|
|
6067
|
+
export function defineStore<T>(initial: T): Store<T> {
|
|
6068
|
+
let value = initial;
|
|
6069
|
+
const subs = new Set<(value: T) => void>();
|
|
6070
|
+
|
|
6071
|
+
return {
|
|
6072
|
+
get: () => value,
|
|
6073
|
+
set(next) {
|
|
6074
|
+
if (Object.is(value, next)) return;
|
|
6075
|
+
value = next;
|
|
6076
|
+
subs.forEach((cb) => {
|
|
6077
|
+
try { cb(value); } catch { /* a bad subscriber must not break a set */ }
|
|
6078
|
+
});
|
|
6079
|
+
},
|
|
6080
|
+
update(fn) {
|
|
6081
|
+
this.set(fn(value));
|
|
6082
|
+
},
|
|
6083
|
+
subscribe(cb) {
|
|
6084
|
+
subs.add(cb);
|
|
6085
|
+
return () => { subs.delete(cb); };
|
|
6086
|
+
},
|
|
6087
|
+
};
|
|
6088
|
+
}
|
|
6089
|
+
|
|
6090
|
+
/**
|
|
6091
|
+
* Anything that can be watched: it exposes a \`subscribe(cb)\` returning an
|
|
6092
|
+
* unsubscribe function. Stores satisfy this directly. (ORM entities are watched
|
|
6093
|
+
* with \`watchEntity\` from your generated \`db.ts\`, which has the same shape.)
|
|
6094
|
+
*/
|
|
6095
|
+
export interface Watchable {
|
|
6096
|
+
subscribe(cb: (...args: any[]) => void): () => void;
|
|
6097
|
+
}
|
|
6098
|
+
|
|
6099
|
+
/**
|
|
6100
|
+
* Bridge a watchable source to a side-effect — typically an RPC emit that pushes
|
|
6101
|
+
* the change to a subscribed webview. Runs \`effect\` on every change. Returns an
|
|
6102
|
+
* unsubscribe function; register it on the panel's \`ctx.subscriptions\` (wrapped
|
|
6103
|
+
* in \`{ dispose }\`) so it's cleaned up when the extension deactivates.
|
|
6104
|
+
*
|
|
6105
|
+
* // host side, in a panel's rpc():
|
|
6106
|
+
* watch(badgeCount, () => server.emit('badge:changed'));
|
|
6107
|
+
*
|
|
6108
|
+
* For ORM entities, use \`watchEntity(Todos, () => server.emit('todos:changed'))\`
|
|
6109
|
+
* from your generated db.ts — same idea, same return.
|
|
6110
|
+
*/
|
|
6111
|
+
export function watch(source: Watchable, effect: () => void): () => void {
|
|
6112
|
+
return source.subscribe(() => effect());
|
|
6113
|
+
}
|
|
5907
6114
|
`,
|
|
5908
6115
|
"react/src/webview/panels/dashboard/App.tsx": `import React, { useEffect, useState } from 'react';
|
|
5909
6116
|
import { connectWebview } from '../../../shared/vsceasy/client';
|
|
@@ -6517,7 +6724,26 @@ function parseModelFile(file) {
|
|
|
6517
6724
|
indexes.push(m[1]);
|
|
6518
6725
|
}
|
|
6519
6726
|
const id = path22.basename(file).replace(/\.(ts|tsx)$/, "");
|
|
6520
|
-
|
|
6727
|
+
const relations = parseRelations(src, name);
|
|
6728
|
+
for (const f of fields) {
|
|
6729
|
+
const r = relations[f.name];
|
|
6730
|
+
if (r)
|
|
6731
|
+
f.relation = r;
|
|
6732
|
+
}
|
|
6733
|
+
return { name, id, plural, collection, primaryKey: pk, indexes, fields, relations, path: file };
|
|
6734
|
+
}
|
|
6735
|
+
function parseRelations(src, name) {
|
|
6736
|
+
const out = {};
|
|
6737
|
+
const block = new RegExp(`export\\s+const\\s+${name}Relations\\s*=\\s*\\{([\\s\\S]*?)\\}\\s*as\\s+const`, "m").exec(src);
|
|
6738
|
+
if (!block)
|
|
6739
|
+
return out;
|
|
6740
|
+
const body = block[1];
|
|
6741
|
+
const re = /([A-Za-z_][A-Za-z0-9_]*)\s*:\s*\{\s*model\s*:\s*['"`]([^'"`]+)['"`]\s*(?:,\s*label\s*:\s*['"`]([^'"`]+)['"`]\s*)?\}/g;
|
|
6742
|
+
let m;
|
|
6743
|
+
while (m = re.exec(body)) {
|
|
6744
|
+
out[m[1]] = { field: m[1], model: m[2], label: m[3] };
|
|
6745
|
+
}
|
|
6746
|
+
return out;
|
|
6521
6747
|
}
|
|
6522
6748
|
function parseInterfaceBody(body) {
|
|
6523
6749
|
const fields = [];
|
|
@@ -6654,12 +6880,26 @@ function addCrud(opts) {
|
|
|
6654
6880
|
formId,
|
|
6655
6881
|
prefix
|
|
6656
6882
|
};
|
|
6883
|
+
const modelsDir = path24.dirname(modelFile);
|
|
6884
|
+
const relations = visible.filter((f) => f.relation).map((f) => {
|
|
6885
|
+
const r = f.relation;
|
|
6886
|
+
const related = parseModelFile(path24.join(modelsDir, `${r.model}.ts`));
|
|
6887
|
+
const labelField = r.label ?? firstStringField(related) ?? related.primaryKey;
|
|
6888
|
+
return {
|
|
6889
|
+
field: f.name,
|
|
6890
|
+
model: r.model,
|
|
6891
|
+
plural: related.plural,
|
|
6892
|
+
pk: related.primaryKey,
|
|
6893
|
+
labelField
|
|
6894
|
+
};
|
|
6895
|
+
});
|
|
6896
|
+
const relVars = buildRelationVars(relations);
|
|
6657
6897
|
const created = [];
|
|
6658
6898
|
const modified = [];
|
|
6659
6899
|
writeFromTpl(path24.join(opts.templatesRoot, "_generators", "crud", "service.ts.tpl"), servicePath, baseVars, created);
|
|
6660
6900
|
writeFromTpl(path24.join(opts.templatesRoot, "_generators", "crud", "formNav.ts.tpl"), path24.join(opts.projectRoot, "src", "services", `${camelLower(model.name)}FormNav.ts`), baseVars, created);
|
|
6661
6901
|
writeFromTpl(path24.join(opts.templatesRoot, "_generators", "crud", "listPanel.ts.tpl"), listPanelPath, baseVars, created);
|
|
6662
|
-
writeFromTpl(path24.join(opts.templatesRoot, "_generators", "crud", "formPanel.ts.tpl"), formPanelPath, baseVars, created);
|
|
6902
|
+
writeFromTpl(path24.join(opts.templatesRoot, "_generators", "crud", "formPanel.ts.tpl"), formPanelPath, { ...baseVars, ...relVars }, created);
|
|
6663
6903
|
fs24.mkdirSync(listWebDir, { recursive: true });
|
|
6664
6904
|
const listVars = {
|
|
6665
6905
|
...baseVars,
|
|
@@ -6675,10 +6915,10 @@ function addCrud(opts) {
|
|
|
6675
6915
|
const formInputs = visible.filter((f) => !cfg.fields?.[f.name]?.hideInForm).map((f) => renderInput(f, cfg.fields?.[f.name])).join(`
|
|
6676
6916
|
`);
|
|
6677
6917
|
const emptyLit = buildEmptyFormLiteral(visible, cfg);
|
|
6678
|
-
writeFromTpl(path24.join(opts.templatesRoot, "_generators", "crud", "formApp.tsx.tpl"), path24.join(formWebDir, "App.tsx"), { ...baseVars, formFieldInputs: formInputs, emptyFormLiteral: emptyLit }, created);
|
|
6918
|
+
writeFromTpl(path24.join(opts.templatesRoot, "_generators", "crud", "formApp.tsx.tpl"), path24.join(formWebDir, "App.tsx"), { ...baseVars, ...relVars, formFieldInputs: formInputs, emptyFormLiteral: emptyLit }, created);
|
|
6679
6919
|
writeFromTpl(path24.join(opts.templatesRoot, "_generators", "crud", "main.tsx.tpl"), path24.join(formWebDir, "main.tsx"), baseVars, created);
|
|
6680
6920
|
appendApi3(apiPath, listApiName, model, created, modified);
|
|
6681
|
-
appendApiForm(apiPath, formApiName, model, created, modified);
|
|
6921
|
+
appendApiForm(apiPath, formApiName, model, created, modified, relations.length > 0);
|
|
6682
6922
|
let menuInfo;
|
|
6683
6923
|
if (opts.menu && opts.menu !== "none") {
|
|
6684
6924
|
menuInfo = wireMenu(opts, model, cfg, listId, formId);
|
|
@@ -6732,6 +6972,14 @@ function renderInput(field, override) {
|
|
|
6732
6972
|
` + ` <span style={{ opacity: 0.8 }}>${labelText}</span>
|
|
6733
6973
|
` + `${input}
|
|
6734
6974
|
` + ` </label>`;
|
|
6975
|
+
if (field.relation) {
|
|
6976
|
+
return wrap(` <select${required} value={(form.${name} as any) ?? ''} onChange={(e) => onChange('${name}', e.target.value as any)}>
|
|
6977
|
+
` + ` <option value=""></option>
|
|
6978
|
+
` + ` {(relOptions['${name}'] ?? []).map((o) => (
|
|
6979
|
+
` + ` <option key={o.value} value={o.value}>{o.label}</option>
|
|
6980
|
+
` + ` ))}
|
|
6981
|
+
` + ` </select>`);
|
|
6982
|
+
}
|
|
6735
6983
|
switch (spec.kind) {
|
|
6736
6984
|
case "number":
|
|
6737
6985
|
return wrap(` <input type="number"${placeholderAttr}${required} value={form.${name} as any ?? ''} onChange={(e) => onChange('${name}', e.target.value === '' ? undefined : Number(e.target.value))} />`);
|
|
@@ -6782,13 +7030,15 @@ export interface ${apiName} {
|
|
|
6782
7030
|
ensureImport(apiPath, model.name);
|
|
6783
7031
|
appendIfMissing(apiPath, apiName, sig, created, modified);
|
|
6784
7032
|
}
|
|
6785
|
-
function appendApiForm(apiPath, apiName, model, created, modified) {
|
|
7033
|
+
function appendApiForm(apiPath, apiName, model, created, modified, hasRelations) {
|
|
7034
|
+
const optionsLine = hasRelations ? ` options(): Promise<Record<string, { value: string; label: string }[]>>;
|
|
7035
|
+
` : "";
|
|
6786
7036
|
const sig = `
|
|
6787
7037
|
export interface ${apiName} {
|
|
6788
7038
|
` + ` pendingId(): Promise<${model.name}['${model.primaryKey}'] | null>;
|
|
6789
7039
|
` + ` get(id: ${model.name}['${model.primaryKey}'] | null): Promise<${model.name} | null>;
|
|
6790
7040
|
` + ` save(row: ${model.name}): Promise<${model.name}>;
|
|
6791
|
-
` + ` cancel(): Promise<void>;
|
|
7041
|
+
` + optionsLine + ` cancel(): Promise<void>;
|
|
6792
7042
|
` + `}
|
|
6793
7043
|
`;
|
|
6794
7044
|
ensureImport(apiPath, model.name);
|
|
@@ -6876,6 +7126,30 @@ function runGen11(cwd) {
|
|
|
6876
7126
|
function which11(cmd) {
|
|
6877
7127
|
return import_child_process12.spawnSync(process.platform === "win32" ? "where" : "which", [cmd], { stdio: "ignore" }).status === 0;
|
|
6878
7128
|
}
|
|
7129
|
+
function firstStringField(model) {
|
|
7130
|
+
return model.fields.find((f) => f.type.trim() === "string" && f.name !== model.primaryKey)?.name;
|
|
7131
|
+
}
|
|
7132
|
+
function buildRelationVars(relations) {
|
|
7133
|
+
if (relations.length === 0) {
|
|
7134
|
+
return { relationImports: "", relationOptionsHandler: "", relationOptionsState: "", relationOptionsLoad: "" };
|
|
7135
|
+
}
|
|
7136
|
+
const uniqueRepos = [...new Map(relations.map((r) => [r.plural, r])).values()];
|
|
7137
|
+
const relationImports = uniqueRepos.map((r) => `import { ${r.plural}Repo } from '../models/${r.model}';`).join(`
|
|
7138
|
+
`) + `
|
|
7139
|
+
`;
|
|
7140
|
+
const handlerLines = relations.map((r) => ` ${r.field}: (await ${r.plural}Repo().findMany()).map((x) => ({ value: String(x.${r.pk}), label: String(x.${r.labelField}) })),`).join(`
|
|
7141
|
+
`);
|
|
7142
|
+
const relationOptionsHandler = ` async options() {
|
|
7143
|
+
` + ` return {
|
|
7144
|
+
` + `${handlerLines}
|
|
7145
|
+
` + ` };
|
|
7146
|
+
` + ` },
|
|
7147
|
+
`;
|
|
7148
|
+
const relationOptionsState = ` const [relOptions, setRelOptions] = useState<Record<string, { value: string; label: string }[]>>({});`;
|
|
7149
|
+
const relationOptionsLoad = `
|
|
7150
|
+
useEffect(() => { void api.options().then(setRelOptions); }, []);`;
|
|
7151
|
+
return { relationImports, relationOptionsHandler, relationOptionsState, relationOptionsLoad };
|
|
7152
|
+
}
|
|
6879
7153
|
var fs24, path24, import_child_process12;
|
|
6880
7154
|
var init_add11 = __esm(() => {
|
|
6881
7155
|
init_scaffold();
|
|
@@ -6908,11 +7182,37 @@ function addModel(opts) {
|
|
|
6908
7182
|
const pkField = explicitPk[0] ?? opts.fields.find((f) => f.name === "id") ?? opts.fields[0];
|
|
6909
7183
|
const primaryKey = pkField.name;
|
|
6910
7184
|
const indexes = opts.fields.filter((f) => f.indexed && f.name !== primaryKey).map((f) => f.name);
|
|
6911
|
-
const
|
|
7185
|
+
const modelsDir = path25.join(opts.projectRoot, "src", "models");
|
|
7186
|
+
for (const f of opts.fields) {
|
|
7187
|
+
if (!f.relation)
|
|
7188
|
+
continue;
|
|
7189
|
+
if (f.relation.model === Name) {} else if (!fs25.existsSync(path25.join(modelsDir, `${f.relation.model}.ts`))) {
|
|
7190
|
+
throw new Error(`Field "${f.name}" references model "${f.relation.model}", but src/models/${f.relation.model}.ts does not exist. ` + `Run \`vsceasy model add --name ${f.relation.model}\` first.`);
|
|
7191
|
+
}
|
|
7192
|
+
}
|
|
7193
|
+
const target = path25.join(modelsDir, `${Name}.ts`);
|
|
6912
7194
|
assertNoOverwrite(opts.projectRoot, target, "Model");
|
|
6913
7195
|
const tpl = path25.join(opts.templatesRoot, "_generators", "model", "model.ts.tpl");
|
|
6914
|
-
const
|
|
7196
|
+
const fkName = (f) => f.relation ? `${f.name}Id` : f.name;
|
|
7197
|
+
const fieldLines = opts.fields.map((f) => {
|
|
7198
|
+
const ts = f.relation ? "string" : f.type;
|
|
7199
|
+
const note = f.relation ? ` // → ${f.relation.model}` : "";
|
|
7200
|
+
return ` ${fkName(f)}${f.optional ? "?" : ""}: ${ts};${note}`;
|
|
7201
|
+
}).join(`
|
|
6915
7202
|
`);
|
|
7203
|
+
const relFields = opts.fields.filter((f) => f.relation);
|
|
7204
|
+
const relationsBlock = relFields.length ? `
|
|
7205
|
+
|
|
7206
|
+
/** Relation metadata — used by \`vsceasy crud add\` to populate pickers. */
|
|
7207
|
+
` + `export const ${Name}Relations = {
|
|
7208
|
+
` + relFields.map((f) => {
|
|
7209
|
+
const r = f.relation;
|
|
7210
|
+
const lbl = r.label ? `, label: '${r.label}'` : "";
|
|
7211
|
+
return ` ${fkName(f)}: { model: '${r.model}'${lbl} },`;
|
|
7212
|
+
}).join(`
|
|
7213
|
+
`) + `
|
|
7214
|
+
} as const;
|
|
7215
|
+
` : "";
|
|
6916
7216
|
const vars = {
|
|
6917
7217
|
name,
|
|
6918
7218
|
Name,
|
|
@@ -6921,11 +7221,12 @@ function addModel(opts) {
|
|
|
6921
7221
|
primaryKey,
|
|
6922
7222
|
fieldLines,
|
|
6923
7223
|
indexesLine: indexes.length ? `
|
|
6924
|
-
indexes: [${indexes.map((i) => `'${i}'`).join(", ")}],` : ""
|
|
7224
|
+
indexes: [${indexes.map((i) => `'${i}'`).join(", ")}],` : "",
|
|
7225
|
+
relationsBlock
|
|
6925
7226
|
};
|
|
6926
7227
|
fs25.mkdirSync(path25.dirname(target), { recursive: true });
|
|
6927
7228
|
fs25.writeFileSync(target, substitute(fs25.readFileSync(tpl, "utf8"), vars));
|
|
6928
|
-
return { created: [target], primaryKey, indexes };
|
|
7229
|
+
return { created: [target], primaryKey, indexes, relations: relFields.map((f) => ({ field: fkName(f), ...f.relation })) };
|
|
6929
7230
|
}
|
|
6930
7231
|
function normalizeCamel8(s) {
|
|
6931
7232
|
const cleaned = s.trim().replace(/[^a-zA-Z0-9]+(.)/g, (_m, c) => c.toUpperCase()).replace(/[^a-zA-Z0-9]/g, "");
|
|
@@ -1,8 +1,18 @@
|
|
|
1
|
+
export interface ParsedRelation {
|
|
2
|
+
/** FK field on this model (e.g. `categoryId`). */
|
|
3
|
+
field: string;
|
|
4
|
+
/** Related model name (e.g. `Category`). */
|
|
5
|
+
model: string;
|
|
6
|
+
/** Field on the related model to show in the picker. */
|
|
7
|
+
label?: string;
|
|
8
|
+
}
|
|
1
9
|
export interface ParsedField {
|
|
2
10
|
name: string;
|
|
3
11
|
/** Raw TS type as written in the interface (e.g. `string`, `number`, `'a' | 'b'`, `Date`). */
|
|
4
12
|
type: string;
|
|
5
13
|
optional: boolean;
|
|
14
|
+
/** Set when this field is a foreign key declared via `<Name>Relations`. */
|
|
15
|
+
relation?: ParsedRelation;
|
|
6
16
|
}
|
|
7
17
|
export interface ParsedModel {
|
|
8
18
|
/** PascalCase interface name. */
|
|
@@ -19,6 +29,8 @@ export interface ParsedModel {
|
|
|
19
29
|
indexes: string[];
|
|
20
30
|
/** Ordered field list from the interface body. */
|
|
21
31
|
fields: ParsedField[];
|
|
32
|
+
/** FK field → relation metadata, keyed by FK field name. */
|
|
33
|
+
relations: Record<string, ParsedRelation>;
|
|
22
34
|
/** Absolute path the model was read from. */
|
|
23
35
|
path: string;
|
|
24
36
|
}
|
package/dist/lib/model/add.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
export interface FieldRelation {
|
|
2
|
+
/** PascalCase name of the related model (e.g. `Category`). */
|
|
3
|
+
model: string;
|
|
4
|
+
/** Field on the related model to show in pickers. Default: first string field, else its pk. */
|
|
5
|
+
label?: string;
|
|
6
|
+
}
|
|
1
7
|
export interface ModelField {
|
|
2
8
|
name: string;
|
|
3
9
|
/** Raw TS type. e.g. `string`, `number`, `string | null`, `Date`, `'a' | 'b'`. */
|
|
@@ -8,6 +14,12 @@ export interface ModelField {
|
|
|
8
14
|
primaryKey?: boolean;
|
|
9
15
|
/** Add to entity `indexes` (speeds up findOne by this field). */
|
|
10
16
|
indexed?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* ManyToOne relation. When set, the field is emitted as a `<name>Id: string`
|
|
19
|
+
* foreign key and recorded in the model's relation metadata so `crud add`
|
|
20
|
+
* renders a populated dropdown. Authored as `name:ref(Model)` in the spec.
|
|
21
|
+
*/
|
|
22
|
+
relation?: FieldRelation;
|
|
11
23
|
}
|
|
12
24
|
export interface AddModelOptions {
|
|
13
25
|
name: string;
|
|
@@ -23,5 +35,11 @@ export interface AddModelResult {
|
|
|
23
35
|
created: string[];
|
|
24
36
|
primaryKey: string;
|
|
25
37
|
indexes: string[];
|
|
38
|
+
/** Foreign-key fields and the models they point at. */
|
|
39
|
+
relations: Array<{
|
|
40
|
+
field: string;
|
|
41
|
+
model: string;
|
|
42
|
+
label?: string;
|
|
43
|
+
}>;
|
|
26
44
|
}
|
|
27
45
|
export declare function addModel(opts: AddModelOptions): AddModelResult;
|