@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/bin/cli.js
CHANGED
|
@@ -3826,7 +3826,7 @@ var init_scaffold = __esm(() => {
|
|
|
3826
3826
|
});
|
|
3827
3827
|
|
|
3828
3828
|
// src/lib/templatesData.ts
|
|
3829
|
-
var TEMPLATES_VERSION = "0.1.
|
|
3829
|
+
var TEMPLATES_VERSION = "0.1.9", TEMPLATE_FILES;
|
|
3830
3830
|
var init_templatesData = __esm(() => {
|
|
3831
3831
|
TEMPLATE_FILES = {
|
|
3832
3832
|
"_generators/command/command.ts.tpl": `import { defineCommand } from '../shared/vsceasy';
|
|
@@ -4038,6 +4038,7 @@ export function App() {
|
|
|
4038
4038
|
const [editingId, setEditingId] = useState<{{Name}}['{{primaryKey}}'] | null>(null);
|
|
4039
4039
|
const [error, setError] = useState<string | null>(null);
|
|
4040
4040
|
const [saving, setSaving] = useState(false);
|
|
4041
|
+
{{relationOptionsState}}
|
|
4041
4042
|
|
|
4042
4043
|
const load = useCallback(async (initial: boolean) => {
|
|
4043
4044
|
// The list stashes a row id before revealing this panel. Pull it (the host
|
|
@@ -4075,6 +4076,7 @@ export function App() {
|
|
|
4075
4076
|
document.removeEventListener('visibilitychange', onVisible);
|
|
4076
4077
|
};
|
|
4077
4078
|
}, [load]);
|
|
4079
|
+
{{relationOptionsLoad}}
|
|
4078
4080
|
|
|
4079
4081
|
const onChange = <K extends keyof FormState>(k: K, v: FormState[K]) => {
|
|
4080
4082
|
setForm((f) => ({ ...f, [k]: v }));
|
|
@@ -4138,7 +4140,7 @@ import { {{Name}}Service } from '../services/{{Name}}Service';
|
|
|
4138
4140
|
import { takePending{{Name}}Id } from '../services/{{name}}FormNav';
|
|
4139
4141
|
import type { {{Name}}FormApi } from '../shared/api';
|
|
4140
4142
|
import type { {{Name}} } from '../models/{{Name}}';
|
|
4141
|
-
|
|
4143
|
+
{{relationImports}}
|
|
4142
4144
|
export default definePanel<{{Name}}FormApi>({
|
|
4143
4145
|
title: '{{title}}',
|
|
4144
4146
|
column: 'beside',
|
|
@@ -4160,7 +4162,7 @@ export default definePanel<{{Name}}FormApi>({
|
|
|
4160
4162
|
void vscode.commands.executeCommand('{{prefix}}.open{{Plural}}List');
|
|
4161
4163
|
return saved;
|
|
4162
4164
|
},
|
|
4163
|
-
async cancel() {
|
|
4165
|
+
{{relationOptionsHandler}} async cancel() {
|
|
4164
4166
|
// No-op — webview closes itself.
|
|
4165
4167
|
},
|
|
4166
4168
|
}),
|
|
@@ -4538,6 +4540,35 @@ export interface Repository<T> {
|
|
|
4538
4540
|
clear(): Promise<void>;
|
|
4539
4541
|
}
|
|
4540
4542
|
|
|
4543
|
+
// ── Change notifications (reactivity) ────────────────────────────────────────
|
|
4544
|
+
|
|
4545
|
+
/**
|
|
4546
|
+
* Per-entity change listeners. Any mutation (insert/upsert/update/delete/...) on
|
|
4547
|
+
* an entity fires its listeners with the entity name. This is the host-side hook
|
|
4548
|
+
* the \`watch()\` helper builds on to push updates to webviews.
|
|
4549
|
+
*/
|
|
4550
|
+
const entityListeners = new Map<string, Set<(entity: string) => void>>();
|
|
4551
|
+
|
|
4552
|
+
function emitChange(entity: string): void {
|
|
4553
|
+
entityListeners.get(entity)?.forEach((cb) => {
|
|
4554
|
+
try { cb(entity); } catch { /* a bad listener must not break a write */ }
|
|
4555
|
+
});
|
|
4556
|
+
}
|
|
4557
|
+
|
|
4558
|
+
/**
|
|
4559
|
+
* Subscribe to changes on an entity. Returns an unsubscribe function.
|
|
4560
|
+
*
|
|
4561
|
+
* watchEntity(Todos, () => server.emit('todos:changed'));
|
|
4562
|
+
*
|
|
4563
|
+
* Prefer the higher-level \`watch()\` helper, which wires this to an RPC emit.
|
|
4564
|
+
*/
|
|
4565
|
+
export function watchEntity<T>(entity: Entity<T>, cb: (entity: string) => void): () => void {
|
|
4566
|
+
let set = entityListeners.get(entity.name);
|
|
4567
|
+
if (!set) entityListeners.set(entity.name, (set = new Set()));
|
|
4568
|
+
set.add(cb);
|
|
4569
|
+
return () => { set!.delete(cb); };
|
|
4570
|
+
}
|
|
4571
|
+
|
|
4541
4572
|
// ── Provider interface (future-proof for sqlite/etc.) ────────────────────────
|
|
4542
4573
|
|
|
4543
4574
|
export interface Provider {
|
|
@@ -4708,6 +4739,7 @@ function makeDb(provider: Provider): Db {
|
|
|
4708
4739
|
}
|
|
4709
4740
|
rows.push(row);
|
|
4710
4741
|
await save(rows);
|
|
4742
|
+
emitChange(entity.name);
|
|
4711
4743
|
return row;
|
|
4712
4744
|
},
|
|
4713
4745
|
async upsert(row) {
|
|
@@ -4715,6 +4747,7 @@ function makeDb(provider: Provider): Db {
|
|
|
4715
4747
|
const i = rows.findIndex((r) => r[pk] === row[pk]);
|
|
4716
4748
|
if (i >= 0) rows[i] = row; else rows.push(row);
|
|
4717
4749
|
await save(rows);
|
|
4750
|
+
emitChange(entity.name);
|
|
4718
4751
|
return row;
|
|
4719
4752
|
},
|
|
4720
4753
|
async update(id, patch) {
|
|
@@ -4723,6 +4756,7 @@ function makeDb(provider: Provider): Db {
|
|
|
4723
4756
|
if (i < 0) return null;
|
|
4724
4757
|
rows[i] = { ...rows[i], ...patch };
|
|
4725
4758
|
await save(rows);
|
|
4759
|
+
emitChange(entity.name);
|
|
4726
4760
|
return rows[i];
|
|
4727
4761
|
},
|
|
4728
4762
|
async delete(id) {
|
|
@@ -4731,17 +4765,19 @@ function makeDb(provider: Provider): Db {
|
|
|
4731
4765
|
const next = rows.filter((r) => r[pk] !== id);
|
|
4732
4766
|
if (next.length === before) return false;
|
|
4733
4767
|
await save(next);
|
|
4768
|
+
emitChange(entity.name);
|
|
4734
4769
|
return true;
|
|
4735
4770
|
},
|
|
4736
4771
|
async deleteMany(where) {
|
|
4737
4772
|
const rows = await load();
|
|
4738
4773
|
const next = rows.filter((r) => !match(r, where));
|
|
4739
4774
|
const removed = rows.length - next.length;
|
|
4740
|
-
if (removed > 0) await save(next);
|
|
4775
|
+
if (removed > 0) { await save(next); emitChange(entity.name); }
|
|
4741
4776
|
return removed;
|
|
4742
4777
|
},
|
|
4743
4778
|
async clear() {
|
|
4744
4779
|
await save([]);
|
|
4780
|
+
emitChange(entity.name);
|
|
4745
4781
|
},
|
|
4746
4782
|
};
|
|
4747
4783
|
};
|
|
@@ -4972,7 +5008,7 @@ export const {{Plural}} = defineEntity<{{Name}}>('{{collection}}', {
|
|
|
4972
5008
|
* import { {{Plural}}Repo } from '../models/{{Name}}';
|
|
4973
5009
|
* await {{Plural}}Repo().insert({ ... });
|
|
4974
5010
|
*/
|
|
4975
|
-
export const {{Plural}}Repo = () => db()({{Plural}});
|
|
5011
|
+
export const {{Plural}}Repo = () => db()({{Plural}});{{relationsBlock}}
|
|
4976
5012
|
`,
|
|
4977
5013
|
"_generators/panel/App.tsx.tpl": `import React from 'react';
|
|
4978
5014
|
{{apiBlock}}
|
|
@@ -5166,6 +5202,30 @@ export default defineStatusBar({
|
|
|
5166
5202
|
alignment: '{{alignment}}',
|
|
5167
5203
|
priority: {{priority}},{{commandLine}}{{panelLine}}
|
|
5168
5204
|
});
|
|
5205
|
+
`,
|
|
5206
|
+
"_generators/store/store.ts.tpl": `import { defineStore } from '../shared/vsceasy';
|
|
5207
|
+
|
|
5208
|
+
/**
|
|
5209
|
+
* Reactive store: a single observable value. Mutate it with \`.set()\` / \`.update()\`
|
|
5210
|
+
* and anything subscribed reacts.
|
|
5211
|
+
*
|
|
5212
|
+
* import { {{name}} } from '../stores/{{name}}';
|
|
5213
|
+
* {{name}}.set({{example}});
|
|
5214
|
+
* {{name}}.update((v) => v);
|
|
5215
|
+
*
|
|
5216
|
+
* To push changes to a webview, watch it on the host and emit over RPC:
|
|
5217
|
+
*
|
|
5218
|
+
* // inside a panel/subpanel rpc():
|
|
5219
|
+
* import { watch } from '../shared/vsceasy';
|
|
5220
|
+
* import { {{name}} } from '../stores/{{name}}';
|
|
5221
|
+
* watch({{name}}, () => server.emit('{{name}}:changed', {{name}}.get()));
|
|
5222
|
+
*
|
|
5223
|
+
* Then listen in the webview:
|
|
5224
|
+
*
|
|
5225
|
+
* import { listen } from '../shared/vsceasy/client';
|
|
5226
|
+
* listen(api, '{{name}}:changed', (v) => render(v));
|
|
5227
|
+
*/
|
|
5228
|
+
export const {{name}} = defineStore<{{type}}>({{initial}});
|
|
5169
5229
|
`,
|
|
5170
5230
|
"_generators/subpanel/App.tsx.tpl": `import React from 'react';
|
|
5171
5231
|
{{apiBlock}}
|
|
@@ -6362,6 +6422,13 @@ function registerTreeView(
|
|
|
6362
6422
|
vscode.commands.registerCommand(refreshCmd, () => provider.refresh()),
|
|
6363
6423
|
);
|
|
6364
6424
|
|
|
6425
|
+
// Keep the tree live: let the def subscribe to a data source and re-run
|
|
6426
|
+
// getChildren on change. The unsubscribe is disposed on deactivate.
|
|
6427
|
+
if (def.watch) {
|
|
6428
|
+
const off = def.watch(() => provider.refresh(), vscode, context);
|
|
6429
|
+
if (off) context.subscriptions.push({ dispose: off });
|
|
6430
|
+
}
|
|
6431
|
+
|
|
6365
6432
|
const dispatchCmd = \`\${registry.prefix}._tree.\${def.id ?? id}.run\`;
|
|
6366
6433
|
context.subscriptions.push(
|
|
6367
6434
|
vscode.commands.registerCommand(dispatchCmd, async (node: TreeNode) => {
|
|
@@ -6438,9 +6505,11 @@ function registerSubpanel(
|
|
|
6438
6505
|
const ui = def.ui ?? \`subpanels/\${def.id ?? id}\`;
|
|
6439
6506
|
view.webview.html = renderHtml(view.webview, context, ui, def.title);
|
|
6440
6507
|
if (def.rpc) {
|
|
6441
|
-
|
|
6442
|
-
const
|
|
6443
|
-
|
|
6508
|
+
let server: ReturnType<typeof createRpcServer> | undefined;
|
|
6509
|
+
const emit = (topic: string, payload?: unknown) => server?.emit(topic, payload);
|
|
6510
|
+
const handlers = def.rpc(vscode, context, emit);
|
|
6511
|
+
server = createRpcServer(webviewTransport(view.webview), handlers);
|
|
6512
|
+
view.onDidDispose(() => server!.dispose());
|
|
6444
6513
|
}
|
|
6445
6514
|
},
|
|
6446
6515
|
};
|
|
@@ -6676,9 +6745,13 @@ function openPanel(context: vscode.ExtensionContext, prefix: string, id: string,
|
|
|
6676
6745
|
panel.webview.html = renderHtml(panel.webview, context, ui, def.title);
|
|
6677
6746
|
|
|
6678
6747
|
if (def.rpc) {
|
|
6679
|
-
|
|
6680
|
-
|
|
6681
|
-
|
|
6748
|
+
// \`emit\` is wired lazily: the rpc factory may call watch()/watchEntity()
|
|
6749
|
+
// immediately, but the server (and thus the real emit) only exists after.
|
|
6750
|
+
let server: ReturnType<typeof createRpcServer> | undefined;
|
|
6751
|
+
const emit = (topic: string, payload?: unknown) => server?.emit(topic, payload);
|
|
6752
|
+
const handlers = def.rpc(vscode, context, emit);
|
|
6753
|
+
server = createRpcServer(webviewTransport(panel.webview), handlers);
|
|
6754
|
+
panel.onDidDispose(() => server!.dispose());
|
|
6682
6755
|
}
|
|
6683
6756
|
|
|
6684
6757
|
openPanels.set(key, panel);
|
|
@@ -6796,6 +6869,33 @@ export {
|
|
|
6796
6869
|
webviewState,
|
|
6797
6870
|
} from './rpc';
|
|
6798
6871
|
export type { RpcClient, Handlers, Transport, RpcClientOptions, WebviewApi } from './rpc';
|
|
6872
|
+
|
|
6873
|
+
export { defineStore } from './store';
|
|
6874
|
+
export type { Store } from './store';
|
|
6875
|
+
|
|
6876
|
+
import type { RpcClient, Handlers } from './rpc';
|
|
6877
|
+
|
|
6878
|
+
/**
|
|
6879
|
+
* Listen for a change pushed from the host over the RPC event channel, and run a
|
|
6880
|
+
* callback when it arrives. This is the webview side of the reactivity model —
|
|
6881
|
+
* the host emits with \`server.emit(topic, …)\` (usually wired via \`watch()\` /
|
|
6882
|
+
* \`watchEntity()\`), and your visual element reacts here. Returns an unsubscribe
|
|
6883
|
+
* function; call it on unmount.
|
|
6884
|
+
*
|
|
6885
|
+
* const api = connectWebview<TodoStatsViewApi>();
|
|
6886
|
+
* // re-read + re-render whenever todos change on the host:
|
|
6887
|
+
* const off = listen(api, 'todos:changed', () => refresh());
|
|
6888
|
+
*
|
|
6889
|
+
* It's a thin, named wrapper over \`api.on(topic, handler)\` so the place you
|
|
6890
|
+
* listen reads clearly in the UI code. The payload (if any) is passed through.
|
|
6891
|
+
*/
|
|
6892
|
+
export function listen<H extends Handlers>(
|
|
6893
|
+
api: RpcClient<H>,
|
|
6894
|
+
topic: string,
|
|
6895
|
+
handler: (payload?: unknown) => void,
|
|
6896
|
+
): () => void {
|
|
6897
|
+
return api.on(topic, handler);
|
|
6898
|
+
}
|
|
6799
6899
|
`,
|
|
6800
6900
|
"react/src/shared/vsceasy/codiconNames.ts": `/**
|
|
6801
6901
|
* AUTO-GENERATED by scripts/genCodiconTypes.ts — do not edit.
|
|
@@ -6998,6 +7098,18 @@ export type CodiconName =
|
|
|
6998
7098
|
import type { Handlers } from './rpc';
|
|
6999
7099
|
import type { CodiconName } from './codiconNames';
|
|
7000
7100
|
|
|
7101
|
+
/**
|
|
7102
|
+
* Push a change to this webview over the RPC event channel. The webview reacts
|
|
7103
|
+
* with \`listen(api, topic, …)\`. Wire it to a data source with \`watch()\` /
|
|
7104
|
+
* \`watchEntity()\` inside \`rpc()\`:
|
|
7105
|
+
*
|
|
7106
|
+
* rpc: (vscode, ctx, emit) => {
|
|
7107
|
+
* watchEntity(Todos, () => emit('todos:changed'));
|
|
7108
|
+
* return { stats: () => … };
|
|
7109
|
+
* }
|
|
7110
|
+
*/
|
|
7111
|
+
export type RpcEmit = (topic: string, payload?: unknown) => void;
|
|
7112
|
+
|
|
7001
7113
|
export interface PanelDef<H extends Handlers = Handlers> {
|
|
7002
7114
|
/** Stable id. Default: file basename. Used as command suffix and webview key. */
|
|
7003
7115
|
id?: string;
|
|
@@ -7009,8 +7121,11 @@ export interface PanelDef<H extends Handlers = Handlers> {
|
|
|
7009
7121
|
column?: 'active' | 'beside' | 'one' | 'two' | 'three';
|
|
7010
7122
|
/** Keep DOM alive when hidden. Default: true. */
|
|
7011
7123
|
retainContext?: boolean;
|
|
7012
|
-
/**
|
|
7013
|
-
|
|
7124
|
+
/**
|
|
7125
|
+
* RPC handlers — receives the vscode namespace, the extension context, and
|
|
7126
|
+
* \`emit\` for pushing change events to this webview (see {@link RpcEmit}).
|
|
7127
|
+
*/
|
|
7128
|
+
rpc?: (vscode: typeof import('vscode'), ctx: vscode.ExtensionContext, emit: RpcEmit) => H;
|
|
7014
7129
|
/** Optional command palette entry that opens this panel. Default: true. */
|
|
7015
7130
|
command?:
|
|
7016
7131
|
| boolean
|
|
@@ -7121,8 +7236,11 @@ export interface SubpanelDef<H extends Handlers = Handlers> {
|
|
|
7121
7236
|
ui?: string;
|
|
7122
7237
|
/** Keep DOM alive when hidden. Default: true. */
|
|
7123
7238
|
retainContext?: boolean;
|
|
7124
|
-
/**
|
|
7125
|
-
|
|
7239
|
+
/**
|
|
7240
|
+
* RPC handlers — receives the vscode namespace, the extension context, and
|
|
7241
|
+
* \`emit\` for pushing change events to this webview (see {@link RpcEmit}).
|
|
7242
|
+
*/
|
|
7243
|
+
rpc?: (vscode: typeof import('vscode'), ctx: vscode.ExtensionContext, emit: RpcEmit) => H;
|
|
7126
7244
|
}
|
|
7127
7245
|
|
|
7128
7246
|
export function defineSubpanel<H extends Handlers = Handlers>(def: SubpanelDef<H>): SubpanelDef<H> {
|
|
@@ -7224,6 +7342,18 @@ export interface TreeViewDef {
|
|
|
7224
7342
|
vscode: typeof import('vscode'),
|
|
7225
7343
|
ctx: vscode.ExtensionContext,
|
|
7226
7344
|
) => TreeNode[] | Promise<TreeNode[]>;
|
|
7345
|
+
/**
|
|
7346
|
+
* Keep the tree live. Receives \`refresh\` — call it (directly or as a callback)
|
|
7347
|
+
* to re-run \`getChildren\`. Subscribe to a data source here so the tree updates
|
|
7348
|
+
* itself; return an unsubscribe to clean up.
|
|
7349
|
+
*
|
|
7350
|
+
* watch: (refresh) => watchEntity(Todos, refresh),
|
|
7351
|
+
*/
|
|
7352
|
+
watch?: (
|
|
7353
|
+
refresh: () => void,
|
|
7354
|
+
vscode: typeof import('vscode'),
|
|
7355
|
+
ctx: vscode.ExtensionContext,
|
|
7356
|
+
) => (() => void) | void;
|
|
7227
7357
|
}
|
|
7228
7358
|
|
|
7229
7359
|
export function defineTreeView(def: TreeViewDef): TreeViewDef {
|
|
@@ -7265,7 +7395,7 @@ export function defineJob(def: JobDef): JobDef {
|
|
|
7265
7395
|
}
|
|
7266
7396
|
`,
|
|
7267
7397
|
"react/src/shared/vsceasy/index.ts": `export { definePanel, defineCommand, defineMenu, defineStatusBar, defineSubpanel, defineTreeView, defineJob } from './define';
|
|
7268
|
-
export type { PanelDef, CommandDef, MenuDef, MenuItem, MenuIcon, StatusBarDef, StatusBarMenuItem, KeybindingDef, SubpanelDef, TreeViewDef, TreeNode, JobDef, JobSchedule, CodiconName } from './define';
|
|
7398
|
+
export type { PanelDef, CommandDef, MenuDef, MenuItem, MenuIcon, StatusBarDef, StatusBarMenuItem, KeybindingDef, SubpanelDef, TreeViewDef, TreeNode, JobDef, JobSchedule, CodiconName, RpcEmit } from './define';
|
|
7269
7399
|
export { bootstrap } from './bootstrap';
|
|
7270
7400
|
export type { Registry, BootstrapOptions, ActivateHook } from './bootstrap';
|
|
7271
7401
|
export {
|
|
@@ -7277,6 +7407,9 @@ export {
|
|
|
7277
7407
|
webviewState,
|
|
7278
7408
|
} from './rpc';
|
|
7279
7409
|
export type { Transport, RpcClient, Handlers, RpcClientOptions, WebviewApi } from './rpc';
|
|
7410
|
+
export { defineStore, watch } from './store';
|
|
7411
|
+
export type { Store, Watchable } from './store';
|
|
7412
|
+
export { listen } from './client';
|
|
7280
7413
|
`,
|
|
7281
7414
|
"react/src/shared/vsceasy/rpc.ts": `// Typed RPC bridge — webview <-> extension.
|
|
7282
7415
|
// Used by both sides. Transport-agnostic core + thin adapters.
|
|
@@ -7492,6 +7625,80 @@ export function webviewState<T>(defaults: T): {
|
|
|
7492
7625
|
},
|
|
7493
7626
|
};
|
|
7494
7627
|
}
|
|
7628
|
+
`,
|
|
7629
|
+
"react/src/shared/vsceasy/store.ts": `/**
|
|
7630
|
+
* Reactive stores — a tiny, framework-agnostic observable value.
|
|
7631
|
+
*
|
|
7632
|
+
* A store holds one value and notifies subscribers when it changes. It's the
|
|
7633
|
+
* non-ORM half of the reactivity model: use it for arbitrary state (a counter,
|
|
7634
|
+
* a flag, a selection) that a visual element should track.
|
|
7635
|
+
*
|
|
7636
|
+
* const counter = defineStore(0);
|
|
7637
|
+
* counter.subscribe((v) => console.log('now', v));
|
|
7638
|
+
* counter.set(1); // logs: now 1
|
|
7639
|
+
* counter.update((n) => n + 1);
|
|
7640
|
+
*
|
|
7641
|
+
* Pair it with \`watch()\` on the host to push changes to a webview, and
|
|
7642
|
+
* \`listen()\` on the webview to react — see those helpers below and in client.ts.
|
|
7643
|
+
*/
|
|
7644
|
+
export interface Store<T> {
|
|
7645
|
+
/** Read the current value. */
|
|
7646
|
+
get(): T;
|
|
7647
|
+
/** Replace the value and notify subscribers (no-op if \`Object.is\`-equal). */
|
|
7648
|
+
set(next: T): void;
|
|
7649
|
+
/** Derive the next value from the current one, then \`set\` it. */
|
|
7650
|
+
update(fn: (current: T) => T): void;
|
|
7651
|
+
/** Subscribe to changes. Returns an unsubscribe function. */
|
|
7652
|
+
subscribe(cb: (value: T) => void): () => void;
|
|
7653
|
+
}
|
|
7654
|
+
|
|
7655
|
+
export function defineStore<T>(initial: T): Store<T> {
|
|
7656
|
+
let value = initial;
|
|
7657
|
+
const subs = new Set<(value: T) => void>();
|
|
7658
|
+
|
|
7659
|
+
return {
|
|
7660
|
+
get: () => value,
|
|
7661
|
+
set(next) {
|
|
7662
|
+
if (Object.is(value, next)) return;
|
|
7663
|
+
value = next;
|
|
7664
|
+
subs.forEach((cb) => {
|
|
7665
|
+
try { cb(value); } catch { /* a bad subscriber must not break a set */ }
|
|
7666
|
+
});
|
|
7667
|
+
},
|
|
7668
|
+
update(fn) {
|
|
7669
|
+
this.set(fn(value));
|
|
7670
|
+
},
|
|
7671
|
+
subscribe(cb) {
|
|
7672
|
+
subs.add(cb);
|
|
7673
|
+
return () => { subs.delete(cb); };
|
|
7674
|
+
},
|
|
7675
|
+
};
|
|
7676
|
+
}
|
|
7677
|
+
|
|
7678
|
+
/**
|
|
7679
|
+
* Anything that can be watched: it exposes a \`subscribe(cb)\` returning an
|
|
7680
|
+
* unsubscribe function. Stores satisfy this directly. (ORM entities are watched
|
|
7681
|
+
* with \`watchEntity\` from your generated \`db.ts\`, which has the same shape.)
|
|
7682
|
+
*/
|
|
7683
|
+
export interface Watchable {
|
|
7684
|
+
subscribe(cb: (...args: any[]) => void): () => void;
|
|
7685
|
+
}
|
|
7686
|
+
|
|
7687
|
+
/**
|
|
7688
|
+
* Bridge a watchable source to a side-effect — typically an RPC emit that pushes
|
|
7689
|
+
* the change to a subscribed webview. Runs \`effect\` on every change. Returns an
|
|
7690
|
+
* unsubscribe function; register it on the panel's \`ctx.subscriptions\` (wrapped
|
|
7691
|
+
* in \`{ dispose }\`) so it's cleaned up when the extension deactivates.
|
|
7692
|
+
*
|
|
7693
|
+
* // host side, in a panel's rpc():
|
|
7694
|
+
* watch(badgeCount, () => server.emit('badge:changed'));
|
|
7695
|
+
*
|
|
7696
|
+
* For ORM entities, use \`watchEntity(Todos, () => server.emit('todos:changed'))\`
|
|
7697
|
+
* from your generated db.ts — same idea, same return.
|
|
7698
|
+
*/
|
|
7699
|
+
export function watch(source: Watchable, effect: () => void): () => void {
|
|
7700
|
+
return source.subscribe(() => effect());
|
|
7701
|
+
}
|
|
7495
7702
|
`,
|
|
7496
7703
|
"react/src/webview/panels/dashboard/App.tsx": `import React, { useEffect, useState } from 'react';
|
|
7497
7704
|
import { connectWebview } from '../../../shared/vsceasy/client';
|
|
@@ -8658,11 +8865,37 @@ function addModel(opts) {
|
|
|
8658
8865
|
const pkField = explicitPk[0] ?? opts.fields.find((f) => f.name === "id") ?? opts.fields[0];
|
|
8659
8866
|
const primaryKey = pkField.name;
|
|
8660
8867
|
const indexes = opts.fields.filter((f) => f.indexed && f.name !== primaryKey).map((f) => f.name);
|
|
8661
|
-
const
|
|
8868
|
+
const modelsDir = path12.join(opts.projectRoot, "src", "models");
|
|
8869
|
+
for (const f of opts.fields) {
|
|
8870
|
+
if (!f.relation)
|
|
8871
|
+
continue;
|
|
8872
|
+
if (f.relation.model === Name) {} else if (!fs11.existsSync(path12.join(modelsDir, `${f.relation.model}.ts`))) {
|
|
8873
|
+
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.`);
|
|
8874
|
+
}
|
|
8875
|
+
}
|
|
8876
|
+
const target = path12.join(modelsDir, `${Name}.ts`);
|
|
8662
8877
|
assertNoOverwrite(opts.projectRoot, target, "Model");
|
|
8663
8878
|
const tpl = path12.join(opts.templatesRoot, "_generators", "model", "model.ts.tpl");
|
|
8664
|
-
const
|
|
8665
|
-
|
|
8879
|
+
const fkName = (f) => f.relation ? `${f.name}Id` : f.name;
|
|
8880
|
+
const fieldLines = opts.fields.map((f) => {
|
|
8881
|
+
const ts = f.relation ? "string" : f.type;
|
|
8882
|
+
const note = f.relation ? ` // → ${f.relation.model}` : "";
|
|
8883
|
+
return ` ${fkName(f)}${f.optional ? "?" : ""}: ${ts};${note}`;
|
|
8884
|
+
}).join(`
|
|
8885
|
+
`);
|
|
8886
|
+
const relFields = opts.fields.filter((f) => f.relation);
|
|
8887
|
+
const relationsBlock = relFields.length ? `
|
|
8888
|
+
|
|
8889
|
+
/** Relation metadata — used by \`vsceasy crud add\` to populate pickers. */
|
|
8890
|
+
` + `export const ${Name}Relations = {
|
|
8891
|
+
` + relFields.map((f) => {
|
|
8892
|
+
const r = f.relation;
|
|
8893
|
+
const lbl = r.label ? `, label: '${r.label}'` : "";
|
|
8894
|
+
return ` ${fkName(f)}: { model: '${r.model}'${lbl} },`;
|
|
8895
|
+
}).join(`
|
|
8896
|
+
`) + `
|
|
8897
|
+
} as const;
|
|
8898
|
+
` : "";
|
|
8666
8899
|
const vars = {
|
|
8667
8900
|
name,
|
|
8668
8901
|
Name,
|
|
@@ -8671,11 +8904,12 @@ function addModel(opts) {
|
|
|
8671
8904
|
primaryKey,
|
|
8672
8905
|
fieldLines,
|
|
8673
8906
|
indexesLine: indexes.length ? `
|
|
8674
|
-
indexes: [${indexes.map((i) => `'${i}'`).join(", ")}],` : ""
|
|
8907
|
+
indexes: [${indexes.map((i) => `'${i}'`).join(", ")}],` : "",
|
|
8908
|
+
relationsBlock
|
|
8675
8909
|
};
|
|
8676
8910
|
fs11.mkdirSync(path12.dirname(target), { recursive: true });
|
|
8677
8911
|
fs11.writeFileSync(target, substitute(fs11.readFileSync(tpl, "utf8"), vars));
|
|
8678
|
-
return { created: [target], primaryKey, indexes };
|
|
8912
|
+
return { created: [target], primaryKey, indexes, relations: relFields.map((f) => ({ field: fkName(f), ...f.relation })) };
|
|
8679
8913
|
}
|
|
8680
8914
|
function normalizeCamel3(s) {
|
|
8681
8915
|
const cleaned = s.trim().replace(/[^a-zA-Z0-9]+(.)/g, (_m, c) => c.toUpperCase()).replace(/[^a-zA-Z0-9]/g, "");
|
|
@@ -8694,7 +8928,27 @@ var init_add5 = __esm(() => {
|
|
|
8694
8928
|
|
|
8695
8929
|
// src/lib/model/parseFields.ts
|
|
8696
8930
|
function parseFieldsSpec(spec) {
|
|
8697
|
-
return spec
|
|
8931
|
+
return splitTopLevel(spec).map((s) => s.trim()).filter(Boolean).map(parseFieldLine);
|
|
8932
|
+
}
|
|
8933
|
+
function splitTopLevel(spec) {
|
|
8934
|
+
const out = [];
|
|
8935
|
+
let depth = 0;
|
|
8936
|
+
let cur = "";
|
|
8937
|
+
for (const ch of spec) {
|
|
8938
|
+
if (ch === "(")
|
|
8939
|
+
depth++;
|
|
8940
|
+
else if (ch === ")")
|
|
8941
|
+
depth = Math.max(0, depth - 1);
|
|
8942
|
+
if (ch === "," && depth === 0) {
|
|
8943
|
+
out.push(cur);
|
|
8944
|
+
cur = "";
|
|
8945
|
+
continue;
|
|
8946
|
+
}
|
|
8947
|
+
cur += ch;
|
|
8948
|
+
}
|
|
8949
|
+
if (cur.trim())
|
|
8950
|
+
out.push(cur);
|
|
8951
|
+
return out;
|
|
8698
8952
|
}
|
|
8699
8953
|
function parseFieldLine(raw) {
|
|
8700
8954
|
const line = raw.trim();
|
|
@@ -8712,6 +8966,10 @@ function parseFieldLine(raw) {
|
|
|
8712
8966
|
optional = true;
|
|
8713
8967
|
name = name.slice(0, -1);
|
|
8714
8968
|
}
|
|
8969
|
+
const relation = parseRef(type);
|
|
8970
|
+
if (relation) {
|
|
8971
|
+
return { name, type: "ref", optional, relation };
|
|
8972
|
+
}
|
|
8715
8973
|
let primaryKey = false;
|
|
8716
8974
|
let indexed = false;
|
|
8717
8975
|
while (type.endsWith("!") || type.endsWith("@")) {
|
|
@@ -8729,6 +8987,14 @@ function parseFieldLine(raw) {
|
|
|
8729
8987
|
throw new Error(`Field "${raw}" has no type after flags.`);
|
|
8730
8988
|
return { name, type, optional, primaryKey, indexed };
|
|
8731
8989
|
}
|
|
8990
|
+
function parseRef(type) {
|
|
8991
|
+
const m = /^ref\s*\(\s*([A-Za-z][A-Za-z0-9_]*)\s*(?:,\s*label\s*=\s*([A-Za-z][A-Za-z0-9_]*)\s*)?\)$/.exec(type.trim());
|
|
8992
|
+
if (!m)
|
|
8993
|
+
return null;
|
|
8994
|
+
const model = m[1];
|
|
8995
|
+
const label = m[2];
|
|
8996
|
+
return label ? { model, label } : { model };
|
|
8997
|
+
}
|
|
8732
8998
|
|
|
8733
8999
|
// src/lib/wizard/run.ts
|
|
8734
9000
|
async function runWizard(opts = {}) {
|
|
@@ -12184,9 +12450,20 @@ var init_init4 = __esm(() => {
|
|
|
12184
12450
|
});
|
|
12185
12451
|
|
|
12186
12452
|
// src/commands/model/add.ts
|
|
12187
|
-
|
|
12453
|
+
function existingModels(projectRoot) {
|
|
12454
|
+
const dir = path38.join(projectRoot, "src", "models");
|
|
12455
|
+
if (!fs25.existsSync(dir))
|
|
12456
|
+
return [];
|
|
12457
|
+
return fs25.readdirSync(dir).filter((f) => /\.ts$/.test(f) && !f.endsWith(".crud.ts")).map((f) => f.replace(/\.ts$/, "")).sort();
|
|
12458
|
+
}
|
|
12459
|
+
async function promptFieldsLoop(projectRoot) {
|
|
12188
12460
|
console.log("\n Field syntax: `name:type` — flags: `!` (primary) `@` (indexed) `?` after name (optional)");
|
|
12189
12461
|
console.log(" Examples: `id:string!`, `email?:string@`, `score:number`");
|
|
12462
|
+
console.log(" Relation: `category:ref(Category)` — FK + dropdown of the related model.");
|
|
12463
|
+
const models = existingModels(projectRoot);
|
|
12464
|
+
if (models.length) {
|
|
12465
|
+
console.log(` Models you can relate to: ${models.join(", ")}`);
|
|
12466
|
+
}
|
|
12190
12467
|
console.log(` Empty line finishes.
|
|
12191
12468
|
`);
|
|
12192
12469
|
const fields = [];
|
|
@@ -12209,24 +12486,26 @@ function pascal4(s) {
|
|
|
12209
12486
|
function plural(s) {
|
|
12210
12487
|
return `${pascal4(s)}s`;
|
|
12211
12488
|
}
|
|
12212
|
-
var import_cli_maker17, path38, FIELD_HELP, addModelCommand, add_default10;
|
|
12489
|
+
var import_cli_maker17, fs25, path38, FIELD_HELP, addModelCommand, add_default10;
|
|
12213
12490
|
var init_add21 = __esm(() => {
|
|
12214
12491
|
init_add5();
|
|
12215
12492
|
init_init();
|
|
12216
12493
|
init_findProject();
|
|
12217
12494
|
import_cli_maker17 = __toESM(require_dist(), 1);
|
|
12495
|
+
fs25 = __toESM(require("fs"));
|
|
12218
12496
|
path38 = __toESM(require("path"));
|
|
12219
12497
|
FIELD_HELP = [
|
|
12220
12498
|
"",
|
|
12221
12499
|
"Interactive field loop: enter `name:type` per line, empty line to finish.",
|
|
12222
12500
|
" Examples:",
|
|
12223
|
-
" id:string!
|
|
12224
|
-
" name:string
|
|
12225
|
-
" email?:string
|
|
12226
|
-
" createdAt:number
|
|
12227
|
-
' role:"a"|"b"
|
|
12228
|
-
" tag:string@
|
|
12229
|
-
" score:number!@
|
|
12501
|
+
" id:string! — `!` after type = primary key",
|
|
12502
|
+
" name:string — required field",
|
|
12503
|
+
" email?:string — `?` after name = optional",
|
|
12504
|
+
" createdAt:number — number type",
|
|
12505
|
+
' role:"a"|"b" — literal union',
|
|
12506
|
+
" tag:string@ — `@` after type = indexed",
|
|
12507
|
+
" score:number!@ — primary key + indexed",
|
|
12508
|
+
" category:ref(Category) — relation → FK categoryId + dropdown of Category",
|
|
12230
12509
|
"",
|
|
12231
12510
|
"If no `!` is set, `id` (or first field) becomes the primary key."
|
|
12232
12511
|
].join(`
|
|
@@ -12266,7 +12545,7 @@ var init_add21 = __esm(() => {
|
|
|
12266
12545
|
if (args.fields) {
|
|
12267
12546
|
fields = parseFieldsSpec(String(args.fields));
|
|
12268
12547
|
} else {
|
|
12269
|
-
fields = await promptFieldsLoop();
|
|
12548
|
+
fields = await promptFieldsLoop(projectRoot);
|
|
12270
12549
|
}
|
|
12271
12550
|
if (fields.length === 0) {
|
|
12272
12551
|
throw new Error("At least one field is required.");
|
|
@@ -12285,6 +12564,12 @@ var init_add21 = __esm(() => {
|
|
|
12285
12564
|
`);
|
|
12286
12565
|
for (const f of result.created)
|
|
12287
12566
|
console.log(` + ${rel(f)}`);
|
|
12567
|
+
if (result.relations.length) {
|
|
12568
|
+
console.log("");
|
|
12569
|
+
for (const r of result.relations) {
|
|
12570
|
+
console.log(` ↪ ${r.field} → ${r.model}${r.label ? ` (label: ${r.label})` : ""} — crud will render a dropdown`);
|
|
12571
|
+
}
|
|
12572
|
+
}
|
|
12288
12573
|
console.log(`
|
|
12289
12574
|
Usage:
|
|
12290
12575
|
import { ${plural(args.name)}Repo } from '../models/${pascal4(args.name)}';
|
|
@@ -12303,9 +12588,9 @@ var init_add21 = __esm(() => {
|
|
|
12303
12588
|
|
|
12304
12589
|
// src/lib/crud/parseModel.ts
|
|
12305
12590
|
function parseModelFile(file) {
|
|
12306
|
-
if (!
|
|
12591
|
+
if (!fs26.existsSync(file))
|
|
12307
12592
|
throw new Error(`Model file not found: ${file}`);
|
|
12308
|
-
const src =
|
|
12593
|
+
const src = fs26.readFileSync(file, "utf8");
|
|
12309
12594
|
const ifaceMatch = /export\s+interface\s+([A-Z][A-Za-z0-9_]*)\s*\{([\s\S]*?)\n\}/.exec(src);
|
|
12310
12595
|
if (!ifaceMatch) {
|
|
12311
12596
|
throw new Error(`Model "${path39.basename(file)}" does not declare \`export interface\`.`);
|
|
@@ -12336,7 +12621,26 @@ function parseModelFile(file) {
|
|
|
12336
12621
|
indexes.push(m[1]);
|
|
12337
12622
|
}
|
|
12338
12623
|
const id = path39.basename(file).replace(/\.(ts|tsx)$/, "");
|
|
12339
|
-
|
|
12624
|
+
const relations = parseRelations(src, name);
|
|
12625
|
+
for (const f of fields) {
|
|
12626
|
+
const r = relations[f.name];
|
|
12627
|
+
if (r)
|
|
12628
|
+
f.relation = r;
|
|
12629
|
+
}
|
|
12630
|
+
return { name, id, plural: plural2, collection, primaryKey: pk, indexes, fields, relations, path: file };
|
|
12631
|
+
}
|
|
12632
|
+
function parseRelations(src, name) {
|
|
12633
|
+
const out = {};
|
|
12634
|
+
const block = new RegExp(`export\\s+const\\s+${name}Relations\\s*=\\s*\\{([\\s\\S]*?)\\}\\s*as\\s+const`, "m").exec(src);
|
|
12635
|
+
if (!block)
|
|
12636
|
+
return out;
|
|
12637
|
+
const body = block[1];
|
|
12638
|
+
const re = /([A-Za-z_][A-Za-z0-9_]*)\s*:\s*\{\s*model\s*:\s*['"`]([^'"`]+)['"`]\s*(?:,\s*label\s*:\s*['"`]([^'"`]+)['"`]\s*)?\}/g;
|
|
12639
|
+
let m;
|
|
12640
|
+
while (m = re.exec(body)) {
|
|
12641
|
+
out[m[1]] = { field: m[1], model: m[2], label: m[3] };
|
|
12642
|
+
}
|
|
12643
|
+
return out;
|
|
12340
12644
|
}
|
|
12341
12645
|
function parseInterfaceBody(body) {
|
|
12342
12646
|
const fields = [];
|
|
@@ -12380,19 +12684,19 @@ function inferInputSpec(type) {
|
|
|
12380
12684
|
return { kind: "text" };
|
|
12381
12685
|
return { kind: "text" };
|
|
12382
12686
|
}
|
|
12383
|
-
var
|
|
12687
|
+
var fs26, path39;
|
|
12384
12688
|
var init_parseModel = __esm(() => {
|
|
12385
|
-
|
|
12689
|
+
fs26 = __toESM(require("fs"));
|
|
12386
12690
|
path39 = __toESM(require("path"));
|
|
12387
12691
|
});
|
|
12388
12692
|
|
|
12389
12693
|
// src/lib/crud/crudConfig.ts
|
|
12390
12694
|
function readCrudConfig(projectRoot, modelName) {
|
|
12391
12695
|
const file = path40.join(projectRoot, "src", "models", `${modelName}.crud.ts`);
|
|
12392
|
-
if (!
|
|
12696
|
+
if (!fs27.existsSync(file))
|
|
12393
12697
|
return {};
|
|
12394
12698
|
try {
|
|
12395
|
-
const src =
|
|
12699
|
+
const src = fs27.readFileSync(file, "utf8");
|
|
12396
12700
|
return parseConfigSource(src);
|
|
12397
12701
|
} catch {
|
|
12398
12702
|
return {};
|
|
@@ -12427,9 +12731,9 @@ function parseConfigSource(src) {
|
|
|
12427
12731
|
return {};
|
|
12428
12732
|
}
|
|
12429
12733
|
}
|
|
12430
|
-
var
|
|
12734
|
+
var fs27, path40;
|
|
12431
12735
|
var init_crudConfig = __esm(() => {
|
|
12432
|
-
|
|
12736
|
+
fs27 = __toESM(require("fs"));
|
|
12433
12737
|
path40 = __toESM(require("path"));
|
|
12434
12738
|
});
|
|
12435
12739
|
|
|
@@ -12460,7 +12764,7 @@ function addCrud(opts) {
|
|
|
12460
12764
|
assertNoOverwrite(opts.projectRoot, formPanelPath, "Form panel");
|
|
12461
12765
|
assertNoOverwrite(opts.projectRoot, listWebDir, "List webview dir");
|
|
12462
12766
|
assertNoOverwrite(opts.projectRoot, formWebDir, "Form webview dir");
|
|
12463
|
-
const pkg = JSON.parse(
|
|
12767
|
+
const pkg = JSON.parse(fs28.readFileSync(path41.join(opts.projectRoot, "package.json"), "utf8"));
|
|
12464
12768
|
const prefix = pkg.vsceasy?.commandPrefix ?? pkg.name?.replace(/^@[^/]+\//, "").replace(/[^a-zA-Z0-9]+/g, "") ?? "ext";
|
|
12465
12769
|
const baseVars = {
|
|
12466
12770
|
Name: model.name,
|
|
@@ -12473,13 +12777,27 @@ function addCrud(opts) {
|
|
|
12473
12777
|
formId,
|
|
12474
12778
|
prefix
|
|
12475
12779
|
};
|
|
12780
|
+
const modelsDir = path41.dirname(modelFile);
|
|
12781
|
+
const relations = visible.filter((f) => f.relation).map((f) => {
|
|
12782
|
+
const r = f.relation;
|
|
12783
|
+
const related = parseModelFile(path41.join(modelsDir, `${r.model}.ts`));
|
|
12784
|
+
const labelField = r.label ?? firstStringField(related) ?? related.primaryKey;
|
|
12785
|
+
return {
|
|
12786
|
+
field: f.name,
|
|
12787
|
+
model: r.model,
|
|
12788
|
+
plural: related.plural,
|
|
12789
|
+
pk: related.primaryKey,
|
|
12790
|
+
labelField
|
|
12791
|
+
};
|
|
12792
|
+
});
|
|
12793
|
+
const relVars = buildRelationVars(relations);
|
|
12476
12794
|
const created = [];
|
|
12477
12795
|
const modified = [];
|
|
12478
12796
|
writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "service.ts.tpl"), servicePath, baseVars, created);
|
|
12479
12797
|
writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "formNav.ts.tpl"), path41.join(opts.projectRoot, "src", "services", `${camelLower(model.name)}FormNav.ts`), baseVars, created);
|
|
12480
12798
|
writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "listPanel.ts.tpl"), listPanelPath, baseVars, created);
|
|
12481
|
-
writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "formPanel.ts.tpl"), formPanelPath, baseVars, created);
|
|
12482
|
-
|
|
12799
|
+
writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "formPanel.ts.tpl"), formPanelPath, { ...baseVars, ...relVars }, created);
|
|
12800
|
+
fs28.mkdirSync(listWebDir, { recursive: true });
|
|
12483
12801
|
const listVars = {
|
|
12484
12802
|
...baseVars,
|
|
12485
12803
|
listHeaderCells: visible.map((f) => ` <th style={{ padding: '6px 8px' }}>${escapeJsx(label(f, cfg))}</th>`).join(`
|
|
@@ -12490,14 +12808,14 @@ function addCrud(opts) {
|
|
|
12490
12808
|
};
|
|
12491
12809
|
writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "listApp.tsx.tpl"), path41.join(listWebDir, "App.tsx"), listVars, created);
|
|
12492
12810
|
writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "main.tsx.tpl"), path41.join(listWebDir, "main.tsx"), baseVars, created);
|
|
12493
|
-
|
|
12811
|
+
fs28.mkdirSync(formWebDir, { recursive: true });
|
|
12494
12812
|
const formInputs = visible.filter((f) => !cfg.fields?.[f.name]?.hideInForm).map((f) => renderInput(f, cfg.fields?.[f.name])).join(`
|
|
12495
12813
|
`);
|
|
12496
12814
|
const emptyLit = buildEmptyFormLiteral(visible, cfg);
|
|
12497
|
-
writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "formApp.tsx.tpl"), path41.join(formWebDir, "App.tsx"), { ...baseVars, formFieldInputs: formInputs, emptyFormLiteral: emptyLit }, created);
|
|
12815
|
+
writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "formApp.tsx.tpl"), path41.join(formWebDir, "App.tsx"), { ...baseVars, ...relVars, formFieldInputs: formInputs, emptyFormLiteral: emptyLit }, created);
|
|
12498
12816
|
writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "main.tsx.tpl"), path41.join(formWebDir, "main.tsx"), baseVars, created);
|
|
12499
12817
|
appendApi3(apiPath, listApiName, model, created, modified);
|
|
12500
|
-
appendApiForm(apiPath, formApiName, model, created, modified);
|
|
12818
|
+
appendApiForm(apiPath, formApiName, model, created, modified, relations.length > 0);
|
|
12501
12819
|
let menuInfo;
|
|
12502
12820
|
if (opts.menu && opts.menu !== "none") {
|
|
12503
12821
|
menuInfo = wireMenu(opts, model, cfg, listId, formId);
|
|
@@ -12508,10 +12826,10 @@ function addCrud(opts) {
|
|
|
12508
12826
|
return { created, modified, menu: menuInfo, genRan };
|
|
12509
12827
|
}
|
|
12510
12828
|
function writeFromTpl(tpl, target, vars, created) {
|
|
12511
|
-
if (!
|
|
12829
|
+
if (!fs28.existsSync(tpl))
|
|
12512
12830
|
throw new Error(`CRUD template missing: ${tpl}`);
|
|
12513
|
-
|
|
12514
|
-
|
|
12831
|
+
fs28.mkdirSync(path41.dirname(target), { recursive: true });
|
|
12832
|
+
fs28.writeFileSync(target, substitute(fs28.readFileSync(tpl, "utf8"), vars));
|
|
12515
12833
|
created.push(target);
|
|
12516
12834
|
}
|
|
12517
12835
|
function orderFields(fields, order) {
|
|
@@ -12551,6 +12869,14 @@ function renderInput(field, override) {
|
|
|
12551
12869
|
` + ` <span style={{ opacity: 0.8 }}>${labelText}</span>
|
|
12552
12870
|
` + `${input}
|
|
12553
12871
|
` + ` </label>`;
|
|
12872
|
+
if (field.relation) {
|
|
12873
|
+
return wrap(` <select${required} value={(form.${name} as any) ?? ''} onChange={(e) => onChange('${name}', e.target.value as any)}>
|
|
12874
|
+
` + ` <option value=""></option>
|
|
12875
|
+
` + ` {(relOptions['${name}'] ?? []).map((o) => (
|
|
12876
|
+
` + ` <option key={o.value} value={o.value}>{o.label}</option>
|
|
12877
|
+
` + ` ))}
|
|
12878
|
+
` + ` </select>`);
|
|
12879
|
+
}
|
|
12554
12880
|
switch (spec.kind) {
|
|
12555
12881
|
case "number":
|
|
12556
12882
|
return wrap(` <input type="number"${placeholderAttr}${required} value={form.${name} as any ?? ''} onChange={(e) => onChange('${name}', e.target.value === '' ? undefined : Number(e.target.value))} />`);
|
|
@@ -12601,45 +12927,47 @@ export interface ${apiName} {
|
|
|
12601
12927
|
ensureImport(apiPath, model.name);
|
|
12602
12928
|
appendIfMissing(apiPath, apiName, sig, created, modified);
|
|
12603
12929
|
}
|
|
12604
|
-
function appendApiForm(apiPath, apiName, model, created, modified) {
|
|
12930
|
+
function appendApiForm(apiPath, apiName, model, created, modified, hasRelations) {
|
|
12931
|
+
const optionsLine = hasRelations ? ` options(): Promise<Record<string, { value: string; label: string }[]>>;
|
|
12932
|
+
` : "";
|
|
12605
12933
|
const sig = `
|
|
12606
12934
|
export interface ${apiName} {
|
|
12607
12935
|
` + ` pendingId(): Promise<${model.name}['${model.primaryKey}'] | null>;
|
|
12608
12936
|
` + ` get(id: ${model.name}['${model.primaryKey}'] | null): Promise<${model.name} | null>;
|
|
12609
12937
|
` + ` save(row: ${model.name}): Promise<${model.name}>;
|
|
12610
|
-
` + ` cancel(): Promise<void>;
|
|
12938
|
+
` + optionsLine + ` cancel(): Promise<void>;
|
|
12611
12939
|
` + `}
|
|
12612
12940
|
`;
|
|
12613
12941
|
ensureImport(apiPath, model.name);
|
|
12614
12942
|
appendIfMissing(apiPath, apiName, sig, created, modified);
|
|
12615
12943
|
}
|
|
12616
12944
|
function ensureImport(apiPath, modelName) {
|
|
12617
|
-
if (!
|
|
12618
|
-
|
|
12619
|
-
|
|
12945
|
+
if (!fs28.existsSync(apiPath)) {
|
|
12946
|
+
fs28.mkdirSync(path41.dirname(apiPath), { recursive: true });
|
|
12947
|
+
fs28.writeFileSync(apiPath, `import type { ${modelName} } from '../models/${modelName}';
|
|
12620
12948
|
`);
|
|
12621
12949
|
return;
|
|
12622
12950
|
}
|
|
12623
|
-
let src =
|
|
12951
|
+
let src = fs28.readFileSync(apiPath, "utf8");
|
|
12624
12952
|
if (new RegExp(`from\\s+['"]\\.\\./models/${modelName}['"]`).test(src))
|
|
12625
12953
|
return;
|
|
12626
12954
|
src = `import type { ${modelName} } from '../models/${modelName}';
|
|
12627
12955
|
` + src;
|
|
12628
|
-
|
|
12956
|
+
fs28.writeFileSync(apiPath, src);
|
|
12629
12957
|
}
|
|
12630
12958
|
function appendIfMissing(apiPath, apiName, block, created, modified) {
|
|
12631
|
-
if (!
|
|
12632
|
-
|
|
12959
|
+
if (!fs28.existsSync(apiPath)) {
|
|
12960
|
+
fs28.writeFileSync(apiPath, block.trimStart());
|
|
12633
12961
|
created.push(apiPath);
|
|
12634
12962
|
return;
|
|
12635
12963
|
}
|
|
12636
|
-
const src =
|
|
12964
|
+
const src = fs28.readFileSync(apiPath, "utf8");
|
|
12637
12965
|
if (new RegExp(`\\bexport\\s+interface\\s+${apiName}\\b`).test(src))
|
|
12638
12966
|
return;
|
|
12639
12967
|
const sep = src.endsWith(`
|
|
12640
12968
|
`) ? "" : `
|
|
12641
12969
|
`;
|
|
12642
|
-
|
|
12970
|
+
fs28.writeFileSync(apiPath, src + sep + block);
|
|
12643
12971
|
if (!modified.includes(apiPath))
|
|
12644
12972
|
modified.push(apiPath);
|
|
12645
12973
|
}
|
|
@@ -12695,7 +13023,31 @@ function runGen11(cwd) {
|
|
|
12695
13023
|
function which12(cmd) {
|
|
12696
13024
|
return import_child_process13.spawnSync(process.platform === "win32" ? "where" : "which", [cmd], { stdio: "ignore" }).status === 0;
|
|
12697
13025
|
}
|
|
12698
|
-
|
|
13026
|
+
function firstStringField(model) {
|
|
13027
|
+
return model.fields.find((f) => f.type.trim() === "string" && f.name !== model.primaryKey)?.name;
|
|
13028
|
+
}
|
|
13029
|
+
function buildRelationVars(relations) {
|
|
13030
|
+
if (relations.length === 0) {
|
|
13031
|
+
return { relationImports: "", relationOptionsHandler: "", relationOptionsState: "", relationOptionsLoad: "" };
|
|
13032
|
+
}
|
|
13033
|
+
const uniqueRepos = [...new Map(relations.map((r) => [r.plural, r])).values()];
|
|
13034
|
+
const relationImports = uniqueRepos.map((r) => `import { ${r.plural}Repo } from '../models/${r.model}';`).join(`
|
|
13035
|
+
`) + `
|
|
13036
|
+
`;
|
|
13037
|
+
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(`
|
|
13038
|
+
`);
|
|
13039
|
+
const relationOptionsHandler = ` async options() {
|
|
13040
|
+
` + ` return {
|
|
13041
|
+
` + `${handlerLines}
|
|
13042
|
+
` + ` };
|
|
13043
|
+
` + ` },
|
|
13044
|
+
`;
|
|
13045
|
+
const relationOptionsState = ` const [relOptions, setRelOptions] = useState<Record<string, { value: string; label: string }[]>>({});`;
|
|
13046
|
+
const relationOptionsLoad = `
|
|
13047
|
+
useEffect(() => { void api.options().then(setRelOptions); }, []);`;
|
|
13048
|
+
return { relationImports, relationOptionsHandler, relationOptionsState, relationOptionsLoad };
|
|
13049
|
+
}
|
|
13050
|
+
var fs28, path41, import_child_process13;
|
|
12699
13051
|
var init_add22 = __esm(() => {
|
|
12700
13052
|
init_scaffold();
|
|
12701
13053
|
init_validate();
|
|
@@ -12703,20 +13055,20 @@ var init_add22 = __esm(() => {
|
|
|
12703
13055
|
init_crudConfig();
|
|
12704
13056
|
init_add7();
|
|
12705
13057
|
init_edit();
|
|
12706
|
-
|
|
13058
|
+
fs28 = __toESM(require("fs"));
|
|
12707
13059
|
path41 = __toESM(require("path"));
|
|
12708
13060
|
import_child_process13 = require("child_process");
|
|
12709
13061
|
});
|
|
12710
13062
|
|
|
12711
13063
|
// src/commands/crud/add.ts
|
|
12712
|
-
var import_cli_maker18, path42,
|
|
13064
|
+
var import_cli_maker18, path42, fs29, NONE_SENTINEL2 = "(no menu)", NEW_SENTINEL = "(create new menu)", addCrudCommand, add_default11;
|
|
12713
13065
|
var init_add23 = __esm(() => {
|
|
12714
13066
|
init_add22();
|
|
12715
13067
|
init_edit();
|
|
12716
13068
|
init_findProject();
|
|
12717
13069
|
import_cli_maker18 = __toESM(require_dist(), 1);
|
|
12718
13070
|
path42 = __toESM(require("path"));
|
|
12719
|
-
|
|
13071
|
+
fs29 = __toESM(require("fs"));
|
|
12720
13072
|
addCrudCommand = {
|
|
12721
13073
|
name: "add",
|
|
12722
13074
|
description: "Generate full CRUD (service + list panel + form panel + RPC + optional menu) for an existing model. Rails-style scaffolding.",
|
|
@@ -12728,10 +13080,10 @@ var init_add23 = __esm(() => {
|
|
|
12728
13080
|
type: import_cli_maker18.ParamType.List,
|
|
12729
13081
|
optionsLoader: () => {
|
|
12730
13082
|
const dir = path42.join(findProjectRoot(), "src", "models");
|
|
12731
|
-
if (!
|
|
13083
|
+
if (!fs29.existsSync(dir)) {
|
|
12732
13084
|
throw new Error("No models found. Run `vsceasy db init && vsceasy model add --name User` first.");
|
|
12733
13085
|
}
|
|
12734
|
-
const names =
|
|
13086
|
+
const names = fs29.readdirSync(dir).filter((f) => f.endsWith(".ts") && !f.endsWith(".crud.ts")).map((f) => f.replace(/\.ts$/, "")).sort();
|
|
12735
13087
|
if (names.length === 0) {
|
|
12736
13088
|
throw new Error("No models found in src/models/. Run `vsceasy model add --name User` first.");
|
|
12737
13089
|
}
|
|
@@ -12853,6 +13205,122 @@ var init_add24 = __esm(() => {
|
|
|
12853
13205
|
add_default12 = addComponentsCommand;
|
|
12854
13206
|
});
|
|
12855
13207
|
|
|
13208
|
+
// src/lib/store/add.ts
|
|
13209
|
+
function addStore(opts) {
|
|
13210
|
+
const name = assertId("store name", normalizeCamel9(opts.name));
|
|
13211
|
+
const type = opts.type ?? "number";
|
|
13212
|
+
const initial = opts.initial?.trim() || DEFAULT_INITIAL[type];
|
|
13213
|
+
const storeTs = path44.join(opts.projectRoot, "src", "stores", `${name}.ts`);
|
|
13214
|
+
assertNoOverwrite(opts.projectRoot, storeTs, "Store");
|
|
13215
|
+
const tplPath = path44.join(opts.templatesRoot, "_generators", "store", "store.ts.tpl");
|
|
13216
|
+
const body = substitute(fs30.readFileSync(tplPath, "utf8"), {
|
|
13217
|
+
name,
|
|
13218
|
+
type: TS_TYPE[type],
|
|
13219
|
+
initial,
|
|
13220
|
+
example: EXAMPLE[type]
|
|
13221
|
+
});
|
|
13222
|
+
fs30.mkdirSync(path44.dirname(storeTs), { recursive: true });
|
|
13223
|
+
fs30.writeFileSync(storeTs, body);
|
|
13224
|
+
return { created: [storeTs] };
|
|
13225
|
+
}
|
|
13226
|
+
function normalizeCamel9(s) {
|
|
13227
|
+
const cleaned = s.trim().replace(/[^a-zA-Z0-9]+(.)/g, (_m, c) => c.toUpperCase()).replace(/[^a-zA-Z0-9]/g, "");
|
|
13228
|
+
if (!cleaned)
|
|
13229
|
+
return "";
|
|
13230
|
+
return cleaned.charAt(0).toLowerCase() + cleaned.slice(1);
|
|
13231
|
+
}
|
|
13232
|
+
var fs30, path44, DEFAULT_INITIAL, TS_TYPE, EXAMPLE;
|
|
13233
|
+
var init_add25 = __esm(() => {
|
|
13234
|
+
init_scaffold();
|
|
13235
|
+
init_validate();
|
|
13236
|
+
fs30 = __toESM(require("fs"));
|
|
13237
|
+
path44 = __toESM(require("path"));
|
|
13238
|
+
DEFAULT_INITIAL = {
|
|
13239
|
+
number: "0",
|
|
13240
|
+
string: "''",
|
|
13241
|
+
boolean: "false",
|
|
13242
|
+
json: "null"
|
|
13243
|
+
};
|
|
13244
|
+
TS_TYPE = {
|
|
13245
|
+
number: "number",
|
|
13246
|
+
string: "string",
|
|
13247
|
+
boolean: "boolean",
|
|
13248
|
+
json: "any"
|
|
13249
|
+
};
|
|
13250
|
+
EXAMPLE = {
|
|
13251
|
+
number: "1",
|
|
13252
|
+
string: "'hello'",
|
|
13253
|
+
boolean: "true",
|
|
13254
|
+
json: "{ open: true }"
|
|
13255
|
+
};
|
|
13256
|
+
});
|
|
13257
|
+
|
|
13258
|
+
// src/commands/store/add.ts
|
|
13259
|
+
var import_cli_maker20, path45, addStoreCommand, add_default13;
|
|
13260
|
+
var init_add26 = __esm(() => {
|
|
13261
|
+
init_add25();
|
|
13262
|
+
init_findProject();
|
|
13263
|
+
import_cli_maker20 = __toESM(require_dist(), 1);
|
|
13264
|
+
path45 = __toESM(require("path"));
|
|
13265
|
+
addStoreCommand = {
|
|
13266
|
+
name: "add",
|
|
13267
|
+
description: "Add a reactive store (an observable value) under src/stores/. Watch it on the host and listen in a webview to keep UI in sync.",
|
|
13268
|
+
params: [
|
|
13269
|
+
{ name: "name", description: "Store name (camelCase, e.g. badgeCount)", required: true, type: import_cli_maker20.ParamType.Text },
|
|
13270
|
+
{
|
|
13271
|
+
name: "type",
|
|
13272
|
+
description: "Value type",
|
|
13273
|
+
required: false,
|
|
13274
|
+
type: import_cli_maker20.ParamType.List,
|
|
13275
|
+
options: ["number", "string", "boolean", "json"]
|
|
13276
|
+
},
|
|
13277
|
+
{
|
|
13278
|
+
name: "initial",
|
|
13279
|
+
description: "Initial value expression (default per type: 0 / '' / false / null)",
|
|
13280
|
+
required: false,
|
|
13281
|
+
type: import_cli_maker20.ParamType.Text
|
|
13282
|
+
}
|
|
13283
|
+
],
|
|
13284
|
+
action: async (args) => {
|
|
13285
|
+
try {
|
|
13286
|
+
const projectRoot = findProjectRoot();
|
|
13287
|
+
const templatesRoot = findTemplatesRoot();
|
|
13288
|
+
const result = addStore({
|
|
13289
|
+
name: String(args.name).trim(),
|
|
13290
|
+
type: args.type ? String(args.type) : undefined,
|
|
13291
|
+
initial: args.initial ? String(args.initial) : undefined,
|
|
13292
|
+
projectRoot,
|
|
13293
|
+
templatesRoot
|
|
13294
|
+
});
|
|
13295
|
+
const rel = (p) => path45.relative(projectRoot, p);
|
|
13296
|
+
const name = String(args.name).trim();
|
|
13297
|
+
console.log(`
|
|
13298
|
+
✓ Store "${name}" created.
|
|
13299
|
+
`);
|
|
13300
|
+
for (const f of result.created)
|
|
13301
|
+
console.log(` + ${rel(f)}`);
|
|
13302
|
+
console.log(`
|
|
13303
|
+
Use it:
|
|
13304
|
+
import { ${name} } from '../stores/${name}';
|
|
13305
|
+
${name}.set(...); ${name}.update((v) => v);
|
|
13306
|
+
`);
|
|
13307
|
+
console.log(` Push changes to a webview — host side, in a panel rpc():
|
|
13308
|
+
watch(${name}, () => server.emit('${name}:changed', ${name}.get()));
|
|
13309
|
+
`);
|
|
13310
|
+
console.log(` React in the webview:
|
|
13311
|
+
listen(api, '${name}:changed', (v) => render(v));
|
|
13312
|
+
`);
|
|
13313
|
+
} catch (err) {
|
|
13314
|
+
console.error(`
|
|
13315
|
+
✗ ${err.message}
|
|
13316
|
+
`);
|
|
13317
|
+
process.exitCode = 1;
|
|
13318
|
+
}
|
|
13319
|
+
}
|
|
13320
|
+
};
|
|
13321
|
+
add_default13 = addStoreCommand;
|
|
13322
|
+
});
|
|
13323
|
+
|
|
12856
13324
|
// src/commands/groups.ts
|
|
12857
13325
|
function group(name, description, subcommands) {
|
|
12858
13326
|
return {
|
|
@@ -12869,7 +13337,7 @@ Use one of: ${subcommands.map((s) => s.name).join(", ")}
|
|
|
12869
13337
|
}
|
|
12870
13338
|
};
|
|
12871
13339
|
}
|
|
12872
|
-
var PanelGroup, MenuGroup, CommandGroup, RpcGroup, StatusBarGroup, SubpanelGroup, TreeViewGroup, TestGroup, PublishGroup, HelperGroup, JobGroup, DbGroup, ModelGroup, CrudGroup, ComponentsGroup;
|
|
13340
|
+
var PanelGroup, MenuGroup, CommandGroup, RpcGroup, StatusBarGroup, SubpanelGroup, TreeViewGroup, TestGroup, PublishGroup, HelperGroup, JobGroup, DbGroup, ModelGroup, CrudGroup, ComponentsGroup, StoreGroup;
|
|
12873
13341
|
var init_groups = __esm(() => {
|
|
12874
13342
|
init_add6();
|
|
12875
13343
|
init_add8();
|
|
@@ -12887,6 +13355,7 @@ var init_groups = __esm(() => {
|
|
|
12887
13355
|
init_add21();
|
|
12888
13356
|
init_add23();
|
|
12889
13357
|
init_add24();
|
|
13358
|
+
init_add26();
|
|
12890
13359
|
PanelGroup = group("panel", "Manage panels (webview + RPC bridge)", [add_default]);
|
|
12891
13360
|
MenuGroup = group("menu", "Manage sidebar menus (activity bar tree views)", [add_default2, edit_default]);
|
|
12892
13361
|
CommandGroup = group("command", "Manage palette commands", [add_default3]);
|
|
@@ -12902,6 +13371,7 @@ var init_groups = __esm(() => {
|
|
|
12902
13371
|
ModelGroup = group("model", "Manage typed models (entities + repos) under src/models/", [add_default10]);
|
|
12903
13372
|
CrudGroup = group("crud", "Scaffold full CRUD UI (service + list panel + form panel + RPC) for a model", [add_default11]);
|
|
12904
13373
|
ComponentsGroup = group("components", "Generate a theme-aware React component library for webviews", [add_default12]);
|
|
13374
|
+
StoreGroup = group("store", "Manage reactive stores (observable values) under src/stores/", [add_default13]);
|
|
12905
13375
|
});
|
|
12906
13376
|
|
|
12907
13377
|
// src/cli.ts
|
|
@@ -12909,17 +13379,17 @@ var exports_cli = {};
|
|
|
12909
13379
|
__export(exports_cli, {
|
|
12910
13380
|
cli: () => cli
|
|
12911
13381
|
});
|
|
12912
|
-
var
|
|
13382
|
+
var import_cli_maker21, cli;
|
|
12913
13383
|
var init_cli = __esm(() => {
|
|
12914
13384
|
init_create();
|
|
12915
13385
|
init_wizard();
|
|
12916
13386
|
init_doctor2();
|
|
12917
13387
|
init_upgrade2();
|
|
12918
13388
|
init_groups();
|
|
12919
|
-
|
|
12920
|
-
cli = new
|
|
13389
|
+
import_cli_maker21 = __toESM(require_dist(), 1);
|
|
13390
|
+
cli = new import_cli_maker21.CLI("vsceasy", "Build VS Code extensions fast — React UI + typed RPC bridge + zero-config build.", {
|
|
12921
13391
|
interactive: true,
|
|
12922
|
-
version: "0.1.
|
|
13392
|
+
version: "0.1.9",
|
|
12923
13393
|
introAnimation: {
|
|
12924
13394
|
enabled: true,
|
|
12925
13395
|
preset: "retro-space",
|
|
@@ -12957,6 +13427,7 @@ var init_cli = __esm(() => {
|
|
|
12957
13427
|
cli.command(ModelGroup);
|
|
12958
13428
|
cli.command(CrudGroup);
|
|
12959
13429
|
cli.command(ComponentsGroup);
|
|
13430
|
+
cli.command(StoreGroup);
|
|
12960
13431
|
cli.command(doctor_default);
|
|
12961
13432
|
cli.command(upgrade_default);
|
|
12962
13433
|
cli.parse(process.argv);
|