zcode-pro 0.20.3 → 1.1.0

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.
@@ -1,7 +1,18 @@
1
- // 功能二:项目“更多”下拉菜单中新增“自定义别名”。
2
- // 识别方式:菜单内存在带 data-testid(值中含项目绝对路径)且文案为“移除/Remove”的菜单项。
3
- // 项目路径优先从 data-testid 提取;提取失败时回退为按项目名匹配 helper 返回的最近项目列表。
4
- import { rpc, getConfig, itemsOf, itemText } from '../core.js';
1
+ // 功能二:项目“更多”下拉菜单中新增“打开文件夹 / 复制路径”。
2
+ // 旧结构(3.15.x 及以前):菜单内存在带 data-testid(值中含项目绝对路径)且文案为“移除/Remove”
3
+ // 的菜单项,路径从 testid 提取;四项全注入(自定义别名/切换文件夹/打开文件夹/复制路径)。
4
+ // 新结构(3.16.1 起):菜单项带稳定 testid(virtual-project-edit/-delete/-remove-entry),
5
+ // 项目行 section[data-project-id];菜单打开时同步捕获触发行的 project-id,异步按 id 查
6
+ // helper 的 /projects-v2(官方任务索引 projects/project_roots)拿项目名与全部目录——
7
+ // 不依赖项目展开状态或任务行。插在“编辑项目”之后:
8
+ // - 打开文件夹:单目录直接打开主目录;多目录悬停展开二级菜单列各目录(自绘浮层,
9
+ // 官方 Radix 子菜单由 React 管理不便仿造)
10
+ // - 复制路径:复制主目录路径
11
+ // - 自定义别名与切换文件夹不再注入:官方“编辑项目”已支持改名与多目录增删
12
+ // (迁移存量别名见 alias-migration.js;旧结构下两项照旧注入)
13
+ import { rpc, getConfig, itemsOf, itemText, isNewProjectSidebar, t } from '../core.js';
14
+ import { showToast } from '../ui.js';
15
+ import { copyToClipboard } from './copy-path.js';
5
16
  import { appendAliasItem } from './alias.js';
6
17
  import { appendRelocateItem } from './relocate-dialog.js';
7
18
  import { appendOpenFolderItem } from './open-folder.js';
@@ -18,13 +29,25 @@ function extractPathFromTestId(testid) {
18
29
  return m ? m[1] : null;
19
30
  }
20
31
 
21
- function basenameOf(p) {
22
- return String(p || '').replace(/[\\/]+$/, '').split(/[\\/]/).pop() || '';
32
+ // 新结构:菜单打开时找到触发的项目行。下拉按钮(virtual-project-menu)的打开态
33
+ // 实测(3.16.1)只反映在 aria-expanded=true 上,data-state 不变化,两者都查;
34
+ // 右键行(virtual-project-row)按 data-state/aria-expanded 兜底。菜单关闭后状态
35
+ // 即消失,必须同步调用
36
+ function openMenuProjectRow() {
37
+ const trigger = document.querySelector(
38
+ '[data-testid="virtual-project-menu"][aria-expanded="true"],'
39
+ + ' [data-testid="virtual-project-menu"][data-state="open"],'
40
+ + ' [data-testid="virtual-project-row"][data-state="open"],'
41
+ + ' [data-testid="virtual-project-row"][aria-expanded="true"]'
42
+ );
43
+ const row = trigger ? trigger.closest('section[data-project-id]') : null;
44
+ if (!row) return null;
45
+ const host = row.getAttribute('data-project-host');
46
+ return host === null || host === 'local' ? row : null;
23
47
  }
24
48
 
25
49
  async function resolveProjectPath(removeItem) {
26
- const testid = removeItem.getAttribute('data-testid') || '';
27
- const extracted = extractPathFromTestId(testid);
50
+ const extracted = extractPathFromTestId(removeItem.getAttribute('data-testid') || '');
28
51
  if (!extracted) return null;
29
52
  // 与 helper 的最近项目列表核对(仅用于确认路径有效;任务派生的项目可能不在列表中)
30
53
  const res = await rpc('/projects');
@@ -33,26 +56,193 @@ async function resolveProjectPath(removeItem) {
33
56
  const hit = res.projects.find((p) => norm(p.path) === norm(extracted));
34
57
  if (hit) return hit;
35
58
  }
36
- return { path: extracted, name: basenameOf(extracted) };
59
+ return { path: extracted, name: String(extracted).replace(/[\\/]+$/, '').split(/[\\/]/).pop() || '' };
60
+ }
61
+
62
+ // —— 新结构注入:编辑项之后插入 ——
63
+ // 官方菜单项结构:[data-radix-collection-item](div,含 svg + 文本)。克隆保证样式一致
64
+ function cloneMenuItem(anchor, testid, label) {
65
+ const item = anchor.cloneNode(true);
66
+ item.removeAttribute('data-testid');
67
+ item.removeAttribute('data-highlighted');
68
+ item.removeAttribute('id');
69
+ item.setAttribute('data-zcodepro-item', testid);
70
+ for (const child of [...item.childNodes]) child.remove();
71
+ const origIcon = anchor.querySelector('svg');
72
+ const iconClass = origIcon ? origIcon.getAttribute('class') : 'h-3.5 w-3.5';
73
+ // 文件夹图标
74
+ const ns = 'http://www.w3.org/2000/svg';
75
+ const svg = document.createElementNS(ns, 'svg');
76
+ svg.setAttribute('viewBox', '0 0 24 24');
77
+ svg.setAttribute('fill', 'none');
78
+ svg.setAttribute('stroke', 'currentColor');
79
+ svg.setAttribute('stroke-width', '2');
80
+ svg.setAttribute('stroke-linecap', 'round');
81
+ svg.setAttribute('stroke-linejoin', 'round');
82
+ svg.setAttribute('class', iconClass);
83
+ const p = document.createElementNS(ns, 'path');
84
+ p.setAttribute('d', 'M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z');
85
+ svg.append(p);
86
+ item.append(svg, document.createTextNode(label));
87
+ return item;
88
+ }
89
+
90
+ function wireHighlight(menu, item) {
91
+ item.addEventListener('mouseenter', () => {
92
+ for (const el of menu.querySelectorAll('[role="menuitem"]')) el.removeAttribute('data-highlighted');
93
+ item.setAttribute('data-highlighted', '');
94
+ });
95
+ item.addEventListener('mouseleave', () => item.removeAttribute('data-highlighted'));
96
+ }
97
+
98
+ // 多目录的二级子菜单:悬停“打开文件夹”即在右侧展开,一级菜单保持可见;
99
+ // 点击该项同样展开(触屏可达)。移出项与子菜单 300ms 后收起;一级菜单被
100
+ // 关闭(Esc/外部点击)时子菜单一并收起
101
+ function attachFolderHover(menu, item, roots) {
102
+ const L = t();
103
+ let sub = null;
104
+ let subHovered = false;
105
+ let hideTimer = 0;
106
+ let aliveTimer = 0;
107
+
108
+ const removeSub = () => {
109
+ clearTimeout(hideTimer);
110
+ clearInterval(aliveTimer);
111
+ sub?.remove();
112
+ sub = null;
113
+ subHovered = false;
114
+ };
115
+ const position = () => {
116
+ const rect = item.getBoundingClientRect();
117
+ sub.style.left = rect.right + 'px';
118
+ sub.style.top = rect.top + 'px';
119
+ const r = sub.getBoundingClientRect();
120
+ if (r.right > window.innerWidth - 8) {
121
+ sub.style.left = '';
122
+ sub.style.right = (window.innerWidth - rect.left) + 'px';
123
+ }
124
+ };
125
+ const ensureSub = () => {
126
+ if (sub) { position(); return; }
127
+ sub = document.createElement('div');
128
+ sub.setAttribute('role', 'menu');
129
+ sub.setAttribute('data-zcodepro-item', 'open-folder-sub');
130
+ sub.className = 'zcodepro-card flex flex-col gap-0.5 rounded-lg border border-border bg-menu p-1 text-ui-sm shadow-lg';
131
+ sub.style.cssText = 'position:fixed;z-index:60;min-width:14rem;';
132
+ for (const root of roots) {
133
+ const base = String(root.path).replace(/[\\/]+$/, '').split(/[\\/]/).pop() || root.path;
134
+ const row = document.createElement('div');
135
+ row.setAttribute('role', 'menuitem');
136
+ row.className = 'flex cursor-pointer items-center gap-2 truncate rounded-md px-2 py-1.5 text-foreground transition-colors hover:bg-surface-hover';
137
+ row.title = root.path;
138
+ row.append(document.createTextNode(base + (root.primary ? ` · ${L.openFolderPrimary}` : '')));
139
+ row.addEventListener('click', () => {
140
+ // 关一级菜单(Escape 走 Radix)再打开目录
141
+ try { menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true, cancelable: true })); } catch { /* ignore */ }
142
+ removeSub();
143
+ void rpc('/open-folder', { method: 'POST', body: { path: root.path } });
144
+ });
145
+ sub.append(row);
146
+ }
147
+ sub.addEventListener('mouseenter', () => { subHovered = true; clearTimeout(hideTimer); });
148
+ sub.addEventListener('mouseleave', () => { subHovered = false; scheduleHide(); });
149
+ document.body.append(sub);
150
+ position();
151
+ // 子菜单存活期间周期确认一级还在(Radix 被关后不残留)
152
+ aliveTimer = setInterval(() => { if (!menu.isConnected) removeSub(); }, 400);
153
+ };
154
+ const scheduleHide = () => {
155
+ clearTimeout(hideTimer);
156
+ hideTimer = setTimeout(() => { if (!subHovered) removeSub(); }, 300);
157
+ };
158
+
159
+ item.addEventListener('mouseenter', () => { clearTimeout(hideTimer); ensureSub(); });
160
+ item.addEventListener('mouseleave', scheduleHide);
161
+ // 点击同样展开且不关一级(项是注入节点,不触发 Radix 的选中即关闭)
162
+ item.addEventListener('click', () => { clearTimeout(hideTimer); ensureSub(); });
163
+ }
164
+
165
+ async function injectNewMenuItems(content, menuRow) {
166
+ const L = t();
167
+ const projectId = menuRow.getAttribute('data-project-id');
168
+ const editItem = content.querySelector('[data-testid="virtual-project-edit"]');
169
+ if (!editItem || !projectId) return;
170
+ if (content.querySelector('[data-zcodepro-item]')) return;
171
+ const config = await getConfig();
172
+ // 同一组开关(旧结构共用 features.projectMenu)
173
+ if (config.features && config.features.projectMenu === false) return;
174
+ const res = await rpc('/projects-v2');
175
+ if (!res.ok || !res.available) return;
176
+ const proj = (res.projects || []).find((p) => p.id === projectId);
177
+ if (!proj) return;
178
+ const roots = (proj.roots || []).slice().sort((a, b) => (b.primary ? 1 : 0) - (a.primary ? 1 : 0));
179
+ if (!roots.length) return;
180
+ const primary = roots.find((r) => r.primary) || roots[0];
181
+
182
+ // 打开文件夹:单目录点击直接打开;多目录悬停/点击展开二级子菜单(带 › 指示)
183
+ const openItem = cloneMenuItem(editItem, 'open-folder', L.openFolderItem);
184
+ wireHighlight(content, openItem);
185
+ if (roots.length === 1) {
186
+ openItem.addEventListener('click', () => {
187
+ try { content.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true, cancelable: true })); } catch { /* ignore */ }
188
+ void rpc('/open-folder', { method: 'POST', body: { path: primary.path } });
189
+ });
190
+ } else {
191
+ // 行尾 › 子级指示(与官方菜单 chevron 同款样式)
192
+ const ns = 'http://www.w3.org/2000/svg';
193
+ const chev = document.createElementNS(ns, 'svg');
194
+ chev.setAttribute('viewBox', '0 0 24 24');
195
+ chev.setAttribute('fill', 'none');
196
+ chev.setAttribute('stroke', 'currentColor');
197
+ chev.setAttribute('stroke-width', '2');
198
+ chev.setAttribute('stroke-linecap', 'round');
199
+ chev.setAttribute('stroke-linejoin', 'round');
200
+ chev.setAttribute('class', 'ml-auto size-3.5 shrink-0 text-foreground-subtle');
201
+ const chevPath = document.createElementNS(ns, 'path');
202
+ chevPath.setAttribute('d', 'm9 18 6-6-6-6');
203
+ chev.append(chevPath);
204
+ openItem.append(chev);
205
+ attachFolderHover(content, openItem, roots);
206
+ }
207
+ editItem.after(openItem);
208
+
209
+ // 复制路径:主目录
210
+ const copyItem = cloneMenuItem(editItem, 'copy-path', L.copyPathItem);
211
+ wireHighlight(content, copyItem);
212
+ copyItem.addEventListener('click', () => {
213
+ try { content.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true, cancelable: true })); } catch { /* ignore */ }
214
+ void copyToClipboard(primary.path).then((ok) => showToast(ok ? L.pathCopied : L.pathCopyFailed, ok ? undefined : 'error'));
215
+ });
216
+ openItem.after(copyItem);
37
217
  }
38
218
 
39
219
  export function handleProjectMenu(content) {
40
- if (content.querySelector('[data-zcodepro-item="alias"]')) return;
220
+ if (content.querySelector('[data-zcodepro-item]')) return;
41
221
  const items = itemsOf(content);
42
- const removeItem = items.find(
43
- (el) => el.hasAttribute('data-testid') && REMOVE_TEXTS.some((x) => itemText(el).startsWith(x))
44
- );
45
- if (!removeItem) return;
222
+ const newUi = isNewProjectSidebar();
223
+ // 菜单关闭后 data-state 即消失:项目行必须在任何 await 之前捕获
224
+ const menuRow = newUi ? openMenuProjectRow() : null;
225
+ const anchor = newUi
226
+ ? items.find((el) => el.getAttribute('data-testid') === 'virtual-project-edit')
227
+ : items.find(
228
+ (el) => el.hasAttribute('data-testid') && REMOVE_TEXTS.some((x) => itemText(el).startsWith(x))
229
+ );
230
+ if (!anchor) return;
231
+ if (newUi) {
232
+ if (!menuRow) return;
233
+ void injectNewMenuItems(content, menuRow);
234
+ return;
235
+ }
46
236
  void (async () => {
47
237
  const config = await getConfig();
48
238
  // 一组开关控制整组菜单项(别名 / 切换文件夹 / 打开文件夹 / 复制路径)
49
239
  if (config.features && config.features.projectMenu === false) return;
50
- const project = await resolveProjectPath(removeItem);
240
+ const project = await resolveProjectPath(anchor);
51
241
  if (!project) return;
52
242
  // 菜单顺序:自定义别名 → 切换文件夹 → 打开文件夹 → 复制路径 → 移除(均插在“移除”之前)
53
- appendAliasItem(content, removeItem, project);
54
- appendRelocateItem(content, removeItem, project);
55
- appendOpenFolderItem(content, removeItem, project);
56
- appendCopyPathItem(content, removeItem, project);
243
+ appendAliasItem(content, anchor, project);
244
+ appendRelocateItem(content, anchor, project);
245
+ appendOpenFolderItem(content, anchor, project);
246
+ appendCopyPathItem(content, anchor, project);
57
247
  })();
58
248
  }
@@ -0,0 +1,121 @@
1
+ // 插件界面沙箱回收:宿主为每个插件交互卡片(识图卡片、压缩记录、评审面板等)
2
+ // 保留一个沙箱,全局上限 64 个(主进程注册表与窗口页面管理器各一道闸),已卸下
3
+ // 的名额默认要等 6 小时 TTL 才自动回收——多卡片会话很快把名额耗尽,之后所有新
4
+ // 卡片报“插件界面数量已达上限”。本功能平时不动作,只在沙箱数量达到上限
5
+ //(sandboxReclaimBudget,默认 60,8–64;留少量余量,避免新卡片抢名额时恰好撞上
6
+ // 64 硬上限)时,调用官方桥 window.zcodePluginSandbox.disposeSandbox 释放最早
7
+ // 创建的沙箱,把数量压回上限以下:被释放的卡片位置退回占位(可能显示已停止
8
+ // 提示),重新打开所在会话即恢复,会话文本内容不受影响。
9
+ //
10
+ // 驱逐顺序:最早创建的优先,但可见的(正在查看的卡片)排最后——先在不可见
11
+ // 沙箱里挑最早的;全部可见时才动最早创建的可见沙箱。
12
+ //
13
+ // 观察手段均为宿主稳定标识,不依赖压缩后的内部变量名:
14
+ // 1) window 消息 zcode:plugin-sandbox-ports:沙箱端口建立时广播 {sandboxId, initId};
15
+ // 2) webview[data-zcode-retained-sandbox]:src 含 /instance/<sandboxId>/;
16
+ // 3) 可见性:webview 所在页根 div[data-sandbox-page-root] 的 opacity——展示中的
17
+ // 页根 opacity:1 且按卡片占位几何摆放;卡片切走/滚下后宿主把页根收进停靠区
18
+ // ([data-sandbox-page-parking] 下,opacity:0.001 + inert 或 display:none)。
19
+ // 页根是固定定位、DOM 挂载点与视觉位置解耦,不能按父容器判断可见性。
20
+ //
21
+ // 窗口最小化/后台不改变布局尺寸,可见性判定不受影响(最小化期间不驱逐)。
22
+ // 开关:features.sandboxReclaim(默认开)。
23
+ import { getConfig } from '../core.js';
24
+
25
+ const CHECK_MS = 30_000; // 巡检周期
26
+ const MIN_BUDGET = 8;
27
+ const MAX_BUDGET = 64;
28
+ const RETRY_COOLDOWN_MS = 10 * 60_000; // 同一沙箱释放失败后的重试冷却
29
+
30
+ const seen = new Map(); // sandboxId → { initId, firstSeen }
31
+ const cooldown = new Map(); // sandboxId → 释放失败冷却截止时刻
32
+ let disabledLogged = false;
33
+
34
+ function onPortsMessage(e) {
35
+ const d = e.data;
36
+ if (!d || d.type !== 'zcode:plugin-sandbox-ports' || typeof d.sandboxId !== 'string') return;
37
+ // 端口广播≈沙箱创建时刻;页面刷新后宿主按幂等键复用同一沙箱并再次广播,
38
+ // 创建时刻随之刷新(视作新名额重新排队)
39
+ seen.set(d.sandboxId, { initId: Number(d.initId) || 0, firstSeen: Date.now() });
40
+ }
41
+
42
+ function extractSandboxId(wv) {
43
+ const m = /instance\/(sb-[0-9a-f]+)\//.exec(wv.getAttribute('src') || '');
44
+ return m ? m[1] : null;
45
+ }
46
+
47
+ // 展示中的页根 opacity:1 且按卡片占位几何摆放;停靠保活的页根 opacity:0.001 +
48
+ // inert(或 display:none)。宿主用固定定位按占位几何摆放页根,DOM 挂载点不代表
49
+ // 视觉位置(可见卡片的页根也可能挂在停靠容器下),因此按 opacity 与布局判定
50
+ function isVisible(wv) {
51
+ const root = wv.closest('[data-sandbox-page-root]');
52
+ if (!root) return wv.getClientRects().length > 0;
53
+ if (!root.getClientRects().length) return false;
54
+ return Number(getComputedStyle(root).opacity) > 0.5;
55
+ }
56
+
57
+ async function tick() {
58
+ const cfg = await getConfig();
59
+ if ((cfg.features || {}).sandboxReclaim === false) {
60
+ seen.clear();
61
+ return;
62
+ }
63
+ // 沙箱桥只在宿主主窗口存在(guest 沙箱页与旧版宿主没有):不在时静默跳过
64
+ const bridge = window.zcodePluginSandbox;
65
+ if (!bridge || typeof bridge.disposeSandbox !== 'function') {
66
+ if (!disabledLogged) {
67
+ disabledLogged = true;
68
+ console.info('[zcodepro] sandbox-reclaim: 当前窗口无 zcodePluginSandbox 桥,沙箱回收跳过');
69
+ }
70
+ return;
71
+ }
72
+ let budget = Number(cfg.sandboxReclaimBudget);
73
+ if (!Number.isFinite(budget)) budget = 60;
74
+ budget = Math.min(MAX_BUDGET, Math.max(MIN_BUDGET, budget));
75
+
76
+ const now = Date.now();
77
+ const live = new Map(); // sandboxId → 是否可见
78
+ for (const wv of document.querySelectorAll('webview[data-zcode-retained-sandbox]')) {
79
+ const id = extractSandboxId(wv);
80
+ if (id) live.set(id, isVisible(wv));
81
+ }
82
+ // 端口广播登记过但 DOM 已无对应 webview 的条目(guest 已销毁):清掉,防 Map 无限增长
83
+ for (const id of [...seen.keys()]) {
84
+ if (!live.has(id)) seen.delete(id);
85
+ }
86
+ // 重注入后端口广播没赶上(Map 为空)的兜底:首次观察到时按现在起算创建时刻
87
+ for (const id of live.keys()) {
88
+ if (!seen.has(id)) seen.set(id, { initId: undefined, firstSeen: now });
89
+ }
90
+
91
+ while (live.size > budget) {
92
+ // 驱逐顺序:不可见的按创建时间最早的 → 全可见时最早的可见沙箱
93
+ const candidates = [...live.entries()]
94
+ .filter(([id]) => (cooldown.get(id) || 0) <= now)
95
+ .sort((a, b) => (a[1] - b[1]) || ((seen.get(a[0]) || {}).firstSeen || 0) - ((seen.get(b[0]) || {}).firstSeen || 0));
96
+ if (!candidates.length) break;
97
+ const [id] = candidates[0];
98
+ const entry = seen.get(id);
99
+ try {
100
+ await bridge.disposeSandbox(id, entry && entry.initId);
101
+ console.info('[zcodepro] sandbox-reclaim: 沙箱满 ' + live.size + ' 个,已驱逐最早的 ' + id);
102
+ } catch (err) {
103
+ console.warn('[zcodepro] sandbox-reclaim: 释放失败 ' + id, err);
104
+ }
105
+ seen.delete(id);
106
+ live.delete(id);
107
+ cooldown.set(id, now + RETRY_COOLDOWN_MS);
108
+ }
109
+ if (cooldown.size > 256) {
110
+ for (const [k, v] of cooldown) {
111
+ if (v <= now) cooldown.delete(k);
112
+ }
113
+ }
114
+ }
115
+
116
+ export function startSandboxReclaim() {
117
+ window.addEventListener('message', onPortsMessage);
118
+ const loop = () => { tick().catch(() => { /* 单次巡检失败不影响后续 */ }); };
119
+ loop();
120
+ setInterval(loop, CHECK_MS);
121
+ }