@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 +4 -0
- package/README.md +8 -0
- package/dist/bin/cli.js +208 -61
- package/dist/index.js +110 -14
- package/dist/lib/crud/parseModel.d.ts +12 -0
- package/dist/lib/model/add.d.ts +18 -0
- package/dist/lib/model/parseFields.d.ts +9 -3
- package/dist/lib/templatesData.d.ts +1 -1
- package/package.json +1 -1
- package/templates/_generators/crud/formApp.tsx.tpl +2 -0
- package/templates/_generators/crud/formPanel.ts.tpl +2 -2
- package/templates/_generators/model/model.ts.tpl +1 -1
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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!
|
|
12429
|
-
" name:string
|
|
12430
|
-
" email?:string
|
|
12431
|
-
" createdAt:number
|
|
12432
|
-
' role:"a"|"b"
|
|
12433
|
-
" tag:string@
|
|
12434
|
-
" score:number!@
|
|
12501
|
+
" id:string! — `!` after type = primary key",
|
|
12502
|
+
" name:string — required field",
|
|
12503
|
+
" email?:string — `?` after name = optional",
|
|
12504
|
+
" createdAt:number — number type",
|
|
12505
|
+
' role:"a"|"b" — literal union',
|
|
12506
|
+
" tag:string@ — `@` after type = indexed",
|
|
12507
|
+
" score:number!@ — primary key + indexed",
|
|
12508
|
+
" category:ref(Category) — relation → FK categoryId + dropdown of Category",
|
|
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 (!
|
|
12591
|
+
if (!fs26.existsSync(file))
|
|
12512
12592
|
throw new Error(`Model file not found: ${file}`);
|
|
12513
|
-
const src =
|
|
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
|
-
|
|
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
|
|
12687
|
+
var fs26, path39;
|
|
12589
12688
|
var init_parseModel = __esm(() => {
|
|
12590
|
-
|
|
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 (!
|
|
12696
|
+
if (!fs27.existsSync(file))
|
|
12598
12697
|
return {};
|
|
12599
12698
|
try {
|
|
12600
|
-
const src =
|
|
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
|
|
12734
|
+
var fs27, path40;
|
|
12636
12735
|
var init_crudConfig = __esm(() => {
|
|
12637
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
12829
|
+
if (!fs28.existsSync(tpl))
|
|
12717
12830
|
throw new Error(`CRUD template missing: ${tpl}`);
|
|
12718
|
-
|
|
12719
|
-
|
|
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 (!
|
|
12823
|
-
|
|
12824
|
-
|
|
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 =
|
|
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
|
-
|
|
12956
|
+
fs28.writeFileSync(apiPath, src);
|
|
12834
12957
|
}
|
|
12835
12958
|
function appendIfMissing(apiPath, apiName, block, created, modified) {
|
|
12836
|
-
if (!
|
|
12837
|
-
|
|
12959
|
+
if (!fs28.existsSync(apiPath)) {
|
|
12960
|
+
fs28.writeFileSync(apiPath, block.trimStart());
|
|
12838
12961
|
created.push(apiPath);
|
|
12839
12962
|
return;
|
|
12840
12963
|
}
|
|
12841
|
-
const src =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 (!
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
13076
|
-
|
|
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
|
|
13232
|
+
var fs30, path44, DEFAULT_INITIAL, TS_TYPE, EXAMPLE;
|
|
13086
13233
|
var init_add25 = __esm(() => {
|
|
13087
13234
|
init_scaffold();
|
|
13088
13235
|
init_validate();
|
|
13089
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|
package/dist/lib/model/add.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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.
|
|
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.
|
|
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}}
|