@vsceasy/cli 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import type { ModelField } from './add';
1
+ import type { ModelField, FieldRelation } from './add';
2
2
  /**
3
3
  * Parse a compact model field spec into `ModelField[]`.
4
4
  *
@@ -7,8 +7,14 @@ import type { ModelField } from './add';
7
7
  * `!` after type → primaryKey
8
8
  * `@` after type → indexed
9
9
  *
10
- * Example: `id:string!,name:string,email?:string@,score:number`
10
+ * Relations use `name:ref(Model)` or `name:ref(Model, label=field)`:
11
+ * category:ref(Category) → FK categoryId, dropdown of Category rows
12
+ * category:ref(Category, label=name) → show Category.name in the dropdown
13
+ *
14
+ * Example: `id:string!,name:string,email?:string@,category:ref(Category)`
11
15
  */
12
16
  export declare function parseFieldsSpec(spec: string): ModelField[];
13
- /** Parse a single `name[?]:type[!][@]` line. Throws on malformed input. */
17
+ /** Parse a single `name[?]:type[!][@]` line (or `name:ref(Model)`). Throws on malformed input. */
14
18
  export declare function parseFieldLine(raw: string): ModelField;
19
+ /** Parse `ref(Model)` / `ref(Model, label=field)`. Returns null when not a ref. */
20
+ export declare function parseRef(type: string): FieldRelation | null;
@@ -0,0 +1,14 @@
1
+ export type StoreType = 'number' | 'string' | 'boolean' | 'json';
2
+ export interface AddStoreOptions {
3
+ name: string;
4
+ /** Value type. Default: 'number'. */
5
+ type?: StoreType;
6
+ /** Raw initial value expression. Defaults per type (0 / '' / false / null). */
7
+ initial?: string;
8
+ projectRoot: string;
9
+ templatesRoot: string;
10
+ }
11
+ export interface AddStoreResult {
12
+ created: string[];
13
+ }
14
+ export declare function addStore(opts: AddStoreOptions): AddStoreResult;
@@ -1,2 +1,2 @@
1
- export declare const TEMPLATES_VERSION = "0.1.7";
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.7",
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
  }),
@@ -64,6 +64,35 @@ export interface Repository<T> {
64
64
  clear(): Promise<void>;
65
65
  }
66
66
 
67
+ // ── Change notifications (reactivity) ────────────────────────────────────────
68
+
69
+ /**
70
+ * Per-entity change listeners. Any mutation (insert/upsert/update/delete/...) on
71
+ * an entity fires its listeners with the entity name. This is the host-side hook
72
+ * the `watch()` helper builds on to push updates to webviews.
73
+ */
74
+ const entityListeners = new Map<string, Set<(entity: string) => void>>();
75
+
76
+ function emitChange(entity: string): void {
77
+ entityListeners.get(entity)?.forEach((cb) => {
78
+ try { cb(entity); } catch { /* a bad listener must not break a write */ }
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Subscribe to changes on an entity. Returns an unsubscribe function.
84
+ *
85
+ * watchEntity(Todos, () => server.emit('todos:changed'));
86
+ *
87
+ * Prefer the higher-level `watch()` helper, which wires this to an RPC emit.
88
+ */
89
+ export function watchEntity<T>(entity: Entity<T>, cb: (entity: string) => void): () => void {
90
+ let set = entityListeners.get(entity.name);
91
+ if (!set) entityListeners.set(entity.name, (set = new Set()));
92
+ set.add(cb);
93
+ return () => { set!.delete(cb); };
94
+ }
95
+
67
96
  // ── Provider interface (future-proof for sqlite/etc.) ────────────────────────
68
97
 
69
98
  export interface Provider {
@@ -234,6 +263,7 @@ function makeDb(provider: Provider): Db {
234
263
  }
235
264
  rows.push(row);
236
265
  await save(rows);
266
+ emitChange(entity.name);
237
267
  return row;
238
268
  },
239
269
  async upsert(row) {
@@ -241,6 +271,7 @@ function makeDb(provider: Provider): Db {
241
271
  const i = rows.findIndex((r) => r[pk] === row[pk]);
242
272
  if (i >= 0) rows[i] = row; else rows.push(row);
243
273
  await save(rows);
274
+ emitChange(entity.name);
244
275
  return row;
245
276
  },
246
277
  async update(id, patch) {
@@ -249,6 +280,7 @@ function makeDb(provider: Provider): Db {
249
280
  if (i < 0) return null;
250
281
  rows[i] = { ...rows[i], ...patch };
251
282
  await save(rows);
283
+ emitChange(entity.name);
252
284
  return rows[i];
253
285
  },
254
286
  async delete(id) {
@@ -257,17 +289,19 @@ function makeDb(provider: Provider): Db {
257
289
  const next = rows.filter((r) => r[pk] !== id);
258
290
  if (next.length === before) return false;
259
291
  await save(next);
292
+ emitChange(entity.name);
260
293
  return true;
261
294
  },
262
295
  async deleteMany(where) {
263
296
  const rows = await load();
264
297
  const next = rows.filter((r) => !match(r, where));
265
298
  const removed = rows.length - next.length;
266
- if (removed > 0) await save(next);
299
+ if (removed > 0) { await save(next); emitChange(entity.name); }
267
300
  return removed;
268
301
  },
269
302
  async clear() {
270
303
  await save([]);
304
+ emitChange(entity.name);
271
305
  },
272
306
  };
273
307
  };
@@ -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}}
@@ -0,0 +1,23 @@
1
+ import { defineStore } from '../shared/vsceasy';
2
+
3
+ /**
4
+ * Reactive store: a single observable value. Mutate it with `.set()` / `.update()`
5
+ * and anything subscribed reacts.
6
+ *
7
+ * import { {{name}} } from '../stores/{{name}}';
8
+ * {{name}}.set({{example}});
9
+ * {{name}}.update((v) => v);
10
+ *
11
+ * To push changes to a webview, watch it on the host and emit over RPC:
12
+ *
13
+ * // inside a panel/subpanel rpc():
14
+ * import { watch } from '../shared/vsceasy';
15
+ * import { {{name}} } from '../stores/{{name}}';
16
+ * watch({{name}}, () => server.emit('{{name}}:changed', {{name}}.get()));
17
+ *
18
+ * Then listen in the webview:
19
+ *
20
+ * import { listen } from '../shared/vsceasy/client';
21
+ * listen(api, '{{name}}:changed', (v) => render(v));
22
+ */
23
+ export const {{name}} = defineStore<{{type}}>({{initial}});
@@ -230,6 +230,13 @@ function registerTreeView(
230
230
  vscode.commands.registerCommand(refreshCmd, () => provider.refresh()),
231
231
  );
232
232
 
233
+ // Keep the tree live: let the def subscribe to a data source and re-run
234
+ // getChildren on change. The unsubscribe is disposed on deactivate.
235
+ if (def.watch) {
236
+ const off = def.watch(() => provider.refresh(), vscode, context);
237
+ if (off) context.subscriptions.push({ dispose: off });
238
+ }
239
+
233
240
  const dispatchCmd = `${registry.prefix}._tree.${def.id ?? id}.run`;
234
241
  context.subscriptions.push(
235
242
  vscode.commands.registerCommand(dispatchCmd, async (node: TreeNode) => {
@@ -306,9 +313,11 @@ function registerSubpanel(
306
313
  const ui = def.ui ?? `subpanels/${def.id ?? id}`;
307
314
  view.webview.html = renderHtml(view.webview, context, ui, def.title);
308
315
  if (def.rpc) {
309
- const handlers = def.rpc(vscode, context);
310
- const server = createRpcServer(webviewTransport(view.webview), handlers);
311
- view.onDidDispose(() => server.dispose());
316
+ let server: ReturnType<typeof createRpcServer> | undefined;
317
+ const emit = (topic: string, payload?: unknown) => server?.emit(topic, payload);
318
+ const handlers = def.rpc(vscode, context, emit);
319
+ server = createRpcServer(webviewTransport(view.webview), handlers);
320
+ view.onDidDispose(() => server!.dispose());
312
321
  }
313
322
  },
314
323
  };
@@ -544,9 +553,13 @@ function openPanel(context: vscode.ExtensionContext, prefix: string, id: string,
544
553
  panel.webview.html = renderHtml(panel.webview, context, ui, def.title);
545
554
 
546
555
  if (def.rpc) {
547
- const handlers = def.rpc(vscode, context);
548
- const server = createRpcServer(webviewTransport(panel.webview), handlers);
549
- panel.onDidDispose(() => server.dispose());
556
+ // `emit` is wired lazily: the rpc factory may call watch()/watchEntity()
557
+ // immediately, but the server (and thus the real emit) only exists after.
558
+ let server: ReturnType<typeof createRpcServer> | undefined;
559
+ const emit = (topic: string, payload?: unknown) => server?.emit(topic, payload);
560
+ const handlers = def.rpc(vscode, context, emit);
561
+ server = createRpcServer(webviewTransport(panel.webview), handlers);
562
+ panel.onDidDispose(() => server!.dispose());
550
563
  }
551
564
 
552
565
  openPanels.set(key, panel);
@@ -6,3 +6,30 @@ export {
6
6
  webviewState,
7
7
  } from './rpc';
8
8
  export type { RpcClient, Handlers, Transport, RpcClientOptions, WebviewApi } from './rpc';
9
+
10
+ export { defineStore } from './store';
11
+ export type { Store } from './store';
12
+
13
+ import type { RpcClient, Handlers } from './rpc';
14
+
15
+ /**
16
+ * Listen for a change pushed from the host over the RPC event channel, and run a
17
+ * callback when it arrives. This is the webview side of the reactivity model —
18
+ * the host emits with `server.emit(topic, …)` (usually wired via `watch()` /
19
+ * `watchEntity()`), and your visual element reacts here. Returns an unsubscribe
20
+ * function; call it on unmount.
21
+ *
22
+ * const api = connectWebview<TodoStatsViewApi>();
23
+ * // re-read + re-render whenever todos change on the host:
24
+ * const off = listen(api, 'todos:changed', () => refresh());
25
+ *
26
+ * It's a thin, named wrapper over `api.on(topic, handler)` so the place you
27
+ * listen reads clearly in the UI code. The payload (if any) is passed through.
28
+ */
29
+ export function listen<H extends Handlers>(
30
+ api: RpcClient<H>,
31
+ topic: string,
32
+ handler: (payload?: unknown) => void,
33
+ ): () => void {
34
+ return api.on(topic, handler);
35
+ }
@@ -2,6 +2,18 @@ import type * as vscode from 'vscode';
2
2
  import type { Handlers } from './rpc';
3
3
  import type { CodiconName } from './codiconNames';
4
4
 
5
+ /**
6
+ * Push a change to this webview over the RPC event channel. The webview reacts
7
+ * with `listen(api, topic, …)`. Wire it to a data source with `watch()` /
8
+ * `watchEntity()` inside `rpc()`:
9
+ *
10
+ * rpc: (vscode, ctx, emit) => {
11
+ * watchEntity(Todos, () => emit('todos:changed'));
12
+ * return { stats: () => … };
13
+ * }
14
+ */
15
+ export type RpcEmit = (topic: string, payload?: unknown) => void;
16
+
5
17
  export interface PanelDef<H extends Handlers = Handlers> {
6
18
  /** Stable id. Default: file basename. Used as command suffix and webview key. */
7
19
  id?: string;
@@ -13,8 +25,11 @@ export interface PanelDef<H extends Handlers = Handlers> {
13
25
  column?: 'active' | 'beside' | 'one' | 'two' | 'three';
14
26
  /** Keep DOM alive when hidden. Default: true. */
15
27
  retainContext?: boolean;
16
- /** RPC handlers — receives vscode namespace + extension context. */
17
- rpc?: (vscode: typeof import('vscode'), ctx: vscode.ExtensionContext) => H;
28
+ /**
29
+ * RPC handlers — receives the vscode namespace, the extension context, and
30
+ * `emit` for pushing change events to this webview (see {@link RpcEmit}).
31
+ */
32
+ rpc?: (vscode: typeof import('vscode'), ctx: vscode.ExtensionContext, emit: RpcEmit) => H;
18
33
  /** Optional command palette entry that opens this panel. Default: true. */
19
34
  command?:
20
35
  | boolean
@@ -125,8 +140,11 @@ export interface SubpanelDef<H extends Handlers = Handlers> {
125
140
  ui?: string;
126
141
  /** Keep DOM alive when hidden. Default: true. */
127
142
  retainContext?: boolean;
128
- /** RPC handlers — receives vscode namespace + extension context. */
129
- rpc?: (vscode: typeof import('vscode'), ctx: vscode.ExtensionContext) => H;
143
+ /**
144
+ * RPC handlers — receives the vscode namespace, the extension context, and
145
+ * `emit` for pushing change events to this webview (see {@link RpcEmit}).
146
+ */
147
+ rpc?: (vscode: typeof import('vscode'), ctx: vscode.ExtensionContext, emit: RpcEmit) => H;
130
148
  }
131
149
 
132
150
  export function defineSubpanel<H extends Handlers = Handlers>(def: SubpanelDef<H>): SubpanelDef<H> {
@@ -228,6 +246,18 @@ export interface TreeViewDef {
228
246
  vscode: typeof import('vscode'),
229
247
  ctx: vscode.ExtensionContext,
230
248
  ) => TreeNode[] | Promise<TreeNode[]>;
249
+ /**
250
+ * Keep the tree live. Receives `refresh` — call it (directly or as a callback)
251
+ * to re-run `getChildren`. Subscribe to a data source here so the tree updates
252
+ * itself; return an unsubscribe to clean up.
253
+ *
254
+ * watch: (refresh) => watchEntity(Todos, refresh),
255
+ */
256
+ watch?: (
257
+ refresh: () => void,
258
+ vscode: typeof import('vscode'),
259
+ ctx: vscode.ExtensionContext,
260
+ ) => (() => void) | void;
231
261
  }
232
262
 
233
263
  export function defineTreeView(def: TreeViewDef): TreeViewDef {
@@ -1,5 +1,5 @@
1
1
  export { definePanel, defineCommand, defineMenu, defineStatusBar, defineSubpanel, defineTreeView, defineJob } from './define';
2
- export type { PanelDef, CommandDef, MenuDef, MenuItem, MenuIcon, StatusBarDef, StatusBarMenuItem, KeybindingDef, SubpanelDef, TreeViewDef, TreeNode, JobDef, JobSchedule, CodiconName } from './define';
2
+ export type { PanelDef, CommandDef, MenuDef, MenuItem, MenuIcon, StatusBarDef, StatusBarMenuItem, KeybindingDef, SubpanelDef, TreeViewDef, TreeNode, JobDef, JobSchedule, CodiconName, RpcEmit } from './define';
3
3
  export { bootstrap } from './bootstrap';
4
4
  export type { Registry, BootstrapOptions, ActivateHook } from './bootstrap';
5
5
  export {
@@ -11,3 +11,6 @@ export {
11
11
  webviewState,
12
12
  } from './rpc';
13
13
  export type { Transport, RpcClient, Handlers, RpcClientOptions, WebviewApi } from './rpc';
14
+ export { defineStore, watch } from './store';
15
+ export type { Store, Watchable } from './store';
16
+ export { listen } from './client';
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Reactive stores — a tiny, framework-agnostic observable value.
3
+ *
4
+ * A store holds one value and notifies subscribers when it changes. It's the
5
+ * non-ORM half of the reactivity model: use it for arbitrary state (a counter,
6
+ * a flag, a selection) that a visual element should track.
7
+ *
8
+ * const counter = defineStore(0);
9
+ * counter.subscribe((v) => console.log('now', v));
10
+ * counter.set(1); // logs: now 1
11
+ * counter.update((n) => n + 1);
12
+ *
13
+ * Pair it with `watch()` on the host to push changes to a webview, and
14
+ * `listen()` on the webview to react — see those helpers below and in client.ts.
15
+ */
16
+ export interface Store<T> {
17
+ /** Read the current value. */
18
+ get(): T;
19
+ /** Replace the value and notify subscribers (no-op if `Object.is`-equal). */
20
+ set(next: T): void;
21
+ /** Derive the next value from the current one, then `set` it. */
22
+ update(fn: (current: T) => T): void;
23
+ /** Subscribe to changes. Returns an unsubscribe function. */
24
+ subscribe(cb: (value: T) => void): () => void;
25
+ }
26
+
27
+ export function defineStore<T>(initial: T): Store<T> {
28
+ let value = initial;
29
+ const subs = new Set<(value: T) => void>();
30
+
31
+ return {
32
+ get: () => value,
33
+ set(next) {
34
+ if (Object.is(value, next)) return;
35
+ value = next;
36
+ subs.forEach((cb) => {
37
+ try { cb(value); } catch { /* a bad subscriber must not break a set */ }
38
+ });
39
+ },
40
+ update(fn) {
41
+ this.set(fn(value));
42
+ },
43
+ subscribe(cb) {
44
+ subs.add(cb);
45
+ return () => { subs.delete(cb); };
46
+ },
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Anything that can be watched: it exposes a `subscribe(cb)` returning an
52
+ * unsubscribe function. Stores satisfy this directly. (ORM entities are watched
53
+ * with `watchEntity` from your generated `db.ts`, which has the same shape.)
54
+ */
55
+ export interface Watchable {
56
+ subscribe(cb: (...args: any[]) => void): () => void;
57
+ }
58
+
59
+ /**
60
+ * Bridge a watchable source to a side-effect — typically an RPC emit that pushes
61
+ * the change to a subscribed webview. Runs `effect` on every change. Returns an
62
+ * unsubscribe function; register it on the panel's `ctx.subscriptions` (wrapped
63
+ * in `{ dispose }`) so it's cleaned up when the extension deactivates.
64
+ *
65
+ * // host side, in a panel's rpc():
66
+ * watch(badgeCount, () => server.emit('badge:changed'));
67
+ *
68
+ * For ORM entities, use `watchEntity(Todos, () => server.emit('todos:changed'))`
69
+ * from your generated db.ts — same idea, same return.
70
+ */
71
+ export function watch(source: Watchable, effect: () => void): () => void {
72
+ return source.subscribe(() => effect());
73
+ }