@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.
@@ -14,3 +14,4 @@ export declare const DbGroup: Command;
14
14
  export declare const ModelGroup: Command;
15
15
  export declare const CrudGroup: Command;
16
16
  export declare const ComponentsGroup: Command;
17
+ export declare const StoreGroup: Command;
@@ -0,0 +1,3 @@
1
+ import { Command } from '@ideascol/cli-maker';
2
+ declare const addStoreCommand: Command;
3
+ export default addStoreCommand;
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.7", TEMPLATE_FILES;
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
- const handlers = def.rpc(vscode, context);
4854
- const server = createRpcServer(webviewTransport(view.webview), handlers);
4855
- view.onDidDispose(() => server.dispose());
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
- const handlers = def.rpc(vscode, context);
5092
- const server = createRpcServer(webviewTransport(panel.webview), handlers);
5093
- panel.onDidDispose(() => server.dispose());
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
- /** RPC handlers — receives vscode namespace + extension context. */
5425
- rpc?: (vscode: typeof import('vscode'), ctx: vscode.ExtensionContext) => H;
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
- /** RPC handlers — receives vscode namespace + extension context. */
5537
- rpc?: (vscode: typeof import('vscode'), ctx: vscode.ExtensionContext) => H;
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
- return { name, id, plural, collection, primaryKey: pk, indexes, fields, path: file };
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 target = path25.join(opts.projectRoot, "src", "models", `${Name}.ts`);
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 fieldLines = opts.fields.map((f) => ` ${f.name}${f.optional ? "?" : ""}: ${f.type};`).join(`
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
  }
@@ -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;