@vsceasy/cli 0.1.6 → 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/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.6", 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';
@@ -2437,21 +2437,35 @@ type FormState = Partial<{{Name}}>;
2437
2437
 
2438
2438
  const emptyForm: FormState = {{emptyFormLiteral}};
2439
2439
 
2440
+ // \`<input type="date">\` only accepts a \`yyyy-MM-dd\` value. Stored dates may be
2441
+ // ISO strings or Date objects, so normalize before binding to the input.
2442
+ function toDateInput(v: unknown): string {
2443
+ if (v == null || v === '') return '';
2444
+ const d = v instanceof Date ? v : new Date(v as string);
2445
+ return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10);
2446
+ }
2447
+
2440
2448
  export function App() {
2441
2449
  const [form, setForm] = useState<FormState>(emptyForm);
2442
2450
  const [editingId, setEditingId] = useState<{{Name}}['{{primaryKey}}'] | null>(null);
2443
2451
  const [error, setError] = useState<string | null>(null);
2444
2452
  const [saving, setSaving] = useState(false);
2445
2453
 
2446
- const load = useCallback(async () => {
2447
- // The list stashes the row id before revealing this panel. Pull it (the host
2448
- // clears it after handing it over) and pre-fill the form for editing.
2454
+ const load = useCallback(async (initial: boolean) => {
2455
+ // The list stashes a row id before revealing this panel. Pull it (the host
2456
+ // clears it after handing it over).
2449
2457
  const id = await api.pendingId();
2450
2458
  if (id == null || id === '') {
2451
- setForm(emptyForm);
2452
- setEditingId(null);
2459
+ // No row was requested. On the first mount, start with an empty "new" form.
2460
+ // On later reveals (focus/visibility), DON'T reset — that would wipe a form
2461
+ // the user is busy filling in. Just leave the current state as-is.
2462
+ if (initial) {
2463
+ setForm(emptyForm);
2464
+ setEditingId(null);
2465
+ }
2453
2466
  return;
2454
2467
  }
2468
+ // The list asked to edit a specific row — load it, replacing the current form.
2455
2469
  const row = await api.get(id);
2456
2470
  if (row) {
2457
2471
  setForm(row);
@@ -2460,11 +2474,12 @@ export function App() {
2460
2474
  }, []);
2461
2475
 
2462
2476
  useEffect(() => {
2463
- void load();
2464
- // Webviews retain state when hidden, so re-load whenever the panel is
2465
- // revealed — the list may have asked to edit a different row.
2466
- const onFocus = () => { void load(); };
2467
- const onVisible = () => { if (document.visibilityState === 'visible') void load(); };
2477
+ void load(true);
2478
+ // Webviews retain state when hidden, so re-check on reveal: the list may have
2479
+ // asked to edit a different row. When nothing is pending, \`load\` leaves the
2480
+ // in-progress form untouched (see above).
2481
+ const onFocus = () => { void load(false); };
2482
+ const onVisible = () => { if (document.visibilityState === 'visible') void load(false); };
2468
2483
  window.addEventListener('focus', onFocus);
2469
2484
  document.addEventListener('visibilitychange', onVisible);
2470
2485
  return () => {
@@ -2935,6 +2950,35 @@ export interface Repository<T> {
2935
2950
  clear(): Promise<void>;
2936
2951
  }
2937
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
+
2938
2982
  // ── Provider interface (future-proof for sqlite/etc.) ────────────────────────
2939
2983
 
2940
2984
  export interface Provider {
@@ -3105,6 +3149,7 @@ function makeDb(provider: Provider): Db {
3105
3149
  }
3106
3150
  rows.push(row);
3107
3151
  await save(rows);
3152
+ emitChange(entity.name);
3108
3153
  return row;
3109
3154
  },
3110
3155
  async upsert(row) {
@@ -3112,6 +3157,7 @@ function makeDb(provider: Provider): Db {
3112
3157
  const i = rows.findIndex((r) => r[pk] === row[pk]);
3113
3158
  if (i >= 0) rows[i] = row; else rows.push(row);
3114
3159
  await save(rows);
3160
+ emitChange(entity.name);
3115
3161
  return row;
3116
3162
  },
3117
3163
  async update(id, patch) {
@@ -3120,6 +3166,7 @@ function makeDb(provider: Provider): Db {
3120
3166
  if (i < 0) return null;
3121
3167
  rows[i] = { ...rows[i], ...patch };
3122
3168
  await save(rows);
3169
+ emitChange(entity.name);
3123
3170
  return rows[i];
3124
3171
  },
3125
3172
  async delete(id) {
@@ -3128,17 +3175,19 @@ function makeDb(provider: Provider): Db {
3128
3175
  const next = rows.filter((r) => r[pk] !== id);
3129
3176
  if (next.length === before) return false;
3130
3177
  await save(next);
3178
+ emitChange(entity.name);
3131
3179
  return true;
3132
3180
  },
3133
3181
  async deleteMany(where) {
3134
3182
  const rows = await load();
3135
3183
  const next = rows.filter((r) => !match(r, where));
3136
3184
  const removed = rows.length - next.length;
3137
- if (removed > 0) await save(next);
3185
+ if (removed > 0) { await save(next); emitChange(entity.name); }
3138
3186
  return removed;
3139
3187
  },
3140
3188
  async clear() {
3141
3189
  await save([]);
3190
+ emitChange(entity.name);
3142
3191
  },
3143
3192
  };
3144
3193
  };
@@ -3563,6 +3612,30 @@ export default defineStatusBar({
3563
3612
  alignment: '{{alignment}}',
3564
3613
  priority: {{priority}},{{commandLine}}{{panelLine}}
3565
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}});
3566
3639
  `,
3567
3640
  "_generators/subpanel/App.tsx.tpl": `import React from 'react';
3568
3641
  {{apiBlock}}
@@ -4759,6 +4832,13 @@ function registerTreeView(
4759
4832
  vscode.commands.registerCommand(refreshCmd, () => provider.refresh()),
4760
4833
  );
4761
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
+
4762
4842
  const dispatchCmd = \`\${registry.prefix}._tree.\${def.id ?? id}.run\`;
4763
4843
  context.subscriptions.push(
4764
4844
  vscode.commands.registerCommand(dispatchCmd, async (node: TreeNode) => {
@@ -4835,9 +4915,11 @@ function registerSubpanel(
4835
4915
  const ui = def.ui ?? \`subpanels/\${def.id ?? id}\`;
4836
4916
  view.webview.html = renderHtml(view.webview, context, ui, def.title);
4837
4917
  if (def.rpc) {
4838
- const handlers = def.rpc(vscode, context);
4839
- const server = createRpcServer(webviewTransport(view.webview), handlers);
4840
- 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());
4841
4923
  }
4842
4924
  },
4843
4925
  };
@@ -5073,9 +5155,13 @@ function openPanel(context: vscode.ExtensionContext, prefix: string, id: string,
5073
5155
  panel.webview.html = renderHtml(panel.webview, context, ui, def.title);
5074
5156
 
5075
5157
  if (def.rpc) {
5076
- const handlers = def.rpc(vscode, context);
5077
- const server = createRpcServer(webviewTransport(panel.webview), handlers);
5078
- 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());
5079
5165
  }
5080
5166
 
5081
5167
  openPanels.set(key, panel);
@@ -5193,6 +5279,33 @@ export {
5193
5279
  webviewState,
5194
5280
  } from './rpc';
5195
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
+ }
5196
5309
  `,
5197
5310
  "react/src/shared/vsceasy/codiconNames.ts": `/**
5198
5311
  * AUTO-GENERATED by scripts/genCodiconTypes.ts — do not edit.
@@ -5395,6 +5508,18 @@ export type CodiconName =
5395
5508
  import type { Handlers } from './rpc';
5396
5509
  import type { CodiconName } from './codiconNames';
5397
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
+
5398
5523
  export interface PanelDef<H extends Handlers = Handlers> {
5399
5524
  /** Stable id. Default: file basename. Used as command suffix and webview key. */
5400
5525
  id?: string;
@@ -5406,8 +5531,11 @@ export interface PanelDef<H extends Handlers = Handlers> {
5406
5531
  column?: 'active' | 'beside' | 'one' | 'two' | 'three';
5407
5532
  /** Keep DOM alive when hidden. Default: true. */
5408
5533
  retainContext?: boolean;
5409
- /** RPC handlers — receives vscode namespace + extension context. */
5410
- 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;
5411
5539
  /** Optional command palette entry that opens this panel. Default: true. */
5412
5540
  command?:
5413
5541
  | boolean
@@ -5518,8 +5646,11 @@ export interface SubpanelDef<H extends Handlers = Handlers> {
5518
5646
  ui?: string;
5519
5647
  /** Keep DOM alive when hidden. Default: true. */
5520
5648
  retainContext?: boolean;
5521
- /** RPC handlers — receives vscode namespace + extension context. */
5522
- 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;
5523
5654
  }
5524
5655
 
5525
5656
  export function defineSubpanel<H extends Handlers = Handlers>(def: SubpanelDef<H>): SubpanelDef<H> {
@@ -5621,6 +5752,18 @@ export interface TreeViewDef {
5621
5752
  vscode: typeof import('vscode'),
5622
5753
  ctx: vscode.ExtensionContext,
5623
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;
5624
5767
  }
5625
5768
 
5626
5769
  export function defineTreeView(def: TreeViewDef): TreeViewDef {
@@ -5662,7 +5805,7 @@ export function defineJob(def: JobDef): JobDef {
5662
5805
  }
5663
5806
  `,
5664
5807
  "react/src/shared/vsceasy/index.ts": `export { definePanel, defineCommand, defineMenu, defineStatusBar, defineSubpanel, defineTreeView, defineJob } from './define';
5665
- 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';
5666
5809
  export { bootstrap } from './bootstrap';
5667
5810
  export type { Registry, BootstrapOptions, ActivateHook } from './bootstrap';
5668
5811
  export {
@@ -5674,6 +5817,9 @@ export {
5674
5817
  webviewState,
5675
5818
  } from './rpc';
5676
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';
5677
5823
  `,
5678
5824
  "react/src/shared/vsceasy/rpc.ts": `// Typed RPC bridge — webview <-> extension.
5679
5825
  // Used by both sides. Transport-agnostic core + thin adapters.
@@ -5889,6 +6035,80 @@ export function webviewState<T>(defaults: T): {
5889
6035
  },
5890
6036
  };
5891
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
+ }
5892
6112
  `,
5893
6113
  "react/src/webview/panels/dashboard/App.tsx": `import React, { useEffect, useState } from 'react';
5894
6114
  import { connectWebview } from '../../../shared/vsceasy/client';
@@ -6723,7 +6943,7 @@ function renderInput(field, override) {
6723
6943
  case "boolean":
6724
6944
  return wrap(` <input type="checkbox" checked={!!form.${name}} onChange={(e) => onChange('${name}', e.target.checked as any)} />`);
6725
6945
  case "date":
6726
- return wrap(` <input type="date"${required} value={(form.${name} as any) ?? ''} onChange={(e) => onChange('${name}', e.target.value as any)} />`);
6946
+ return wrap(` <input type="date"${required} value={toDateInput(form.${name})} onChange={(e) => onChange('${name}', e.target.value as any)} />`);
6727
6947
  case "select": {
6728
6948
  const opts = (spec.options ?? []).map((o) => ` <option value=${JSON.stringify(o)}>${escapeJsx(o)}</option>`).join(`
6729
6949
  `);
@@ -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.6";
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.6",
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": {
@@ -9,21 +9,35 @@ type FormState = Partial<{{Name}}>;
9
9
 
10
10
  const emptyForm: FormState = {{emptyFormLiteral}};
11
11
 
12
+ // `<input type="date">` only accepts a `yyyy-MM-dd` value. Stored dates may be
13
+ // ISO strings or Date objects, so normalize before binding to the input.
14
+ function toDateInput(v: unknown): string {
15
+ if (v == null || v === '') return '';
16
+ const d = v instanceof Date ? v : new Date(v as string);
17
+ return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10);
18
+ }
19
+
12
20
  export function App() {
13
21
  const [form, setForm] = useState<FormState>(emptyForm);
14
22
  const [editingId, setEditingId] = useState<{{Name}}['{{primaryKey}}'] | null>(null);
15
23
  const [error, setError] = useState<string | null>(null);
16
24
  const [saving, setSaving] = useState(false);
17
25
 
18
- const load = useCallback(async () => {
19
- // The list stashes the row id before revealing this panel. Pull it (the host
20
- // clears it after handing it over) and pre-fill the form for editing.
26
+ const load = useCallback(async (initial: boolean) => {
27
+ // The list stashes a row id before revealing this panel. Pull it (the host
28
+ // clears it after handing it over).
21
29
  const id = await api.pendingId();
22
30
  if (id == null || id === '') {
23
- setForm(emptyForm);
24
- setEditingId(null);
31
+ // No row was requested. On the first mount, start with an empty "new" form.
32
+ // On later reveals (focus/visibility), DON'T reset — that would wipe a form
33
+ // the user is busy filling in. Just leave the current state as-is.
34
+ if (initial) {
35
+ setForm(emptyForm);
36
+ setEditingId(null);
37
+ }
25
38
  return;
26
39
  }
40
+ // The list asked to edit a specific row — load it, replacing the current form.
27
41
  const row = await api.get(id);
28
42
  if (row) {
29
43
  setForm(row);
@@ -32,11 +46,12 @@ export function App() {
32
46
  }, []);
33
47
 
34
48
  useEffect(() => {
35
- void load();
36
- // Webviews retain state when hidden, so re-load whenever the panel is
37
- // revealed — the list may have asked to edit a different row.
38
- const onFocus = () => { void load(); };
39
- const onVisible = () => { if (document.visibilityState === 'visible') void load(); };
49
+ void load(true);
50
+ // Webviews retain state when hidden, so re-check on reveal: the list may have
51
+ // asked to edit a different row. When nothing is pending, `load` leaves the
52
+ // in-progress form untouched (see above).
53
+ const onFocus = () => { void load(false); };
54
+ const onVisible = () => { if (document.visibilityState === 'visible') void load(false); };
40
55
  window.addEventListener('focus', onFocus);
41
56
  document.addEventListener('visibilitychange', onVisible);
42
57
  return () => {
@@ -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);