comate-ui-inspector 0.1.5-alpha.3 → 0.1.5-alpha.5

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/bridge/server.mjs CHANGED
@@ -10,13 +10,15 @@
10
10
  * 安全:只绑 127.0.0.1,不暴露到网络;远程访问一律走 `ssh -L` 把端口转发到本地 127.0.0.1。
11
11
  */
12
12
  import {createServer} from 'node:http';
13
- import {readFileSync, writeFileSync, mkdirSync} from 'node:fs';
13
+ import {readFileSync, writeFileSync, appendFileSync, mkdirSync} from 'node:fs';
14
14
  import {dirname, resolve} from 'node:path';
15
15
  import {homedir} from 'node:os';
16
16
 
17
17
  const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
18
18
  const statusFile = resolve(homedir(), '.ui-inspector/status.json');
19
+ // 录制:元信息 + append-only 事件流分离,避免多请求读改写竞态把 active 盖回 true。
19
20
  const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
21
+ const eventsFile = resolve(homedir(), '.ui-inspector/recording-events.ndjson');
20
22
  const PORT = Number(process.env.UI_INSPECTOR_BRIDGE_PORT) || 8917;
21
23
  // 默认只绑 127.0.0.1(走 SSH 隧道);直连模式(笔记本能直达开发机内网 IP)时用 0.0.0.0 暴露到网卡。
22
24
  const HOST = process.env.UI_INSPECTOR_BRIDGE_HOST || '127.0.0.1';
@@ -43,51 +45,43 @@ function writeStore(list) {
43
45
  writeFileSync(storeFile, `${JSON.stringify(list, null, 4)}\n`, 'utf8');
44
46
  }
45
47
 
46
- // 录制累积:跨刷新/跨页面逐条追加进 recording.json(缓冲在后端,不在页面内存)。
47
- const REC_CAP = 1000;
48
- function readRecording() {
48
+ // 录制累积:元信息与事件流分离,避免多请求读改写竞态。
49
+ function readMeta() {
49
50
  try {
50
51
  return JSON.parse(readFileSync(recordingFile, 'utf8'));
51
52
  } catch {
52
53
  return null;
53
54
  }
54
55
  }
55
- function writeRecording(rec) {
56
+ function writeMeta(meta) {
56
57
  mkdirSync(dirname(recordingFile), {recursive: true});
57
- writeFileSync(recordingFile, `${JSON.stringify(rec ?? null, null, 4)}\n`, 'utf8');
58
+ writeFileSync(recordingFile, `${JSON.stringify(meta ?? null, null, 4)}\n`, 'utf8');
58
59
  }
59
60
  function recStart(startedAt, pageUrl) {
60
- const cur = readRecording();
61
+ const cur = readMeta();
61
62
  if (cur && cur.active && cur.startedAt === startedAt) {
62
- return; // 同一会话重复 start(多标签页),忽略
63
+ return; // 同一会话重复 start,忽略
63
64
  }
64
- writeRecording({active: true, startedAt, pageUrl, clicks: [], console: [], network: []});
65
+ writeMeta({active: true, startedAt, pageUrl});
66
+ mkdirSync(dirname(eventsFile), {recursive: true});
67
+ writeFileSync(eventsFile, '', 'utf8');
65
68
  }
66
69
  function recEvent(kind, item) {
67
- const cur = readRecording();
70
+ const cur = readMeta();
68
71
  if (!cur || !cur.active) {
69
72
  return;
70
73
  }
71
- const key = kind === 'click' ? 'clicks' : kind === 'console' ? 'console' : kind === 'network' ? 'network' : null;
72
- if (!key) {
73
- return;
74
- }
75
- if (!Array.isArray(cur[key])) {
76
- cur[key] = [];
77
- }
78
- if (cur[key].length < REC_CAP) {
79
- cur[key].push(item);
80
- writeRecording(cur);
81
- }
74
+ mkdirSync(dirname(eventsFile), {recursive: true});
75
+ appendFileSync(eventsFile, `${JSON.stringify({kind, item})}\n`, 'utf8');
82
76
  }
83
77
  function recStop(endedAt) {
84
- const cur = readRecording();
78
+ const cur = readMeta();
85
79
  if (!cur) {
86
80
  return;
87
81
  }
88
82
  cur.active = false;
89
83
  cur.endedAt = endedAt || Date.now();
90
- writeRecording(cur);
84
+ writeMeta(cur);
91
85
  }
92
86
 
93
87
  function readBody(req, cb) {
@@ -4,7 +4,7 @@
4
4
  id="rec-console"
5
5
  checked
6
6
  />
7
- 录制控制台输出(console)
7
+ 录制控制台输出
8
8
  </label>
9
9
  <label class="radio">
10
10
  <input
@@ -12,7 +12,7 @@
12
12
  id="rec-network"
13
13
  checked
14
14
  />
15
- 录制网络请求(仅同域)
15
+ 录制网络请求
16
16
  </label>
17
17
  <button
18
18
  id="rec-toggle"
@@ -19,6 +19,7 @@
19
19
  const {recording} = await chrome.storage.local.get({recording: null});
20
20
  if (recording && recording.active) {
21
21
  await chrome.storage.local.set({recording: {active: false}});
22
+ refresh();
22
23
  } else {
23
24
  await chrome.storage.local.set({
24
25
  recording: {
@@ -28,8 +29,9 @@
28
29
  startedAt: Date.now(),
29
30
  },
30
31
  });
32
+ // 开始录制后关闭 popup,让用户直接去页面操作;页面顶部会出现「正在录制」提示条。
33
+ window.close();
31
34
  }
32
- refresh();
33
35
  };
34
36
 
35
37
  refresh();
@@ -133,11 +133,26 @@
133
133
  p.then(
134
134
  function (r) {
135
135
  var t1 = Date.now();
136
- emit('network', {method: method, url: url, status: r.status, startTs: t0, endTs: t1, ms: t1 - t0});
136
+ emit('network', {
137
+ method: method,
138
+ url: url,
139
+ status: r.status,
140
+ startTs: t0,
141
+ endTs: t1,
142
+ ms: t1 - t0,
143
+ });
137
144
  },
138
145
  function () {
139
146
  var t1 = Date.now();
140
- emit('network', {method: method, url: url, status: 0, startTs: t0, endTs: t1, ms: t1 - t0, error: true});
147
+ emit('network', {
148
+ method: method,
149
+ url: url,
150
+ status: 0,
151
+ startTs: t0,
152
+ endTs: t1,
153
+ ms: t1 - t0,
154
+ error: true,
155
+ });
141
156
  }
142
157
  );
143
158
  }
@@ -99,7 +99,13 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
99
99
  if (!msg || typeof msg.type !== 'string') {
100
100
  return;
101
101
  }
102
- if (msg.type === 'ingest' || msg.type === 'state' || msg.type === 'rec-start' || msg.type === 'rec-event' || msg.type === 'rec-stop') {
102
+ if (
103
+ msg.type === 'ingest' ||
104
+ msg.type === 'state' ||
105
+ msg.type === 'rec-start' ||
106
+ msg.type === 'rec-event' ||
107
+ msg.type === 'rec-stop'
108
+ ) {
103
109
  dispatch(msg).then(sendResponse);
104
110
  return true; // 异步 sendResponse
105
111
  }
@@ -27,6 +27,200 @@
27
27
  'opacity:0;transition:opacity 0.15s;pointer-events:none;';
28
28
  document.body.appendChild(hint);
29
29
 
30
+ // 右侧列表弹窗:有选中元素时列出全部已选,支持逐条删除。pointer-events 开(要点删除按钮),
31
+ // 但需在选取逻辑里把它自身排除,避免 Alt 点面板又被当成选元素。
32
+ var panel = document.createElement('div');
33
+ panel.id = 'oreate-ui-inspector-panel';
34
+ panel.style.cssText =
35
+ 'position:fixed;top:16px;right:16px;width:260px;max-height:60vh;overflow:auto;z-index:2147483646;' +
36
+ 'display:none;background:#111;color:#fff;border-radius:8px;padding:10px 10px 8px;' +
37
+ 'font:12px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;box-shadow:0 2px 12px rgba(0,0,0,0.3);';
38
+ var panelHead = document.createElement('div');
39
+ panelHead.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;';
40
+ var panelCount = document.createElement('span');
41
+ panelCount.style.cssText = 'font-weight:600;';
42
+ var panelClear = document.createElement('button');
43
+ panelClear.textContent = '清空';
44
+ panelClear.style.cssText =
45
+ 'border:none;background:#2f2f2f;color:#fff;border-radius:4px;padding:2px 8px;cursor:pointer;font:inherit;';
46
+ panelClear.onclick = function () {
47
+ clearSelection();
48
+ postSelection();
49
+ renderPanel();
50
+ showHint('已清空全部选中');
51
+ };
52
+ panelHead.appendChild(panelCount);
53
+ panelHead.appendChild(panelClear);
54
+ var panelList = document.createElement('div');
55
+ panel.appendChild(panelHead);
56
+ panel.appendChild(panelList);
57
+ document.body.appendChild(panel);
58
+
59
+ // 顶部左侧「层级选择」面包屑:悬停元素时列出 标签 + 其 Vue 组件祖先(含 __file),点一级即选中那一层。
60
+ var crumbBar = document.createElement('div');
61
+ crumbBar.id = 'oreate-ui-inspector-crumbs';
62
+ crumbBar.style.cssText =
63
+ 'position:fixed;top:16px;left:16px;max-width:60vw;z-index:2147483646;display:none;flex-wrap:wrap;' +
64
+ 'align-items:center;gap:4px;background:#111;color:#fff;border-radius:8px;padding:6px 8px;' +
65
+ 'font:12px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;box-shadow:0 2px 12px rgba(0,0,0,0.3);';
66
+ document.body.appendChild(crumbBar);
67
+
68
+ // 该元素是否属于本插件自身 UI(面板/面包屑/overlay/hint),选取时要跳过。
69
+ function isOwnUi(el) {
70
+ return (
71
+ !!el &&
72
+ (el === overlay ||
73
+ el === hint ||
74
+ el === panel ||
75
+ panel.contains(el) ||
76
+ el === crumbBar ||
77
+ crumbBar.contains(el))
78
+ );
79
+ }
80
+
81
+ // DOM 树上下级导航状态:navPath 从叶子往上累积,navIdx 当前停在哪一层(0 = 叶子)。
82
+ var navPath = [];
83
+ var navIdx = 0;
84
+
85
+ function renderPanel() {
86
+ if (!selected.length) {
87
+ panel.style.display = 'none';
88
+ return;
89
+ }
90
+ panel.style.display = 'block';
91
+ panelCount.textContent = '已选中 ' + selected.length + ' 个';
92
+ panelList.textContent = '';
93
+ selected.forEach(function (item, i) {
94
+ var info = item.info || {};
95
+ var files = (info.componentChain || []).map(function (c) {
96
+ return c && c.file;
97
+ }).filter(Boolean);
98
+ var row = document.createElement('div');
99
+ row.style.cssText =
100
+ 'display:flex;align-items:flex-start;gap:6px;padding:6px 0;border-top:1px solid #2a2a2a;';
101
+ var meta = document.createElement('div');
102
+ meta.style.cssText = 'flex:1;min-width:0;';
103
+ var title = document.createElement('div');
104
+ title.style.cssText = 'font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
105
+ title.textContent = i + 1 + '. <' + (info.tag || '?') + '>' + (info.text ? ' ' + info.text.slice(0, 20) : '');
106
+ var sub = document.createElement('div');
107
+ sub.style.cssText = 'color:#9aa0a6;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
108
+ sub.textContent = files.length ? files[0].split('/').pop() : info.selector || '';
109
+ sub.title = files.length ? files.join(' <- ') : info.selector || '';
110
+ meta.appendChild(title);
111
+ meta.appendChild(sub);
112
+ var del = document.createElement('button');
113
+ del.textContent = '删除';
114
+ del.style.cssText =
115
+ 'flex:none;border:none;background:none;color:#ff6b6b;cursor:pointer;padding:0 2px;font:inherit;';
116
+ del.onclick = function () {
117
+ removeAt(i);
118
+ };
119
+ row.appendChild(meta);
120
+ row.appendChild(del);
121
+ panelList.appendChild(row);
122
+ });
123
+ }
124
+
125
+ function removeAt(i) {
126
+ if (i < 0 || i >= selected.length) {
127
+ return;
128
+ }
129
+ selected[i].box.remove();
130
+ selected.splice(i, 1);
131
+ postSelection();
132
+ renderPanel();
133
+ showHint(selected.length ? '已删除(当前 ' + selected.length + ' 个)' : '已删除全部选中');
134
+ }
135
+
136
+ // 取元素的 Vue 组件祖先链,并带上每层组件的根 DOM 元素(可能没有,fragment 时为 null)。
137
+ function componentChainEls(el) {
138
+ var out = [];
139
+ var inst = el && el.__vueParentComponent;
140
+ while (inst && out.length < 8) {
141
+ var type = inst.type || {};
142
+ var file = type.__file || null;
143
+ var name = type.__name || type.name || null;
144
+ var node = (inst.vnode && inst.vnode.el) || (inst.subTree && inst.subTree.el) || null;
145
+ if (file || name) {
146
+ out.push({name: name, file: file, el: node && node.nodeType === 1 ? node : null});
147
+ }
148
+ inst = inst.parent;
149
+ }
150
+ return out;
151
+ }
152
+
153
+ // 渲染「层级选择」面包屑:叶子标签 + 组件祖先;带 DOM 元素的可点击,点击即选中那一层。
154
+ function renderBreadcrumb(el) {
155
+ if (!active || !el) {
156
+ crumbBar.style.display = 'none';
157
+ return;
158
+ }
159
+ var crumbs = [{label: '<' + el.nodeName.toLowerCase() + '>', el: el}];
160
+ componentChainEls(el).forEach(function (c) {
161
+ crumbs.push({label: c.name || (c.file ? c.file.split('/').pop() : '组件'), el: c.el, file: c.file});
162
+ });
163
+ crumbBar.textContent = '';
164
+ var tip = document.createElement('span');
165
+ tip.textContent = '层级:';
166
+ tip.style.cssText = 'color:#9aa0a6;';
167
+ crumbBar.appendChild(tip);
168
+ crumbs.forEach(function (c, i) {
169
+ if (i) {
170
+ var sep = document.createElement('span');
171
+ sep.textContent = '›';
172
+ sep.style.cssText = 'color:#555;';
173
+ crumbBar.appendChild(sep);
174
+ }
175
+ var btn = document.createElement(c.el ? 'button' : 'span');
176
+ btn.textContent = c.label;
177
+ btn.title = c.file || '';
178
+ btn.style.cssText = c.el
179
+ ? 'border:none;background:#2f2f2f;color:#fff;border-radius:4px;padding:2px 6px;cursor:pointer;font:inherit;'
180
+ : 'color:#9aa0a6;';
181
+ if (c.el) {
182
+ btn.onclick = function () {
183
+ toggleSelect(c.el);
184
+ };
185
+ }
186
+ crumbBar.appendChild(btn);
187
+ });
188
+ crumbBar.style.display = 'flex';
189
+ }
190
+
191
+ // 悬停到页面元素:重置导航路径并高亮,刷新面包屑。
192
+ function setHover(el) {
193
+ navPath = [el];
194
+ navIdx = 0;
195
+ current = el;
196
+ moveOverlay(el);
197
+ renderBreadcrumb(el);
198
+ }
199
+ // ↑ 选父级:按需把父节点压入 navPath,再上移一层。
200
+ function navUp() {
201
+ if (navIdx === navPath.length - 1) {
202
+ var p = navPath[navIdx] && navPath[navIdx].parentElement;
203
+ if (!p || p === document.body || p === document.documentElement || isOwnUi(p)) {
204
+ return;
205
+ }
206
+ navPath.push(p);
207
+ }
208
+ navIdx++;
209
+ current = navPath[navIdx];
210
+ moveOverlay(current);
211
+ renderBreadcrumb(current);
212
+ }
213
+ // ↓ 回子级:沿来时路径下移一层。
214
+ function navDown() {
215
+ if (navIdx === 0) {
216
+ return;
217
+ }
218
+ navIdx--;
219
+ current = navPath[navIdx];
220
+ moveOverlay(current);
221
+ renderBreadcrumb(current);
222
+ }
223
+
30
224
  function showHint(text) {
31
225
  window.clearTimeout(showStatus._t);
32
226
  hint.style.background = '#111';
@@ -130,12 +324,34 @@
130
324
  return el && el.isConnected && el.getClientRects().length > 0;
131
325
  }
132
326
 
133
- // 每帧跟随元素位置;同时剔除已消失(被删除/隐藏)的选中项,连同高亮框一起移除。
327
+ // 解析选中项当前对应的真实元素:优先用已记引用;若框架重渲染把节点换掉了(引用失效),
328
+ // 用当初记录的 CSS 选择器回查找回,避免「还在页面上却被当成消失」而误删。
329
+ function resolveEl(item) {
330
+ if (item.el && item.el.isConnected) {
331
+ return item.el;
332
+ }
333
+ var sel = item.info && item.info.selector;
334
+ if (sel) {
335
+ try {
336
+ var found = document.querySelector(sel);
337
+ if (found) {
338
+ item.el = found;
339
+ return found;
340
+ }
341
+ } catch (e) {
342
+ // 选择器非法,忽略
343
+ }
344
+ }
345
+ return null;
346
+ }
347
+
348
+ // 跟随元素位置;剔除真正消失(引用失效且选择器也找不回)的选中项,连同高亮框一起移除。
134
349
  function repositionBoxes() {
135
350
  var removed = 0;
136
351
  for (var i = selected.length - 1; i >= 0; i--) {
137
- if (isRendered(selected[i].el)) {
138
- placeBox(selected[i].box, selected[i].el);
352
+ var el = resolveEl(selected[i]);
353
+ if (el && isRendered(el)) {
354
+ placeBox(selected[i].box, el);
139
355
  } else {
140
356
  selected[i].box.remove();
141
357
  selected.splice(i, 1);
@@ -144,6 +360,7 @@
144
360
  }
145
361
  if (removed) {
146
362
  postSelection();
363
+ renderPanel();
147
364
  showHint(
148
365
  selected.length
149
366
  ? '有选中元素已消失,已移除(当前 ' + selected.length + ' 个)'
@@ -152,11 +369,45 @@
152
369
  }
153
370
  }
154
371
 
372
+ // 事件驱动重定位:scroll/resize/ResizeObserver/MutationObserver 触发,用 rAF 合并成每帧一次,
373
+ // 再加一个 500ms 轮询兜底(纯 CSS 动画位移这类不触发上述事件的情况)。替代原来每帧常开的 rAF。
374
+ var repoScheduled = false;
375
+ function scheduleReposition() {
376
+ if (repoScheduled || !selected.length) {
377
+ return;
378
+ }
379
+ repoScheduled = true;
380
+ window.requestAnimationFrame(function () {
381
+ repoScheduled = false;
382
+ if (selected.length) {
383
+ repositionBoxes();
384
+ }
385
+ });
386
+ }
387
+ var sizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(scheduleReposition) : null;
388
+ function observeSelected() {
389
+ if (!sizeObserver) {
390
+ return;
391
+ }
392
+ sizeObserver.disconnect();
393
+ for (var i = 0; i < selected.length; i++) {
394
+ if (selected[i].el) {
395
+ try {
396
+ sizeObserver.observe(selected[i].el);
397
+ } catch (e) {
398
+ // 已脱离文档,忽略
399
+ }
400
+ }
401
+ }
402
+ }
403
+
155
404
  function clearSelection() {
156
405
  for (var i = 0; i < selected.length; i++) {
157
406
  selected[i].box.remove();
158
407
  }
159
408
  selected = [];
409
+ observeSelected();
410
+ renderPanel();
160
411
  }
161
412
 
162
413
  function indexOfEl(el) {
@@ -192,6 +443,8 @@
192
443
  selected.push({el: el, info: collect(el), box: box});
193
444
  showHint('已选中 ' + selected.length + ' 个,可继续选,或回对话写意见');
194
445
  }
446
+ observeSelected();
447
+ renderPanel();
195
448
  postSelection();
196
449
  }
197
450
  window.addEventListener(
@@ -199,14 +452,24 @@
199
452
  function (e) {
200
453
  if (e.key === 'Alt' && !active) {
201
454
  active = true;
202
- showHint('选择模式:点击元素可多选,再点取消;松开 Alt 退出。点不动时按 Enter 选中当前悬停元素');
455
+ showHint('选择模式:Alt 点选可多选;↑/↓ 选上一级/下一级;Enter 选中当前悬停;松开 Alt 退出');
203
456
  }
204
457
  // 有些元素会吞掉 click(preventDefault/覆盖层),改用 Enter 选中当前 hover 的元素
205
- if (e.key === 'Enter' && active && current && current !== overlay && current !== hint) {
458
+ if (e.key === 'Enter' && active && current && !isOwnUi(current)) {
206
459
  e.preventDefault();
207
460
  e.stopPropagation();
208
461
  toggleSelect(current);
209
462
  }
463
+ // ↑/↓ 在 DOM 树上下级之间切换当前高亮元素(只在按住 Alt 的选择模式下拦截)
464
+ if (active && current && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
465
+ e.preventDefault();
466
+ e.stopPropagation();
467
+ if (e.key === 'ArrowUp') {
468
+ navUp();
469
+ } else {
470
+ navDown();
471
+ }
472
+ }
210
473
  },
211
474
  true
212
475
  );
@@ -216,6 +479,7 @@
216
479
  if (e.key === 'Alt') {
217
480
  active = false;
218
481
  overlay.style.display = 'none';
482
+ crumbBar.style.display = 'none';
219
483
  }
220
484
  },
221
485
  true
@@ -227,9 +491,13 @@
227
491
  return;
228
492
  }
229
493
  var el = document.elementFromPoint(e.clientX, e.clientY);
230
- if (el && el !== current && el !== overlay && el !== hint) {
231
- current = el;
232
- moveOverlay(el);
494
+ // 悬停在插件自身 UI(面板/面包屑)上:不高亮,也不清空当前,便于点面包屑或删除按钮。
495
+ if (isOwnUi(el)) {
496
+ overlay.style.display = 'none';
497
+ return;
498
+ }
499
+ if (el && el !== current) {
500
+ setHover(el);
233
501
  }
234
502
  },
235
503
  true
@@ -240,26 +508,32 @@
240
508
  if (!active || !e.altKey) {
241
509
  return;
242
510
  }
511
+ var el = document.elementFromPoint(e.clientX, e.clientY);
512
+ // 点到面板(如「删除」按钮):放行默认行为,别 preventDefault/选取。
513
+ if (isOwnUi(el)) {
514
+ return;
515
+ }
243
516
  e.preventDefault();
244
517
  e.stopPropagation();
245
- var el = document.elementFromPoint(e.clientX, e.clientY);
246
- if (el && el !== overlay && el !== hint) {
518
+ if (el) {
247
519
  toggleSelect(el);
248
520
  }
249
521
  },
250
522
  true
251
523
  );
252
524
 
253
- window.addEventListener('scroll', repositionBoxes, true);
254
- window.addEventListener('resize', repositionBoxes, true);
255
-
256
- // 每帧跟随并清理:元素被删/隐藏后,repositionBoxes 会移除它的高亮框(scroll/resize 不一定触发)。
257
- (function raf() {
525
+ // 事件驱动的位置跟随(替代每帧常开 rAF):滚动/缩放 + ResizeObserver + DOM 变更,合并到每帧一次;
526
+ // 再加 500ms 轮询兜底,覆盖纯 CSS 位移这类不触发上述事件的情况。
527
+ window.addEventListener('scroll', scheduleReposition, true);
528
+ window.addEventListener('resize', scheduleReposition, true);
529
+ if (typeof MutationObserver !== 'undefined') {
530
+ new MutationObserver(scheduleReposition).observe(document.documentElement, {childList: true, subtree: true});
531
+ }
532
+ window.setInterval(function () {
258
533
  if (selected.length) {
259
534
  repositionBoxes();
260
535
  }
261
- window.requestAnimationFrame(raf);
262
- })();
536
+ }, 500);
263
537
 
264
538
  // bridge-content 轮询回发的消息:cleared(撤高亮)/ status(toast 提示)
265
539
  window.addEventListener('message', function (event) {
@@ -11,14 +11,17 @@
11
11
  *
12
12
  * 数据文件与 MCP server / hook 共用:统一在用户主目录 ~/.ui-inspector/pending.json(与仓库位置无关)。
13
13
  */
14
- import {readFileSync, writeFileSync, mkdirSync} from 'node:fs';
14
+ import {readFileSync, writeFileSync, appendFileSync, mkdirSync} from 'node:fs';
15
15
  import {dirname, resolve} from 'node:path';
16
16
  import {homedir} from 'node:os';
17
17
 
18
18
  // 共享落盘:一台机器一份,放用户主目录,不绑定任何仓库——一份代码可服务多个项目。
19
19
  const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
20
20
  const statusFile = resolve(homedir(), '.ui-inspector/status.json');
21
+ // 录制拆成两份:元信息(active/startedAt/pageUrl/endedAt)与事件流(append-only,每行一条)。
22
+ // 分开是为了避免「每条事件读改写整份 JSON」的多进程竞态把 active 盖回 true——事件只追加,不碰 active。
21
23
  const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
24
+ const eventsFile = resolve(homedir(), '.ui-inspector/recording-events.ndjson');
22
25
 
23
26
  function readStore() {
24
27
  try {
@@ -42,52 +45,44 @@ function writeStore(list) {
42
45
  writeFileSync(storeFile, `${JSON.stringify(list, null, 4)}\n`, 'utf8');
43
46
  }
44
47
 
45
- // 录制累积:跨刷新/跨页面逐条追加进 recording.json(缓冲在后端,不在页面内存)。
46
- const REC_CAP = 1000; // 每类事件上限,防爆
47
- function readRecording() {
48
+ // 录制累积:跨刷新/跨页面逐条追加。元信息与事件流分离,避免多进程读改写竞态。
49
+ function readMeta() {
48
50
  try {
49
51
  return JSON.parse(readFileSync(recordingFile, 'utf8'));
50
52
  } catch {
51
53
  return null;
52
54
  }
53
55
  }
54
- function writeRecording(rec) {
56
+ function writeMeta(meta) {
55
57
  mkdirSync(dirname(recordingFile), {recursive: true});
56
- writeFileSync(recordingFile, `${JSON.stringify(rec ?? null, null, 4)}\n`, 'utf8');
58
+ writeFileSync(recordingFile, `${JSON.stringify(meta ?? null, null, 4)}\n`, 'utf8');
57
59
  }
58
60
  function recStart(startedAt, pageUrl) {
59
- const cur = readRecording();
60
- // 同一会话(startedAt 相同)重复 start(多标签页)直接忽略,避免互相清空。
61
+ const cur = readMeta();
62
+ // 同一会话(startedAt 相同)重复 start(多标签页/弹窗+页面双触发)直接忽略,避免清空已录数据。
61
63
  if (cur && cur.active && cur.startedAt === startedAt) {
62
64
  return;
63
65
  }
64
- writeRecording({active: true, startedAt, pageUrl, clicks: [], console: [], network: []});
66
+ writeMeta({active: true, startedAt, pageUrl});
67
+ mkdirSync(dirname(eventsFile), {recursive: true});
68
+ writeFileSync(eventsFile, '', 'utf8'); // 清空事件流
65
69
  }
66
70
  function recEvent(kind, item) {
67
- const cur = readRecording();
71
+ const cur = readMeta();
68
72
  if (!cur || !cur.active) {
69
73
  return; // 未在录制,丢弃
70
74
  }
71
- const key = kind === 'click' ? 'clicks' : kind === 'console' ? 'console' : kind === 'network' ? 'network' : null;
72
- if (!key) {
73
- return;
74
- }
75
- if (!Array.isArray(cur[key])) {
76
- cur[key] = [];
77
- }
78
- if (cur[key].length < REC_CAP) {
79
- cur[key].push(item);
80
- writeRecording(cur);
81
- }
75
+ mkdirSync(dirname(eventsFile), {recursive: true});
76
+ appendFileSync(eventsFile, `${JSON.stringify({kind, item})}\n`, 'utf8'); // 追加,原子、无竞态
82
77
  }
83
78
  function recStop(endedAt) {
84
- const cur = readRecording();
79
+ const cur = readMeta();
85
80
  if (!cur) {
86
81
  return;
87
82
  }
88
83
  cur.active = false;
89
84
  cur.endedAt = endedAt || Date.now();
90
- writeRecording(cur);
85
+ writeMeta(cur); // 只有 start/stop 动 active,事件追加不碰它 → stop 后 active 不会被盖回
91
86
  }
92
87
 
93
88
  function send(obj) {
@@ -16,7 +16,9 @@ import {homedir} from 'node:os';
16
16
 
17
17
  // 共享落盘:读用户主目录下的 pending / recording(与 host / bridge / mcp 同一路径),一份代码可服务多个仓库。
18
18
  const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
19
+ // 录制:元信息(active/startedAt/pageUrl/endedAt)+ append-only 事件流(每行一条 {kind,item})。
19
20
  const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
21
+ const eventsFile = resolve(homedir(), '.ui-inspector/recording-events.ndjson');
20
22
 
21
23
  function emit(output) {
22
24
  process.stdout.write(JSON.stringify(output));
@@ -31,17 +33,50 @@ function readJson(file, fallback) {
31
33
  }
32
34
  }
33
35
 
36
+ // 读 append-only 事件流,按类型归并
37
+ function readEvents() {
38
+ const clicks = [];
39
+ const con = [];
40
+ const network = [];
41
+ let text = '';
42
+ try {
43
+ text = readFileSync(eventsFile, 'utf8');
44
+ } catch {
45
+ return {clicks, console: con, network};
46
+ }
47
+ for (const line of text.split('\n')) {
48
+ if (!line.trim()) {
49
+ continue;
50
+ }
51
+ let ev;
52
+ try {
53
+ ev = JSON.parse(line);
54
+ } catch {
55
+ continue;
56
+ }
57
+ if (!ev || !ev.item) {
58
+ continue;
59
+ }
60
+ if (ev.kind === 'click') {
61
+ clicks.push(ev.item);
62
+ } else if (ev.kind === 'console') {
63
+ con.push(ev.item);
64
+ } else if (ev.kind === 'network') {
65
+ network.push(ev.item);
66
+ }
67
+ }
68
+ return {clicks, console: con, network};
69
+ }
70
+
34
71
  const parsed = readJson(storeFile, []);
35
72
  const list = Array.isArray(parsed) ? parsed : [];
36
- const recording = readJson(recordingFile, null);
73
+ const meta = readJson(recordingFile, null);
37
74
  // 只注入**已停止**的录制(active === false)。录制进行中(跨刷新/跨页面累积)时不消费、不清空,
38
75
  // 以免中途给 Comate 发消息把整段会话吞掉。
39
- const recDone = recording && recording.active === false;
40
- const hasRec =
41
- recDone &&
42
- ((recording.clicks && recording.clicks.length) ||
43
- (recording.console && recording.console.length) ||
44
- (recording.network && recording.network.length));
76
+ const recDone = meta && meta.active === false;
77
+ const events = recDone ? readEvents() : {clicks: [], console: [], network: []};
78
+ const recording = recDone ? {...meta, ...events} : null;
79
+ const hasRec = recDone && (events.clicks.length || events.console.length || events.network.length);
45
80
 
46
81
  if (!list.length && !hasRec) {
47
82
  emit({decision: 'allow'});
@@ -113,12 +148,13 @@ sections.push(
113
148
  '请据此定位源码并按用户意见修改。页面选中态已自动取消;改动过程中可调用 MCP 工具 notify_browser 向页面推送状态(如「修改中…」「已完成」),会在浏览器弹 toast。'
114
149
  );
115
150
 
116
- // 「拿到即消费」:注入后清空 pending;录制仅在确实注入(已停止)时清空,进行中的保留。
151
+ // 「拿到即消费」:注入后清空 pending;录制仅在确实注入(已停止)时清空元信息与事件流,进行中的保留。
117
152
  try {
118
153
  mkdirSync(dirname(storeFile), {recursive: true});
119
154
  writeFileSync(storeFile, '[]\n', 'utf8');
120
155
  if (hasRec) {
121
156
  writeFileSync(recordingFile, 'null\n', 'utf8');
157
+ writeFileSync(eventsFile, '', 'utf8');
122
158
  }
123
159
  } catch {
124
160
  // 清空失败不影响注入
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comate-ui-inspector",
3
- "version": "0.1.5-alpha.3",
3
+ "version": "0.1.5-alpha.5",
4
4
  "description": "按住 Alt 点选页面元素,把「元素 + Vue 组件源码路径」交给 Comate 定位源码修改。浏览器扩展 + Native Messaging host + MCP + hook。",
5
5
  "type": "module",
6
6
  "license": "MIT",