@yuhere/js-jsonrpc2 0.0.0-stage → 2.1.2

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.
Files changed (174) hide show
  1. package/README.md +277 -2
  2. package/SKILL.md +237 -0
  3. package/dist/cli/cli.d.ts +3 -0
  4. package/dist/cli/cli.d.ts.map +1 -0
  5. package/dist/cli/cli.js +132 -0
  6. package/dist/cli/cli.js.map +1 -0
  7. package/dist/cli/demo-methods.d.ts +10 -0
  8. package/dist/cli/demo-methods.d.ts.map +1 -0
  9. package/dist/cli/demo-methods.js +477 -0
  10. package/dist/cli/demo-methods.js.map +1 -0
  11. package/dist/cli/express.d.ts +2 -0
  12. package/dist/cli/express.d.ts.map +1 -0
  13. package/dist/cli/express.js +42 -0
  14. package/dist/cli/express.js.map +1 -0
  15. package/dist/cli/stdio.d.ts +2 -0
  16. package/dist/cli/stdio.d.ts.map +1 -0
  17. package/dist/cli/stdio.js +5 -0
  18. package/dist/cli/stdio.js.map +1 -0
  19. package/dist/core/Repository.d.ts +24 -0
  20. package/dist/core/Repository.d.ts.map +1 -0
  21. package/dist/core/Repository.js +153 -0
  22. package/dist/core/Repository.js.map +1 -0
  23. package/dist/core/convert.d.ts +4 -0
  24. package/dist/core/convert.d.ts.map +1 -0
  25. package/dist/core/convert.js +29 -0
  26. package/dist/core/convert.js.map +1 -0
  27. package/dist/core/descriptor.d.ts +27 -0
  28. package/dist/core/descriptor.d.ts.map +1 -0
  29. package/dist/core/descriptor.js +83 -0
  30. package/dist/core/descriptor.js.map +1 -0
  31. package/dist/core/index.d.ts +4 -0
  32. package/dist/core/index.d.ts.map +1 -0
  33. package/dist/core/index.js +3 -0
  34. package/dist/core/index.js.map +1 -0
  35. package/dist/core/permission.d.ts +4 -0
  36. package/dist/core/permission.d.ts.map +1 -0
  37. package/dist/core/permission.js +18 -0
  38. package/dist/core/permission.js.map +1 -0
  39. package/dist/core/spec-check.d.ts +3 -0
  40. package/dist/core/spec-check.d.ts.map +1 -0
  41. package/dist/core/spec-check.js +11 -0
  42. package/dist/core/spec-check.js.map +1 -0
  43. package/dist/core/system-methods.d.ts +3 -0
  44. package/dist/core/system-methods.d.ts.map +1 -0
  45. package/dist/core/system-methods.js +76 -0
  46. package/dist/core/system-methods.js.map +1 -0
  47. package/dist/errors/RpcError.d.ts +9 -0
  48. package/dist/errors/RpcError.d.ts.map +1 -0
  49. package/dist/errors/RpcError.js +23 -0
  50. package/dist/errors/RpcError.js.map +1 -0
  51. package/dist/errors/codes.d.ts +7 -0
  52. package/dist/errors/codes.d.ts.map +1 -0
  53. package/dist/errors/codes.js +7 -0
  54. package/dist/errors/codes.js.map +1 -0
  55. package/dist/errors/index.d.ts +3 -0
  56. package/dist/errors/index.d.ts.map +1 -0
  57. package/dist/errors/index.js +3 -0
  58. package/dist/errors/index.js.map +1 -0
  59. package/dist/index.d.ts +18 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +17 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/schema/index.d.ts +4 -0
  64. package/dist/schema/index.d.ts.map +1 -0
  65. package/dist/schema/index.js +4 -0
  66. package/dist/schema/index.js.map +1 -0
  67. package/dist/schema/json/index.d.ts +19 -0
  68. package/dist/schema/json/index.d.ts.map +1 -0
  69. package/dist/schema/json/index.js +6 -0
  70. package/dist/schema/json/index.js.map +1 -0
  71. package/dist/schema/json/marker.d.ts +23 -0
  72. package/dist/schema/json/marker.d.ts.map +1 -0
  73. package/dist/schema/json/marker.js +27 -0
  74. package/dist/schema/json/marker.js.map +1 -0
  75. package/dist/schema/json/primitives.d.ts +202 -0
  76. package/dist/schema/json/primitives.d.ts.map +1 -0
  77. package/dist/schema/json/primitives.js +598 -0
  78. package/dist/schema/json/primitives.js.map +1 -0
  79. package/dist/schema/primitives.d.ts +25 -0
  80. package/dist/schema/primitives.d.ts.map +1 -0
  81. package/dist/schema/primitives.js +153 -0
  82. package/dist/schema/primitives.js.map +1 -0
  83. package/dist/schema/rpc.d.ts +21 -0
  84. package/dist/schema/rpc.d.ts.map +1 -0
  85. package/dist/schema/rpc.js +17 -0
  86. package/dist/schema/rpc.js.map +1 -0
  87. package/dist/schema/z/protocol.d.ts +9 -0
  88. package/dist/schema/z/protocol.d.ts.map +1 -0
  89. package/dist/schema/z/protocol.js +9 -0
  90. package/dist/schema/z/protocol.js.map +1 -0
  91. package/dist/schema/z/register.d.ts +9 -0
  92. package/dist/schema/z/register.d.ts.map +1 -0
  93. package/dist/schema/z/register.js +21 -0
  94. package/dist/schema/z/register.js.map +1 -0
  95. package/dist/stream/index.d.ts +3 -0
  96. package/dist/stream/index.d.ts.map +1 -0
  97. package/dist/stream/index.js +2 -0
  98. package/dist/stream/index.js.map +1 -0
  99. package/dist/stream/stringify-stream.d.ts +15 -0
  100. package/dist/stream/stringify-stream.d.ts.map +1 -0
  101. package/dist/stream/stringify-stream.js +322 -0
  102. package/dist/stream/stringify-stream.js.map +1 -0
  103. package/dist/stream/stringify.d.ts +19 -0
  104. package/dist/stream/stringify.d.ts.map +1 -0
  105. package/dist/stream/stringify.js +217 -0
  106. package/dist/stream/stringify.js.map +1 -0
  107. package/dist/transport/express/body.d.ts +9 -0
  108. package/dist/transport/express/body.d.ts.map +1 -0
  109. package/dist/transport/express/body.js +28 -0
  110. package/dist/transport/express/body.js.map +1 -0
  111. package/dist/transport/express/index.d.ts +3 -0
  112. package/dist/transport/express/index.d.ts.map +1 -0
  113. package/dist/transport/express/index.js +2 -0
  114. package/dist/transport/express/index.js.map +1 -0
  115. package/dist/transport/express/middleware.d.ts +12 -0
  116. package/dist/transport/express/middleware.d.ts.map +1 -0
  117. package/dist/transport/express/middleware.js +73 -0
  118. package/dist/transport/express/middleware.js.map +1 -0
  119. package/dist/transport/express/respond.d.ts +4 -0
  120. package/dist/transport/express/respond.d.ts.map +1 -0
  121. package/dist/transport/express/respond.js +125 -0
  122. package/dist/transport/express/respond.js.map +1 -0
  123. package/dist/transport/stdio/index.d.ts +2 -0
  124. package/dist/transport/stdio/index.d.ts.map +1 -0
  125. package/dist/transport/stdio/index.js +2 -0
  126. package/dist/transport/stdio/index.js.map +1 -0
  127. package/dist/transport/stdio/middleware.d.ts +34 -0
  128. package/dist/transport/stdio/middleware.d.ts.map +1 -0
  129. package/dist/transport/stdio/middleware.js +274 -0
  130. package/dist/transport/stdio/middleware.js.map +1 -0
  131. package/dist/transport/ws/index.d.ts +3 -0
  132. package/dist/transport/ws/index.d.ts.map +1 -0
  133. package/dist/transport/ws/index.js +3 -0
  134. package/dist/transport/ws/index.js.map +1 -0
  135. package/dist/transport/ws/middleware.d.ts +25 -0
  136. package/dist/transport/ws/middleware.d.ts.map +1 -0
  137. package/dist/transport/ws/middleware.js +209 -0
  138. package/dist/transport/ws/middleware.js.map +1 -0
  139. package/dist/transport/ws/standalone.d.ts +22 -0
  140. package/dist/transport/ws/standalone.d.ts.map +1 -0
  141. package/dist/transport/ws/standalone.js +39 -0
  142. package/dist/transport/ws/standalone.js.map +1 -0
  143. package/dist/types.d.ts +75 -0
  144. package/dist/types.d.ts.map +1 -0
  145. package/dist/types.js +2 -0
  146. package/dist/types.js.map +1 -0
  147. package/htdoc/assets/index-CQr-lnlD.js +9286 -0
  148. package/htdoc/assets/index-CQr-lnlD.js.map +1 -0
  149. package/htdoc/assets/index-hcP2-9jw.css +729 -0
  150. package/htdoc/index.html +13 -0
  151. package/package.json +89 -3
  152. package/webui/index.html +12 -0
  153. package/webui/src/App.tsx +56 -0
  154. package/webui/src/api/httpTransport.ts +148 -0
  155. package/webui/src/api/paramForm.ts +79 -0
  156. package/webui/src/api/rpc.ts +57 -0
  157. package/webui/src/api/schema.ts +12 -0
  158. package/webui/src/api/types.ts +16 -0
  159. package/webui/src/api/wsTransport.ts +305 -0
  160. package/webui/src/components/MethodDetail.tsx +43 -0
  161. package/webui/src/components/MethodTree.tsx +118 -0
  162. package/webui/src/components/SchemaForm.tsx +696 -0
  163. package/webui/src/components/SchemaView.tsx +151 -0
  164. package/webui/src/components/TestPanel.tsx +279 -0
  165. package/webui/src/components/Toolbar.tsx +70 -0
  166. package/webui/src/hooks/useMethodDetail.ts +61 -0
  167. package/webui/src/hooks/useMethods.ts +27 -0
  168. package/webui/src/main.tsx +13 -0
  169. package/webui/src/styles/geek.css +729 -0
  170. package/webui/src/theme/ThemeProvider.tsx +79 -0
  171. package/webui/src/vite-env.d.ts +1 -0
  172. package/webui/tsconfig.json +34 -0
  173. package/webui/tsconfig.node.json +13 -0
  174. package/webui/vite.config.ts +15 -0
@@ -0,0 +1,305 @@
1
+ import type { RpcResponse } from './types';
2
+ import {
3
+ RPCError,
4
+ isStreamResult,
5
+ type CallResult,
6
+ type RpcClient,
7
+ } from './rpc';
8
+
9
+ export interface WsRpcOptions {
10
+ /** 默认:根据当前页面 URL 推导(http->ws / https->wss,同路径) */
11
+ endpoint?: string;
12
+ connectTimeoutMs?: number;
13
+ callTimeoutMs?: number;
14
+ }
15
+
16
+ interface Pending {
17
+ resolve: (v: unknown) => void;
18
+ reject: (e: unknown) => void;
19
+ timer: ReturnType<typeof setTimeout>;
20
+ }
21
+
22
+ interface StreamSub {
23
+ push: (v: unknown) => void;
24
+ finish: () => void;
25
+ fail: (e: unknown) => void;
26
+ }
27
+
28
+ interface StreamFrame {
29
+ jsonrpc: '2.0';
30
+ id: number | string;
31
+ done: boolean;
32
+ index?: number;
33
+ value?: unknown;
34
+ error?: { code: number; message: string };
35
+ }
36
+
37
+ /** 首帧等待器:resolve 到原始消息对象,由 call 决定走哪条路。 */
38
+ type FirstWaiter = (msg: RpcResponse | StreamFrame) => void;
39
+
40
+ let idCounter = 0;
41
+
42
+ function deriveWsEndpoint(): string {
43
+ const loc = window.location;
44
+ const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:';
45
+ return `${proto}//${loc.host}${loc.pathname}`;
46
+ }
47
+
48
+ function isStreamFrame(v: unknown): v is StreamFrame {
49
+ return !!v && typeof v === 'object' && typeof (v as StreamFrame).done === 'boolean';
50
+ }
51
+
52
+ export function createWsRpc(options: WsRpcOptions = {}): RpcClient {
53
+ const {
54
+ endpoint = deriveWsEndpoint(),
55
+ connectTimeoutMs = 5000,
56
+ callTimeoutMs = 15000,
57
+ } = options;
58
+
59
+ const pending = new Map<number | string, Pending>();
60
+ const streams = new Map<number | string, StreamSub>();
61
+ const firstWaiters = new Map<number | string, FirstWaiter>();
62
+ let socket: WebSocket | null = null;
63
+ let openPromise: Promise<void> | null = null;
64
+ let closedByUser = false;
65
+
66
+ function rejectAll(reason: string) {
67
+ for (const [, p] of pending) {
68
+ clearTimeout(p.timer);
69
+ p.reject(new Error(reason));
70
+ }
71
+ pending.clear();
72
+ for (const [, sub] of streams) {
73
+ sub.fail(new Error(reason));
74
+ }
75
+ streams.clear();
76
+ for (const [, w] of firstWaiters) {
77
+ // 首帧没等到连接就关了:用一个假的 failure 消息唤醒,让它走普通失败分支
78
+ w({
79
+ jsonrpc: '2.0',
80
+ id: 0,
81
+ error: { code: -32603, message: reason },
82
+ } as RpcResponse);
83
+ }
84
+ firstWaiters.clear();
85
+ }
86
+
87
+ function attach(ws: WebSocket) {
88
+ ws.addEventListener('message', (ev) => {
89
+ let data: RpcResponse | RpcResponse[] | StreamFrame | StreamFrame[] | null = null;
90
+ try {
91
+ data = JSON.parse(ev.data as string);
92
+ } catch {
93
+ return;
94
+ }
95
+ const list = Array.isArray(data) ? data : [data];
96
+ for (const msg of list) {
97
+ if (msg.id == null) continue;
98
+
99
+ // 1) 首帧等待者
100
+ const fw = firstWaiters.get(msg.id);
101
+ if (fw) {
102
+ firstWaiters.delete(msg.id);
103
+ fw(msg as RpcResponse | StreamFrame);
104
+ continue;
105
+ }
106
+
107
+ // 2) 流帧
108
+ if (isStreamFrame(msg)) {
109
+ const sub = streams.get(msg.id);
110
+ if (!sub) continue;
111
+ if (msg.error) {
112
+ streams.delete(msg.id);
113
+ sub.fail(new RPCError(msg.error));
114
+ continue;
115
+ }
116
+ if (msg.done) {
117
+ streams.delete(msg.id);
118
+ sub.finish();
119
+ continue;
120
+ }
121
+ if (msg.value !== undefined) sub.push(msg.value);
122
+ continue;
123
+ }
124
+
125
+ // 3) 普通响应
126
+ const p = pending.get(msg.id);
127
+ if (!p) continue;
128
+ pending.delete(msg.id);
129
+ clearTimeout(p.timer);
130
+ const plain = msg as RpcResponse;
131
+ if (plain.error) p.reject(new RPCError(plain.error));
132
+ else p.resolve(plain.result);
133
+ }
134
+ });
135
+
136
+ ws.addEventListener('close', () => {
137
+ if (!closedByUser) rejectAll('WebSocket 已关闭');
138
+ socket = null;
139
+ openPromise = null;
140
+ });
141
+ }
142
+
143
+ function ensureOpen(): Promise<void> {
144
+ if (socket && socket.readyState === WebSocket.OPEN) return Promise.resolve();
145
+ if (openPromise) return openPromise;
146
+
147
+ openPromise = new Promise<void>((resolve, reject) => {
148
+ let settled = false;
149
+ const ws = new WebSocket(endpoint);
150
+ const timer = setTimeout(() => {
151
+ if (settled) return;
152
+ settled = true;
153
+ try { ws.close(); } catch { /* ignore */ }
154
+ reject(new Error(`WebSocket 连接超时(${connectTimeoutMs}ms)`));
155
+ }, connectTimeoutMs);
156
+
157
+ attach(ws);
158
+
159
+ ws.addEventListener('open', () => {
160
+ if (settled) return;
161
+ settled = true;
162
+ clearTimeout(timer);
163
+ socket = ws;
164
+ resolve();
165
+ }, { once: true });
166
+
167
+ ws.addEventListener('close', () => {
168
+ if (settled) return;
169
+ settled = true;
170
+ clearTimeout(timer);
171
+ reject(new Error('WebSocket 连接被拒绝'));
172
+ }, { once: true });
173
+
174
+ ws.addEventListener('error', () => {
175
+ if (settled) return;
176
+ settled = true;
177
+ clearTimeout(timer);
178
+ reject(new Error('WebSocket 连接失败'));
179
+ }, { once: true });
180
+ }).finally(() => {
181
+ if (!socket) openPromise = null;
182
+ });
183
+
184
+ return openPromise;
185
+ }
186
+
187
+ function call<T = unknown>(method: string, ...params: unknown[]): CallResult<T> {
188
+ return (async () => {
189
+ await ensureOpen();
190
+ const ws = socket;
191
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
192
+ throw new Error('WebSocket 未连接');
193
+ }
194
+
195
+ const id = ++idCounter;
196
+ const payload = { jsonrpc: '2.0', method, params, id };
197
+
198
+ // 等第一帧
199
+ const first = new Promise<RpcResponse | StreamFrame>((resolve, reject) => {
200
+ const timer = setTimeout(() => {
201
+ firstWaiters.delete(id);
202
+ reject(new Error(`调用 ${method} 超时(${callTimeoutMs}ms)`));
203
+ }, callTimeoutMs);
204
+
205
+ firstWaiters.set(id, (msg) => {
206
+ clearTimeout(timer);
207
+ resolve(msg);
208
+ });
209
+ });
210
+
211
+ try {
212
+ ws.send(JSON.stringify(payload));
213
+ } catch (e) {
214
+ firstWaiters.delete(id);
215
+ throw e;
216
+ }
217
+
218
+ let firstMsg: RpcResponse | StreamFrame;
219
+ try {
220
+ firstMsg = await first;
221
+ } catch (e) {
222
+ firstWaiters.delete(id);
223
+ throw e;
224
+ }
225
+
226
+ // 流式
227
+ if (isStreamFrame(firstMsg)) {
228
+ // 桥接到 AsyncGenerator
229
+ const queue: T[] = [];
230
+ let finished = false;
231
+ let failure: unknown = null;
232
+ let waiter: (() => void) | null = null;
233
+ const wake = () => {
234
+ const w = waiter;
235
+ waiter = null;
236
+ w?.();
237
+ };
238
+
239
+ // 先处理首帧
240
+ if (firstMsg.error) {
241
+ throw new RPCError(firstMsg.error);
242
+ }
243
+ if (firstMsg.done) {
244
+ // 空流:直接返回空 generator
245
+ return (async function* (): AsyncGenerator<T, void, unknown> {})();
246
+ }
247
+ if (firstMsg.value !== undefined) queue.push(firstMsg.value as T);
248
+
249
+ const sub: StreamSub = {
250
+ push: (v) => {
251
+ queue.push(v as T);
252
+ wake();
253
+ },
254
+ finish: () => {
255
+ finished = true;
256
+ wake();
257
+ },
258
+ fail: (e) => {
259
+ failure = e;
260
+ finished = true;
261
+ wake();
262
+ },
263
+ };
264
+ streams.set(id, sub);
265
+
266
+ return (async function* (): AsyncGenerator<T, void, unknown> {
267
+ try {
268
+ while (true) {
269
+ if (failure) throw failure;
270
+ if (queue.length > 0) {
271
+ yield queue.shift() as T;
272
+ continue;
273
+ }
274
+ if (finished) return;
275
+ await new Promise<void>((resolve) => {
276
+ waiter = resolve;
277
+ });
278
+ }
279
+ } finally {
280
+ streams.delete(id);
281
+ }
282
+ })();
283
+ }
284
+
285
+ // 普通
286
+ if (firstMsg.error) {
287
+ throw new RPCError(firstMsg.error);
288
+ }
289
+ return (firstMsg as RpcResponse).result as T;
290
+ })();
291
+ }
292
+
293
+ return {
294
+ call,
295
+ close() {
296
+ closedByUser = true;
297
+ rejectAll('客户端已关闭');
298
+ if (socket) {
299
+ try { socket.close(1000, 'client closed'); } catch { /* ignore */ }
300
+ }
301
+ socket = null;
302
+ openPromise = null;
303
+ },
304
+ };
305
+ }
@@ -0,0 +1,43 @@
1
+ import type { MethodDetail as MethodDetailData } from '../hooks/useMethodDetail';
2
+ import { SchemaView } from './SchemaView';
3
+
4
+ export function MethodDetail({
5
+ method,
6
+ detail,
7
+ }: {
8
+ method: string;
9
+ detail: MethodDetailData;
10
+ }) {
11
+ if (!method) {
12
+ return (
13
+ <section className="detail">
14
+ <p className="hint">← 从左侧选择一个方法查看帮助与签名。</p>
15
+ </section>
16
+ );
17
+ }
18
+
19
+ const [returnSchema] = detail.signature;
20
+
21
+ return (
22
+ <section className="detail">
23
+ <h2>{method}</h2>
24
+ {detail.loading && <p className="hint">加载中…</p>}
25
+ {detail.error && <p className="error">加载失败:{detail.error}</p>}
26
+
27
+ <h3>Help</h3>
28
+ <pre className="help">{detail.help || '(无帮助信息)'}</pre>
29
+
30
+ <h3>
31
+ 返回类型{' '}
32
+ <span className="hint">
33
+ (服务端不会校验执行结果是否符合此类型,实际返回值可能会有所不同)
34
+ </span>
35
+ </h3>
36
+ {returnSchema ? (
37
+ <SchemaView schema={returnSchema} depth={0} defaultOpen />
38
+ ) : (
39
+ <p className="hint">(暂无)</p>
40
+ )}
41
+ </section>
42
+ );
43
+ }
@@ -0,0 +1,118 @@
1
+ import { useMemo, useState } from 'react';
2
+
3
+ interface TreeNode {
4
+ name: string;
5
+ fullName: string;
6
+ children: TreeNode[];
7
+ isLeaf: boolean;
8
+ }
9
+
10
+ function buildTree(methods: string[]): TreeNode[] {
11
+ const root: TreeNode = { name: '', fullName: '', children: [], isLeaf: false };
12
+ for (const m of methods) {
13
+ const parts = m.split('.');
14
+ let cur = root;
15
+ parts.forEach((part, i) => {
16
+ const isLeaf = i === parts.length - 1;
17
+ let child = cur.children.find((c) => c.name === part);
18
+ if (!child) {
19
+ child = {
20
+ name: part,
21
+ fullName: parts.slice(0, i + 1).join('.'),
22
+ children: [],
23
+ isLeaf,
24
+ };
25
+ cur.children.push(child);
26
+ }
27
+ cur = child;
28
+ });
29
+ }
30
+ const sort = (nodes: TreeNode[]) => {
31
+ nodes.sort((a, b) => {
32
+ if (a.isLeaf !== b.isLeaf) return a.isLeaf ? 1 : -1;
33
+ return a.name.localeCompare(b.name);
34
+ });
35
+ nodes.forEach((n) => sort(n.children));
36
+ };
37
+ sort(root.children);
38
+ return root.children;
39
+ }
40
+
41
+ interface MethodTreeProps {
42
+ methods: string[];
43
+ selected: string;
44
+ onSelect: (method: string) => void;
45
+ }
46
+
47
+ export function MethodTree({ methods, selected, onSelect }: MethodTreeProps) {
48
+ const tree = useMemo(() => buildTree(methods), [methods]);
49
+
50
+ if (methods.length === 0) {
51
+ return <p className="empty">暂无可用方法。</p>;
52
+ }
53
+
54
+ return (
55
+ <ul className="tree">
56
+ {tree.map((n) => (
57
+ <Node
58
+ key={n.fullName}
59
+ node={n}
60
+ selected={selected}
61
+ onSelect={onSelect}
62
+ depth={0}
63
+ />
64
+ ))}
65
+ </ul>
66
+ );
67
+ }
68
+
69
+ interface NodeProps {
70
+ node: TreeNode;
71
+ selected: string;
72
+ onSelect: (method: string) => void;
73
+ depth: number;
74
+ }
75
+
76
+ function Node({ node, selected, onSelect, depth }: NodeProps) {
77
+ const [open, setOpen] = useState(false); // ← 默认折叠
78
+
79
+ if (node.isLeaf) {
80
+ const active = selected === node.fullName;
81
+ return (
82
+ <li
83
+ className={`leaf${active ? ' active' : ''}`}
84
+ style={{ paddingLeft: depth * 14 }}
85
+ onClick={() => onSelect(node.fullName)}
86
+ title={node.fullName}
87
+ >
88
+ {node.name}
89
+ </li>
90
+ );
91
+ }
92
+
93
+ return (
94
+ <li>
95
+ <div
96
+ className="dir-row"
97
+ style={{ paddingLeft: depth * 14 }}
98
+ onClick={() => setOpen((o) => !o)}
99
+ >
100
+ <span className="expander">{open ? '▾' : '▸'}</span>
101
+ <span className="dir">{node.name}</span>
102
+ </div>
103
+ {open && (
104
+ <ul>
105
+ {node.children.map((c) => (
106
+ <Node
107
+ key={c.fullName}
108
+ node={c}
109
+ selected={selected}
110
+ onSelect={onSelect}
111
+ depth={depth + 1}
112
+ />
113
+ ))}
114
+ </ul>
115
+ )}
116
+ </li>
117
+ );
118
+ }