@vsceasy/cli 0.1.8 → 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 CHANGED
@@ -5,6 +5,10 @@ All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.
5
5
  ## [Unreleased]
6
6
 
7
7
  ### Added
8
+ - **Model relations — `ref(Model)` fields with populated CRUD dropdowns.** Symfony-`make:entity`-style relations.
9
+ - `vsceasy model add --fields "…,category:ref(Category)"` emits a `categoryId` foreign key plus a `<Name>Relations` metadata block. `ref(Category, label=name)` picks the dropdown label field. The referenced model must exist (errors otherwise, naming what to create); the interactive loop lists relatable models.
10
+ - `crud add` reads the relation metadata and generates a populated `<select>`: an `options()` RPC handler on the form panel loads the related rows, and the form webview renders a dropdown of them and stores the chosen id. Non-relational CRUD output is unchanged.
11
+ - ManyToOne only (FK on this model) — no join table or cascade. See the [Relations guide](https://vsceasy.dev/guides/relations/).
8
12
  - **Reactivity — keep a webview in sync with data.** A visual element can now track a source and update the instant it changes, no manual refresh.
9
13
  - ORM entities fire change events on every mutation; subscribe with `watchEntity(Todos, () => emit('todos:changed'))` from your generated `db.ts`.
10
14
  - `defineStore(initial)` — a framework-agnostic observable value (`get`/`set`/`update`/`subscribe`) for non-ORM state. Scaffold one with **`vsceasy store add --name X --type number|string|boolean|json`**.
package/README.md CHANGED
@@ -8,6 +8,14 @@
8
8
 
9
9
  <p align="center"><em>Pronounced <strong>"vee-see-easy"</strong> in English (<code>VSC</code> + <code>easy</code>) — or <strong>"visici"</strong> for Spanish speakers.</em></p>
10
10
 
11
+ <p align="center">
12
+ <video src="https://github.com/jairoFernandez/vsceasy/releases/download/promo-assets/vsceasy-promo.mp4" width="720" controls></video>
13
+ </p>
14
+
15
+ <p align="center">
16
+ <a href="https://github.com/jairoFernandez/vsceasy/releases/download/promo-assets/vsceasy-promo.mp4">▶ Watch the 30-second promo</a>
17
+ </p>
18
+
11
19
  > Status: v0.1 — React UI. Typed RPC bridge + file-based registry + scaffolding for panels, commands, menus, tree views, subpanels, status bars.
12
20
 
13
21
  ## Install
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.8", 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
  }),
@@ -5006,7 +5008,7 @@ export const {{Plural}} = defineEntity<{{Name}}>('{{collection}}', {
5006
5008
  * import { {{Plural}}Repo } from '../models/{{Name}}';
5007
5009
  * await {{Plural}}Repo().insert({ ... });
5008
5010
  */
5009
- export const {{Plural}}Repo = () => db()({{Plural}});
5011
+ export const {{Plural}}Repo = () => db()({{Plural}});{{relationsBlock}}
5010
5012
  `,
5011
5013
  "_generators/panel/App.tsx.tpl": `import React from 'react';
5012
5014
  {{apiBlock}}
@@ -8863,11 +8865,37 @@ function addModel(opts) {
8863
8865
  const pkField = explicitPk[0] ?? opts.fields.find((f) => f.name === "id") ?? opts.fields[0];
8864
8866
  const primaryKey = pkField.name;
8865
8867
  const indexes = opts.fields.filter((f) => f.indexed && f.name !== primaryKey).map((f) => f.name);
8866
- 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`);
8867
8877
  assertNoOverwrite(opts.projectRoot, target, "Model");
8868
8878
  const tpl = path12.join(opts.templatesRoot, "_generators", "model", "model.ts.tpl");
8869
- const fieldLines = opts.fields.map((f) => ` ${f.name}${f.optional ? "?" : ""}: ${f.type};`).join(`
8870
- `);
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
+ ` : "";
8871
8899
  const vars = {
8872
8900
  name,
8873
8901
  Name,
@@ -8876,11 +8904,12 @@ function addModel(opts) {
8876
8904
  primaryKey,
8877
8905
  fieldLines,
8878
8906
  indexesLine: indexes.length ? `
8879
- indexes: [${indexes.map((i) => `'${i}'`).join(", ")}],` : ""
8907
+ indexes: [${indexes.map((i) => `'${i}'`).join(", ")}],` : "",
8908
+ relationsBlock
8880
8909
  };
8881
8910
  fs11.mkdirSync(path12.dirname(target), { recursive: true });
8882
8911
  fs11.writeFileSync(target, substitute(fs11.readFileSync(tpl, "utf8"), vars));
8883
- return { created: [target], primaryKey, indexes };
8912
+ return { created: [target], primaryKey, indexes, relations: relFields.map((f) => ({ field: fkName(f), ...f.relation })) };
8884
8913
  }
8885
8914
  function normalizeCamel3(s) {
8886
8915
  const cleaned = s.trim().replace(/[^a-zA-Z0-9]+(.)/g, (_m, c) => c.toUpperCase()).replace(/[^a-zA-Z0-9]/g, "");
@@ -8899,7 +8928,27 @@ var init_add5 = __esm(() => {
8899
8928
 
8900
8929
  // src/lib/model/parseFields.ts
8901
8930
  function parseFieldsSpec(spec) {
8902
- 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;
8903
8952
  }
8904
8953
  function parseFieldLine(raw) {
8905
8954
  const line = raw.trim();
@@ -8917,6 +8966,10 @@ function parseFieldLine(raw) {
8917
8966
  optional = true;
8918
8967
  name = name.slice(0, -1);
8919
8968
  }
8969
+ const relation = parseRef(type);
8970
+ if (relation) {
8971
+ return { name, type: "ref", optional, relation };
8972
+ }
8920
8973
  let primaryKey = false;
8921
8974
  let indexed = false;
8922
8975
  while (type.endsWith("!") || type.endsWith("@")) {
@@ -8934,6 +8987,14 @@ function parseFieldLine(raw) {
8934
8987
  throw new Error(`Field "${raw}" has no type after flags.`);
8935
8988
  return { name, type, optional, primaryKey, indexed };
8936
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
+ }
8937
8998
 
8938
8999
  // src/lib/wizard/run.ts
8939
9000
  async function runWizard(opts = {}) {
@@ -12389,9 +12450,20 @@ var init_init4 = __esm(() => {
12389
12450
  });
12390
12451
 
12391
12452
  // src/commands/model/add.ts
12392
- 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) {
12393
12460
  console.log("\n Field syntax: `name:type` — flags: `!` (primary) `@` (indexed) `?` after name (optional)");
12394
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
+ }
12395
12467
  console.log(` Empty line finishes.
12396
12468
  `);
12397
12469
  const fields = [];
@@ -12414,24 +12486,26 @@ function pascal4(s) {
12414
12486
  function plural(s) {
12415
12487
  return `${pascal4(s)}s`;
12416
12488
  }
12417
- var import_cli_maker17, path38, FIELD_HELP, addModelCommand, add_default10;
12489
+ var import_cli_maker17, fs25, path38, FIELD_HELP, addModelCommand, add_default10;
12418
12490
  var init_add21 = __esm(() => {
12419
12491
  init_add5();
12420
12492
  init_init();
12421
12493
  init_findProject();
12422
12494
  import_cli_maker17 = __toESM(require_dist(), 1);
12495
+ fs25 = __toESM(require("fs"));
12423
12496
  path38 = __toESM(require("path"));
12424
12497
  FIELD_HELP = [
12425
12498
  "",
12426
12499
  "Interactive field loop: enter `name:type` per line, empty line to finish.",
12427
12500
  " Examples:",
12428
- " id:string! — `!` after type = primary key",
12429
- " name:string — required field",
12430
- " email?:string — `?` after name = optional",
12431
- " createdAt:number — number type",
12432
- ' role:"a"|"b" — literal union',
12433
- " tag:string@ — `@` after type = indexed",
12434
- " 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",
12435
12509
  "",
12436
12510
  "If no `!` is set, `id` (or first field) becomes the primary key."
12437
12511
  ].join(`
@@ -12471,7 +12545,7 @@ var init_add21 = __esm(() => {
12471
12545
  if (args.fields) {
12472
12546
  fields = parseFieldsSpec(String(args.fields));
12473
12547
  } else {
12474
- fields = await promptFieldsLoop();
12548
+ fields = await promptFieldsLoop(projectRoot);
12475
12549
  }
12476
12550
  if (fields.length === 0) {
12477
12551
  throw new Error("At least one field is required.");
@@ -12490,6 +12564,12 @@ var init_add21 = __esm(() => {
12490
12564
  `);
12491
12565
  for (const f of result.created)
12492
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
+ }
12493
12573
  console.log(`
12494
12574
  Usage:
12495
12575
  import { ${plural(args.name)}Repo } from '../models/${pascal4(args.name)}';
@@ -12508,9 +12588,9 @@ var init_add21 = __esm(() => {
12508
12588
 
12509
12589
  // src/lib/crud/parseModel.ts
12510
12590
  function parseModelFile(file) {
12511
- if (!fs25.existsSync(file))
12591
+ if (!fs26.existsSync(file))
12512
12592
  throw new Error(`Model file not found: ${file}`);
12513
- const src = fs25.readFileSync(file, "utf8");
12593
+ const src = fs26.readFileSync(file, "utf8");
12514
12594
  const ifaceMatch = /export\s+interface\s+([A-Z][A-Za-z0-9_]*)\s*\{([\s\S]*?)\n\}/.exec(src);
12515
12595
  if (!ifaceMatch) {
12516
12596
  throw new Error(`Model "${path39.basename(file)}" does not declare \`export interface\`.`);
@@ -12541,7 +12621,26 @@ function parseModelFile(file) {
12541
12621
  indexes.push(m[1]);
12542
12622
  }
12543
12623
  const id = path39.basename(file).replace(/\.(ts|tsx)$/, "");
12544
- 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;
12545
12644
  }
12546
12645
  function parseInterfaceBody(body) {
12547
12646
  const fields = [];
@@ -12585,19 +12684,19 @@ function inferInputSpec(type) {
12585
12684
  return { kind: "text" };
12586
12685
  return { kind: "text" };
12587
12686
  }
12588
- var fs25, path39;
12687
+ var fs26, path39;
12589
12688
  var init_parseModel = __esm(() => {
12590
- fs25 = __toESM(require("fs"));
12689
+ fs26 = __toESM(require("fs"));
12591
12690
  path39 = __toESM(require("path"));
12592
12691
  });
12593
12692
 
12594
12693
  // src/lib/crud/crudConfig.ts
12595
12694
  function readCrudConfig(projectRoot, modelName) {
12596
12695
  const file = path40.join(projectRoot, "src", "models", `${modelName}.crud.ts`);
12597
- if (!fs26.existsSync(file))
12696
+ if (!fs27.existsSync(file))
12598
12697
  return {};
12599
12698
  try {
12600
- const src = fs26.readFileSync(file, "utf8");
12699
+ const src = fs27.readFileSync(file, "utf8");
12601
12700
  return parseConfigSource(src);
12602
12701
  } catch {
12603
12702
  return {};
@@ -12632,9 +12731,9 @@ function parseConfigSource(src) {
12632
12731
  return {};
12633
12732
  }
12634
12733
  }
12635
- var fs26, path40;
12734
+ var fs27, path40;
12636
12735
  var init_crudConfig = __esm(() => {
12637
- fs26 = __toESM(require("fs"));
12736
+ fs27 = __toESM(require("fs"));
12638
12737
  path40 = __toESM(require("path"));
12639
12738
  });
12640
12739
 
@@ -12665,7 +12764,7 @@ function addCrud(opts) {
12665
12764
  assertNoOverwrite(opts.projectRoot, formPanelPath, "Form panel");
12666
12765
  assertNoOverwrite(opts.projectRoot, listWebDir, "List webview dir");
12667
12766
  assertNoOverwrite(opts.projectRoot, formWebDir, "Form webview dir");
12668
- 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"));
12669
12768
  const prefix = pkg.vsceasy?.commandPrefix ?? pkg.name?.replace(/^@[^/]+\//, "").replace(/[^a-zA-Z0-9]+/g, "") ?? "ext";
12670
12769
  const baseVars = {
12671
12770
  Name: model.name,
@@ -12678,13 +12777,27 @@ function addCrud(opts) {
12678
12777
  formId,
12679
12778
  prefix
12680
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);
12681
12794
  const created = [];
12682
12795
  const modified = [];
12683
12796
  writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "service.ts.tpl"), servicePath, baseVars, created);
12684
12797
  writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "formNav.ts.tpl"), path41.join(opts.projectRoot, "src", "services", `${camelLower(model.name)}FormNav.ts`), baseVars, created);
12685
12798
  writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "listPanel.ts.tpl"), listPanelPath, baseVars, created);
12686
- writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "formPanel.ts.tpl"), formPanelPath, baseVars, created);
12687
- 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 });
12688
12801
  const listVars = {
12689
12802
  ...baseVars,
12690
12803
  listHeaderCells: visible.map((f) => ` <th style={{ padding: '6px 8px' }}>${escapeJsx(label(f, cfg))}</th>`).join(`
@@ -12695,14 +12808,14 @@ function addCrud(opts) {
12695
12808
  };
12696
12809
  writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "listApp.tsx.tpl"), path41.join(listWebDir, "App.tsx"), listVars, created);
12697
12810
  writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "main.tsx.tpl"), path41.join(listWebDir, "main.tsx"), baseVars, created);
12698
- fs27.mkdirSync(formWebDir, { recursive: true });
12811
+ fs28.mkdirSync(formWebDir, { recursive: true });
12699
12812
  const formInputs = visible.filter((f) => !cfg.fields?.[f.name]?.hideInForm).map((f) => renderInput(f, cfg.fields?.[f.name])).join(`
12700
12813
  `);
12701
12814
  const emptyLit = buildEmptyFormLiteral(visible, cfg);
12702
- 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);
12703
12816
  writeFromTpl(path41.join(opts.templatesRoot, "_generators", "crud", "main.tsx.tpl"), path41.join(formWebDir, "main.tsx"), baseVars, created);
12704
12817
  appendApi3(apiPath, listApiName, model, created, modified);
12705
- appendApiForm(apiPath, formApiName, model, created, modified);
12818
+ appendApiForm(apiPath, formApiName, model, created, modified, relations.length > 0);
12706
12819
  let menuInfo;
12707
12820
  if (opts.menu && opts.menu !== "none") {
12708
12821
  menuInfo = wireMenu(opts, model, cfg, listId, formId);
@@ -12713,10 +12826,10 @@ function addCrud(opts) {
12713
12826
  return { created, modified, menu: menuInfo, genRan };
12714
12827
  }
12715
12828
  function writeFromTpl(tpl, target, vars, created) {
12716
- if (!fs27.existsSync(tpl))
12829
+ if (!fs28.existsSync(tpl))
12717
12830
  throw new Error(`CRUD template missing: ${tpl}`);
12718
- fs27.mkdirSync(path41.dirname(target), { recursive: true });
12719
- 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));
12720
12833
  created.push(target);
12721
12834
  }
12722
12835
  function orderFields(fields, order) {
@@ -12756,6 +12869,14 @@ function renderInput(field, override) {
12756
12869
  ` + ` <span style={{ opacity: 0.8 }}>${labelText}</span>
12757
12870
  ` + `${input}
12758
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
+ }
12759
12880
  switch (spec.kind) {
12760
12881
  case "number":
12761
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))} />`);
@@ -12806,45 +12927,47 @@ export interface ${apiName} {
12806
12927
  ensureImport(apiPath, model.name);
12807
12928
  appendIfMissing(apiPath, apiName, sig, created, modified);
12808
12929
  }
12809
- 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
+ ` : "";
12810
12933
  const sig = `
12811
12934
  export interface ${apiName} {
12812
12935
  ` + ` pendingId(): Promise<${model.name}['${model.primaryKey}'] | null>;
12813
12936
  ` + ` get(id: ${model.name}['${model.primaryKey}'] | null): Promise<${model.name} | null>;
12814
12937
  ` + ` save(row: ${model.name}): Promise<${model.name}>;
12815
- ` + ` cancel(): Promise<void>;
12938
+ ` + optionsLine + ` cancel(): Promise<void>;
12816
12939
  ` + `}
12817
12940
  `;
12818
12941
  ensureImport(apiPath, model.name);
12819
12942
  appendIfMissing(apiPath, apiName, sig, created, modified);
12820
12943
  }
12821
12944
  function ensureImport(apiPath, modelName) {
12822
- if (!fs27.existsSync(apiPath)) {
12823
- fs27.mkdirSync(path41.dirname(apiPath), { recursive: true });
12824
- 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}';
12825
12948
  `);
12826
12949
  return;
12827
12950
  }
12828
- let src = fs27.readFileSync(apiPath, "utf8");
12951
+ let src = fs28.readFileSync(apiPath, "utf8");
12829
12952
  if (new RegExp(`from\\s+['"]\\.\\./models/${modelName}['"]`).test(src))
12830
12953
  return;
12831
12954
  src = `import type { ${modelName} } from '../models/${modelName}';
12832
12955
  ` + src;
12833
- fs27.writeFileSync(apiPath, src);
12956
+ fs28.writeFileSync(apiPath, src);
12834
12957
  }
12835
12958
  function appendIfMissing(apiPath, apiName, block, created, modified) {
12836
- if (!fs27.existsSync(apiPath)) {
12837
- fs27.writeFileSync(apiPath, block.trimStart());
12959
+ if (!fs28.existsSync(apiPath)) {
12960
+ fs28.writeFileSync(apiPath, block.trimStart());
12838
12961
  created.push(apiPath);
12839
12962
  return;
12840
12963
  }
12841
- const src = fs27.readFileSync(apiPath, "utf8");
12964
+ const src = fs28.readFileSync(apiPath, "utf8");
12842
12965
  if (new RegExp(`\\bexport\\s+interface\\s+${apiName}\\b`).test(src))
12843
12966
  return;
12844
12967
  const sep = src.endsWith(`
12845
12968
  `) ? "" : `
12846
12969
  `;
12847
- fs27.writeFileSync(apiPath, src + sep + block);
12970
+ fs28.writeFileSync(apiPath, src + sep + block);
12848
12971
  if (!modified.includes(apiPath))
12849
12972
  modified.push(apiPath);
12850
12973
  }
@@ -12900,7 +13023,31 @@ function runGen11(cwd) {
12900
13023
  function which12(cmd) {
12901
13024
  return import_child_process13.spawnSync(process.platform === "win32" ? "where" : "which", [cmd], { stdio: "ignore" }).status === 0;
12902
13025
  }
12903
- 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;
12904
13051
  var init_add22 = __esm(() => {
12905
13052
  init_scaffold();
12906
13053
  init_validate();
@@ -12908,20 +13055,20 @@ var init_add22 = __esm(() => {
12908
13055
  init_crudConfig();
12909
13056
  init_add7();
12910
13057
  init_edit();
12911
- fs27 = __toESM(require("fs"));
13058
+ fs28 = __toESM(require("fs"));
12912
13059
  path41 = __toESM(require("path"));
12913
13060
  import_child_process13 = require("child_process");
12914
13061
  });
12915
13062
 
12916
13063
  // src/commands/crud/add.ts
12917
- 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;
12918
13065
  var init_add23 = __esm(() => {
12919
13066
  init_add22();
12920
13067
  init_edit();
12921
13068
  init_findProject();
12922
13069
  import_cli_maker18 = __toESM(require_dist(), 1);
12923
13070
  path42 = __toESM(require("path"));
12924
- fs28 = __toESM(require("fs"));
13071
+ fs29 = __toESM(require("fs"));
12925
13072
  addCrudCommand = {
12926
13073
  name: "add",
12927
13074
  description: "Generate full CRUD (service + list panel + form panel + RPC + optional menu) for an existing model. Rails-style scaffolding.",
@@ -12933,10 +13080,10 @@ var init_add23 = __esm(() => {
12933
13080
  type: import_cli_maker18.ParamType.List,
12934
13081
  optionsLoader: () => {
12935
13082
  const dir = path42.join(findProjectRoot(), "src", "models");
12936
- if (!fs28.existsSync(dir)) {
13083
+ if (!fs29.existsSync(dir)) {
12937
13084
  throw new Error("No models found. Run `vsceasy db init && vsceasy model add --name User` first.");
12938
13085
  }
12939
- 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();
12940
13087
  if (names.length === 0) {
12941
13088
  throw new Error("No models found in src/models/. Run `vsceasy model add --name User` first.");
12942
13089
  }
@@ -13066,14 +13213,14 @@ function addStore(opts) {
13066
13213
  const storeTs = path44.join(opts.projectRoot, "src", "stores", `${name}.ts`);
13067
13214
  assertNoOverwrite(opts.projectRoot, storeTs, "Store");
13068
13215
  const tplPath = path44.join(opts.templatesRoot, "_generators", "store", "store.ts.tpl");
13069
- const body = substitute(fs29.readFileSync(tplPath, "utf8"), {
13216
+ const body = substitute(fs30.readFileSync(tplPath, "utf8"), {
13070
13217
  name,
13071
13218
  type: TS_TYPE[type],
13072
13219
  initial,
13073
13220
  example: EXAMPLE[type]
13074
13221
  });
13075
- fs29.mkdirSync(path44.dirname(storeTs), { recursive: true });
13076
- fs29.writeFileSync(storeTs, body);
13222
+ fs30.mkdirSync(path44.dirname(storeTs), { recursive: true });
13223
+ fs30.writeFileSync(storeTs, body);
13077
13224
  return { created: [storeTs] };
13078
13225
  }
13079
13226
  function normalizeCamel9(s) {
@@ -13082,11 +13229,11 @@ function normalizeCamel9(s) {
13082
13229
  return "";
13083
13230
  return cleaned.charAt(0).toLowerCase() + cleaned.slice(1);
13084
13231
  }
13085
- var fs29, path44, DEFAULT_INITIAL, TS_TYPE, EXAMPLE;
13232
+ var fs30, path44, DEFAULT_INITIAL, TS_TYPE, EXAMPLE;
13086
13233
  var init_add25 = __esm(() => {
13087
13234
  init_scaffold();
13088
13235
  init_validate();
13089
- fs29 = __toESM(require("fs"));
13236
+ fs30 = __toESM(require("fs"));
13090
13237
  path44 = __toESM(require("path"));
13091
13238
  DEFAULT_INITIAL = {
13092
13239
  number: "0",
@@ -13242,7 +13389,7 @@ var init_cli = __esm(() => {
13242
13389
  import_cli_maker21 = __toESM(require_dist(), 1);
13243
13390
  cli = new import_cli_maker21.CLI("vsceasy", "Build VS Code extensions fast — React UI + typed RPC bridge + zero-config build.", {
13244
13391
  interactive: true,
13245
- version: "0.1.8",
13392
+ version: "0.1.9",
13246
13393
  introAnimation: {
13247
13394
  enabled: true,
13248
13395
  preset: "retro-space",
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.8", 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
  }),
@@ -3418,7 +3420,7 @@ export const {{Plural}} = defineEntity<{{Name}}>('{{collection}}', {
3418
3420
  * import { {{Plural}}Repo } from '../models/{{Name}}';
3419
3421
  * await {{Plural}}Repo().insert({ ... });
3420
3422
  */
3421
- export const {{Plural}}Repo = () => db()({{Plural}});
3423
+ export const {{Plural}}Repo = () => db()({{Plural}});{{relationsBlock}}
3422
3424
  `,
3423
3425
  "_generators/panel/App.tsx.tpl": `import React from 'react';
3424
3426
  {{apiBlock}}
@@ -6722,7 +6724,26 @@ function parseModelFile(file) {
6722
6724
  indexes.push(m[1]);
6723
6725
  }
6724
6726
  const id = path22.basename(file).replace(/\.(ts|tsx)$/, "");
6725
- 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;
6726
6747
  }
6727
6748
  function parseInterfaceBody(body) {
6728
6749
  const fields = [];
@@ -6859,12 +6880,26 @@ function addCrud(opts) {
6859
6880
  formId,
6860
6881
  prefix
6861
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);
6862
6897
  const created = [];
6863
6898
  const modified = [];
6864
6899
  writeFromTpl(path24.join(opts.templatesRoot, "_generators", "crud", "service.ts.tpl"), servicePath, baseVars, created);
6865
6900
  writeFromTpl(path24.join(opts.templatesRoot, "_generators", "crud", "formNav.ts.tpl"), path24.join(opts.projectRoot, "src", "services", `${camelLower(model.name)}FormNav.ts`), baseVars, created);
6866
6901
  writeFromTpl(path24.join(opts.templatesRoot, "_generators", "crud", "listPanel.ts.tpl"), listPanelPath, baseVars, created);
6867
- 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);
6868
6903
  fs24.mkdirSync(listWebDir, { recursive: true });
6869
6904
  const listVars = {
6870
6905
  ...baseVars,
@@ -6880,10 +6915,10 @@ function addCrud(opts) {
6880
6915
  const formInputs = visible.filter((f) => !cfg.fields?.[f.name]?.hideInForm).map((f) => renderInput(f, cfg.fields?.[f.name])).join(`
6881
6916
  `);
6882
6917
  const emptyLit = buildEmptyFormLiteral(visible, cfg);
6883
- 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);
6884
6919
  writeFromTpl(path24.join(opts.templatesRoot, "_generators", "crud", "main.tsx.tpl"), path24.join(formWebDir, "main.tsx"), baseVars, created);
6885
6920
  appendApi3(apiPath, listApiName, model, created, modified);
6886
- appendApiForm(apiPath, formApiName, model, created, modified);
6921
+ appendApiForm(apiPath, formApiName, model, created, modified, relations.length > 0);
6887
6922
  let menuInfo;
6888
6923
  if (opts.menu && opts.menu !== "none") {
6889
6924
  menuInfo = wireMenu(opts, model, cfg, listId, formId);
@@ -6937,6 +6972,14 @@ function renderInput(field, override) {
6937
6972
  ` + ` <span style={{ opacity: 0.8 }}>${labelText}</span>
6938
6973
  ` + `${input}
6939
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
+ }
6940
6983
  switch (spec.kind) {
6941
6984
  case "number":
6942
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))} />`);
@@ -6987,13 +7030,15 @@ export interface ${apiName} {
6987
7030
  ensureImport(apiPath, model.name);
6988
7031
  appendIfMissing(apiPath, apiName, sig, created, modified);
6989
7032
  }
6990
- 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
+ ` : "";
6991
7036
  const sig = `
6992
7037
  export interface ${apiName} {
6993
7038
  ` + ` pendingId(): Promise<${model.name}['${model.primaryKey}'] | null>;
6994
7039
  ` + ` get(id: ${model.name}['${model.primaryKey}'] | null): Promise<${model.name} | null>;
6995
7040
  ` + ` save(row: ${model.name}): Promise<${model.name}>;
6996
- ` + ` cancel(): Promise<void>;
7041
+ ` + optionsLine + ` cancel(): Promise<void>;
6997
7042
  ` + `}
6998
7043
  `;
6999
7044
  ensureImport(apiPath, model.name);
@@ -7081,6 +7126,30 @@ function runGen11(cwd) {
7081
7126
  function which11(cmd) {
7082
7127
  return import_child_process12.spawnSync(process.platform === "win32" ? "where" : "which", [cmd], { stdio: "ignore" }).status === 0;
7083
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
+ }
7084
7153
  var fs24, path24, import_child_process12;
7085
7154
  var init_add11 = __esm(() => {
7086
7155
  init_scaffold();
@@ -7113,11 +7182,37 @@ function addModel(opts) {
7113
7182
  const pkField = explicitPk[0] ?? opts.fields.find((f) => f.name === "id") ?? opts.fields[0];
7114
7183
  const primaryKey = pkField.name;
7115
7184
  const indexes = opts.fields.filter((f) => f.indexed && f.name !== primaryKey).map((f) => f.name);
7116
- 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`);
7117
7194
  assertNoOverwrite(opts.projectRoot, target, "Model");
7118
7195
  const tpl = path25.join(opts.templatesRoot, "_generators", "model", "model.ts.tpl");
7119
- 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(`
7120
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
+ ` : "";
7121
7216
  const vars = {
7122
7217
  name,
7123
7218
  Name,
@@ -7126,11 +7221,12 @@ function addModel(opts) {
7126
7221
  primaryKey,
7127
7222
  fieldLines,
7128
7223
  indexesLine: indexes.length ? `
7129
- indexes: [${indexes.map((i) => `'${i}'`).join(", ")}],` : ""
7224
+ indexes: [${indexes.map((i) => `'${i}'`).join(", ")}],` : "",
7225
+ relationsBlock
7130
7226
  };
7131
7227
  fs25.mkdirSync(path25.dirname(target), { recursive: true });
7132
7228
  fs25.writeFileSync(target, substitute(fs25.readFileSync(tpl, "utf8"), vars));
7133
- return { created: [target], primaryKey, indexes };
7229
+ return { created: [target], primaryKey, indexes, relations: relFields.map((f) => ({ field: fkName(f), ...f.relation })) };
7134
7230
  }
7135
7231
  function normalizeCamel8(s) {
7136
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;
@@ -1,4 +1,4 @@
1
- import type { ModelField } from './add';
1
+ import type { ModelField, FieldRelation } from './add';
2
2
  /**
3
3
  * Parse a compact model field spec into `ModelField[]`.
4
4
  *
@@ -7,8 +7,14 @@ import type { ModelField } from './add';
7
7
  * `!` after type → primaryKey
8
8
  * `@` after type → indexed
9
9
  *
10
- * Example: `id:string!,name:string,email?:string@,score:number`
10
+ * Relations use `name:ref(Model)` or `name:ref(Model, label=field)`:
11
+ * category:ref(Category) → FK categoryId, dropdown of Category rows
12
+ * category:ref(Category, label=name) → show Category.name in the dropdown
13
+ *
14
+ * Example: `id:string!,name:string,email?:string@,category:ref(Category)`
11
15
  */
12
16
  export declare function parseFieldsSpec(spec: string): ModelField[];
13
- /** Parse a single `name[?]:type[!][@]` line. Throws on malformed input. */
17
+ /** Parse a single `name[?]:type[!][@]` line (or `name:ref(Model)`). Throws on malformed input. */
14
18
  export declare function parseFieldLine(raw: string): ModelField;
19
+ /** Parse `ref(Model)` / `ref(Model, label=field)`. Returns null when not a ref. */
20
+ export declare function parseRef(type: string): FieldRelation | null;
@@ -1,2 +1,2 @@
1
- export declare const TEMPLATES_VERSION = "0.1.8";
1
+ export declare const TEMPLATES_VERSION = "0.1.9";
2
2
  export declare const TEMPLATE_FILES: Record<string, string>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vsceasy/cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Build VS Code extensions fast — React UI + typed RPC bridge between extension and webview + file-based routing for panels, commands, menus, tree views, and subpanels.",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
@@ -22,6 +22,7 @@ export function App() {
22
22
  const [editingId, setEditingId] = useState<{{Name}}['{{primaryKey}}'] | null>(null);
23
23
  const [error, setError] = useState<string | null>(null);
24
24
  const [saving, setSaving] = useState(false);
25
+ {{relationOptionsState}}
25
26
 
26
27
  const load = useCallback(async (initial: boolean) => {
27
28
  // The list stashes a row id before revealing this panel. Pull it (the host
@@ -59,6 +60,7 @@ export function App() {
59
60
  document.removeEventListener('visibilitychange', onVisible);
60
61
  };
61
62
  }, [load]);
63
+ {{relationOptionsLoad}}
62
64
 
63
65
  const onChange = <K extends keyof FormState>(k: K, v: FormState[K]) => {
64
66
  setForm((f) => ({ ...f, [k]: v }));
@@ -3,7 +3,7 @@ import { {{Name}}Service } from '../services/{{Name}}Service';
3
3
  import { takePending{{Name}}Id } from '../services/{{name}}FormNav';
4
4
  import type { {{Name}}FormApi } from '../shared/api';
5
5
  import type { {{Name}} } from '../models/{{Name}}';
6
-
6
+ {{relationImports}}
7
7
  export default definePanel<{{Name}}FormApi>({
8
8
  title: '{{title}}',
9
9
  column: 'beside',
@@ -25,7 +25,7 @@ export default definePanel<{{Name}}FormApi>({
25
25
  void vscode.commands.executeCommand('{{prefix}}.open{{Plural}}List');
26
26
  return saved;
27
27
  },
28
- async cancel() {
28
+ {{relationOptionsHandler}} async cancel() {
29
29
  // No-op — webview closes itself.
30
30
  },
31
31
  }),
@@ -14,4 +14,4 @@ export const {{Plural}} = defineEntity<{{Name}}>('{{collection}}', {
14
14
  * import { {{Plural}}Repo } from '../models/{{Name}}';
15
15
  * await {{Plural}}Repo().insert({ ... });
16
16
  */
17
- export const {{Plural}}Repo = () => db()({{Plural}});
17
+ export const {{Plural}}Repo = () => db()({{Plural}});{{relationsBlock}}