@vsceasy/cli 0.1.8 → 0.1.10

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.
Files changed (30) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +40 -0
  3. package/dist/bin/cli.js +858 -76
  4. package/dist/index.js +717 -19
  5. package/dist/lib/config.d.ts +2 -0
  6. package/dist/lib/contributesMerge.d.ts +18 -0
  7. package/dist/lib/crud/parseModel.d.ts +12 -0
  8. package/dist/lib/helper/add.d.ts +1 -1
  9. package/dist/lib/model/add.d.ts +18 -0
  10. package/dist/lib/model/parseFields.d.ts +9 -3
  11. package/dist/lib/scaffold.d.ts +4 -0
  12. package/dist/lib/templatesData.d.ts +1 -1
  13. package/package.json +1 -1
  14. package/templates/_assets/language/README.language.md +39 -0
  15. package/templates/_assets/language/contributes.extra.json +40 -0
  16. package/templates/_assets/language/fileicons/{{langId}}-icon-theme.json +13 -0
  17. package/templates/_assets/language/icons/{{langId}}.svg +5 -0
  18. package/templates/_assets/language/language-configuration.json +30 -0
  19. package/templates/_assets/language/snippets/{{langId}}.json +12 -0
  20. package/templates/_assets/language/src/colorize.ts +31 -0
  21. package/templates/_assets/language/src/commands/applyColors.ts +11 -0
  22. package/templates/_assets/language/src/commands/removeColors.ts +11 -0
  23. package/templates/_assets/language/src/extension/extension.ts +25 -0
  24. package/templates/_assets/language/src/helpers/colorize.ts +70 -0
  25. package/templates/_assets/language/syntaxes/{{langId}}.tmLanguage.json +45 -0
  26. package/templates/_generators/crud/formApp.tsx.tpl +2 -0
  27. package/templates/_generators/crud/formPanel.ts.tpl +2 -2
  28. package/templates/_generators/helper/colorize.ts.tpl +85 -0
  29. package/templates/_generators/model/model.ts.tpl +1 -1
  30. package/templates/react/scripts/gen.ts +55 -0
package/dist/index.js CHANGED
@@ -134,15 +134,26 @@ async function scaffold(opts) {
134
134
  throw new Error(`Target directory not empty: ${opts.targetDir}`);
135
135
  }
136
136
  fs2.mkdirSync(opts.targetDir, { recursive: true });
137
+ const type = opts.type ?? "ui";
137
138
  const vars = buildVars(opts);
138
139
  await copyTree(src, opts.targetDir, vars);
139
- applyPreset(opts.targetDir, opts.preset ?? "full");
140
+ applyType(opts.targetDir, type, opts.preset ?? "full", opts.templatesRoot, vars);
140
141
  writeConfig(opts.targetDir, {
141
142
  publisher: opts.publisher,
142
143
  commandPrefix: vars.commandPrefix,
143
- ui: opts.ui
144
+ type,
145
+ ...type === "ui" ? { ui: opts.ui } : {}
144
146
  });
145
147
  }
148
+ function applyType(targetDir, type, preset, templatesRoot, vars) {
149
+ if (type === "ui") {
150
+ applyPreset(targetDir, preset);
151
+ return;
152
+ }
153
+ stripWebview(targetDir);
154
+ if (type === "language")
155
+ applyLanguage(targetDir, templatesRoot, vars);
156
+ }
146
157
  function applyPreset(targetDir, preset) {
147
158
  if (preset === "full")
148
159
  return;
@@ -165,15 +176,104 @@ function applyPreset(targetDir, preset) {
165
176
  `);
166
177
  }
167
178
  }
179
+ function stripWebview(targetDir) {
180
+ const removals = [
181
+ "src/panels",
182
+ "src/webview",
183
+ "src/commands/hello.ts",
184
+ "vite.config.ts"
185
+ ];
186
+ for (const rel of removals) {
187
+ const abs = path2.join(targetDir, rel);
188
+ if (fs2.existsSync(abs))
189
+ fs2.rmSync(abs, { recursive: true, force: true });
190
+ }
191
+ const apiPath = path2.join(targetDir, "src", "shared", "api.ts");
192
+ if (fs2.existsSync(apiPath)) {
193
+ fs2.writeFileSync(apiPath, `// RPC contracts go here (add a panel with \`vsceasy panel add\`).
194
+ `);
195
+ }
196
+ trimReactFromPackageJson(targetDir);
197
+ }
198
+ function trimReactFromPackageJson(targetDir) {
199
+ const pkgPath = path2.join(targetDir, "package.json");
200
+ if (!fs2.existsSync(pkgPath))
201
+ return;
202
+ const pkg = JSON.parse(fs2.readFileSync(pkgPath, "utf8"));
203
+ for (const section of ["dependencies", "devDependencies"]) {
204
+ if (!pkg[section])
205
+ continue;
206
+ for (const dep of Object.keys(pkg[section])) {
207
+ if (REACT_DEPS.has(dep))
208
+ delete pkg[section][dep];
209
+ }
210
+ if (Object.keys(pkg[section]).length === 0)
211
+ delete pkg[section];
212
+ }
213
+ if (pkg.scripts) {
214
+ for (const key of Object.keys(pkg.scripts)) {
215
+ if (UI_SCRIPT_KEYS.has(key))
216
+ delete pkg.scripts[key];
217
+ }
218
+ if (pkg.scripts.dev) {
219
+ pkg.scripts.dev = "bun run gen:scan && bun run dev:ext";
220
+ }
221
+ for (const [k, v] of Object.entries(pkg.scripts)) {
222
+ pkg.scripts[k] = v.replace(/\s*&&\s*bun run build:ui/g, "");
223
+ }
224
+ }
225
+ fs2.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + `
226
+ `);
227
+ }
228
+ function applyLanguage(targetDir, templatesRoot, vars) {
229
+ const assetsDir = path2.join(templatesRoot, "_assets", "language");
230
+ if (!fs2.existsSync(assetsDir)) {
231
+ throw new Error(`Language assets not found: ${assetsDir}`);
232
+ }
233
+ copyAssetsTree(assetsDir, targetDir, vars);
234
+ const langReadme = path2.join(targetDir, "README.language.md");
235
+ if (fs2.existsSync(langReadme)) {
236
+ fs2.renameSync(langReadme, path2.join(targetDir, "README.md"));
237
+ }
238
+ const pkgPath = path2.join(targetDir, "package.json");
239
+ if (fs2.existsSync(pkgPath)) {
240
+ const pkg = JSON.parse(fs2.readFileSync(pkgPath, "utf8"));
241
+ pkg.activationEvents = [`onLanguage:${vars.langId}`];
242
+ fs2.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + `
243
+ `);
244
+ }
245
+ }
246
+ function copyAssetsTree(srcDir, destDir, vars) {
247
+ for (const entry of fs2.readdirSync(srcDir, { withFileTypes: true })) {
248
+ if (SKIP_NAMES.has(entry.name))
249
+ continue;
250
+ const srcPath = path2.join(srcDir, entry.name);
251
+ const destName = substitute(entry.name, vars);
252
+ const destPath = path2.join(destDir, destName);
253
+ if (entry.isDirectory()) {
254
+ fs2.mkdirSync(destPath, { recursive: true });
255
+ copyAssetsTree(srcPath, destPath, vars);
256
+ } else if (entry.isFile()) {
257
+ fs2.mkdirSync(path2.dirname(destPath), { recursive: true });
258
+ fs2.writeFileSync(destPath, substitute(fs2.readFileSync(srcPath, "utf8"), vars));
259
+ }
260
+ }
261
+ }
168
262
  function buildVars(opts) {
169
263
  const simpleName = opts.name.replace(/^@[^/]+\//, "");
170
264
  const commandPrefix = simpleName.replace(/[^a-zA-Z0-9]+/g, "");
265
+ const langId = simpleName.toLowerCase().replace(/[^a-z0-9]+/g, "");
266
+ const scopeName = `source.${langId}`;
267
+ const langExt = langId.slice(0, 4).toUpperCase();
171
268
  return {
172
269
  name: opts.name,
173
270
  displayName: opts.displayName,
174
271
  description: opts.description,
175
272
  publisher: opts.publisher,
176
- commandPrefix
273
+ commandPrefix,
274
+ langId,
275
+ scopeName,
276
+ langExt
177
277
  };
178
278
  }
179
279
  async function copyTree(srcDir, destDir, vars) {
@@ -199,7 +299,7 @@ async function copyTree(srcDir, destDir, vars) {
199
299
  function substitute(input, vars) {
200
300
  return input.replace(/\{\{(\w+)\}\}/g, (_m, key) => vars[key] ?? `{{${key}}}`);
201
301
  }
202
- var fs2, path2, PLACEHOLDER_EXTS, SKIP_NAMES;
302
+ var fs2, path2, PLACEHOLDER_EXTS, SKIP_NAMES, REACT_DEPS, UI_SCRIPT_KEYS;
203
303
  var init_scaffold = __esm(() => {
204
304
  init_config();
205
305
  fs2 = __toESM(require("fs"));
@@ -219,6 +319,15 @@ var init_scaffold = __esm(() => {
219
319
  ".yaml"
220
320
  ]);
221
321
  SKIP_NAMES = new Set(["node_modules", "dist", ".DS_Store"]);
322
+ REACT_DEPS = new Set([
323
+ "react",
324
+ "react-dom",
325
+ "@types/react",
326
+ "@types/react-dom",
327
+ "@vitejs/plugin-react",
328
+ "vite"
329
+ ]);
330
+ UI_SCRIPT_KEYS = new Set(["dev:ui", "build:ui"]);
222
331
  });
223
332
 
224
333
  // src/lib/validate.ts
@@ -1580,6 +1689,9 @@ function runDoctor(opts) {
1580
1689
  results.push(...checkStatusBars(root));
1581
1690
  results.push(...checkSubpanels(root));
1582
1691
  results.push(checkContributesSync(root, pkg));
1692
+ const langCheck = checkLanguageAssets(root, pkg);
1693
+ if (langCheck)
1694
+ results.push(langCheck);
1583
1695
  results.push(checkActivationEvents(pkg));
1584
1696
  results.push(checkMarketplaceIcon(root, pkg));
1585
1697
  results.push(checkGenScript(root));
@@ -1842,6 +1954,50 @@ function checkContributesSync(root, pkg) {
1842
1954
  details: stale
1843
1955
  };
1844
1956
  }
1957
+ function checkLanguageAssets(root, pkg) {
1958
+ const sources = [pkg?.contributes];
1959
+ const extraPath = path12.join(root, "contributes.extra.json");
1960
+ if (fs12.existsSync(extraPath)) {
1961
+ try {
1962
+ sources.push(JSON.parse(fs12.readFileSync(extraPath, "utf8")));
1963
+ } catch {
1964
+ return {
1965
+ id: "language",
1966
+ level: "error",
1967
+ message: "contributes.extra.json is not valid JSON"
1968
+ };
1969
+ }
1970
+ }
1971
+ const refs = new Set;
1972
+ for (const c of sources) {
1973
+ if (!c)
1974
+ continue;
1975
+ for (const l of c.languages ?? [])
1976
+ if (l.configuration)
1977
+ refs.add(l.configuration);
1978
+ for (const g of c.grammars ?? [])
1979
+ if (g.path)
1980
+ refs.add(g.path);
1981
+ for (const s of c.snippets ?? [])
1982
+ if (s.path)
1983
+ refs.add(s.path);
1984
+ for (const t of c.iconThemes ?? [])
1985
+ if (t.path)
1986
+ refs.add(t.path);
1987
+ }
1988
+ if (refs.size === 0)
1989
+ return null;
1990
+ const missing = [...refs].filter((rel) => !fs12.existsSync(path12.join(root, rel)));
1991
+ if (missing.length === 0) {
1992
+ return { id: "language", level: "ok", message: `language ${refs.size} asset(s) present` };
1993
+ }
1994
+ return {
1995
+ id: "language",
1996
+ level: "error",
1997
+ message: `language: ${missing.length} referenced asset(s) missing`,
1998
+ details: missing
1999
+ };
2000
+ }
1845
2001
  function checkGitignore(root) {
1846
2002
  const file = path12.join(root, ".gitignore");
1847
2003
  const required = ["dist", "node_modules"];
@@ -2238,9 +2394,314 @@ var init_upgrade = __esm(() => {
2238
2394
  });
2239
2395
 
2240
2396
  // src/lib/templatesData.ts
2241
- var TEMPLATES_VERSION = "0.1.8", TEMPLATE_FILES;
2397
+ var TEMPLATES_VERSION = "0.1.10", TEMPLATE_FILES;
2242
2398
  var init_templatesData = __esm(() => {
2243
2399
  TEMPLATE_FILES = {
2400
+ "_assets/language/README.language.md": "## {{displayName}} language support\n\nThis extension was scaffolded with `vsceasy create --type language`. It provides\neditor support for `.{{langId}}` files:\n\n- **Syntax highlighting** — TextMate grammar in `syntaxes/{{langId}}.tmLanguage.json`\n- **Language configuration** — comments, brackets, auto-closing pairs, folding in\n `language-configuration.json`\n- **Snippets** — `snippets/{{langId}}.json`\n- **File icon** (opt-in) — `fileicons/{{langId}}-icon-theme.json`\n\n### How contributions are wired\n\n`vsceasy`'s `scripts/gen.ts` regenerates the generated parts of\n`package.json#contributes` (commands, views…) on every build. Language\ncontributions are **not** generated — they live in **`contributes.extra.json`**\nat the project root and are deep-merged into `package.json` by `gen.ts`. Edit\n`contributes.extra.json` to change languages / grammars / snippets / iconThemes,\nthen run `bun run gen`.\n\n### File icon is opt-in\n\nVS Code file icons are provided by an **icon theme**, which is global: activating\nit replaces *all* file icons in the workbench, not just `.{{langId}}`. This\nextension ships a `{{displayName}} Icons` theme but does **not** force it. To use\nit: `Preferences: File Icon Theme` → pick `{{displayName}} Icons`. If you don't\nwant to override every icon, leave it unselected — highlighting, config and\nsnippets work regardless.\n\n### Develop\n\n```sh\nbun install\nbun run gen # sync package.json#contributes\nbun run package # build a .vsix\n```\n\nPress `F5` (or run `bun run launch`) to open an Extension Development Host and\nopen a `.{{langId}}` file to see highlighting.\n",
2401
+ "_assets/language/contributes.extra.json": `{
2402
+ "languages": [
2403
+ {
2404
+ "id": "{{langId}}",
2405
+ "aliases": ["{{displayName}}", "{{langId}}"],
2406
+ "extensions": [".{{langId}}"],
2407
+ "configuration": "./language-configuration.json"
2408
+ }
2409
+ ],
2410
+ "grammars": [
2411
+ {
2412
+ "language": "{{langId}}",
2413
+ "scopeName": "{{scopeName}}",
2414
+ "path": "./syntaxes/{{langId}}.tmLanguage.json"
2415
+ }
2416
+ ],
2417
+ "snippets": [
2418
+ {
2419
+ "language": "{{langId}}",
2420
+ "path": "./snippets/{{langId}}.json"
2421
+ }
2422
+ ],
2423
+ "iconThemes": [
2424
+ {
2425
+ "id": "{{langId}}-icons",
2426
+ "label": "{{displayName}} Icons",
2427
+ "path": "./fileicons/{{langId}}-icon-theme.json"
2428
+ }
2429
+ ],
2430
+ "configuration": {
2431
+ "title": "{{displayName}}",
2432
+ "properties": {
2433
+ "{{commandPrefix}}.colorize": {
2434
+ "type": "boolean",
2435
+ "default": true,
2436
+ "markdownDescription": "Automatically apply distinct section colors to \`.{{langId}}\` files (scoped to \`{{scopeName}}\`, so other languages are unaffected). Turn off to keep your theme's default colors."
2437
+ }
2438
+ }
2439
+ }
2440
+ }
2441
+ `,
2442
+ "_assets/language/fileicons/{{langId}}-icon-theme.json": `{
2443
+ "iconDefinitions": {
2444
+ "{{langId}}_file": {
2445
+ "iconPath": "../icons/{{langId}}.svg"
2446
+ }
2447
+ },
2448
+ "languageIds": {
2449
+ "{{langId}}": "{{langId}}_file"
2450
+ },
2451
+ "fileExtensions": {
2452
+ "{{langId}}": "{{langId}}_file"
2453
+ }
2454
+ }
2455
+ `,
2456
+ "_assets/language/icons/{{langId}}.svg": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
2457
+ <path fill="#8a8a8a" d="M6 3h14l6 6v20a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/>
2458
+ <path fill="#5a5a5a" d="M20 3l6 6h-6z"/>
2459
+ <text x="16" y="22" font-family="monospace" font-size="9" font-weight="bold" fill="#fff" text-anchor="middle">{{langExt}}</text>
2460
+ </svg>
2461
+ `,
2462
+ "_assets/language/language-configuration.json": `{
2463
+ "comments": {
2464
+ "lineComment": "#"
2465
+ },
2466
+ "brackets": [
2467
+ ["{", "}"],
2468
+ ["[", "]"],
2469
+ ["(", ")"]
2470
+ ],
2471
+ "autoClosingPairs": [
2472
+ { "open": "{", "close": "}" },
2473
+ { "open": "[", "close": "]" },
2474
+ { "open": "(", "close": ")" },
2475
+ { "open": "\\"", "close": "\\"", "notIn": ["string"] },
2476
+ { "open": "'", "close": "'", "notIn": ["string"] }
2477
+ ],
2478
+ "surroundingPairs": [
2479
+ ["{", "}"],
2480
+ ["[", "]"],
2481
+ ["(", ")"],
2482
+ ["\\"", "\\""],
2483
+ ["'", "'"]
2484
+ ],
2485
+ "folding": {
2486
+ "markers": {
2487
+ "start": "^\\\\s*#\\\\s*region\\\\b",
2488
+ "end": "^\\\\s*#\\\\s*endregion\\\\b"
2489
+ }
2490
+ }
2491
+ }
2492
+ `,
2493
+ "_assets/language/snippets/{{langId}}.json": `{
2494
+ "Section": {
2495
+ "prefix": "section",
2496
+ "body": ["[\${1:name}]", "$0"],
2497
+ "description": "A {{displayName}} section"
2498
+ },
2499
+ "Key/Value": {
2500
+ "prefix": "kv",
2501
+ "body": ["\${1:key} = \${2:value}"],
2502
+ "description": "A key/value pair"
2503
+ }
2504
+ }
2505
+ `,
2506
+ "_assets/language/src/colorize.ts": `import type * as vscode from 'vscode';
2507
+ import { applyTokenColors, removeTokenColors, type TokenColorRule } from './helpers/colorize';
2508
+
2509
+ /** Root TextMate scope of this language — rules are applied only to these files. */
2510
+ export const SCOPE = '{{scopeName}}';
2511
+
2512
+ /**
2513
+ * Default token colors for {{displayName}}, emphasizing each construct. Edit
2514
+ * freely — they are applied to \`[{{scopeName}}]\` only, so other languages keep
2515
+ * the user's theme. Scope names must match your grammar
2516
+ * (syntaxes/{{langId}}.tmLanguage.json).
2517
+ */
2518
+ export const RULES: TokenColorRule[] = [
2519
+ { scope: 'comment.line.number-sign.{{langId}}', settings: { foreground: '#6b7a6e', fontStyle: 'italic' } },
2520
+ { scope: 'string.quoted.double.basic.{{langId}}', settings: { foreground: '#98c379' } },
2521
+ { scope: 'string.quoted.single.literal.{{langId}}', settings: { foreground: '#98c379' } },
2522
+ { scope: 'constant.numeric.{{langId}}', settings: { foreground: '#d19a66' } },
2523
+ ];
2524
+
2525
+ export async function applyColors(vscodeNs: typeof vscode): Promise<void> {
2526
+ await applyTokenColors(SCOPE, RULES);
2527
+ }
2528
+
2529
+ export async function removeColors(vscodeNs: typeof vscode): Promise<void> {
2530
+ await removeTokenColors(SCOPE);
2531
+ }
2532
+
2533
+ /** True when the user has opted in (default) to automatic coloring. */
2534
+ export function colorizeEnabled(vscodeNs: typeof vscode): boolean {
2535
+ return vscodeNs.workspace.getConfiguration('{{commandPrefix}}').get<boolean>('colorize', true);
2536
+ }
2537
+ `,
2538
+ "_assets/language/src/commands/applyColors.ts": `import { defineCommand } from '../shared/vsceasy';
2539
+ import { applyColors } from '../colorize';
2540
+
2541
+ export default defineCommand({
2542
+ id: 'applyColors',
2543
+ title: '{{displayName}}: Apply Colors',
2544
+ run: async (vscode) => {
2545
+ await applyColors(vscode);
2546
+ vscode.window.showInformationMessage('{{displayName}} colors applied.');
2547
+ },
2548
+ });
2549
+ `,
2550
+ "_assets/language/src/commands/removeColors.ts": `import { defineCommand } from '../shared/vsceasy';
2551
+ import { removeColors } from '../colorize';
2552
+
2553
+ export default defineCommand({
2554
+ id: 'removeColors',
2555
+ title: '{{displayName}}: Remove Colors',
2556
+ run: async (vscode) => {
2557
+ await removeColors(vscode);
2558
+ vscode.window.showInformationMessage('{{displayName}} colors removed.');
2559
+ },
2560
+ });
2561
+ `,
2562
+ "_assets/language/src/extension/extension.ts": `import { bootstrap } from '../shared/vsceasy';
2563
+ import { registry } from './_registry';
2564
+ import { applyColors, removeColors, colorizeEnabled } from '../colorize';
2565
+
2566
+ export const activate = bootstrap(registry, {
2567
+ onActivate: [
2568
+ async (context, vscode) => {
2569
+ // Auto-apply scoped token colors on activate when opted in (default).
2570
+ // Scoped to {{scopeName}} only — other languages are untouched.
2571
+ if (colorizeEnabled(vscode)) {
2572
+ await applyColors(vscode);
2573
+ }
2574
+ // React to the user toggling \`{{commandPrefix}}.colorize\` at runtime.
2575
+ context.subscriptions.push(
2576
+ vscode.workspace.onDidChangeConfiguration(async (e) => {
2577
+ if (!e.affectsConfiguration('{{commandPrefix}}.colorize')) return;
2578
+ if (colorizeEnabled(vscode)) await applyColors(vscode);
2579
+ else await removeColors(vscode);
2580
+ }),
2581
+ );
2582
+ },
2583
+ ],
2584
+ });
2585
+
2586
+ export function deactivate() {}
2587
+ `,
2588
+ "_assets/language/src/helpers/colorize.ts": `import * as vscode from 'vscode';
2589
+
2590
+ /**
2591
+ * Apply theme-independent token colors to a single TextMate scope (e.g. a
2592
+ * language's root scope like \`{{scopeName}}\`), written to the user's
2593
+ * \`editor.tokenColorCustomizations\`. Because the rules are keyed by
2594
+ * \`[<scope>]\`, only files in that scope are recolored — every other language
2595
+ * keeps the active theme's colors.
2596
+ *
2597
+ * Rules are tagged with a marker so {@link removeTokenColors} can strip exactly
2598
+ * the ones this extension added, preserving any the user wrote by hand.
2599
+ */
2600
+
2601
+ export interface TokenColorRule {
2602
+ /** Comma-separated TextMate scopes, e.g. 'entity.name.section.foo, comment.line.foo'. */
2603
+ scope: string;
2604
+ settings: { foreground?: string; background?: string; fontStyle?: string };
2605
+ }
2606
+
2607
+ type TaggedRule = TokenColorRule & { [MARK]?: true };
2608
+
2609
+ /** Marker key identifying rules this extension wrote (vs. the user's own). */
2610
+ const MARK = '{{commandPrefix}}Colorize';
2611
+ const SECTION = 'editor.tokenColorCustomizations';
2612
+
2613
+ const blockKey = (scope: string) => \`[\${scope}]\`;
2614
+
2615
+ /**
2616
+ * Merge \`rules\` into \`editor.tokenColorCustomizations["[<scope>]"].textMateRules\`,
2617
+ * preserving the user's own rules and other scope keys. Idempotent — re-applying
2618
+ * replaces only previously-applied rules from this extension.
2619
+ */
2620
+ export async function applyTokenColors(
2621
+ scope: string,
2622
+ rules: TokenColorRule[],
2623
+ target: vscode.ConfigurationTarget = vscode.ConfigurationTarget.Global,
2624
+ ): Promise<void> {
2625
+ const cfg = vscode.workspace.getConfiguration();
2626
+ const current = (cfg.get<Record<string, any>>(SECTION) ?? {}) as Record<string, any>;
2627
+ const key = blockKey(scope);
2628
+ const block = (current[key] ?? {}) as { textMateRules?: TaggedRule[] };
2629
+ const existing = Array.isArray(block.textMateRules) ? block.textMateRules : [];
2630
+ const userRules = existing.filter((r) => !r[MARK]);
2631
+ const ours: TaggedRule[] = rules.map((r) => ({ ...r, [MARK]: true }));
2632
+ const next = { ...current, [key]: { ...block, textMateRules: [...userRules, ...ours] } };
2633
+ await cfg.update(SECTION, next, target);
2634
+ }
2635
+
2636
+ /** Remove only the rules this extension added for \`scope\`; leave the rest intact. */
2637
+ export async function removeTokenColors(
2638
+ scope: string,
2639
+ target: vscode.ConfigurationTarget = vscode.ConfigurationTarget.Global,
2640
+ ): Promise<void> {
2641
+ const cfg = vscode.workspace.getConfiguration();
2642
+ const current = cfg.get<Record<string, any>>(SECTION);
2643
+ const key = blockKey(scope);
2644
+ if (!current || !current[key]) return;
2645
+ const block = current[key] as { textMateRules?: TaggedRule[] };
2646
+ const userRules = (block.textMateRules ?? []).filter((r) => !r[MARK]);
2647
+
2648
+ const nextBlock: Record<string, unknown> = { ...block };
2649
+ if (userRules.length) nextBlock.textMateRules = userRules;
2650
+ else delete nextBlock.textMateRules;
2651
+
2652
+ const next = { ...current };
2653
+ if (Object.keys(nextBlock).length) next[key] = nextBlock;
2654
+ else delete next[key];
2655
+
2656
+ await cfg.update(SECTION, Object.keys(next).length ? next : undefined, target);
2657
+ }
2658
+ `,
2659
+ "_assets/language/syntaxes/{{langId}}.tmLanguage.json": `{
2660
+ "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
2661
+ "name": "{{displayName}}",
2662
+ "scopeName": "{{scopeName}}",
2663
+ "patterns": [
2664
+ { "include": "#comments" },
2665
+ { "include": "#strings" },
2666
+ { "include": "#numbers" }
2667
+ ],
2668
+ "repository": {
2669
+ "comments": {
2670
+ "patterns": [
2671
+ {
2672
+ "name": "comment.line.number-sign.{{langId}}",
2673
+ "match": "#.*$"
2674
+ }
2675
+ ]
2676
+ },
2677
+ "strings": {
2678
+ "patterns": [
2679
+ {
2680
+ "name": "string.quoted.double.{{langId}}",
2681
+ "begin": "\\"",
2682
+ "end": "\\"",
2683
+ "patterns": [
2684
+ { "name": "constant.character.escape.{{langId}}", "match": "\\\\\\\\." }
2685
+ ]
2686
+ },
2687
+ {
2688
+ "name": "string.quoted.single.{{langId}}",
2689
+ "begin": "'",
2690
+ "end": "'"
2691
+ }
2692
+ ]
2693
+ },
2694
+ "numbers": {
2695
+ "patterns": [
2696
+ {
2697
+ "name": "constant.numeric.{{langId}}",
2698
+ "match": "\\\\b[0-9]+(\\\\.[0-9]+)?\\\\b"
2699
+ }
2700
+ ]
2701
+ }
2702
+ }
2703
+ }
2704
+ `,
2244
2705
  "_generators/command/command.ts.tpl": `import { defineCommand } from '../shared/vsceasy';
2245
2706
 
2246
2707
  export default defineCommand({
@@ -2450,6 +2911,7 @@ export function App() {
2450
2911
  const [editingId, setEditingId] = useState<{{Name}}['{{primaryKey}}'] | null>(null);
2451
2912
  const [error, setError] = useState<string | null>(null);
2452
2913
  const [saving, setSaving] = useState(false);
2914
+ {{relationOptionsState}}
2453
2915
 
2454
2916
  const load = useCallback(async (initial: boolean) => {
2455
2917
  // The list stashes a row id before revealing this panel. Pull it (the host
@@ -2487,6 +2949,7 @@ export function App() {
2487
2949
  document.removeEventListener('visibilitychange', onVisible);
2488
2950
  };
2489
2951
  }, [load]);
2952
+ {{relationOptionsLoad}}
2490
2953
 
2491
2954
  const onChange = <K extends keyof FormState>(k: K, v: FormState[K]) => {
2492
2955
  setForm((f) => ({ ...f, [k]: v }));
@@ -2550,7 +3013,7 @@ import { {{Name}}Service } from '../services/{{Name}}Service';
2550
3013
  import { takePending{{Name}}Id } from '../services/{{name}}FormNav';
2551
3014
  import type { {{Name}}FormApi } from '../shared/api';
2552
3015
  import type { {{Name}} } from '../models/{{Name}}';
2553
-
3016
+ {{relationImports}}
2554
3017
  export default definePanel<{{Name}}FormApi>({
2555
3018
  title: '{{title}}',
2556
3019
  column: 'beside',
@@ -2572,7 +3035,7 @@ export default definePanel<{{Name}}FormApi>({
2572
3035
  void vscode.commands.executeCommand('{{prefix}}.open{{Plural}}List');
2573
3036
  return saved;
2574
3037
  },
2575
- async cancel() {
3038
+ {{relationOptionsHandler}} async cancel() {
2576
3039
  // No-op — webview closes itself.
2577
3040
  },
2578
3041
  }),
@@ -2846,6 +3309,92 @@ export function createCache<V = unknown>(opts: CacheOptions = {}): Cache<V> {
2846
3309
 
2847
3310
  return cache;
2848
3311
  }
3312
+ `,
3313
+ "_generators/helper/colorize.ts.tpl": `import * as vscode from 'vscode';
3314
+
3315
+ /**
3316
+ * Apply theme-independent token colors to a single TextMate scope (e.g. a
3317
+ * language's root scope like \`source.toml\`), written to the user's
3318
+ * \`editor.tokenColorCustomizations\`. Because the rules are keyed by
3319
+ * \`[<scope>]\`, only files in that scope are recolored — every other language
3320
+ * keeps the active theme's colors.
3321
+ *
3322
+ * Rules are tagged with a marker so {@link removeTokenColors} can strip exactly
3323
+ * the ones this extension added, preserving any the user wrote by hand.
3324
+ *
3325
+ * Typical use — auto-apply on activate behind an opt-out setting:
3326
+ *
3327
+ * // extension.ts (onActivate hook)
3328
+ * if (config.get<boolean>('colorize', true)) {
3329
+ * await applyTokenColors('source.{{commandPrefix}}', MY_RULES);
3330
+ * }
3331
+ * vscode.workspace.onDidChangeConfiguration(async (e) => {
3332
+ * if (!e.affectsConfiguration('{{commandPrefix}}.colorize')) return;
3333
+ * if (config.get<boolean>('colorize', true)) await applyTokenColors('source.{{commandPrefix}}', MY_RULES);
3334
+ * else await removeTokenColors('source.{{commandPrefix}}');
3335
+ * });
3336
+ *
3337
+ * Declare the opt-out in package.json#contributes.configuration:
3338
+ * "{{commandPrefix}}.colorize": { "type": "boolean", "default": true }
3339
+ */
3340
+
3341
+ export interface TokenColorRule {
3342
+ /** Comma-separated TextMate scopes, e.g. 'entity.name.section.foo, comment.line.foo'. */
3343
+ scope: string;
3344
+ settings: { foreground?: string; background?: string; fontStyle?: string };
3345
+ }
3346
+
3347
+ type TaggedRule = TokenColorRule & { [MARK]?: true };
3348
+
3349
+ /** Marker key identifying rules this extension wrote (vs. the user's own). */
3350
+ const MARK = '{{commandPrefix}}Colorize';
3351
+ const SECTION = 'editor.tokenColorCustomizations';
3352
+
3353
+ const blockKey = (scope: string) => \`[\${scope}]\`;
3354
+
3355
+ /**
3356
+ * Merge \`rules\` into \`editor.tokenColorCustomizations["[<scope>]"].textMateRules\`,
3357
+ * preserving the user's own rules and other scope keys. Idempotent — re-applying
3358
+ * replaces only previously-applied rules from this extension.
3359
+ */
3360
+ export async function applyTokenColors(
3361
+ scope: string,
3362
+ rules: TokenColorRule[],
3363
+ target: vscode.ConfigurationTarget = vscode.ConfigurationTarget.Global,
3364
+ ): Promise<void> {
3365
+ const cfg = vscode.workspace.getConfiguration();
3366
+ const current = (cfg.get<Record<string, any>>(SECTION) ?? {}) as Record<string, any>;
3367
+ const key = blockKey(scope);
3368
+ const block = (current[key] ?? {}) as { textMateRules?: TaggedRule[] };
3369
+ const existing = Array.isArray(block.textMateRules) ? block.textMateRules : [];
3370
+ const userRules = existing.filter((r) => !r[MARK]);
3371
+ const ours: TaggedRule[] = rules.map((r) => ({ ...r, [MARK]: true }));
3372
+ const next = { ...current, [key]: { ...block, textMateRules: [...userRules, ...ours] } };
3373
+ await cfg.update(SECTION, next, target);
3374
+ }
3375
+
3376
+ /** Remove only the rules this extension added for \`scope\`; leave the rest intact. */
3377
+ export async function removeTokenColors(
3378
+ scope: string,
3379
+ target: vscode.ConfigurationTarget = vscode.ConfigurationTarget.Global,
3380
+ ): Promise<void> {
3381
+ const cfg = vscode.workspace.getConfiguration();
3382
+ const current = cfg.get<Record<string, any>>(SECTION);
3383
+ const key = blockKey(scope);
3384
+ if (!current || !current[key]) return;
3385
+ const block = current[key] as { textMateRules?: TaggedRule[] };
3386
+ const userRules = (block.textMateRules ?? []).filter((r) => !r[MARK]);
3387
+
3388
+ const nextBlock: Record<string, unknown> = { ...block };
3389
+ if (userRules.length) nextBlock.textMateRules = userRules;
3390
+ else delete nextBlock.textMateRules;
3391
+
3392
+ const next = { ...current };
3393
+ if (Object.keys(nextBlock).length) next[key] = nextBlock;
3394
+ else delete next[key];
3395
+
3396
+ await cfg.update(SECTION, Object.keys(next).length ? next : undefined, target);
3397
+ }
2849
3398
  `,
2850
3399
  "_generators/helper/config.ts.tpl": `import * as vscode from 'vscode';
2851
3400
 
@@ -3418,7 +3967,7 @@ export const {{Plural}} = defineEntity<{{Name}}>('{{collection}}', {
3418
3967
  * import { {{Plural}}Repo } from '../models/{{Name}}';
3419
3968
  * await {{Plural}}Repo().insert({ ... });
3420
3969
  */
3421
- export const {{Plural}}Repo = () => db()({{Plural}});
3970
+ export const {{Plural}}Repo = () => db()({{Plural}});{{relationsBlock}}
3422
3971
  `,
3423
3972
  "_generators/panel/App.tsx.tpl": `import React from 'react';
3424
3973
  {{apiBlock}}
@@ -4164,6 +4713,12 @@ bun run package # → {{name}}-0.0.1.vsix
4164
4713
  "react/scripts/gen.ts": `#!/usr/bin/env bun
4165
4714
  // Scans src/panels, src/commands, and src/menus; writes src/extension/_registry.ts
4166
4715
  // and syncs package.json#contributes (commands, viewsContainers, views).
4716
+ //
4717
+ // Non-generated contributes (e.g. languages, grammars, snippets, themes,
4718
+ // iconThemes, walkthroughs) go in an optional \`contributes.extra.json\` at the
4719
+ // project root. It is deep-merged into package.json#contributes on every run —
4720
+ // the keys gen.ts owns (commands, keybindings, menus.commandPalette,
4721
+ // viewsContainers, views) always win, everything else from extra is preserved.
4167
4722
 
4168
4723
  import * as fs from 'fs';
4169
4724
  import * as path from 'path';
@@ -4179,6 +4734,10 @@ const TREE_VIEWS_DIR = path.join(SRC, 'treeViews');
4179
4734
  const JOBS_DIR = path.join(SRC, 'jobs');
4180
4735
  const OUT = path.join(SRC, 'extension', '_registry.ts');
4181
4736
  const PKG_PATH = path.join(ROOT, 'package.json');
4737
+ const EXTRA_PATH = path.join(ROOT, 'contributes.extra.json');
4738
+
4739
+ /** Keys gen.ts owns — never overridden by contributes.extra.json. */
4740
+ const GEN_OWNED_KEYS = new Set(['commands', 'keybindings', 'viewsContainers', 'views']);
4182
4741
 
4183
4742
  interface Discovered {
4184
4743
  id: string;
@@ -4367,9 +4926,54 @@ function syncPackageJson(
4367
4926
  delete contributes.views;
4368
4927
  }
4369
4928
 
4929
+ mergeExtraContributes(contributes);
4930
+
4370
4931
  fs.writeFileSync(PKG_PATH, JSON.stringify(pkg, null, 2) + '\\n');
4371
4932
  }
4372
4933
 
4934
+ /**
4935
+ * Deep-merge the optional \`contributes.extra.json\` (project root) into the
4936
+ * package's \`contributes\`. Use for any contribution point gen.ts doesn't
4937
+ * generate — languages, grammars, snippets, themes, iconThemes, walkthroughs…
4938
+ *
4939
+ * Rules:
4940
+ * - gen-owned keys (commands, keybindings, viewsContainers, views) are ignored
4941
+ * if present in extra — gen.ts stays authoritative for those.
4942
+ * - plain objects merge recursively; arrays and primitives from extra replace.
4943
+ *
4944
+ * NOTE: this is an inline copy of src/lib/contributesMerge.ts in the vsceasy
4945
+ * source (the script must run standalone). Keep the two in sync.
4946
+ */
4947
+ function mergeExtraContributes(contributes: Record<string, any>) {
4948
+ if (!fs.existsSync(EXTRA_PATH)) return;
4949
+ let extra: Record<string, any>;
4950
+ try {
4951
+ extra = JSON.parse(fs.readFileSync(EXTRA_PATH, 'utf8'));
4952
+ } catch (err) {
4953
+ console.warn(\`! Skipping contributes.extra.json — invalid JSON: \${(err as Error).message}\`);
4954
+ return;
4955
+ }
4956
+ if (!extra || typeof extra !== 'object') return;
4957
+ for (const [key, value] of Object.entries(extra)) {
4958
+ if (GEN_OWNED_KEYS.has(key)) continue;
4959
+ contributes[key] = deepMerge(contributes[key], value);
4960
+ }
4961
+ }
4962
+
4963
+ function isPlainObject(v: unknown): v is Record<string, any> {
4964
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
4965
+ }
4966
+
4967
+ function deepMerge(base: any, override: any): any {
4968
+ if (isPlainObject(base) && isPlainObject(override)) {
4969
+ const out: Record<string, any> = { ...base };
4970
+ for (const [k, v] of Object.entries(override)) out[k] = deepMerge(base[k], v);
4971
+ return out;
4972
+ }
4973
+ // arrays and primitives: override wins
4974
+ return override;
4975
+ }
4976
+
4373
4977
  function loadDef(file: string): {
4374
4978
  id?: string;
4375
4979
  title?: string;
@@ -6535,7 +7139,7 @@ var init_add9 = __esm(() => {
6535
7139
  init_config();
6536
7140
  fs18 = __toESM(require("fs"));
6537
7141
  path18 = __toESM(require("path"));
6538
- HELPER_KINDS = ["secrets", "config", "state", "notifications", "cache"];
7142
+ HELPER_KINDS = ["secrets", "config", "state", "notifications", "cache", "colorize"];
6539
7143
  });
6540
7144
 
6541
7145
  // src/lib/job/add.ts
@@ -6722,7 +7326,26 @@ function parseModelFile(file) {
6722
7326
  indexes.push(m[1]);
6723
7327
  }
6724
7328
  const id = path22.basename(file).replace(/\.(ts|tsx)$/, "");
6725
- return { name, id, plural, collection, primaryKey: pk, indexes, fields, path: file };
7329
+ const relations = parseRelations(src, name);
7330
+ for (const f of fields) {
7331
+ const r = relations[f.name];
7332
+ if (r)
7333
+ f.relation = r;
7334
+ }
7335
+ return { name, id, plural, collection, primaryKey: pk, indexes, fields, relations, path: file };
7336
+ }
7337
+ function parseRelations(src, name) {
7338
+ const out = {};
7339
+ const block = new RegExp(`export\\s+const\\s+${name}Relations\\s*=\\s*\\{([\\s\\S]*?)\\}\\s*as\\s+const`, "m").exec(src);
7340
+ if (!block)
7341
+ return out;
7342
+ const body = block[1];
7343
+ const re = /([A-Za-z_][A-Za-z0-9_]*)\s*:\s*\{\s*model\s*:\s*['"`]([^'"`]+)['"`]\s*(?:,\s*label\s*:\s*['"`]([^'"`]+)['"`]\s*)?\}/g;
7344
+ let m;
7345
+ while (m = re.exec(body)) {
7346
+ out[m[1]] = { field: m[1], model: m[2], label: m[3] };
7347
+ }
7348
+ return out;
6726
7349
  }
6727
7350
  function parseInterfaceBody(body) {
6728
7351
  const fields = [];
@@ -6859,12 +7482,26 @@ function addCrud(opts) {
6859
7482
  formId,
6860
7483
  prefix
6861
7484
  };
7485
+ const modelsDir = path24.dirname(modelFile);
7486
+ const relations = visible.filter((f) => f.relation).map((f) => {
7487
+ const r = f.relation;
7488
+ const related = parseModelFile(path24.join(modelsDir, `${r.model}.ts`));
7489
+ const labelField = r.label ?? firstStringField(related) ?? related.primaryKey;
7490
+ return {
7491
+ field: f.name,
7492
+ model: r.model,
7493
+ plural: related.plural,
7494
+ pk: related.primaryKey,
7495
+ labelField
7496
+ };
7497
+ });
7498
+ const relVars = buildRelationVars(relations);
6862
7499
  const created = [];
6863
7500
  const modified = [];
6864
7501
  writeFromTpl(path24.join(opts.templatesRoot, "_generators", "crud", "service.ts.tpl"), servicePath, baseVars, created);
6865
7502
  writeFromTpl(path24.join(opts.templatesRoot, "_generators", "crud", "formNav.ts.tpl"), path24.join(opts.projectRoot, "src", "services", `${camelLower(model.name)}FormNav.ts`), baseVars, created);
6866
7503
  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);
7504
+ writeFromTpl(path24.join(opts.templatesRoot, "_generators", "crud", "formPanel.ts.tpl"), formPanelPath, { ...baseVars, ...relVars }, created);
6868
7505
  fs24.mkdirSync(listWebDir, { recursive: true });
6869
7506
  const listVars = {
6870
7507
  ...baseVars,
@@ -6880,10 +7517,10 @@ function addCrud(opts) {
6880
7517
  const formInputs = visible.filter((f) => !cfg.fields?.[f.name]?.hideInForm).map((f) => renderInput(f, cfg.fields?.[f.name])).join(`
6881
7518
  `);
6882
7519
  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);
7520
+ writeFromTpl(path24.join(opts.templatesRoot, "_generators", "crud", "formApp.tsx.tpl"), path24.join(formWebDir, "App.tsx"), { ...baseVars, ...relVars, formFieldInputs: formInputs, emptyFormLiteral: emptyLit }, created);
6884
7521
  writeFromTpl(path24.join(opts.templatesRoot, "_generators", "crud", "main.tsx.tpl"), path24.join(formWebDir, "main.tsx"), baseVars, created);
6885
7522
  appendApi3(apiPath, listApiName, model, created, modified);
6886
- appendApiForm(apiPath, formApiName, model, created, modified);
7523
+ appendApiForm(apiPath, formApiName, model, created, modified, relations.length > 0);
6887
7524
  let menuInfo;
6888
7525
  if (opts.menu && opts.menu !== "none") {
6889
7526
  menuInfo = wireMenu(opts, model, cfg, listId, formId);
@@ -6937,6 +7574,14 @@ function renderInput(field, override) {
6937
7574
  ` + ` <span style={{ opacity: 0.8 }}>${labelText}</span>
6938
7575
  ` + `${input}
6939
7576
  ` + ` </label>`;
7577
+ if (field.relation) {
7578
+ return wrap(` <select${required} value={(form.${name} as any) ?? ''} onChange={(e) => onChange('${name}', e.target.value as any)}>
7579
+ ` + ` <option value=""></option>
7580
+ ` + ` {(relOptions['${name}'] ?? []).map((o) => (
7581
+ ` + ` <option key={o.value} value={o.value}>{o.label}</option>
7582
+ ` + ` ))}
7583
+ ` + ` </select>`);
7584
+ }
6940
7585
  switch (spec.kind) {
6941
7586
  case "number":
6942
7587
  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 +7632,15 @@ export interface ${apiName} {
6987
7632
  ensureImport(apiPath, model.name);
6988
7633
  appendIfMissing(apiPath, apiName, sig, created, modified);
6989
7634
  }
6990
- function appendApiForm(apiPath, apiName, model, created, modified) {
7635
+ function appendApiForm(apiPath, apiName, model, created, modified, hasRelations) {
7636
+ const optionsLine = hasRelations ? ` options(): Promise<Record<string, { value: string; label: string }[]>>;
7637
+ ` : "";
6991
7638
  const sig = `
6992
7639
  export interface ${apiName} {
6993
7640
  ` + ` pendingId(): Promise<${model.name}['${model.primaryKey}'] | null>;
6994
7641
  ` + ` get(id: ${model.name}['${model.primaryKey}'] | null): Promise<${model.name} | null>;
6995
7642
  ` + ` save(row: ${model.name}): Promise<${model.name}>;
6996
- ` + ` cancel(): Promise<void>;
7643
+ ` + optionsLine + ` cancel(): Promise<void>;
6997
7644
  ` + `}
6998
7645
  `;
6999
7646
  ensureImport(apiPath, model.name);
@@ -7081,6 +7728,30 @@ function runGen11(cwd) {
7081
7728
  function which11(cmd) {
7082
7729
  return import_child_process12.spawnSync(process.platform === "win32" ? "where" : "which", [cmd], { stdio: "ignore" }).status === 0;
7083
7730
  }
7731
+ function firstStringField(model) {
7732
+ return model.fields.find((f) => f.type.trim() === "string" && f.name !== model.primaryKey)?.name;
7733
+ }
7734
+ function buildRelationVars(relations) {
7735
+ if (relations.length === 0) {
7736
+ return { relationImports: "", relationOptionsHandler: "", relationOptionsState: "", relationOptionsLoad: "" };
7737
+ }
7738
+ const uniqueRepos = [...new Map(relations.map((r) => [r.plural, r])).values()];
7739
+ const relationImports = uniqueRepos.map((r) => `import { ${r.plural}Repo } from '../models/${r.model}';`).join(`
7740
+ `) + `
7741
+ `;
7742
+ 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(`
7743
+ `);
7744
+ const relationOptionsHandler = ` async options() {
7745
+ ` + ` return {
7746
+ ` + `${handlerLines}
7747
+ ` + ` };
7748
+ ` + ` },
7749
+ `;
7750
+ const relationOptionsState = ` const [relOptions, setRelOptions] = useState<Record<string, { value: string; label: string }[]>>({});`;
7751
+ const relationOptionsLoad = `
7752
+ useEffect(() => { void api.options().then(setRelOptions); }, []);`;
7753
+ return { relationImports, relationOptionsHandler, relationOptionsState, relationOptionsLoad };
7754
+ }
7084
7755
  var fs24, path24, import_child_process12;
7085
7756
  var init_add11 = __esm(() => {
7086
7757
  init_scaffold();
@@ -7113,11 +7784,37 @@ function addModel(opts) {
7113
7784
  const pkField = explicitPk[0] ?? opts.fields.find((f) => f.name === "id") ?? opts.fields[0];
7114
7785
  const primaryKey = pkField.name;
7115
7786
  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`);
7787
+ const modelsDir = path25.join(opts.projectRoot, "src", "models");
7788
+ for (const f of opts.fields) {
7789
+ if (!f.relation)
7790
+ continue;
7791
+ if (f.relation.model === Name) {} else if (!fs25.existsSync(path25.join(modelsDir, `${f.relation.model}.ts`))) {
7792
+ 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.`);
7793
+ }
7794
+ }
7795
+ const target = path25.join(modelsDir, `${Name}.ts`);
7117
7796
  assertNoOverwrite(opts.projectRoot, target, "Model");
7118
7797
  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(`
7798
+ const fkName = (f) => f.relation ? `${f.name}Id` : f.name;
7799
+ const fieldLines = opts.fields.map((f) => {
7800
+ const ts = f.relation ? "string" : f.type;
7801
+ const note = f.relation ? ` // → ${f.relation.model}` : "";
7802
+ return ` ${fkName(f)}${f.optional ? "?" : ""}: ${ts};${note}`;
7803
+ }).join(`
7120
7804
  `);
7805
+ const relFields = opts.fields.filter((f) => f.relation);
7806
+ const relationsBlock = relFields.length ? `
7807
+
7808
+ /** Relation metadata — used by \`vsceasy crud add\` to populate pickers. */
7809
+ ` + `export const ${Name}Relations = {
7810
+ ` + relFields.map((f) => {
7811
+ const r = f.relation;
7812
+ const lbl = r.label ? `, label: '${r.label}'` : "";
7813
+ return ` ${fkName(f)}: { model: '${r.model}'${lbl} },`;
7814
+ }).join(`
7815
+ `) + `
7816
+ } as const;
7817
+ ` : "";
7121
7818
  const vars = {
7122
7819
  name,
7123
7820
  Name,
@@ -7126,11 +7823,12 @@ function addModel(opts) {
7126
7823
  primaryKey,
7127
7824
  fieldLines,
7128
7825
  indexesLine: indexes.length ? `
7129
- indexes: [${indexes.map((i) => `'${i}'`).join(", ")}],` : ""
7826
+ indexes: [${indexes.map((i) => `'${i}'`).join(", ")}],` : "",
7827
+ relationsBlock
7130
7828
  };
7131
7829
  fs25.mkdirSync(path25.dirname(target), { recursive: true });
7132
7830
  fs25.writeFileSync(target, substitute(fs25.readFileSync(tpl, "utf8"), vars));
7133
- return { created: [target], primaryKey, indexes };
7831
+ return { created: [target], primaryKey, indexes, relations: relFields.map((f) => ({ field: fkName(f), ...f.relation })) };
7134
7832
  }
7135
7833
  function normalizeCamel8(s) {
7136
7834
  const cleaned = s.trim().replace(/[^a-zA-Z0-9]+(.)/g, (_m, c) => c.toUpperCase()).replace(/[^a-zA-Z0-9]/g, "");