@vsceasy/cli 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,12 @@ All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.
5
5
  ## [Unreleased]
6
6
 
7
7
  ### Added
8
+ - **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
+ - ORM entities fire change events on every mutation; subscribe with `watchEntity(Todos, () => emit('todos:changed'))` from your generated `db.ts`.
10
+ - `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`**.
11
+ - `watch(source, effect)` (host) bridges a store/entity to an RPC emit; `listen(api, topic, cb)` (webview) runs a callback when the event arrives. Both return an unsubscribe.
12
+ - Panel/subpanel `rpc` factories receive a third arg, `emit`, for pushing events to their own webview. (Reuses the existing RPC event channel — no transport change.)
13
+ - See the [Reactivity guide](https://vsceasy.dev/guides/reactivity/) and tutorial step 8.
8
14
  - **`vsceasy create` post-scaffold setup** — after generating the project, `create` now offers to **initialize a git repository** (`git init`) and **install dependencies** (`bun`, falling back to `npm`). Both prompts default to yes in an interactive terminal. New `--git` / `--install` flags (and `--git=false` / `--install=false`) skip the prompts for scripting/CI. Non-interactive runs without the flags skip both, as before.
9
15
  - **`vsceasy job add`** — scaffold recurring / event-triggered jobs into `src/jobs/`. Schedules: `--every "60s"`, `--dailyAt "09:00"`, `--on startup|saveDocument|openDocument|changeActiveEditor|changeConfig`, `--onFile "**/*.md"`. Optional `--minIntervalMs` throttles re-runs via globalState. Runtime (`bootstrap`) auto-registers timers/listeners + cleanup on deactivate, catches errors so they don't crash the host.
10
16
  - **`command add --when <expr>`** — declare VS Code `when` clauses on commands. Auto-written to `contributes.commands[].enablement` and `contributes.menus.commandPalette` by `bun run gen`. Enables context-aware visibility (e.g. `editorTextFocus`, `resourceLangId == typescript`).
package/README.md CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  <p align="center">Build VS Code extensions fast. React UI + typed RPC bridge between extension and webview + zero-config build.</p>
8
8
 
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
+
9
11
  > Status: v0.1 — React UI. Typed RPC bridge + file-based registry + scaffolding for panels, commands, menus, tree views, subpanels, status bars.
10
12
 
11
13
  ## 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.7", TEMPLATE_FILES;
3829
+ var TEMPLATES_VERSION = "0.1.8", TEMPLATE_FILES;
3830
3830
  var init_templatesData = __esm(() => {
3831
3831
  TEMPLATE_FILES = {
3832
3832
  "_generators/command/command.ts.tpl": `import { defineCommand } from '../shared/vsceasy';
@@ -4538,6 +4538,35 @@ export interface Repository<T> {
4538
4538
  clear(): Promise<void>;
4539
4539
  }
4540
4540
 
4541
+ // ── Change notifications (reactivity) ────────────────────────────────────────
4542
+
4543
+ /**
4544
+ * Per-entity change listeners. Any mutation (insert/upsert/update/delete/...) on
4545
+ * an entity fires its listeners with the entity name. This is the host-side hook
4546
+ * the \`watch()\` helper builds on to push updates to webviews.
4547
+ */
4548
+ const entityListeners = new Map<string, Set<(entity: string) => void>>();
4549
+
4550
+ function emitChange(entity: string): void {
4551
+ entityListeners.get(entity)?.forEach((cb) => {
4552
+ try { cb(entity); } catch { /* a bad listener must not break a write */ }
4553
+ });
4554
+ }
4555
+
4556
+ /**
4557
+ * Subscribe to changes on an entity. Returns an unsubscribe function.
4558
+ *
4559
+ * watchEntity(Todos, () => server.emit('todos:changed'));
4560
+ *
4561
+ * Prefer the higher-level \`watch()\` helper, which wires this to an RPC emit.
4562
+ */
4563
+ export function watchEntity<T>(entity: Entity<T>, cb: (entity: string) => void): () => void {
4564
+ let set = entityListeners.get(entity.name);
4565
+ if (!set) entityListeners.set(entity.name, (set = new Set()));
4566
+ set.add(cb);
4567
+ return () => { set!.delete(cb); };
4568
+ }
4569
+
4541
4570
  // ── Provider interface (future-proof for sqlite/etc.) ────────────────────────
4542
4571
 
4543
4572
  export interface Provider {
@@ -4708,6 +4737,7 @@ function makeDb(provider: Provider): Db {
4708
4737
  }
4709
4738
  rows.push(row);
4710
4739
  await save(rows);
4740
+ emitChange(entity.name);
4711
4741
  return row;
4712
4742
  },
4713
4743
  async upsert(row) {
@@ -4715,6 +4745,7 @@ function makeDb(provider: Provider): Db {
4715
4745
  const i = rows.findIndex((r) => r[pk] === row[pk]);
4716
4746
  if (i >= 0) rows[i] = row; else rows.push(row);
4717
4747
  await save(rows);
4748
+ emitChange(entity.name);
4718
4749
  return row;
4719
4750
  },
4720
4751
  async update(id, patch) {
@@ -4723,6 +4754,7 @@ function makeDb(provider: Provider): Db {
4723
4754
  if (i < 0) return null;
4724
4755
  rows[i] = { ...rows[i], ...patch };
4725
4756
  await save(rows);
4757
+ emitChange(entity.name);
4726
4758
  return rows[i];
4727
4759
  },
4728
4760
  async delete(id) {
@@ -4731,17 +4763,19 @@ function makeDb(provider: Provider): Db {
4731
4763
  const next = rows.filter((r) => r[pk] !== id);
4732
4764
  if (next.length === before) return false;
4733
4765
  await save(next);
4766
+ emitChange(entity.name);
4734
4767
  return true;
4735
4768
  },
4736
4769
  async deleteMany(where) {
4737
4770
  const rows = await load();
4738
4771
  const next = rows.filter((r) => !match(r, where));
4739
4772
  const removed = rows.length - next.length;
4740
- if (removed > 0) await save(next);
4773
+ if (removed > 0) { await save(next); emitChange(entity.name); }
4741
4774
  return removed;
4742
4775
  },
4743
4776
  async clear() {
4744
4777
  await save([]);
4778
+ emitChange(entity.name);
4745
4779
  },
4746
4780
  };
4747
4781
  };
@@ -5166,6 +5200,30 @@ export default defineStatusBar({
5166
5200
  alignment: '{{alignment}}',
5167
5201
  priority: {{priority}},{{commandLine}}{{panelLine}}
5168
5202
  });
5203
+ `,
5204
+ "_generators/store/store.ts.tpl": `import { defineStore } from '../shared/vsceasy';
5205
+
5206
+ /**
5207
+ * Reactive store: a single observable value. Mutate it with \`.set()\` / \`.update()\`
5208
+ * and anything subscribed reacts.
5209
+ *
5210
+ * import { {{name}} } from '../stores/{{name}}';
5211
+ * {{name}}.set({{example}});
5212
+ * {{name}}.update((v) => v);
5213
+ *
5214
+ * To push changes to a webview, watch it on the host and emit over RPC:
5215
+ *
5216
+ * // inside a panel/subpanel rpc():
5217
+ * import { watch } from '../shared/vsceasy';
5218
+ * import { {{name}} } from '../stores/{{name}}';
5219
+ * watch({{name}}, () => server.emit('{{name}}:changed', {{name}}.get()));
5220
+ *
5221
+ * Then listen in the webview:
5222
+ *
5223
+ * import { listen } from '../shared/vsceasy/client';
5224
+ * listen(api, '{{name}}:changed', (v) => render(v));
5225
+ */
5226
+ export const {{name}} = defineStore<{{type}}>({{initial}});
5169
5227
  `,
5170
5228
  "_generators/subpanel/App.tsx.tpl": `import React from 'react';
5171
5229
  {{apiBlock}}
@@ -6362,6 +6420,13 @@ function registerTreeView(
6362
6420
  vscode.commands.registerCommand(refreshCmd, () => provider.refresh()),
6363
6421
  );
6364
6422
 
6423
+ // Keep the tree live: let the def subscribe to a data source and re-run
6424
+ // getChildren on change. The unsubscribe is disposed on deactivate.
6425
+ if (def.watch) {
6426
+ const off = def.watch(() => provider.refresh(), vscode, context);
6427
+ if (off) context.subscriptions.push({ dispose: off });
6428
+ }
6429
+
6365
6430
  const dispatchCmd = \`\${registry.prefix}._tree.\${def.id ?? id}.run\`;
6366
6431
  context.subscriptions.push(
6367
6432
  vscode.commands.registerCommand(dispatchCmd, async (node: TreeNode) => {
@@ -6438,9 +6503,11 @@ function registerSubpanel(
6438
6503
  const ui = def.ui ?? \`subpanels/\${def.id ?? id}\`;
6439
6504
  view.webview.html = renderHtml(view.webview, context, ui, def.title);
6440
6505
  if (def.rpc) {
6441
- const handlers = def.rpc(vscode, context);
6442
- const server = createRpcServer(webviewTransport(view.webview), handlers);
6443
- view.onDidDispose(() => server.dispose());
6506
+ let server: ReturnType<typeof createRpcServer> | undefined;
6507
+ const emit = (topic: string, payload?: unknown) => server?.emit(topic, payload);
6508
+ const handlers = def.rpc(vscode, context, emit);
6509
+ server = createRpcServer(webviewTransport(view.webview), handlers);
6510
+ view.onDidDispose(() => server!.dispose());
6444
6511
  }
6445
6512
  },
6446
6513
  };
@@ -6676,9 +6743,13 @@ function openPanel(context: vscode.ExtensionContext, prefix: string, id: string,
6676
6743
  panel.webview.html = renderHtml(panel.webview, context, ui, def.title);
6677
6744
 
6678
6745
  if (def.rpc) {
6679
- const handlers = def.rpc(vscode, context);
6680
- const server = createRpcServer(webviewTransport(panel.webview), handlers);
6681
- panel.onDidDispose(() => server.dispose());
6746
+ // \`emit\` is wired lazily: the rpc factory may call watch()/watchEntity()
6747
+ // immediately, but the server (and thus the real emit) only exists after.
6748
+ let server: ReturnType<typeof createRpcServer> | undefined;
6749
+ const emit = (topic: string, payload?: unknown) => server?.emit(topic, payload);
6750
+ const handlers = def.rpc(vscode, context, emit);
6751
+ server = createRpcServer(webviewTransport(panel.webview), handlers);
6752
+ panel.onDidDispose(() => server!.dispose());
6682
6753
  }
6683
6754
 
6684
6755
  openPanels.set(key, panel);
@@ -6796,6 +6867,33 @@ export {
6796
6867
  webviewState,
6797
6868
  } from './rpc';
6798
6869
  export type { RpcClient, Handlers, Transport, RpcClientOptions, WebviewApi } from './rpc';
6870
+
6871
+ export { defineStore } from './store';
6872
+ export type { Store } from './store';
6873
+
6874
+ import type { RpcClient, Handlers } from './rpc';
6875
+
6876
+ /**
6877
+ * Listen for a change pushed from the host over the RPC event channel, and run a
6878
+ * callback when it arrives. This is the webview side of the reactivity model —
6879
+ * the host emits with \`server.emit(topic, …)\` (usually wired via \`watch()\` /
6880
+ * \`watchEntity()\`), and your visual element reacts here. Returns an unsubscribe
6881
+ * function; call it on unmount.
6882
+ *
6883
+ * const api = connectWebview<TodoStatsViewApi>();
6884
+ * // re-read + re-render whenever todos change on the host:
6885
+ * const off = listen(api, 'todos:changed', () => refresh());
6886
+ *
6887
+ * It's a thin, named wrapper over \`api.on(topic, handler)\` so the place you
6888
+ * listen reads clearly in the UI code. The payload (if any) is passed through.
6889
+ */
6890
+ export function listen<H extends Handlers>(
6891
+ api: RpcClient<H>,
6892
+ topic: string,
6893
+ handler: (payload?: unknown) => void,
6894
+ ): () => void {
6895
+ return api.on(topic, handler);
6896
+ }
6799
6897
  `,
6800
6898
  "react/src/shared/vsceasy/codiconNames.ts": `/**
6801
6899
  * AUTO-GENERATED by scripts/genCodiconTypes.ts — do not edit.
@@ -6998,6 +7096,18 @@ export type CodiconName =
6998
7096
  import type { Handlers } from './rpc';
6999
7097
  import type { CodiconName } from './codiconNames';
7000
7098
 
7099
+ /**
7100
+ * Push a change to this webview over the RPC event channel. The webview reacts
7101
+ * with \`listen(api, topic, …)\`. Wire it to a data source with \`watch()\` /
7102
+ * \`watchEntity()\` inside \`rpc()\`:
7103
+ *
7104
+ * rpc: (vscode, ctx, emit) => {
7105
+ * watchEntity(Todos, () => emit('todos:changed'));
7106
+ * return { stats: () => … };
7107
+ * }
7108
+ */
7109
+ export type RpcEmit = (topic: string, payload?: unknown) => void;
7110
+
7001
7111
  export interface PanelDef<H extends Handlers = Handlers> {
7002
7112
  /** Stable id. Default: file basename. Used as command suffix and webview key. */
7003
7113
  id?: string;
@@ -7009,8 +7119,11 @@ export interface PanelDef<H extends Handlers = Handlers> {
7009
7119
  column?: 'active' | 'beside' | 'one' | 'two' | 'three';
7010
7120
  /** Keep DOM alive when hidden. Default: true. */
7011
7121
  retainContext?: boolean;
7012
- /** RPC handlers — receives vscode namespace + extension context. */
7013
- rpc?: (vscode: typeof import('vscode'), ctx: vscode.ExtensionContext) => H;
7122
+ /**
7123
+ * RPC handlers — receives the vscode namespace, the extension context, and
7124
+ * \`emit\` for pushing change events to this webview (see {@link RpcEmit}).
7125
+ */
7126
+ rpc?: (vscode: typeof import('vscode'), ctx: vscode.ExtensionContext, emit: RpcEmit) => H;
7014
7127
  /** Optional command palette entry that opens this panel. Default: true. */
7015
7128
  command?:
7016
7129
  | boolean
@@ -7121,8 +7234,11 @@ export interface SubpanelDef<H extends Handlers = Handlers> {
7121
7234
  ui?: string;
7122
7235
  /** Keep DOM alive when hidden. Default: true. */
7123
7236
  retainContext?: boolean;
7124
- /** RPC handlers — receives vscode namespace + extension context. */
7125
- rpc?: (vscode: typeof import('vscode'), ctx: vscode.ExtensionContext) => H;
7237
+ /**
7238
+ * RPC handlers — receives the vscode namespace, the extension context, and
7239
+ * \`emit\` for pushing change events to this webview (see {@link RpcEmit}).
7240
+ */
7241
+ rpc?: (vscode: typeof import('vscode'), ctx: vscode.ExtensionContext, emit: RpcEmit) => H;
7126
7242
  }
7127
7243
 
7128
7244
  export function defineSubpanel<H extends Handlers = Handlers>(def: SubpanelDef<H>): SubpanelDef<H> {
@@ -7224,6 +7340,18 @@ export interface TreeViewDef {
7224
7340
  vscode: typeof import('vscode'),
7225
7341
  ctx: vscode.ExtensionContext,
7226
7342
  ) => TreeNode[] | Promise<TreeNode[]>;
7343
+ /**
7344
+ * Keep the tree live. Receives \`refresh\` — call it (directly or as a callback)
7345
+ * to re-run \`getChildren\`. Subscribe to a data source here so the tree updates
7346
+ * itself; return an unsubscribe to clean up.
7347
+ *
7348
+ * watch: (refresh) => watchEntity(Todos, refresh),
7349
+ */
7350
+ watch?: (
7351
+ refresh: () => void,
7352
+ vscode: typeof import('vscode'),
7353
+ ctx: vscode.ExtensionContext,
7354
+ ) => (() => void) | void;
7227
7355
  }
7228
7356
 
7229
7357
  export function defineTreeView(def: TreeViewDef): TreeViewDef {
@@ -7265,7 +7393,7 @@ export function defineJob(def: JobDef): JobDef {
7265
7393
  }
7266
7394
  `,
7267
7395
  "react/src/shared/vsceasy/index.ts": `export { definePanel, defineCommand, defineMenu, defineStatusBar, defineSubpanel, defineTreeView, defineJob } from './define';
7268
- export type { PanelDef, CommandDef, MenuDef, MenuItem, MenuIcon, StatusBarDef, StatusBarMenuItem, KeybindingDef, SubpanelDef, TreeViewDef, TreeNode, JobDef, JobSchedule, CodiconName } from './define';
7396
+ export type { PanelDef, CommandDef, MenuDef, MenuItem, MenuIcon, StatusBarDef, StatusBarMenuItem, KeybindingDef, SubpanelDef, TreeViewDef, TreeNode, JobDef, JobSchedule, CodiconName, RpcEmit } from './define';
7269
7397
  export { bootstrap } from './bootstrap';
7270
7398
  export type { Registry, BootstrapOptions, ActivateHook } from './bootstrap';
7271
7399
  export {
@@ -7277,6 +7405,9 @@ export {
7277
7405
  webviewState,
7278
7406
  } from './rpc';
7279
7407
  export type { Transport, RpcClient, Handlers, RpcClientOptions, WebviewApi } from './rpc';
7408
+ export { defineStore, watch } from './store';
7409
+ export type { Store, Watchable } from './store';
7410
+ export { listen } from './client';
7280
7411
  `,
7281
7412
  "react/src/shared/vsceasy/rpc.ts": `// Typed RPC bridge — webview <-> extension.
7282
7413
  // Used by both sides. Transport-agnostic core + thin adapters.
@@ -7492,6 +7623,80 @@ export function webviewState<T>(defaults: T): {
7492
7623
  },
7493
7624
  };
7494
7625
  }
7626
+ `,
7627
+ "react/src/shared/vsceasy/store.ts": `/**
7628
+ * Reactive stores — a tiny, framework-agnostic observable value.
7629
+ *
7630
+ * A store holds one value and notifies subscribers when it changes. It's the
7631
+ * non-ORM half of the reactivity model: use it for arbitrary state (a counter,
7632
+ * a flag, a selection) that a visual element should track.
7633
+ *
7634
+ * const counter = defineStore(0);
7635
+ * counter.subscribe((v) => console.log('now', v));
7636
+ * counter.set(1); // logs: now 1
7637
+ * counter.update((n) => n + 1);
7638
+ *
7639
+ * Pair it with \`watch()\` on the host to push changes to a webview, and
7640
+ * \`listen()\` on the webview to react — see those helpers below and in client.ts.
7641
+ */
7642
+ export interface Store<T> {
7643
+ /** Read the current value. */
7644
+ get(): T;
7645
+ /** Replace the value and notify subscribers (no-op if \`Object.is\`-equal). */
7646
+ set(next: T): void;
7647
+ /** Derive the next value from the current one, then \`set\` it. */
7648
+ update(fn: (current: T) => T): void;
7649
+ /** Subscribe to changes. Returns an unsubscribe function. */
7650
+ subscribe(cb: (value: T) => void): () => void;
7651
+ }
7652
+
7653
+ export function defineStore<T>(initial: T): Store<T> {
7654
+ let value = initial;
7655
+ const subs = new Set<(value: T) => void>();
7656
+
7657
+ return {
7658
+ get: () => value,
7659
+ set(next) {
7660
+ if (Object.is(value, next)) return;
7661
+ value = next;
7662
+ subs.forEach((cb) => {
7663
+ try { cb(value); } catch { /* a bad subscriber must not break a set */ }
7664
+ });
7665
+ },
7666
+ update(fn) {
7667
+ this.set(fn(value));
7668
+ },
7669
+ subscribe(cb) {
7670
+ subs.add(cb);
7671
+ return () => { subs.delete(cb); };
7672
+ },
7673
+ };
7674
+ }
7675
+
7676
+ /**
7677
+ * Anything that can be watched: it exposes a \`subscribe(cb)\` returning an
7678
+ * unsubscribe function. Stores satisfy this directly. (ORM entities are watched
7679
+ * with \`watchEntity\` from your generated \`db.ts\`, which has the same shape.)
7680
+ */
7681
+ export interface Watchable {
7682
+ subscribe(cb: (...args: any[]) => void): () => void;
7683
+ }
7684
+
7685
+ /**
7686
+ * Bridge a watchable source to a side-effect — typically an RPC emit that pushes
7687
+ * the change to a subscribed webview. Runs \`effect\` on every change. Returns an
7688
+ * unsubscribe function; register it on the panel's \`ctx.subscriptions\` (wrapped
7689
+ * in \`{ dispose }\`) so it's cleaned up when the extension deactivates.
7690
+ *
7691
+ * // host side, in a panel's rpc():
7692
+ * watch(badgeCount, () => server.emit('badge:changed'));
7693
+ *
7694
+ * For ORM entities, use \`watchEntity(Todos, () => server.emit('todos:changed'))\`
7695
+ * from your generated db.ts — same idea, same return.
7696
+ */
7697
+ export function watch(source: Watchable, effect: () => void): () => void {
7698
+ return source.subscribe(() => effect());
7699
+ }
7495
7700
  `,
7496
7701
  "react/src/webview/panels/dashboard/App.tsx": `import React, { useEffect, useState } from 'react';
7497
7702
  import { connectWebview } from '../../../shared/vsceasy/client';
@@ -12853,6 +13058,122 @@ var init_add24 = __esm(() => {
12853
13058
  add_default12 = addComponentsCommand;
12854
13059
  });
12855
13060
 
13061
+ // src/lib/store/add.ts
13062
+ function addStore(opts) {
13063
+ const name = assertId("store name", normalizeCamel9(opts.name));
13064
+ const type = opts.type ?? "number";
13065
+ const initial = opts.initial?.trim() || DEFAULT_INITIAL[type];
13066
+ const storeTs = path44.join(opts.projectRoot, "src", "stores", `${name}.ts`);
13067
+ assertNoOverwrite(opts.projectRoot, storeTs, "Store");
13068
+ const tplPath = path44.join(opts.templatesRoot, "_generators", "store", "store.ts.tpl");
13069
+ const body = substitute(fs29.readFileSync(tplPath, "utf8"), {
13070
+ name,
13071
+ type: TS_TYPE[type],
13072
+ initial,
13073
+ example: EXAMPLE[type]
13074
+ });
13075
+ fs29.mkdirSync(path44.dirname(storeTs), { recursive: true });
13076
+ fs29.writeFileSync(storeTs, body);
13077
+ return { created: [storeTs] };
13078
+ }
13079
+ function normalizeCamel9(s) {
13080
+ const cleaned = s.trim().replace(/[^a-zA-Z0-9]+(.)/g, (_m, c) => c.toUpperCase()).replace(/[^a-zA-Z0-9]/g, "");
13081
+ if (!cleaned)
13082
+ return "";
13083
+ return cleaned.charAt(0).toLowerCase() + cleaned.slice(1);
13084
+ }
13085
+ var fs29, path44, DEFAULT_INITIAL, TS_TYPE, EXAMPLE;
13086
+ var init_add25 = __esm(() => {
13087
+ init_scaffold();
13088
+ init_validate();
13089
+ fs29 = __toESM(require("fs"));
13090
+ path44 = __toESM(require("path"));
13091
+ DEFAULT_INITIAL = {
13092
+ number: "0",
13093
+ string: "''",
13094
+ boolean: "false",
13095
+ json: "null"
13096
+ };
13097
+ TS_TYPE = {
13098
+ number: "number",
13099
+ string: "string",
13100
+ boolean: "boolean",
13101
+ json: "any"
13102
+ };
13103
+ EXAMPLE = {
13104
+ number: "1",
13105
+ string: "'hello'",
13106
+ boolean: "true",
13107
+ json: "{ open: true }"
13108
+ };
13109
+ });
13110
+
13111
+ // src/commands/store/add.ts
13112
+ var import_cli_maker20, path45, addStoreCommand, add_default13;
13113
+ var init_add26 = __esm(() => {
13114
+ init_add25();
13115
+ init_findProject();
13116
+ import_cli_maker20 = __toESM(require_dist(), 1);
13117
+ path45 = __toESM(require("path"));
13118
+ addStoreCommand = {
13119
+ name: "add",
13120
+ description: "Add a reactive store (an observable value) under src/stores/. Watch it on the host and listen in a webview to keep UI in sync.",
13121
+ params: [
13122
+ { name: "name", description: "Store name (camelCase, e.g. badgeCount)", required: true, type: import_cli_maker20.ParamType.Text },
13123
+ {
13124
+ name: "type",
13125
+ description: "Value type",
13126
+ required: false,
13127
+ type: import_cli_maker20.ParamType.List,
13128
+ options: ["number", "string", "boolean", "json"]
13129
+ },
13130
+ {
13131
+ name: "initial",
13132
+ description: "Initial value expression (default per type: 0 / '' / false / null)",
13133
+ required: false,
13134
+ type: import_cli_maker20.ParamType.Text
13135
+ }
13136
+ ],
13137
+ action: async (args) => {
13138
+ try {
13139
+ const projectRoot = findProjectRoot();
13140
+ const templatesRoot = findTemplatesRoot();
13141
+ const result = addStore({
13142
+ name: String(args.name).trim(),
13143
+ type: args.type ? String(args.type) : undefined,
13144
+ initial: args.initial ? String(args.initial) : undefined,
13145
+ projectRoot,
13146
+ templatesRoot
13147
+ });
13148
+ const rel = (p) => path45.relative(projectRoot, p);
13149
+ const name = String(args.name).trim();
13150
+ console.log(`
13151
+ ✓ Store "${name}" created.
13152
+ `);
13153
+ for (const f of result.created)
13154
+ console.log(` + ${rel(f)}`);
13155
+ console.log(`
13156
+ Use it:
13157
+ import { ${name} } from '../stores/${name}';
13158
+ ${name}.set(...); ${name}.update((v) => v);
13159
+ `);
13160
+ console.log(` Push changes to a webview — host side, in a panel rpc():
13161
+ watch(${name}, () => server.emit('${name}:changed', ${name}.get()));
13162
+ `);
13163
+ console.log(` React in the webview:
13164
+ listen(api, '${name}:changed', (v) => render(v));
13165
+ `);
13166
+ } catch (err) {
13167
+ console.error(`
13168
+ ✗ ${err.message}
13169
+ `);
13170
+ process.exitCode = 1;
13171
+ }
13172
+ }
13173
+ };
13174
+ add_default13 = addStoreCommand;
13175
+ });
13176
+
12856
13177
  // src/commands/groups.ts
12857
13178
  function group(name, description, subcommands) {
12858
13179
  return {
@@ -12869,7 +13190,7 @@ Use one of: ${subcommands.map((s) => s.name).join(", ")}
12869
13190
  }
12870
13191
  };
12871
13192
  }
12872
- var PanelGroup, MenuGroup, CommandGroup, RpcGroup, StatusBarGroup, SubpanelGroup, TreeViewGroup, TestGroup, PublishGroup, HelperGroup, JobGroup, DbGroup, ModelGroup, CrudGroup, ComponentsGroup;
13193
+ var PanelGroup, MenuGroup, CommandGroup, RpcGroup, StatusBarGroup, SubpanelGroup, TreeViewGroup, TestGroup, PublishGroup, HelperGroup, JobGroup, DbGroup, ModelGroup, CrudGroup, ComponentsGroup, StoreGroup;
12873
13194
  var init_groups = __esm(() => {
12874
13195
  init_add6();
12875
13196
  init_add8();
@@ -12887,6 +13208,7 @@ var init_groups = __esm(() => {
12887
13208
  init_add21();
12888
13209
  init_add23();
12889
13210
  init_add24();
13211
+ init_add26();
12890
13212
  PanelGroup = group("panel", "Manage panels (webview + RPC bridge)", [add_default]);
12891
13213
  MenuGroup = group("menu", "Manage sidebar menus (activity bar tree views)", [add_default2, edit_default]);
12892
13214
  CommandGroup = group("command", "Manage palette commands", [add_default3]);
@@ -12902,6 +13224,7 @@ var init_groups = __esm(() => {
12902
13224
  ModelGroup = group("model", "Manage typed models (entities + repos) under src/models/", [add_default10]);
12903
13225
  CrudGroup = group("crud", "Scaffold full CRUD UI (service + list panel + form panel + RPC) for a model", [add_default11]);
12904
13226
  ComponentsGroup = group("components", "Generate a theme-aware React component library for webviews", [add_default12]);
13227
+ StoreGroup = group("store", "Manage reactive stores (observable values) under src/stores/", [add_default13]);
12905
13228
  });
12906
13229
 
12907
13230
  // src/cli.ts
@@ -12909,17 +13232,17 @@ var exports_cli = {};
12909
13232
  __export(exports_cli, {
12910
13233
  cli: () => cli
12911
13234
  });
12912
- var import_cli_maker20, cli;
13235
+ var import_cli_maker21, cli;
12913
13236
  var init_cli = __esm(() => {
12914
13237
  init_create();
12915
13238
  init_wizard();
12916
13239
  init_doctor2();
12917
13240
  init_upgrade2();
12918
13241
  init_groups();
12919
- import_cli_maker20 = __toESM(require_dist(), 1);
12920
- cli = new import_cli_maker20.CLI("vsceasy", "Build VS Code extensions fast — React UI + typed RPC bridge + zero-config build.", {
13242
+ import_cli_maker21 = __toESM(require_dist(), 1);
13243
+ cli = new import_cli_maker21.CLI("vsceasy", "Build VS Code extensions fast — React UI + typed RPC bridge + zero-config build.", {
12921
13244
  interactive: true,
12922
- version: "0.1.7",
13245
+ version: "0.1.8",
12923
13246
  introAnimation: {
12924
13247
  enabled: true,
12925
13248
  preset: "retro-space",
@@ -12957,6 +13280,7 @@ var init_cli = __esm(() => {
12957
13280
  cli.command(ModelGroup);
12958
13281
  cli.command(CrudGroup);
12959
13282
  cli.command(ComponentsGroup);
13283
+ cli.command(StoreGroup);
12960
13284
  cli.command(doctor_default);
12961
13285
  cli.command(upgrade_default);
12962
13286
  cli.parse(process.argv);
@@ -14,3 +14,4 @@ export declare const DbGroup: Command;
14
14
  export declare const ModelGroup: Command;
15
15
  export declare const CrudGroup: Command;
16
16
  export declare const ComponentsGroup: Command;
17
+ export declare const StoreGroup: Command;
@@ -0,0 +1,3 @@
1
+ import { Command } from '@ideascol/cli-maker';
2
+ declare const addStoreCommand: Command;
3
+ export default addStoreCommand;
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.7", TEMPLATE_FILES;
2241
+ var TEMPLATES_VERSION = "0.1.8", TEMPLATE_FILES;
2242
2242
  var init_templatesData = __esm(() => {
2243
2243
  TEMPLATE_FILES = {
2244
2244
  "_generators/command/command.ts.tpl": `import { defineCommand } from '../shared/vsceasy';
@@ -2950,6 +2950,35 @@ export interface Repository<T> {
2950
2950
  clear(): Promise<void>;
2951
2951
  }
2952
2952
 
2953
+ // ── Change notifications (reactivity) ────────────────────────────────────────
2954
+
2955
+ /**
2956
+ * Per-entity change listeners. Any mutation (insert/upsert/update/delete/...) on
2957
+ * an entity fires its listeners with the entity name. This is the host-side hook
2958
+ * the \`watch()\` helper builds on to push updates to webviews.
2959
+ */
2960
+ const entityListeners = new Map<string, Set<(entity: string) => void>>();
2961
+
2962
+ function emitChange(entity: string): void {
2963
+ entityListeners.get(entity)?.forEach((cb) => {
2964
+ try { cb(entity); } catch { /* a bad listener must not break a write */ }
2965
+ });
2966
+ }
2967
+
2968
+ /**
2969
+ * Subscribe to changes on an entity. Returns an unsubscribe function.
2970
+ *
2971
+ * watchEntity(Todos, () => server.emit('todos:changed'));
2972
+ *
2973
+ * Prefer the higher-level \`watch()\` helper, which wires this to an RPC emit.
2974
+ */
2975
+ export function watchEntity<T>(entity: Entity<T>, cb: (entity: string) => void): () => void {
2976
+ let set = entityListeners.get(entity.name);
2977
+ if (!set) entityListeners.set(entity.name, (set = new Set()));
2978
+ set.add(cb);
2979
+ return () => { set!.delete(cb); };
2980
+ }
2981
+
2953
2982
  // ── Provider interface (future-proof for sqlite/etc.) ────────────────────────
2954
2983
 
2955
2984
  export interface Provider {
@@ -3120,6 +3149,7 @@ function makeDb(provider: Provider): Db {
3120
3149
  }
3121
3150
  rows.push(row);
3122
3151
  await save(rows);
3152
+ emitChange(entity.name);
3123
3153
  return row;
3124
3154
  },
3125
3155
  async upsert(row) {
@@ -3127,6 +3157,7 @@ function makeDb(provider: Provider): Db {
3127
3157
  const i = rows.findIndex((r) => r[pk] === row[pk]);
3128
3158
  if (i >= 0) rows[i] = row; else rows.push(row);
3129
3159
  await save(rows);
3160
+ emitChange(entity.name);
3130
3161
  return row;
3131
3162
  },
3132
3163
  async update(id, patch) {
@@ -3135,6 +3166,7 @@ function makeDb(provider: Provider): Db {
3135
3166
  if (i < 0) return null;
3136
3167
  rows[i] = { ...rows[i], ...patch };
3137
3168
  await save(rows);
3169
+ emitChange(entity.name);
3138
3170
  return rows[i];
3139
3171
  },
3140
3172
  async delete(id) {
@@ -3143,17 +3175,19 @@ function makeDb(provider: Provider): Db {
3143
3175
  const next = rows.filter((r) => r[pk] !== id);
3144
3176
  if (next.length === before) return false;
3145
3177
  await save(next);
3178
+ emitChange(entity.name);
3146
3179
  return true;
3147
3180
  },
3148
3181
  async deleteMany(where) {
3149
3182
  const rows = await load();
3150
3183
  const next = rows.filter((r) => !match(r, where));
3151
3184
  const removed = rows.length - next.length;
3152
- if (removed > 0) await save(next);
3185
+ if (removed > 0) { await save(next); emitChange(entity.name); }
3153
3186
  return removed;
3154
3187
  },
3155
3188
  async clear() {
3156
3189
  await save([]);
3190
+ emitChange(entity.name);
3157
3191
  },
3158
3192
  };
3159
3193
  };
@@ -3578,6 +3612,30 @@ export default defineStatusBar({
3578
3612
  alignment: '{{alignment}}',
3579
3613
  priority: {{priority}},{{commandLine}}{{panelLine}}
3580
3614
  });
3615
+ `,
3616
+ "_generators/store/store.ts.tpl": `import { defineStore } from '../shared/vsceasy';
3617
+
3618
+ /**
3619
+ * Reactive store: a single observable value. Mutate it with \`.set()\` / \`.update()\`
3620
+ * and anything subscribed reacts.
3621
+ *
3622
+ * import { {{name}} } from '../stores/{{name}}';
3623
+ * {{name}}.set({{example}});
3624
+ * {{name}}.update((v) => v);
3625
+ *
3626
+ * To push changes to a webview, watch it on the host and emit over RPC:
3627
+ *
3628
+ * // inside a panel/subpanel rpc():
3629
+ * import { watch } from '../shared/vsceasy';
3630
+ * import { {{name}} } from '../stores/{{name}}';
3631
+ * watch({{name}}, () => server.emit('{{name}}:changed', {{name}}.get()));
3632
+ *
3633
+ * Then listen in the webview:
3634
+ *
3635
+ * import { listen } from '../shared/vsceasy/client';
3636
+ * listen(api, '{{name}}:changed', (v) => render(v));
3637
+ */
3638
+ export const {{name}} = defineStore<{{type}}>({{initial}});
3581
3639
  `,
3582
3640
  "_generators/subpanel/App.tsx.tpl": `import React from 'react';
3583
3641
  {{apiBlock}}
@@ -4774,6 +4832,13 @@ function registerTreeView(
4774
4832
  vscode.commands.registerCommand(refreshCmd, () => provider.refresh()),
4775
4833
  );
4776
4834
 
4835
+ // Keep the tree live: let the def subscribe to a data source and re-run
4836
+ // getChildren on change. The unsubscribe is disposed on deactivate.
4837
+ if (def.watch) {
4838
+ const off = def.watch(() => provider.refresh(), vscode, context);
4839
+ if (off) context.subscriptions.push({ dispose: off });
4840
+ }
4841
+
4777
4842
  const dispatchCmd = \`\${registry.prefix}._tree.\${def.id ?? id}.run\`;
4778
4843
  context.subscriptions.push(
4779
4844
  vscode.commands.registerCommand(dispatchCmd, async (node: TreeNode) => {
@@ -4850,9 +4915,11 @@ function registerSubpanel(
4850
4915
  const ui = def.ui ?? \`subpanels/\${def.id ?? id}\`;
4851
4916
  view.webview.html = renderHtml(view.webview, context, ui, def.title);
4852
4917
  if (def.rpc) {
4853
- const handlers = def.rpc(vscode, context);
4854
- const server = createRpcServer(webviewTransport(view.webview), handlers);
4855
- view.onDidDispose(() => server.dispose());
4918
+ let server: ReturnType<typeof createRpcServer> | undefined;
4919
+ const emit = (topic: string, payload?: unknown) => server?.emit(topic, payload);
4920
+ const handlers = def.rpc(vscode, context, emit);
4921
+ server = createRpcServer(webviewTransport(view.webview), handlers);
4922
+ view.onDidDispose(() => server!.dispose());
4856
4923
  }
4857
4924
  },
4858
4925
  };
@@ -5088,9 +5155,13 @@ function openPanel(context: vscode.ExtensionContext, prefix: string, id: string,
5088
5155
  panel.webview.html = renderHtml(panel.webview, context, ui, def.title);
5089
5156
 
5090
5157
  if (def.rpc) {
5091
- const handlers = def.rpc(vscode, context);
5092
- const server = createRpcServer(webviewTransport(panel.webview), handlers);
5093
- panel.onDidDispose(() => server.dispose());
5158
+ // \`emit\` is wired lazily: the rpc factory may call watch()/watchEntity()
5159
+ // immediately, but the server (and thus the real emit) only exists after.
5160
+ let server: ReturnType<typeof createRpcServer> | undefined;
5161
+ const emit = (topic: string, payload?: unknown) => server?.emit(topic, payload);
5162
+ const handlers = def.rpc(vscode, context, emit);
5163
+ server = createRpcServer(webviewTransport(panel.webview), handlers);
5164
+ panel.onDidDispose(() => server!.dispose());
5094
5165
  }
5095
5166
 
5096
5167
  openPanels.set(key, panel);
@@ -5208,6 +5279,33 @@ export {
5208
5279
  webviewState,
5209
5280
  } from './rpc';
5210
5281
  export type { RpcClient, Handlers, Transport, RpcClientOptions, WebviewApi } from './rpc';
5282
+
5283
+ export { defineStore } from './store';
5284
+ export type { Store } from './store';
5285
+
5286
+ import type { RpcClient, Handlers } from './rpc';
5287
+
5288
+ /**
5289
+ * Listen for a change pushed from the host over the RPC event channel, and run a
5290
+ * callback when it arrives. This is the webview side of the reactivity model —
5291
+ * the host emits with \`server.emit(topic, …)\` (usually wired via \`watch()\` /
5292
+ * \`watchEntity()\`), and your visual element reacts here. Returns an unsubscribe
5293
+ * function; call it on unmount.
5294
+ *
5295
+ * const api = connectWebview<TodoStatsViewApi>();
5296
+ * // re-read + re-render whenever todos change on the host:
5297
+ * const off = listen(api, 'todos:changed', () => refresh());
5298
+ *
5299
+ * It's a thin, named wrapper over \`api.on(topic, handler)\` so the place you
5300
+ * listen reads clearly in the UI code. The payload (if any) is passed through.
5301
+ */
5302
+ export function listen<H extends Handlers>(
5303
+ api: RpcClient<H>,
5304
+ topic: string,
5305
+ handler: (payload?: unknown) => void,
5306
+ ): () => void {
5307
+ return api.on(topic, handler);
5308
+ }
5211
5309
  `,
5212
5310
  "react/src/shared/vsceasy/codiconNames.ts": `/**
5213
5311
  * AUTO-GENERATED by scripts/genCodiconTypes.ts — do not edit.
@@ -5410,6 +5508,18 @@ export type CodiconName =
5410
5508
  import type { Handlers } from './rpc';
5411
5509
  import type { CodiconName } from './codiconNames';
5412
5510
 
5511
+ /**
5512
+ * Push a change to this webview over the RPC event channel. The webview reacts
5513
+ * with \`listen(api, topic, …)\`. Wire it to a data source with \`watch()\` /
5514
+ * \`watchEntity()\` inside \`rpc()\`:
5515
+ *
5516
+ * rpc: (vscode, ctx, emit) => {
5517
+ * watchEntity(Todos, () => emit('todos:changed'));
5518
+ * return { stats: () => … };
5519
+ * }
5520
+ */
5521
+ export type RpcEmit = (topic: string, payload?: unknown) => void;
5522
+
5413
5523
  export interface PanelDef<H extends Handlers = Handlers> {
5414
5524
  /** Stable id. Default: file basename. Used as command suffix and webview key. */
5415
5525
  id?: string;
@@ -5421,8 +5531,11 @@ export interface PanelDef<H extends Handlers = Handlers> {
5421
5531
  column?: 'active' | 'beside' | 'one' | 'two' | 'three';
5422
5532
  /** Keep DOM alive when hidden. Default: true. */
5423
5533
  retainContext?: boolean;
5424
- /** RPC handlers — receives vscode namespace + extension context. */
5425
- rpc?: (vscode: typeof import('vscode'), ctx: vscode.ExtensionContext) => H;
5534
+ /**
5535
+ * RPC handlers — receives the vscode namespace, the extension context, and
5536
+ * \`emit\` for pushing change events to this webview (see {@link RpcEmit}).
5537
+ */
5538
+ rpc?: (vscode: typeof import('vscode'), ctx: vscode.ExtensionContext, emit: RpcEmit) => H;
5426
5539
  /** Optional command palette entry that opens this panel. Default: true. */
5427
5540
  command?:
5428
5541
  | boolean
@@ -5533,8 +5646,11 @@ export interface SubpanelDef<H extends Handlers = Handlers> {
5533
5646
  ui?: string;
5534
5647
  /** Keep DOM alive when hidden. Default: true. */
5535
5648
  retainContext?: boolean;
5536
- /** RPC handlers — receives vscode namespace + extension context. */
5537
- rpc?: (vscode: typeof import('vscode'), ctx: vscode.ExtensionContext) => H;
5649
+ /**
5650
+ * RPC handlers — receives the vscode namespace, the extension context, and
5651
+ * \`emit\` for pushing change events to this webview (see {@link RpcEmit}).
5652
+ */
5653
+ rpc?: (vscode: typeof import('vscode'), ctx: vscode.ExtensionContext, emit: RpcEmit) => H;
5538
5654
  }
5539
5655
 
5540
5656
  export function defineSubpanel<H extends Handlers = Handlers>(def: SubpanelDef<H>): SubpanelDef<H> {
@@ -5636,6 +5752,18 @@ export interface TreeViewDef {
5636
5752
  vscode: typeof import('vscode'),
5637
5753
  ctx: vscode.ExtensionContext,
5638
5754
  ) => TreeNode[] | Promise<TreeNode[]>;
5755
+ /**
5756
+ * Keep the tree live. Receives \`refresh\` — call it (directly or as a callback)
5757
+ * to re-run \`getChildren\`. Subscribe to a data source here so the tree updates
5758
+ * itself; return an unsubscribe to clean up.
5759
+ *
5760
+ * watch: (refresh) => watchEntity(Todos, refresh),
5761
+ */
5762
+ watch?: (
5763
+ refresh: () => void,
5764
+ vscode: typeof import('vscode'),
5765
+ ctx: vscode.ExtensionContext,
5766
+ ) => (() => void) | void;
5639
5767
  }
5640
5768
 
5641
5769
  export function defineTreeView(def: TreeViewDef): TreeViewDef {
@@ -5677,7 +5805,7 @@ export function defineJob(def: JobDef): JobDef {
5677
5805
  }
5678
5806
  `,
5679
5807
  "react/src/shared/vsceasy/index.ts": `export { definePanel, defineCommand, defineMenu, defineStatusBar, defineSubpanel, defineTreeView, defineJob } from './define';
5680
- export type { PanelDef, CommandDef, MenuDef, MenuItem, MenuIcon, StatusBarDef, StatusBarMenuItem, KeybindingDef, SubpanelDef, TreeViewDef, TreeNode, JobDef, JobSchedule, CodiconName } from './define';
5808
+ export type { PanelDef, CommandDef, MenuDef, MenuItem, MenuIcon, StatusBarDef, StatusBarMenuItem, KeybindingDef, SubpanelDef, TreeViewDef, TreeNode, JobDef, JobSchedule, CodiconName, RpcEmit } from './define';
5681
5809
  export { bootstrap } from './bootstrap';
5682
5810
  export type { Registry, BootstrapOptions, ActivateHook } from './bootstrap';
5683
5811
  export {
@@ -5689,6 +5817,9 @@ export {
5689
5817
  webviewState,
5690
5818
  } from './rpc';
5691
5819
  export type { Transport, RpcClient, Handlers, RpcClientOptions, WebviewApi } from './rpc';
5820
+ export { defineStore, watch } from './store';
5821
+ export type { Store, Watchable } from './store';
5822
+ export { listen } from './client';
5692
5823
  `,
5693
5824
  "react/src/shared/vsceasy/rpc.ts": `// Typed RPC bridge — webview <-> extension.
5694
5825
  // Used by both sides. Transport-agnostic core + thin adapters.
@@ -5904,6 +6035,80 @@ export function webviewState<T>(defaults: T): {
5904
6035
  },
5905
6036
  };
5906
6037
  }
6038
+ `,
6039
+ "react/src/shared/vsceasy/store.ts": `/**
6040
+ * Reactive stores — a tiny, framework-agnostic observable value.
6041
+ *
6042
+ * A store holds one value and notifies subscribers when it changes. It's the
6043
+ * non-ORM half of the reactivity model: use it for arbitrary state (a counter,
6044
+ * a flag, a selection) that a visual element should track.
6045
+ *
6046
+ * const counter = defineStore(0);
6047
+ * counter.subscribe((v) => console.log('now', v));
6048
+ * counter.set(1); // logs: now 1
6049
+ * counter.update((n) => n + 1);
6050
+ *
6051
+ * Pair it with \`watch()\` on the host to push changes to a webview, and
6052
+ * \`listen()\` on the webview to react — see those helpers below and in client.ts.
6053
+ */
6054
+ export interface Store<T> {
6055
+ /** Read the current value. */
6056
+ get(): T;
6057
+ /** Replace the value and notify subscribers (no-op if \`Object.is\`-equal). */
6058
+ set(next: T): void;
6059
+ /** Derive the next value from the current one, then \`set\` it. */
6060
+ update(fn: (current: T) => T): void;
6061
+ /** Subscribe to changes. Returns an unsubscribe function. */
6062
+ subscribe(cb: (value: T) => void): () => void;
6063
+ }
6064
+
6065
+ export function defineStore<T>(initial: T): Store<T> {
6066
+ let value = initial;
6067
+ const subs = new Set<(value: T) => void>();
6068
+
6069
+ return {
6070
+ get: () => value,
6071
+ set(next) {
6072
+ if (Object.is(value, next)) return;
6073
+ value = next;
6074
+ subs.forEach((cb) => {
6075
+ try { cb(value); } catch { /* a bad subscriber must not break a set */ }
6076
+ });
6077
+ },
6078
+ update(fn) {
6079
+ this.set(fn(value));
6080
+ },
6081
+ subscribe(cb) {
6082
+ subs.add(cb);
6083
+ return () => { subs.delete(cb); };
6084
+ },
6085
+ };
6086
+ }
6087
+
6088
+ /**
6089
+ * Anything that can be watched: it exposes a \`subscribe(cb)\` returning an
6090
+ * unsubscribe function. Stores satisfy this directly. (ORM entities are watched
6091
+ * with \`watchEntity\` from your generated \`db.ts\`, which has the same shape.)
6092
+ */
6093
+ export interface Watchable {
6094
+ subscribe(cb: (...args: any[]) => void): () => void;
6095
+ }
6096
+
6097
+ /**
6098
+ * Bridge a watchable source to a side-effect — typically an RPC emit that pushes
6099
+ * the change to a subscribed webview. Runs \`effect\` on every change. Returns an
6100
+ * unsubscribe function; register it on the panel's \`ctx.subscriptions\` (wrapped
6101
+ * in \`{ dispose }\`) so it's cleaned up when the extension deactivates.
6102
+ *
6103
+ * // host side, in a panel's rpc():
6104
+ * watch(badgeCount, () => server.emit('badge:changed'));
6105
+ *
6106
+ * For ORM entities, use \`watchEntity(Todos, () => server.emit('todos:changed'))\`
6107
+ * from your generated db.ts — same idea, same return.
6108
+ */
6109
+ export function watch(source: Watchable, effect: () => void): () => void {
6110
+ return source.subscribe(() => effect());
6111
+ }
5907
6112
  `,
5908
6113
  "react/src/webview/panels/dashboard/App.tsx": `import React, { useEffect, useState } from 'react';
5909
6114
  import { connectWebview } from '../../../shared/vsceasy/client';
@@ -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.8";
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.8",
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": {
@@ -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
  };
@@ -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
+ }