@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 +6 -0
- package/README.md +2 -0
- package/dist/bin/cli.js +342 -18
- package/dist/commands/groups.d.ts +1 -0
- package/dist/commands/store/add.d.ts +3 -0
- package/dist/index.js +218 -13
- package/dist/lib/store/add.d.ts +14 -0
- package/dist/lib/templatesData.d.ts +1 -1
- package/package.json +1 -1
- package/templates/_generators/helper/db.ts.tpl +35 -1
- package/templates/_generators/store/store.ts.tpl +23 -0
- package/templates/react/src/shared/vsceasy/bootstrap.ts +19 -6
- package/templates/react/src/shared/vsceasy/client.ts +27 -0
- package/templates/react/src/shared/vsceasy/define.ts +34 -4
- package/templates/react/src/shared/vsceasy/index.ts +4 -1
- package/templates/react/src/shared/vsceasy/store.ts +73 -0
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.
|
|
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
|
-
|
|
6442
|
-
const
|
|
6443
|
-
|
|
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
|
-
|
|
6680
|
-
|
|
6681
|
-
|
|
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
|
-
/**
|
|
7013
|
-
|
|
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
|
-
/**
|
|
7125
|
-
|
|
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
|
|
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
|
-
|
|
12920
|
-
cli = new
|
|
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.
|
|
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);
|
package/dist/index.js
CHANGED
|
@@ -2238,7 +2238,7 @@ var init_upgrade = __esm(() => {
|
|
|
2238
2238
|
});
|
|
2239
2239
|
|
|
2240
2240
|
// src/lib/templatesData.ts
|
|
2241
|
-
var TEMPLATES_VERSION = "0.1.
|
|
2241
|
+
var TEMPLATES_VERSION = "0.1.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
|
-
|
|
4854
|
-
const
|
|
4855
|
-
|
|
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
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
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
|
-
/**
|
|
5425
|
-
|
|
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
|
-
/**
|
|
5537
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
310
|
-
const
|
|
311
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
/**
|
|
17
|
-
|
|
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
|
-
/**
|
|
129
|
-
|
|
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
|
+
}
|