@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/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.7", TEMPLATE_FILES;
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
- const handlers = def.rpc(vscode, context);
6442
- const server = createRpcServer(webviewTransport(view.webview), handlers);
6443
- view.onDidDispose(() => server.dispose());
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
- const handlers = def.rpc(vscode, context);
6680
- const server = createRpcServer(webviewTransport(panel.webview), handlers);
6681
- panel.onDidDispose(() => server.dispose());
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
- /** RPC handlers — receives vscode namespace + extension context. */
7013
- rpc?: (vscode: typeof import('vscode'), ctx: vscode.ExtensionContext) => H;
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
- /** RPC handlers — receives vscode namespace + extension context. */
7125
- rpc?: (vscode: typeof import('vscode'), ctx: vscode.ExtensionContext) => H;
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 target = path12.join(opts.projectRoot, "src", "models", `${Name}.ts`);
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 fieldLines = opts.fields.map((f) => ` ${f.name}${f.optional ? "?" : ""}: ${f.type};`).join(`
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.split(",").map((s) => s.trim()).filter(Boolean).map(parseFieldLine);
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
- async function promptFieldsLoop() {
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! — `!` after type = primary key",
12224
- " name:string — required field",
12225
- " email?:string — `?` after name = optional",
12226
- " createdAt:number — number type",
12227
- ' role:"a"|"b" — literal union',
12228
- " tag:string@ — `@` after type = indexed",
12229
- " score:number!@ — primary key + indexed",
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 (!fs25.existsSync(file))
12591
+ if (!fs26.existsSync(file))
12307
12592
  throw new Error(`Model file not found: ${file}`);
12308
- const src = fs25.readFileSync(file, "utf8");
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
- return { name, id, plural: plural2, collection, primaryKey: pk, indexes, fields, path: file };
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 fs25, path39;
12687
+ var fs26, path39;
12384
12688
  var init_parseModel = __esm(() => {
12385
- fs25 = __toESM(require("fs"));
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 (!fs26.existsSync(file))
12696
+ if (!fs27.existsSync(file))
12393
12697
  return {};
12394
12698
  try {
12395
- const src = fs26.readFileSync(file, "utf8");
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 fs26, path40;
12734
+ var fs27, path40;
12431
12735
  var init_crudConfig = __esm(() => {
12432
- fs26 = __toESM(require("fs"));
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(fs27.readFileSync(path41.join(opts.projectRoot, "package.json"), "utf8"));
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
- fs27.mkdirSync(listWebDir, { recursive: true });
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
- fs27.mkdirSync(formWebDir, { recursive: true });
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 (!fs27.existsSync(tpl))
12829
+ if (!fs28.existsSync(tpl))
12512
12830
  throw new Error(`CRUD template missing: ${tpl}`);
12513
- fs27.mkdirSync(path41.dirname(target), { recursive: true });
12514
- fs27.writeFileSync(target, substitute(fs27.readFileSync(tpl, "utf8"), vars));
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 (!fs27.existsSync(apiPath)) {
12618
- fs27.mkdirSync(path41.dirname(apiPath), { recursive: true });
12619
- fs27.writeFileSync(apiPath, `import type { ${modelName} } from '../models/${modelName}';
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 = fs27.readFileSync(apiPath, "utf8");
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
- fs27.writeFileSync(apiPath, src);
12956
+ fs28.writeFileSync(apiPath, src);
12629
12957
  }
12630
12958
  function appendIfMissing(apiPath, apiName, block, created, modified) {
12631
- if (!fs27.existsSync(apiPath)) {
12632
- fs27.writeFileSync(apiPath, block.trimStart());
12959
+ if (!fs28.existsSync(apiPath)) {
12960
+ fs28.writeFileSync(apiPath, block.trimStart());
12633
12961
  created.push(apiPath);
12634
12962
  return;
12635
12963
  }
12636
- const src = fs27.readFileSync(apiPath, "utf8");
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
- fs27.writeFileSync(apiPath, src + sep + block);
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
- var fs27, path41, import_child_process13;
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
- fs27 = __toESM(require("fs"));
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, fs28, NONE_SENTINEL2 = "(no menu)", NEW_SENTINEL = "(create new menu)", addCrudCommand, add_default11;
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
- fs28 = __toESM(require("fs"));
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 (!fs28.existsSync(dir)) {
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 = fs28.readdirSync(dir).filter((f) => f.endsWith(".ts") && !f.endsWith(".crud.ts")).map((f) => f.replace(/\.ts$/, "")).sort();
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 import_cli_maker20, cli;
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
- import_cli_maker20 = __toESM(require_dist(), 1);
12920
- cli = new import_cli_maker20.CLI("vsceasy", "Build VS Code extensions fast — React UI + typed RPC bridge + zero-config build.", {
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.7",
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);