zcode-pro 0.16.0__py3-none-any.whl

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 (41) hide show
  1. zcode_pro/__init__.py +3 -0
  2. zcode_pro/__main__.py +4 -0
  3. zcode_pro/app/cli.mjs +9 -0
  4. zcode_pro/app/dist/inject.js +5821 -0
  5. zcode_pro/app/src/host/cdp.mjs +126 -0
  6. zcode_pro/app/src/host/helper.mjs +2134 -0
  7. zcode_pro/app/src/host/main.mjs +262 -0
  8. zcode_pro/app/src/host/paths.mjs +73 -0
  9. zcode_pro/app/src/host/pickFolder.mjs +152 -0
  10. zcode_pro/app/src/host/settings.mjs +61 -0
  11. zcode_pro/app/src/host/taskIndex.mjs +255 -0
  12. zcode_pro/app/src/host/taskOrder.mjs +117 -0
  13. zcode_pro/app/src/host/webdav.mjs +105 -0
  14. zcode_pro/app/src/inject/core.js +751 -0
  15. zcode_pro/app/src/inject/features/alias.js +211 -0
  16. zcode_pro/app/src/inject/features/copy-path.js +78 -0
  17. zcode_pro/app/src/inject/features/file-menu.js +147 -0
  18. zcode_pro/app/src/inject/features/idle-reclaim.js +179 -0
  19. zcode_pro/app/src/inject/features/image-menu.js +215 -0
  20. zcode_pro/app/src/inject/features/open-folder.js +43 -0
  21. zcode_pro/app/src/inject/features/pinned-collapse.js +132 -0
  22. zcode_pro/app/src/inject/features/pinned-expand.js +142 -0
  23. zcode_pro/app/src/inject/features/plugin-updates.js +28 -0
  24. zcode_pro/app/src/inject/features/project-menu.js +58 -0
  25. zcode_pro/app/src/inject/features/relocate-dialog.js +156 -0
  26. zcode_pro/app/src/inject/features/session-switch.js +244 -0
  27. zcode_pro/app/src/inject/features/settings-dialog.js +1503 -0
  28. zcode_pro/app/src/inject/features/settings-entry.js +21 -0
  29. zcode_pro/app/src/inject/features/styles.js +311 -0
  30. zcode_pro/app/src/inject/features/task-order.js +78 -0
  31. zcode_pro/app/src/inject/features/title-lock.js +242 -0
  32. zcode_pro/app/src/inject/features/toolbar-icons.js +243 -0
  33. zcode_pro/app/src/inject/features/ws-running.js +472 -0
  34. zcode_pro/app/src/inject/index.js +55 -0
  35. zcode_pro/app/src/inject/ui.js +734 -0
  36. zcode_pro/cli.py +71 -0
  37. zcode_pro-0.16.0.dist-info/METADATA +45 -0
  38. zcode_pro-0.16.0.dist-info/RECORD +41 -0
  39. zcode_pro-0.16.0.dist-info/WHEEL +4 -0
  40. zcode_pro-0.16.0.dist-info/entry_points.txt +2 -0
  41. zcode_pro-0.16.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,211 @@
1
+ // 功能三:项目自定义别名——只改侧边栏渲染文本,不改动磁盘目录与任何 ZCode 数据。
2
+ // 存储:helper 的 zcodepro.json aliases 表(路径规范化无尾分隔符 → 名称)。
3
+ // 渲染:侧边栏项目行有唯一文本叶子 div.min-w-0.truncate(逆向确认),
4
+ // 按行 testid(workspace-item-<绝对路径>)查表替换;React 重渲染后由 MutationObserver 重新套用。
5
+ import { h, t, rpc, getConfig, clearConfigCache, errText } from '../core.js';
6
+ import { openDialog, dialogFooter, btnPrimary, btnSecondary, textInput, showToast, ensureStyle } from '../ui.js';
7
+
8
+ const norm = (p) => String(p || '').replace(/[\\/]+$/, '');
9
+ const basenameOf = (p) => norm(p).split(/[\\/]/).pop() || '';
10
+
11
+ function extractPathFromTestId(testid) {
12
+ let m = testid.match(/[-:=,|](\/.+)$/);
13
+ if (m) return m[1];
14
+ m = testid.match(/[-:=,|]([A-Za-z]:\\.*)$/);
15
+ return m ? m[1] : null;
16
+ }
17
+
18
+ // —— 渲染替换 ——
19
+
20
+ let aliases = {}; // 规范化路径 → 别名(与 helper 配置同步)
21
+ let enabled = true; // 功能总开关(关闭时还原真实名)
22
+ let observer = null;
23
+
24
+ // 只关心项目行的变动(行新增、行内重渲染、行内文本替换);聊天区流式输出等
25
+ // 全页高频变动在此滤掉,不再触发别名重扫(否则长会话流式期间 60ms 一轮全页查询)。
26
+ const rowSel = '[data-testid^="workspace-item-"]';
27
+ const inProjectRow = (node) => node instanceof Element && !!node.closest && !!node.closest(rowSel);
28
+ function projectRowMutations(muts) {
29
+ for (const m of muts) {
30
+ if (m.type === 'characterData') {
31
+ if (m.target.parentElement && inProjectRow(m.target.parentElement)) return true;
32
+ continue;
33
+ }
34
+ if (inProjectRow(m.target)) return true;
35
+ for (const n of m.addedNodes) {
36
+ if (n instanceof Element && (n.matches(rowSel) || !!n.querySelector(rowSel))) return true;
37
+ }
38
+ }
39
+ return false;
40
+ }
41
+
42
+ export async function startAliasWatcher() {
43
+ await syncFromConfig();
44
+ if (observer) return;
45
+ observer = new MutationObserver((muts) => { if (projectRowMutations(muts)) scheduleApply(); });
46
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
47
+ applyAliass();
48
+ }
49
+
50
+ async function syncFromConfig() {
51
+ const config = await getConfig();
52
+ enabled = !(config.features && config.features.projectMenu === false);
53
+ aliases = enabled ? (config.aliases || {}) : {};
54
+ }
55
+
56
+ let applyTimer = 0;
57
+ function scheduleApply() {
58
+ clearTimeout(applyTimer);
59
+ applyTimer = setTimeout(applyAliass, 60);
60
+ }
61
+
62
+ export function applyAliass() {
63
+ const rows = document.querySelectorAll('[data-testid^="workspace-item-"]');
64
+ for (const row of rows) {
65
+ const path = norm(extractPathFromTestId(row.getAttribute('data-testid') || ''));
66
+ if (!path) continue;
67
+ // 行内唯一文本叶子即项目名元素
68
+ const nameEl = [...row.querySelectorAll('*')].find((e) => e.children.length === 0 && e.textContent.trim());
69
+ if (!nameEl) continue;
70
+ const real = nameEl.textContent.trim();
71
+ const want = aliases[path];
72
+ if (want) {
73
+ // 记录真实名,供清除别名时恢复(React 不重渲染也能还原)
74
+ if (!row.dataset.zcodeproRealName) row.dataset.zcodeproRealName = real;
75
+ if (real !== want) nameEl.textContent = want;
76
+ } else if (row.dataset.zcodeproRealName) {
77
+ nameEl.textContent = row.dataset.zcodeproRealName;
78
+ delete row.dataset.zcodeproRealName;
79
+ }
80
+ }
81
+ }
82
+
83
+ export async function refreshAliases() {
84
+ await syncFromConfig();
85
+ applyAliass();
86
+ }
87
+
88
+ // —— “更多”菜单入口 ——
89
+
90
+ export function appendAliasItem(menu, anchorItem, project) {
91
+ if (menu.querySelector('[data-zcodepro-item="alias"]')) return;
92
+ const L = t();
93
+ const item = anchorItem.cloneNode(true);
94
+ item.removeAttribute('data-testid');
95
+ item.removeAttribute('data-highlighted');
96
+ item.setAttribute('data-zcodepro-item', 'alias');
97
+ for (const child of [...item.childNodes]) child.remove();
98
+ const origIcon = anchorItem.querySelector('svg');
99
+ const iconClass = origIcon ? origIcon.getAttribute('class') : 'h-3.5 w-3.5';
100
+ item.append(hTagIcon(iconClass), document.createTextNode(L.aliasItem));
101
+ item.addEventListener('mouseenter', () => {
102
+ for (const el of menu.querySelectorAll('[role="menuitem"]')) el.removeAttribute('data-highlighted');
103
+ item.setAttribute('data-highlighted', '');
104
+ });
105
+ item.addEventListener('mouseleave', () => item.removeAttribute('data-highlighted'));
106
+ item.addEventListener('click', () => {
107
+ try { menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true, cancelable: true })); } catch { /* ignore */ }
108
+ setTimeout(() => openAliasDialog(project), 80);
109
+ });
110
+ anchorItem.before(item);
111
+ }
112
+
113
+ function hTagIcon(cls) {
114
+ const ns = 'http://www.w3.org/2000/svg';
115
+ const svg = document.createElementNS(ns, 'svg');
116
+ svg.setAttribute('viewBox', '0 0 24 24');
117
+ svg.setAttribute('fill', 'none');
118
+ svg.setAttribute('stroke', 'currentColor');
119
+ svg.setAttribute('stroke-width', '2');
120
+ svg.setAttribute('stroke-linecap', 'round');
121
+ svg.setAttribute('stroke-linejoin', 'round');
122
+ svg.setAttribute('class', cls);
123
+ const p = document.createElementNS(ns, 'path');
124
+ // 标签(tag)图标
125
+ p.setAttribute('d', 'M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z');
126
+ svg.append(p);
127
+ const c = document.createElementNS(ns, 'circle');
128
+ c.setAttribute('cx', '7.5');
129
+ c.setAttribute('cy', '7.5');
130
+ c.setAttribute('r', '.5');
131
+ c.setAttribute('fill', 'currentColor');
132
+ svg.append(c);
133
+ return svg;
134
+ }
135
+
136
+ // —— 弹窗 ——
137
+
138
+ export function openAliasDialog(project) {
139
+ ensureStyle();
140
+ const L = t();
141
+ const current = aliases[norm(project.path)] || '';
142
+ const realName = project.name || basenameOf(project.path);
143
+ let submitting = false;
144
+
145
+ openDialog({
146
+ title: L.aliasTitle,
147
+ description: L.aliasDesc,
148
+ width: 'sm:max-w-md',
149
+ onMount: ({ body, close }) => {
150
+ const input = textInput({
151
+ value: current || realName,
152
+ placeholder: L.aliasPlaceholder,
153
+ onEnter: () => submit(),
154
+ });
155
+ const submitBtn = btnPrimary(L.aliasConfirm, () => submit(), 'min-w-24');
156
+ const submit = async () => {
157
+ if (submitting) return;
158
+ const name = input.value.trim();
159
+ if (name && name === current) { close(); return; }
160
+ submitting = true;
161
+ submitBtn.setAttribute('disabled', 'true');
162
+ const res = await rpc('/project/alias', {
163
+ method: 'POST',
164
+ body: { path: project.path, alias: name === realName ? '' : name },
165
+ });
166
+ submitting = false;
167
+ submitBtn.removeAttribute('disabled');
168
+ if (res.ok) {
169
+ close();
170
+ clearConfigCache();
171
+ await refreshAliases();
172
+ showToast(name ? L.aliasSaved : L.aliasCleared);
173
+ return;
174
+ }
175
+ showToast((L.failed) + ': ' + (errText(res) || L.retryHint), 'error');
176
+ };
177
+
178
+ body.append(
179
+ h('div', { class: 'space-y-3' },
180
+ h('div', {},
181
+ h('div', { class: 'mb-1.5 text-ui-sm font-medium text-foreground' }, L.aliasLabel),
182
+ input),
183
+ h('div', {},
184
+ h('div', { class: 'mb-1 text-ui-xs text-foreground-subtle' }, L.pathLabel),
185
+ h('div', { class: 'break-all rounded-lg border border-border bg-surface-hover/50 px-3 py-1.5 font-mono text-ui-xs text-foreground-subtle' }, project.path)),
186
+ h('p', { class: 'text-ui-xs/relaxed text-foreground-subtle' }, L.aliasHint)
187
+ ),
188
+ (() => {
189
+ const footer = document.createElement('div');
190
+ footer.className = 'mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end';
191
+ if (current) {
192
+ footer.append(btnSecondary(L.aliasClear, async () => {
193
+ if (submitting) return;
194
+ submitting = true;
195
+ const res = await rpc('/project/alias', { method: 'POST', body: { path: project.path, alias: '' } });
196
+ submitting = false;
197
+ if (res.ok) {
198
+ close();
199
+ clearConfigCache();
200
+ await refreshAliases();
201
+ showToast(L.aliasCleared);
202
+ } else showToast((L.failed) + ': ' + (errText(res) || L.retryHint), 'error');
203
+ }));
204
+ }
205
+ footer.append(btnSecondary(L.cancel, () => close()), submitBtn);
206
+ return footer;
207
+ })()
208
+ );
209
+ },
210
+ });
211
+ }
@@ -0,0 +1,78 @@
1
+ // “复制路径”:项目“更多”菜单中把项目绝对路径复制到剪贴板。
2
+ // navigator.clipboard 在渲染进程有焦点时可用;失败时回退 execCommand 写入隐藏输入框。
3
+ import { h, t, closeRadixMenu } from '../core.js';
4
+ import { showToast } from '../ui.js';
5
+
6
+ export async function appendCopyPathItem(menu, anchorItem, project) {
7
+ if (menu.querySelector('[data-zcodepro-item="copy-path"]')) return;
8
+ const L = t();
9
+ const item = anchorItem.cloneNode(true);
10
+ item.removeAttribute('data-testid');
11
+ item.removeAttribute('data-highlighted');
12
+ item.setAttribute('data-zcodepro-item', 'copy-path');
13
+ for (const child of [...item.childNodes]) child.remove();
14
+ const origIcon = anchorItem.querySelector('svg');
15
+ const iconClass = origIcon ? origIcon.getAttribute('class') : 'h-3.5 w-3.5';
16
+ item.append(hCopyIcon(iconClass), document.createTextNode(L.copyPathItem));
17
+ item.addEventListener('mouseenter', () => {
18
+ for (const el of menu.querySelectorAll('[role="menuitem"]')) el.removeAttribute('data-highlighted');
19
+ item.setAttribute('data-highlighted', '');
20
+ });
21
+ item.addEventListener('mouseleave', () => item.removeAttribute('data-highlighted'));
22
+ item.addEventListener('click', () => {
23
+ closeRadixMenu(menu);
24
+ void copyText(project.path);
25
+ });
26
+ anchorItem.before(item);
27
+ }
28
+
29
+ async function copyText(text) {
30
+ const L = t();
31
+ if (await copyToClipboard(text)) showToast(L.pathCopied);
32
+ else showToast(L.pathCopyFailed, 'error');
33
+ }
34
+
35
+ // 通用文本复制:成功返回 true,调用方自己决定提示文案
36
+ export async function copyToClipboard(text) {
37
+ try {
38
+ await navigator.clipboard.writeText(text);
39
+ return true;
40
+ } catch {
41
+ return copyViaHiddenInput(text);
42
+ }
43
+ }
44
+
45
+ // 剪贴板 API 不可用时的回退:临时输入框 + execCommand('copy')
46
+ function copyViaHiddenInput(text) {
47
+ try {
48
+ const input = h('input', { type: 'text', style: 'position:fixed;left:-9999px;top:0;opacity:0' });
49
+ input.value = text;
50
+ document.body.append(input);
51
+ input.select();
52
+ const ok = document.execCommand('copy');
53
+ input.remove();
54
+ return ok;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ export function hCopyIcon(cls) {
61
+ const ns = 'http://www.w3.org/2000/svg';
62
+ const svg = document.createElementNS(ns, 'svg');
63
+ svg.setAttribute('viewBox', '0 0 24 24');
64
+ svg.setAttribute('fill', 'none');
65
+ svg.setAttribute('stroke', 'currentColor');
66
+ svg.setAttribute('stroke-width', '2');
67
+ svg.setAttribute('stroke-linecap', 'round');
68
+ svg.setAttribute('stroke-linejoin', 'round');
69
+ svg.setAttribute('class', cls);
70
+ // lucide copy
71
+ for (const d of ['M8 8h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2Z',
72
+ 'M4 16c-1.1 0-2-.9-2-2V4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2']) {
73
+ const p = document.createElementNS(ns, 'path');
74
+ p.setAttribute('d', d);
75
+ svg.append(p);
76
+ }
77
+ return svg;
78
+ }
@@ -0,0 +1,147 @@
1
+ // 功能五:文件菜单增强——文件操作弹出菜单(含“复制绝对路径”项的菜单,右键文件链接 chip
2
+ // 与预览卡片“打开 ▾”下拉均有):
3
+ // - “打开”分组内、可用编辑器列表(无可用时为“未找到可用 App”禁用项)上方新增“默认应用打开”
4
+ // (helper /open-path 用系统默认应用打开;旧版曾用宿主桥 window.zcode.openExternalFile,新版已移除该桥);
5
+ // - “复制绝对路径”上方新增“打开所在目录”(helper /reveal-path 在文件管理器中定位文件;
6
+ // 不用宿主 openInFileManager——其在 Linux/Windows 的实现只是 shell.openPath,即用默认应用打开)。
7
+ // 路径获取(按序尝试):触发器 title(文件 chip 右键场景,message.tsx 写入绝对路径)→
8
+ // 触发器/菜单的 React fiber props(OpenSplitButton 场景 target/fileLink/row.path)。
9
+ import { t, closeRadixMenu, itemsOf, itemText, rpc } from '../core.js';
10
+ import { getConfig } from '../core.js';
11
+
12
+ const COPY_ABS_TEXTS = ['复制绝对路径', 'Copy absolute path'];
13
+ const NO_APPS_TEXTS = ['未找到可用 App', 'No apps found'];
14
+
15
+ // lucide 图标:app-window(默认应用打开)/ folder-open(打开所在目录)
16
+ const ICON_OPEN_DEFAULT = [
17
+ { rect: { x: '2', y: '4', width: '20', height: '16', rx: '2' } },
18
+ { d: 'M10 4v4' },
19
+ { d: 'M2 8h20' },
20
+ { d: 'M6 4v4' },
21
+ ];
22
+ const ICON_REVEAL = [
23
+ { d: 'm6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h3.93a2 2 0 0 1 1.66.9l.82 1.22a2 2 0 0 0 1.66.9H18a2 2 0 0 1 2 2v2' },
24
+ ];
25
+
26
+ function buildIcon(cls, shapes) {
27
+ const ns = 'http://www.w3.org/2000/svg';
28
+ const svg = document.createElementNS(ns, 'svg');
29
+ svg.setAttribute('viewBox', '0 0 24 24');
30
+ svg.setAttribute('fill', 'none');
31
+ svg.setAttribute('stroke', 'currentColor');
32
+ svg.setAttribute('stroke-width', '2');
33
+ svg.setAttribute('stroke-linecap', 'round');
34
+ svg.setAttribute('stroke-linejoin', 'round');
35
+ if (cls) svg.setAttribute('class', cls);
36
+ for (const s of shapes) {
37
+ if (s.rect) {
38
+ const r = document.createElementNS(ns, 'rect');
39
+ for (const [k, v] of Object.entries(s.rect)) r.setAttribute(k, v);
40
+ svg.append(r);
41
+ } else {
42
+ const p = document.createElementNS(ns, 'path');
43
+ p.setAttribute('d', s.d);
44
+ svg.append(p);
45
+ }
46
+ }
47
+ return svg;
48
+ }
49
+
50
+ // 触发器 title 里应是绝对路径(Unix 或 Windows 盘符),防误取其他 title
51
+ function looksLikePath(v) {
52
+ return typeof v === 'string' && (/^[\\/]/.test(v) || /^[A-Za-z]:[\\/]/.test(v));
53
+ }
54
+
55
+ // 从 React fiber 的祖先链上取文件路径(OpenSplitButton 等场景路径只在 props 里)
56
+ function pathFromReactFiber(el) {
57
+ try {
58
+ const key = Object.keys(el).find((k) => k.startsWith('__reactFiber$'));
59
+ let fiber = key ? el[key] : null;
60
+ for (let i = 0; fiber && i < 40; i++, fiber = fiber.return) {
61
+ const props = fiber.memoizedProps;
62
+ if (!props || typeof props !== 'object') continue;
63
+ for (const k of ['target', 'fileLink', 'row']) {
64
+ const v = props[k];
65
+ if (v && looksLikePath(v.path)) return v.path;
66
+ }
67
+ if (looksLikePath(props.path)) return props.path;
68
+ }
69
+ } catch { /* ignore */ }
70
+ return null;
71
+ }
72
+
73
+ function resolveTargetPath(content) {
74
+ const id = content.id;
75
+ if (id) {
76
+ try {
77
+ const trigger = document.querySelector(`[aria-controls="${CSS.escape(id)}"]`);
78
+ const p = trigger?.getAttribute('title');
79
+ if (looksLikePath(p)) return p;
80
+ const fromFiber = trigger && pathFromReactFiber(trigger);
81
+ if (fromFiber) return fromFiber;
82
+ } catch { /* ignore */ }
83
+ }
84
+ // 兜底:当前打开状态的右键触发器(同一时刻通常只有一个菜单)
85
+ const open = document.querySelector('[data-slot="context-menu-trigger"][data-state="open"]');
86
+ const p2 = open?.getAttribute('title');
87
+ if (looksLikePath(p2)) return p2;
88
+ return pathFromReactFiber(content);
89
+ }
90
+
91
+ function appendMenuItem(content, template, anchor, { marker, label, icon, onPick }) {
92
+ // 模板必须取可用项(复制绝对路径):禁用项带 data-disabled 与 pointer-events:none 类,
93
+ // 克隆会导致灰显且点击穿透。悬停高亮是 Radix 行为,克隆节点需手动模拟。
94
+ const item = template.cloneNode(true);
95
+ item.removeAttribute('data-testid');
96
+ item.removeAttribute('data-highlighted');
97
+ item.removeAttribute('data-disabled');
98
+ item.removeAttribute('aria-disabled');
99
+ item.setAttribute('data-zcodepro-item', marker);
100
+ for (const child of [...item.childNodes]) child.remove();
101
+ const origIcon = template.querySelector('svg');
102
+ item.append(buildIcon(origIcon?.getAttribute('class') || 'size-4', icon), document.createTextNode(label));
103
+ item.addEventListener('mouseenter', () => {
104
+ for (const el of content.querySelectorAll('[role="menuitem"]')) el.removeAttribute('data-highlighted');
105
+ item.setAttribute('data-highlighted', '');
106
+ });
107
+ item.addEventListener('mouseleave', () => item.removeAttribute('data-highlighted'));
108
+ item.addEventListener('click', () => {
109
+ closeRadixMenu(content);
110
+ onPick();
111
+ });
112
+ anchor.before(item);
113
+ return item;
114
+ }
115
+
116
+ export function handleFileMenu(content) {
117
+ if (content.dataset.zcodeproFileMenu) return;
118
+ const items = itemsOf(content);
119
+ const copyAbs = items.find((el) => COPY_ABS_TEXTS.some((x) => itemText(el).startsWith(x)));
120
+ if (!copyAbs) return;
121
+ // 同步占位防竞态:注入在下方异步段执行,多个观察者回调同时进入时只允许第一个注入
122
+ content.dataset.zcodeproFileMenu = '1';
123
+ void (async () => {
124
+ const config = await getConfig();
125
+ if (config.features && config.features.fileActions === false) return;
126
+ const path = resolveTargetPath(content);
127
+ if (!path) return;
128
+ const L = t();
129
+ // “默认应用打开”锚在编辑器列表末尾(无可用编辑器时即“未找到可用 App”禁用项)之上;
130
+ // 找不到该组时退而锚在“复制绝对路径”上,保持“打开方式在前、路径操作在后”的次序
131
+ const noApps = items.find((el) => NO_APPS_TEXTS.some((x) => itemText(el) === x))
132
+ || content.querySelector('[data-disabled][role="menuitem"]');
133
+ const openAnchor = noApps && content.contains(noApps) ? noApps : copyAbs;
134
+ appendMenuItem(content, copyAbs, openAnchor, {
135
+ marker: 'file-open-default',
136
+ label: L.fileOpenDefault,
137
+ icon: ICON_OPEN_DEFAULT,
138
+ onPick: () => { try { void rpc('/open-path', { method: 'POST', body: { path } }); } catch { /* ignore */ } },
139
+ });
140
+ appendMenuItem(content, copyAbs, copyAbs, {
141
+ marker: 'file-reveal',
142
+ label: L.fileReveal,
143
+ icon: ICON_REVEAL,
144
+ onPick: () => { try { void rpc('/reveal-path', { method: 'POST', body: { path } }); } catch { /* ignore */ } },
145
+ });
146
+ })();
147
+ }
@@ -0,0 +1,179 @@
1
+ // 空闲项目进程回收:ZCode 为每个打开过的项目常驻一个会话进程(含其 MCP 子进程),
2
+ // 应用自身不回收这类进程(回收能力只接给了内部的 MCP 状态探测进程)。本功能对
3
+ // “长时间无活动”的项目调用应用自身的 releaseWorkspacePreparation 接口主动释放:
4
+ // 会话记录不受影响,下次点开该项目的会话时进程自动重建,仅第一条消息有几秒冷启动。
5
+ //
6
+ // 不回收(任一命中即跳过):
7
+ // 1) 当前选中的项目;
8
+ // 2) 有运行中会话的项目(含子智能体/工作流,它们都在会话回合内运行)——先用
9
+ // 折叠项目运行检测(ws-running)的现场记录,回收前再无感展开复核一次兜底;
10
+ // 3) 有仍在运行的后台命令的项目——helper 扫描会话进程的子进程(/idle-busy,
11
+ // Linux/macOS/Windows 各走系统自带工具),插件/MCP 基础设施子进程除外,
12
+ // 剩余视为用户命令;扫描不可用时整个功能停用,不做盲回收。
13
+ // 定时/闲时任务由宿主进程调度、触发时自动重建会话进程,无需排除。
14
+ //
15
+ // 开关:features.idleReclaim(默认开)。
16
+ // 两个时长:idleReclaimMinutes 常规空闲判定(默认 30,1–1440);
17
+ // idleReclaimStartupMinutes 启动一次性回收的延后分钟(默认 5,0–1440,0 = 启动后
18
+ // 尽快收一次,不受常规空闲时长限制——应用启动会为每个恢复的项目预热进程,这次
19
+ // 回收专治启动瞬间的内存高峰)。
20
+ import { rpc, getConfig } from '../core.js';
21
+ import { wsRunningSnapshot, verifyWorkspaceNotRunning } from './ws-running.js';
22
+
23
+ const CHECK_MS = 30_000; // 巡检周期
24
+ const WS_SEL = '[data-testid^="workspace-item-"]';
25
+ const MIN_MINUTES = 1;
26
+ const MAX_MINUTES = 1440;
27
+
28
+ const idleSince = new Map(); // 项目路径 → 首次判定空闲的时刻
29
+ const blockUntil = new Map(); // 项目路径 → 此前不得再次回收的时刻(回收/复核失败后的冷却)
30
+ let scanDisabledLogged = false;
31
+ const bootAt = Date.now(); // 注入时刻 ≈ 应用启动时刻
32
+ let startupSweepDone = false; // 启动一次性回收是否已执行
33
+
34
+ // 应用内部服务对象(含 zcodeTaskService.releaseWorkspacePreparation):从 React
35
+ // fiber 树上找持有它的组件(file-menu.js 同款取 fiber 的思路),找到后缓存
36
+ let servicesRef = null;
37
+ function findServices() {
38
+ if (servicesRef) return servicesRef;
39
+ try {
40
+ const rootEl = document.getElementById('root');
41
+ const containerKey = rootEl && Object.keys(rootEl).find((k) => k.startsWith('__reactContainer$'));
42
+ if (!containerKey) return null;
43
+ const seen = new Set();
44
+ let found = null;
45
+ const scanObj = (o) => {
46
+ if (!o || typeof o !== 'object' || seen.has(o) || found) return;
47
+ seen.add(o);
48
+ const ts = o.zcodeTaskService;
49
+ if (ts && typeof ts === 'object' && typeof ts.releaseWorkspacePreparation === 'function') found = o;
50
+ };
51
+ let visited = 0;
52
+ const visit = (f) => {
53
+ if (!f || found || visited > 60000) return;
54
+ visited++;
55
+ for (const holder of [f.memoizedProps, f.memoizedState]) {
56
+ if (!holder || typeof holder !== 'object') continue;
57
+ scanObj(holder);
58
+ for (const v of Object.values(holder)) {
59
+ if (v && typeof v === 'object') scanObj(v);
60
+ }
61
+ }
62
+ visit(f.child);
63
+ visit(f.sibling);
64
+ };
65
+ visit(rootEl[containerKey]);
66
+ if (found) servicesRef = found;
67
+ return found;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function sidebarWorkspaces() {
74
+ const out = new Set();
75
+ for (const el of document.querySelectorAll(WS_SEL)) {
76
+ out.add(el.getAttribute('data-testid').slice('workspace-item-'.length));
77
+ }
78
+ return out;
79
+ }
80
+
81
+ async function reclaim(ws) {
82
+ const services = findServices();
83
+ if (!services) return false;
84
+ try {
85
+ // 应用内部的工作区键带尾分隔符(宿主日志可见),不带则键对不上、调用成空操作
86
+ await services.zcodeTaskService.releaseWorkspacePreparation({
87
+ workspacePath: ws.endsWith('/') ? ws : ws + '/',
88
+ });
89
+ console.info('[zcodepro] idle-reclaim: 已释放空闲项目进程 ' + ws);
90
+ return true;
91
+ } catch (err) {
92
+ console.warn('[zcodepro] idle-reclaim: 释放失败 ' + ws, err);
93
+ return false;
94
+ }
95
+ }
96
+
97
+ // 回收单个项目:回收前复核运行状态与当前归属,未通过则冷却后由后续周期重试
98
+ async function tryReclaim(ws, cooldownMs) {
99
+ // 回收前最后复核:折叠项目的运行状态可能没被现场捕获到,无感展开读取再收起;
100
+ // 复核没做成(窗口隐藏/行不可达)也按忙处理,不回收
101
+ const verifiedIdle = await verifyWorkspaceNotRunning(ws);
102
+ if (!verifiedIdle) { blockUntil.set(ws, Date.now() + cooldownMs); return; }
103
+ // 复核要展开读行、网络请求也有耗时:期间用户可能已切到该项目,回收前再确认一次归属
104
+ const currentNow = wsRunningSnapshot().current;
105
+ if (!currentNow || currentNow === ws) { blockUntil.set(ws, Date.now() + cooldownMs); return; }
106
+ await reclaim(ws);
107
+ // 无论成败都冷却:失败退避重试;成功后用户再点开则进程重建、重新计时
108
+ blockUntil.set(ws, Date.now() + cooldownMs);
109
+ }
110
+
111
+ async function tick() {
112
+ const cfg = await getConfig();
113
+ if ((cfg.features || {}).idleReclaim === false) {
114
+ idleSince.clear();
115
+ return;
116
+ }
117
+ const minutes = Math.min(MAX_MINUTES, Math.max(MIN_MINUTES, Number(cfg.idleReclaimMinutes) || 30));
118
+ const threshold = minutes * 60_000;
119
+ let startupMinutes = Number(cfg.idleReclaimStartupMinutes);
120
+ if (!Number.isFinite(startupMinutes)) startupMinutes = 5;
121
+ startupMinutes = Math.min(MAX_MINUTES, Math.max(0, startupMinutes));
122
+
123
+ const snap = wsRunningSnapshot();
124
+ // 当前项目无法定位(新建任务输入态等没有选中会话行的状态):无法保证不误回收
125
+ // 用户正在使用的项目,本轮跳过回收,空闲计时作废重新累计
126
+ if (!snap.current) {
127
+ idleSince.clear();
128
+ return;
129
+ }
130
+ const busy = new Set(snap.running);
131
+ busy.add(snap.current);
132
+
133
+ const workspaces = sidebarWorkspaces();
134
+ for (const ws of [...idleSince.keys(), ...blockUntil.keys()]) {
135
+ if (!workspaces.has(ws)) { idleSince.delete(ws); blockUntil.delete(ws); }
136
+ }
137
+
138
+ const candidates = [...workspaces].filter((ws) => !busy.has(ws));
139
+ const res = candidates.length
140
+ ? await rpc('/idle-busy', { method: 'POST', body: { paths: candidates } })
141
+ : { ok: true, scan: true, busy: [] };
142
+ if (!res.ok || res.scan === false) {
143
+ // 无法确认后台命令状态(系统工具缺失 / helper 旧版):整体停用
144
+ if (!scanDisabledLogged) {
145
+ scanDisabledLogged = true;
146
+ console.info('[zcodepro] idle-reclaim: 后台命令扫描不可用,空闲回收停用');
147
+ }
148
+ idleSince.clear();
149
+ return;
150
+ }
151
+ const procBusy = new Set(res.busy || []);
152
+ const idleCandidates = candidates.filter((ws) => !procBusy.has(ws));
153
+
154
+ const now = Date.now();
155
+ // 启动一次性回收:应用启动会为每个恢复的项目预热会话进程,这次回收不等空闲
156
+ // 时长,到点把当时符合条件的项目一次收掉(0 = 启动后尽快回收一次;当前项目
157
+ // 定位不到时顺延到下一周期)。之后回归常规空闲计时
158
+ if (!startupSweepDone && now - bootAt >= startupMinutes * 60_000) {
159
+ startupSweepDone = true;
160
+ for (const ws of idleCandidates) {
161
+ if ((blockUntil.get(ws) || 0) > now) continue;
162
+ await tryReclaim(ws, threshold);
163
+ }
164
+ }
165
+ for (const ws of workspaces) {
166
+ if (busy.has(ws) || procBusy.has(ws)) { idleSince.delete(ws); continue; }
167
+ if (!idleSince.has(ws)) { idleSince.set(ws, now); continue; }
168
+ if (now - idleSince.get(ws) < threshold) continue;
169
+ if ((blockUntil.get(ws) || 0) > now) continue;
170
+ idleSince.delete(ws);
171
+ await tryReclaim(ws, threshold);
172
+ }
173
+ }
174
+
175
+ export function startIdleReclaim() {
176
+ const loop = () => { tick().catch(() => { /* 单次巡检失败不影响后续 */ }); };
177
+ loop();
178
+ setInterval(loop, CHECK_MS);
179
+ }