zcode-pro 0.20.3 → 1.0.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.
@@ -0,0 +1,470 @@
1
+ // 别名迁移:3.16.1 起官方“编辑项目”支持修改项目名称,自定义别名功能停用;
2
+ // 本模块把 zcodepro.json 里已保存的别名引导写入官方项目名称。
3
+ // - 启动时满足条件(版本 >= 3.16.1、仍有别名、未曾处理过)弹一次性对话框;
4
+ // “迁移所选”/“暂不迁移”都会记 migrations.aliasRenameDismissed,不再自动弹。
5
+ // - 设置页“功能”标签页放一张常驻卡片(仍有别名时显示),随时可重新打开对话框。
6
+ // - 自动迁移走官方界面:打开项目“更多”菜单 → 编辑项目 → 填名称 → 保存,
7
+ // 全程使用官方 testid(virtual-project-menu / -edit / -name / -save),
8
+ // 任一步超时即该项失败,不影响其余项;成功一项就从配置清掉一条别名。
9
+ import { h, t, rpc, getConfig, clearConfigCache, officialRenameSupported, isNewProjectSidebar } from '../core.js';
10
+ import { openDialog, btnPrimary, btnSecondary, btnSmall, showToast, ensureStyle } from '../ui.js';
11
+ import { findProjectRow, rowPath } from './ws-rows.js';
12
+
13
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
14
+ const basenameOf = (p) => String(p || '').replace(/[\\/]+$/, '').split(/[\\/]/).pop() || '';
15
+
16
+ function waitFor(getter, timeoutMs, step) {
17
+ const start = Date.now();
18
+ return new Promise((resolve, reject) => {
19
+ const tick = () => {
20
+ let v = null;
21
+ try { v = getter(); } catch { /* 取值异常按未就绪处理 */ }
22
+ if (v) { resolve(v); return; }
23
+ if (Date.now() - start >= timeoutMs) { reject(new Error(step)); return; }
24
+ setTimeout(tick, 120);
25
+ };
26
+ tick();
27
+ });
28
+ }
29
+
30
+ // React 受控输入赋值:原生 value setter 绕过 React 的合成属性,再派发 input 事件
31
+ function setReactInputValue(input, value) {
32
+ const setter = Object.getOwnPropertyDescriptor(input.constructor.prototype, 'value')?.set
33
+ || Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
34
+ if (!setter) throw new Error('input-setter');
35
+ setter.call(input, value);
36
+ input.dispatchEvent(new Event('input', { bubbles: true }));
37
+ }
38
+
39
+ // 关掉还开着的官方弹层(菜单/对话框),Radix 在 document 上监听 Escape
40
+ function pressEscape() {
41
+ try {
42
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true, cancelable: true }));
43
+ } catch { /* ignore */ }
44
+ }
45
+
46
+ async function markDismissed() {
47
+ await rpc('/config', { method: 'POST', body: { migrations: { aliasRenameDismissed: true } } });
48
+ clearConfigCache();
49
+ }
50
+
51
+ // 跨窗口锁:两个 ZCode 窗口同时启动时只有一个弹迁移对话框(localStorage 同源共享;
52
+ // 5 分钟过期,锁残留不会永久吞掉弹窗——设置页卡片始终可用)
53
+ const MIGRATE_LOCK_KEY = 'zcodepro:alias-migrate-lock';
54
+ const MIGRATE_LOCK_MS = 5 * 60_000;
55
+ function migrateLockFresh() {
56
+ try {
57
+ const at = Number(localStorage.getItem(MIGRATE_LOCK_KEY));
58
+ return Number.isFinite(at) && Date.now() - at < MIGRATE_LOCK_MS;
59
+ } catch { return false; }
60
+ }
61
+ function touchMigrateLock() {
62
+ try { localStorage.setItem(MIGRATE_LOCK_KEY, String(Date.now())); } catch { /* ignore */ }
63
+ }
64
+
65
+ // 把一个别名写入官方项目名称。row 可能被 React 整体重渲染,全程按
66
+ // data-project-id 重新查询;路径匹配不到时按“官方名 = 目录名”兜底找行。
67
+ async function migrateOne(path, alias) {
68
+ // 上一次失败的残留弹层(编辑对话框/菜单)先关掉,避免后续步骤匹配到旧元素
69
+ if (document.querySelector('[data-testid="virtual-project-name"], [role="menu"]')) {
70
+ pressEscape();
71
+ await sleep(300);
72
+ }
73
+ let row = findProjectRow(path);
74
+ if (!row) {
75
+ // 按名兜底只认本地项目:远程分区可能撞名,误编辑远程项目
76
+ const byName = [...document.querySelectorAll('section[data-project-id]')]
77
+ .filter((el) => {
78
+ const host = el.getAttribute('data-project-host');
79
+ return (host === null || host === 'local') && el.getAttribute('data-project-name') === basenameOf(path);
80
+ });
81
+ if (byName.length === 1) row = byName[0];
82
+ }
83
+ if (!row) throw new Error('project-row');
84
+ const projectId = row.getAttribute('data-project-id');
85
+ const rowById = () => document.querySelector(`section[data-project-id="${CSS.escape(projectId)}"]`);
86
+
87
+ try { row.scrollIntoView({ block: 'center' }); } catch { /* ignore */ }
88
+ const trigger = await waitFor(
89
+ () => rowById()?.querySelector('[data-testid="virtual-project-menu"]'),
90
+ 4000, 'menu-trigger'
91
+ );
92
+ // Radix 触发器监听 pointerdown(纯合成 click 打不开菜单,实测 3.16.1),派发完整指针序列
93
+ trigger.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0 }));
94
+ trigger.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0 }));
95
+ trigger.click();
96
+ const menu = await waitFor(() => {
97
+ for (const m of document.querySelectorAll('[role="menu"]')) {
98
+ if (m.querySelector('[data-testid="virtual-project-edit"]')) return m;
99
+ }
100
+ return null;
101
+ }, 4000, 'menu-open');
102
+ menu.querySelector('[data-testid="virtual-project-edit"]').click();
103
+
104
+ const input = await waitFor(
105
+ () => document.querySelector('[data-testid="virtual-project-name"]'),
106
+ 5000, 'editor-open'
107
+ );
108
+ // 官方保存时按 name.trim() 写入数据库,这里先 trim 再填入,校验标准才一致
109
+ const wantName = alias.trim();
110
+ setReactInputValue(input, wantName);
111
+ const save = await waitFor(
112
+ () => {
113
+ const btn = document.querySelector('[data-testid="virtual-project-save"]');
114
+ return btn && !btn.disabled ? btn : null;
115
+ },
116
+ 4000, 'save-ready'
117
+ );
118
+ save.click();
119
+
120
+ await waitFor(() => {
121
+ const r = rowById();
122
+ return r && r.getAttribute('data-project-name') === wantName ? r : null;
123
+ }, 8000, 'name-updated');
124
+ // 保存成功后编辑对话框通常会自行关闭;个别情况仍开着时补一个 Escape
125
+ await sleep(250);
126
+ if (document.querySelector('[data-testid="virtual-project-name"]')) pressEscape();
127
+
128
+ // 成功:从 zcodepro.json 清掉这条别名(失败保留,设置页卡片可重试)
129
+ const res = await rpc('/project/alias', { method: 'POST', body: { path, alias: '' } });
130
+ if (!res.ok) throw new Error('alias-clear');
131
+ }
132
+
133
+ // —— 迁移对话框(启动时与设置页卡片共用) ——
134
+
135
+ // 预判一条别名的目标项目现状:
136
+ // - done:现存项目官方名已是该别名(用户已手动迁移过),默认跳过
137
+ // - merged:路径是某现存项目的附加目录(3.16.1 多目录合并),默认跳过——把别名
138
+ // 写上去会覆盖那个项目的名字
139
+ // - deleted:项目真删除过(数据库软删除标记)
140
+ // - missing:路径不在任何项目里(典型:旧机器路径,目录本机不存在)
141
+ // - '':路径是某现存项目的主目录且官方名不同,可直接迁移;项目名是用户手动
142
+ // 改过的(name_overridden)时附 overridden=true,默认不勾(尊重用户手改的名字)
143
+ // 优先用 helper 的 /projects-v2(任务索引中的 projects/project_roots 表);读不到时
144
+ // 降级为纯 DOM 检测(看不到附加目录与软删除标记,合并目录会误报 missing)
145
+ async function classifyEntry(path, alias, db) {
146
+ const want = norm0(path);
147
+ if (db) {
148
+ let alsoDeleted = false;
149
+ for (const proj of db.projects || []) {
150
+ const hit = (proj.roots || []).find((r) => norm0(r.path) === want);
151
+ if (!hit) continue;
152
+ if (hit.primary) {
153
+ if (proj.name === alias) return { state: 'done', note: '' };
154
+ return { state: '', note: '', overridden: !!proj.overridden };
155
+ }
156
+ // 附加目录:并入现存项目;该路径同时可能还是已删除的独立项目(合并后删旧),
157
+ // 两种状态都带回,徽标并排显示
158
+ alsoDeleted = (db.deletedRoots || []).some((p) => norm0(p) === want);
159
+ return { state: 'merged', note: proj.name, alsoDeleted };
160
+ }
161
+ if ((db.deletedRoots || []).some((p) => norm0(p) === want)) return { state: 'deleted', note: '' };
162
+ return { state: 'missing', note: '' };
163
+ }
164
+ // —— DOM 降级路径 ——
165
+ const localRows = [...document.querySelectorAll('section[data-project-id]')]
166
+ .filter((el) => {
167
+ const host = el.getAttribute('data-project-host');
168
+ return host === null || host === 'local';
169
+ });
170
+ const byPath = localRows.find((el) => norm0(rowPath(el)) === want);
171
+ if (byPath) {
172
+ return (byPath.getAttribute('data-project-name') || '') === alias
173
+ ? { state: 'done', note: '' }
174
+ : { state: '', note: '' };
175
+ }
176
+ const byName = localRows.filter((el) => el.getAttribute('data-project-name') === alias);
177
+ if (byName.length === 1) return { state: 'done', note: '' };
178
+ // 目录被并入其他项目:该路径下的会话行出现在别的项目分区里
179
+ for (const task of document.querySelectorAll('[data-task-item-key]')) {
180
+ const key = task.getAttribute('data-task-item-key') || '';
181
+ const cut = key.lastIndexOf(':');
182
+ if (cut <= 1 || norm0(key.slice(0, cut)) !== want) continue;
183
+ const owner = task.closest('section[data-project-id]');
184
+ const ownerName = owner ? (owner.getAttribute('data-project-name') || '') : '';
185
+ if (ownerName) return { state: 'merged', note: ownerName };
186
+ }
187
+ return { state: 'missing', note: '' };
188
+ }
189
+
190
+ // 拉取虚拟项目数据(失败返回 null,classifyEntry 走 DOM 降级)
191
+ async function fetchProjectDb() {
192
+ const res = await rpc('/projects-v2');
193
+ return res.ok && res.available ? res : null;
194
+ }
195
+
196
+ const norm0 = (p) => String(p || '').replace(/[\\/]+$/, '');
197
+
198
+ export function openMigrationDialog() {
199
+ ensureStyle();
200
+ const L = t();
201
+ let running = false;
202
+ // 无论点“迁移所选”、“暂不迁移”还是 Esc/点外部关闭,都记一次“已处理”,
203
+ // 弹窗只打扰一次;RPC 失败则下次启动还可能再弹(可接受)。decide 保证只发一次。
204
+ let decided = false;
205
+ const decide = () => { if (decided) return; decided = true; void markDismissed(); };
206
+ touchMigrateLock();
207
+
208
+ openDialog({
209
+ title: L.aliasMigrateTitle,
210
+ description: L.aliasMigrateDesc,
211
+ width: 'sm:max-w-md',
212
+ // 迁移不可中途撤销且过程可见:点遮罩不关闭(防误触),右上角 ✕ 关闭
213
+ dismissOnOutside: false,
214
+ closable: true,
215
+ onClose: () => {
216
+ decide();
217
+ // 设置页的迁移卡片据此重算(叠开场景下关闭弹窗后数字立即更新)
218
+ window.dispatchEvent(new CustomEvent('zcodepro:aliases-changed'));
219
+ },
220
+ onMount: ({ body, close }) => {
221
+ void (async () => {
222
+ const config = await getConfig(true);
223
+ const db = await fetchProjectDb();
224
+ const entries = [];
225
+ for (const [path, aliasRaw] of Object.entries(config.aliases || {})) {
226
+ const alias = String(aliasRaw || '').trim();
227
+ if (!alias) continue;
228
+ const cls = await classifyEntry(path, alias, db);
229
+ // 预分类结果放 state('' 可迁移 / done / merged / deleted / missing),
230
+ // 迁移过程中改写为 ok / fail。除“可直接迁移”外一律不可勾选:
231
+ // 已并入的会把别名写到所属项目名上(覆盖现名),手改过名的迁移会
232
+ // 覆盖用户改好的名字——都违背预期;要改项目名走官方“编辑项目”即可。
233
+ // 手改项同样不默认勾选(disabled 且勾着会让“迁移所选”误覆盖)
234
+ entries.push({
235
+ path, alias,
236
+ checked: cls.state === '' && !cls.overridden,
237
+ state: cls.state, note: cls.note || '',
238
+ overridden: !!cls.overridden,
239
+ alsoDeleted: !!cls.alsoDeleted,
240
+ });
241
+ }
242
+ if (!entries.length) { close(); return; }
243
+
244
+ // 预分类徽标(行尾,可多个并排):运行态(迁移中/已迁移/失败)+ 预分类
245
+ //(已迁移绿 / 已并入琥珀 / 项目已删除、本机不存在、项目已重命名灰)
246
+ const badgesOf = (e) => {
247
+ const out = [];
248
+ if (e.state === 'run') out.push({ text: L.aliasMigrateRunning, cls: 'text-amber-500' });
249
+ if (e.state === 'ok') out.push({ text: L.aliasMigrateStateDone, cls: 'text-emerald-600' });
250
+ if (e.state === 'fail') out.push({ text: L.aliasMigrateStateFailed, cls: 'text-destructive' });
251
+ if (e.state === 'done') out.push({ text: L.aliasMigrateStateDone, cls: 'text-emerald-600' });
252
+ if (e.state === 'merged') out.push({ text: L.aliasMigrateStateMerged.replace('{name}', e.note), cls: 'text-amber-500' });
253
+ if (e.state === 'deleted' || (e.state === 'merged' && e.alsoDeleted)) out.push({ text: L.aliasMigrateStateDeleted, cls: 'text-foreground-subtle/70' });
254
+ if (e.state === 'missing') out.push({ text: L.aliasMigrateStateMissing, cls: 'text-foreground-subtle/70' });
255
+ if (e.state === '' && e.overridden) out.push({ text: L.aliasMigrateStateRenamed, cls: 'text-foreground-subtle/70' });
256
+ return out;
257
+ };
258
+ // 别名多时列表自身滚动,避免撑爆视口;其上方一行迁移进度(迁移中显示)
259
+ const progressEl = h('div', { class: 'mb-1 text-ui-xs text-foreground-subtle', style: 'display:none' });
260
+ const listEl = h('div', { class: 'max-h-[50vh] space-y-1.5 overflow-y-auto' });
261
+ // 失效条目(本机不存在 / 项目已删除)默认折叠成摘要行,可展开、可一键清除
262
+ //——多来自设置同步带来的旧机器路径,正常迁移用不到它们
263
+ let deadOpen = false;
264
+ const renderList = () => {
265
+ const alive = entries.filter((e) => e.state !== 'missing' && e.state !== 'deleted');
266
+ const dead = entries.filter((e) => e.state === 'missing' || e.state === 'deleted');
267
+ const rowOf = (e, deadRow) => {
268
+ const cb = h('input', { type: 'checkbox' });
269
+ cb.checked = e.checked;
270
+ // 除“可直接迁移”(含失败重试)外一律禁勾;手改过名的可迁移项也禁勾
271
+ cb.disabled = running || (e.state !== '' && e.state !== 'fail') || e.overridden;
272
+ cb.addEventListener('change', () => { e.checked = cb.checked; });
273
+ const badges = badgesOf(e);
274
+ const dim = deadRow || (e.state !== '' && e.state !== 'fail' && e.state !== 'run') || e.overridden;
275
+ return h('label', { class: 'flex cursor-pointer items-start gap-2 rounded-lg p-2 transition-colors hover:bg-surface-hover' + (dim ? ' opacity-60' : '') },
276
+ h('span', { class: 'mt-0.5' }, cb),
277
+ h('span', { class: 'min-w-0 flex-1' },
278
+ h('span', { class: 'block truncate text-ui-sm font-medium text-foreground' }, e.alias),
279
+ h('span', { class: 'block truncate font-mono text-ui-xs text-foreground-subtle' }, e.path)),
280
+ ...badges.map((b) => h('span', { class: 'shrink-0 self-center text-ui-xs ' + b.cls }, b.text)));
281
+ };
282
+ const parts = alive.map((e) => rowOf(e, false));
283
+ if (dead.length) {
284
+ const deadRows = h('div', { class: 'space-y-1', style: deadOpen ? '' : 'display:none' },
285
+ ...dead.map((e) => rowOf(e, true)));
286
+ const toggle = h('button', {
287
+ type: 'button',
288
+ class: 'flex w-full items-center gap-1 rounded-lg p-2 text-left text-ui-xs text-foreground-subtle transition-colors hover:bg-surface-hover',
289
+ onClick: () => { deadOpen = !deadOpen; renderList(); },
290
+ }, (deadOpen ? '▾ ' : '▸ ') + L.aliasMigrateDeadSummary.replace('{n}', String(dead.length)));
291
+ parts.push(toggle, deadRows);
292
+ }
293
+ listEl.replaceChildren(...parts);
294
+ };
295
+ renderList();
296
+ // 一键清除失效条目:逐条写空别名(helper 端即删除该键),成功后从列表移除
297
+ const clearDead = async () => {
298
+ if (running) return;
299
+ const dead = entries.filter((e) => e.state === 'missing' || e.state === 'deleted');
300
+ if (!dead.length) return;
301
+ running = true;
302
+ goBtn.setAttribute('disabled', 'true');
303
+ let ok = 0;
304
+ for (const e of dead) {
305
+ const r = await rpc('/project/alias', { method: 'POST', body: { path: e.path, alias: '' } });
306
+ if (r.ok) { ok++; entries.splice(entries.indexOf(e), 1); }
307
+ }
308
+ running = false;
309
+ goBtn.removeAttribute('disabled');
310
+ goBtn.textContent = L.aliasMigrateGo;
311
+ clearConfigCache();
312
+ renderList();
313
+ window.dispatchEvent(new CustomEvent('zcodepro:aliases-changed'));
314
+ if (ok === dead.length) showToast(L.aliasMigrateDeadCleared.replace('{n}', String(ok)));
315
+ else showToast(L.aliasMigrateDeadClearFailed, 'error');
316
+ };
317
+
318
+ const goBtn = btnPrimary(L.aliasMigrateGo, () => { void run(); }, 'min-w-24');
319
+ const run = async () => {
320
+ if (running) return;
321
+ const todo = entries.filter((e) => e.checked);
322
+ if (!todo.length) return;
323
+ running = true;
324
+ goBtn.setAttribute('disabled', 'true');
325
+ goBtn.textContent = L.aliasMigrateRunning;
326
+ let ok = 0;
327
+ const failed = [];
328
+ for (let i = 0; i < todo.length; i++) {
329
+ const e = todo[i];
330
+ progressEl.style.display = '';
331
+ progressEl.textContent = L.aliasMigrateProgress.replace('{i}', String(i + 1)).replace('{n}', String(todo.length)).replace('{name}', e.alias);
332
+ e.state = 'run';
333
+ renderList();
334
+ try {
335
+ await migrateOne(e.path, e.alias);
336
+ e.state = 'ok';
337
+ e.checked = false;
338
+ ok++;
339
+ } catch (err) {
340
+ // 步骤名(menu-open / save-ready…)进控制台,用户反馈时可定位卡在哪一步
341
+ console.warn('[zcodepro] alias-migrate: ' + e.path + ' 失败于 ' + (err && err.message));
342
+ e.state = 'fail';
343
+ failed.push(e);
344
+ }
345
+ renderList();
346
+ }
347
+ running = false;
348
+ progressEl.style.display = 'none';
349
+ goBtn.removeAttribute('disabled');
350
+ goBtn.textContent = L.aliasMigrateGo;
351
+ decide();
352
+ close();
353
+ if (!failed.length) {
354
+ showToast(L.aliasMigrateDone.replace('{n}', String(ok)), 'ok');
355
+ } else if (ok) {
356
+ showToast(L.aliasMigratePartial.replace('{n}', String(ok)).replace('{m}', String(failed.length)), 'error');
357
+ } else {
358
+ showToast(L.aliasMigrateAllFailed, 'error');
359
+ }
360
+ };
361
+
362
+ body.append(
363
+ progressEl,
364
+ listEl,
365
+ h('p', { class: 'text-ui-xs/relaxed text-foreground-subtle' }, L.aliasMigrateItemHint),
366
+ (() => {
367
+ const footer = document.createElement('div');
368
+ footer.className = 'mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end';
369
+ const hasDead = entries.some((e) => e.state === 'missing' || e.state === 'deleted');
370
+ if (hasDead) {
371
+ const clearBtn = btnSecondary(L.aliasMigrateClearDead, () => {
372
+ void clearDead().then(() => {
373
+ if (!entries.some((e) => e.state === 'missing' || e.state === 'deleted')) clearBtn.style.display = 'none';
374
+ });
375
+ });
376
+ footer.append(clearBtn);
377
+ }
378
+ footer.append(
379
+ btnSecondary(L.aliasMigrateSkip, () => {
380
+ if (running) return;
381
+ decide();
382
+ close();
383
+ }),
384
+ goBtn
385
+ );
386
+ return footer;
387
+ })()
388
+ );
389
+ })();
390
+ },
391
+ });
392
+ }
393
+
394
+ // —— 设置页卡片(settings-dialog 在“功能”标签页插入) ——
395
+
396
+ export function migrationCard() {
397
+ const L = t();
398
+ const card = h('div', {
399
+ class: 'flex items-center gap-2 rounded-lg p-2',
400
+ 'data-zcodepro-migration-card': '',
401
+ });
402
+ const render = (count) => {
403
+ card.replaceChildren(
404
+ h('div', { class: 'min-w-0 flex-1' },
405
+ h('div', { class: 'text-ui-sm font-medium text-foreground' }, L.aliasMigrateCardName),
406
+ h('div', { class: 'mt-0.5 text-ui-xs/relaxed text-foreground-subtle' },
407
+ count === null ? '…' : L.aliasMigrateCardDesc.replace('{n}', String(count)))),
408
+ btnSmall(L.aliasMigrateRun, () => { openMigrationDialog(); }));
409
+ };
410
+ render(null); // 先渲染占位(取到配置前不显示具体数字),无别名时整个卡片移除
411
+ const refresh = async () => {
412
+ const config = await getConfig(true);
413
+ const db = await fetchProjectDb();
414
+ let count = 0;
415
+ for (const [p, a] of Object.entries(config.aliases || {})) {
416
+ if (!String(a || '').trim()) continue;
417
+ const cls = await classifyEntry(p, String(a).trim(), db);
418
+ // 只数真正可迁移的:项目名已重命名的虽分类为可迁移但弹窗里禁勾,不计
419
+ if (cls.state === '' && !cls.overridden) count++;
420
+ }
421
+ if (!count) { card.remove(); return; }
422
+ render(count);
423
+ };
424
+ // 迁移弹窗(可叠在设置窗上)关闭或清除失效条目后,卡片立即重算——
425
+ // 否则设置窗开着的期间数字是打开时的快照,与实际不一致。卡片随设置页
426
+ // 标签切换会被整卡重建,监听器须随旧卡自我移除,否则逐次累积
427
+ const onAliasesChanged = () => {
428
+ if (!card.isConnected) {
429
+ window.removeEventListener('zcodepro:aliases-changed', onAliasesChanged);
430
+ return;
431
+ }
432
+ void refresh();
433
+ };
434
+ window.addEventListener('zcodepro:aliases-changed', onAliasesChanged);
435
+ void refresh();
436
+ return card;
437
+ }
438
+
439
+ // —— 启动检查 ——
440
+
441
+ export function startAliasMigrationWatcher() {
442
+ // 版本读不到(如 asar 读取失败)时按 DOM 兜底:新侧栏在场即认为官方改名可用,
443
+ // 与其他功能的判定条件一致
444
+ if (!officialRenameSupported() && !isNewProjectSidebar()) return;
445
+ void (async () => {
446
+ try {
447
+ const config = await getConfig();
448
+ const hasAliases = Object.values(config.aliases || {}).some((a) => String(a || '').trim());
449
+ if (!hasAliases) return;
450
+ if (config.migrations && config.migrations.aliasRenameDismissed) return;
451
+ // 等新侧栏就绪;多窗口场景只有可见且聚焦的窗口弹,其余窗口本次不弹。
452
+ // 焦点等待放宽到 5 分钟:启动时用户常切在别的窗口,切回 ZCode 点一下即弹
453
+ await waitFor(() => document.querySelector('section[data-project-id]'), 45000, 'sidebar');
454
+ let focused = false;
455
+ for (let i = 0; i < 600; i++) {
456
+ if (document.visibilityState === 'visible' && document.hasFocus()) { focused = true; break; }
457
+ await sleep(500);
458
+ }
459
+ if (!focused) return;
460
+ // 其他窗口刚弹过(5 分钟内):本窗口不再弹,由设置页卡片兜底
461
+ if (migrateLockFresh()) return;
462
+ // 已有 ZCode Pro 弹窗(如设置窗)打开时等它关闭,最多等 2 分钟
463
+ await waitFor(() => (document.querySelector('.zcodepro-overlay') ? null : true), 120000, 'dialog-busy');
464
+ // 弹之前按最新配置再核一次(可能刚在别的窗口迁移完/选择了暂不)
465
+ const fresh = await getConfig(true);
466
+ const still = Object.values(fresh.aliases || {}).some((a) => String(a || '').trim());
467
+ if (still && !(fresh.migrations && fresh.migrations.aliasRenameDismissed)) openMigrationDialog();
468
+ } catch { /* 等待超时等:本次不弹即可,不影响其他功能 */ }
469
+ })();
470
+ }
@@ -2,7 +2,7 @@
2
2
  // 存储:helper 的 zcodepro.json aliases 表(路径规范化无尾分隔符 → 名称)。
3
3
  // 渲染:侧边栏项目行有唯一文本叶子 div.min-w-0.truncate(逆向确认),
4
4
  // 按行 testid(workspace-item-<绝对路径>)查表替换;React 重渲染后由 MutationObserver 重新套用。
5
- import { h, t, rpc, getConfig, clearConfigCache, errText } from '../core.js';
5
+ import { h, t, rpc, getConfig, clearConfigCache, errText, isNewProjectSidebar } from '../core.js';
6
6
  import { openDialog, dialogFooter, btnPrimary, btnSecondary, textInput, showToast, ensureStyle } from '../ui.js';
7
7
 
8
8
  const norm = (p) => String(p || '').replace(/[\\/]+$/, '');
@@ -40,6 +40,11 @@ function projectRowMutations(muts) {
40
40
  }
41
41
 
42
42
  export async function startAliasWatcher() {
43
+ // 3.16.1 起新侧栏为 section[data-project-id],旧 testid 行已不存在;且官方
44
+ // “编辑项目”已支持修改名称。别名功能整体停用(菜单入口同步隐藏,见
45
+ // project-menu.js):不再观察、不再替换侧栏文本;已保存的别名保留在配置里,
46
+ // 由 alias-migration.js 引导写入官方项目名称。
47
+ if (isNewProjectSidebar()) return;
43
48
  await syncFromConfig();
44
49
  if (observer) return;
45
50
  observer = new MutationObserver((muts) => { if (projectRowMutations(muts)) scheduleApply(); });
@@ -18,10 +18,9 @@
18
18
  // 尽快收一次,不受常规空闲时长限制——应用启动会为每个恢复的项目预热进程,这次
19
19
  // 回收专治启动瞬间的内存高峰)。
20
20
  import { rpc, getConfig } from '../core.js';
21
- import { wsRunningSnapshot, verifyWorkspaceNotRunning } from './ws-running.js';
21
+ import { wsRunningSnapshot, verifyWorkspaceNotRunning, findServices, authoritativeIdle } from './ws-running.js';
22
22
 
23
23
  const CHECK_MS = 30_000; // 巡检周期
24
- const WS_SEL = '[data-testid^="workspace-item-"]';
25
24
  const MIN_MINUTES = 1;
26
25
  const MAX_MINUTES = 1440;
27
26
 
@@ -31,49 +30,19 @@ let scanDisabledLogged = false;
31
30
  const bootAt = Date.now(); // 注入时刻 ≈ 应用启动时刻
32
31
  let startupSweepDone = false; // 启动一次性回收是否已执行
33
32
 
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
33
 
73
- function sidebarWorkspaces() {
34
+ // 项目清单改读 helper 的 /projects-v2(官方任务索引 projects/project_roots):
35
+ // 含多目录项目的每个目录(每个目录都可能有会话进程),且不依赖侧栏展开状态
36
+ async function sidebarWorkspaces() {
74
37
  const out = new Set();
75
- for (const el of document.querySelectorAll(WS_SEL)) {
76
- out.add(el.getAttribute('data-testid').slice('workspace-item-'.length));
38
+ const res = await rpc('/projects-v2').catch(() => null);
39
+ if (res && res.ok && res.available) {
40
+ for (const proj of res.projects || []) {
41
+ for (const r of proj.roots || []) {
42
+ const p = String(r.path || '').replace(/[\/]+$/, '');
43
+ if (p) out.add(p);
44
+ }
45
+ }
77
46
  }
78
47
  return out;
79
48
  }
@@ -94,12 +63,18 @@ async function reclaim(ws) {
94
63
  }
95
64
  }
96
65
 
66
+
97
67
  // 回收单个项目:回收前复核运行状态与当前归属,未通过则冷却后由后续周期重试
98
68
  async function tryReclaim(ws, cooldownMs) {
99
- // 回收前最后复核:折叠项目的运行状态可能没被现场捕获到,无感展开读取再收起;
100
- // 复核没做成(窗口隐藏/行不可达)也按忙处理,不回收
101
- const verifiedIdle = await verifyWorkspaceNotRunning(ws);
102
- if (!verifiedIdle) { blockUntil.set(ws, Date.now() + cooldownMs); return; }
69
+ // 回收前最后复核。优先官方权威判定(listActiveTaskIds);接口不可用
70
+ // (≤3.15.x)时退回 DOM 复核链。任何“未知”都按忙处理,不回收
71
+ const auth = await authoritativeIdle(ws);
72
+ if (auth === null) {
73
+ const verifiedIdle = await verifyWorkspaceNotRunning(ws);
74
+ if (!verifiedIdle) { blockUntil.set(ws, Date.now() + cooldownMs); return; }
75
+ } else if (!auth) {
76
+ blockUntil.set(ws, Date.now() + cooldownMs); return;
77
+ }
103
78
  // 复核要展开读行、网络请求也有耗时:期间用户可能已切到该项目,回收前再确认一次归属
104
79
  const currentNow = wsRunningSnapshot().current;
105
80
  if (!currentNow || currentNow === ws) { blockUntil.set(ws, Date.now() + cooldownMs); return; }
@@ -130,7 +105,7 @@ async function tick() {
130
105
  const busy = new Set(snap.running);
131
106
  busy.add(snap.current);
132
107
 
133
- const workspaces = sidebarWorkspaces();
108
+ const workspaces = await sidebarWorkspaces();
134
109
  for (const ws of [...idleSince.keys(), ...blockUntil.keys()]) {
135
110
  if (!workspaces.has(ws)) { idleSince.delete(ws); blockUntil.delete(ws); }
136
111
  }
@@ -15,11 +15,12 @@ const SCAN_FALLBACK_MS = 5000; // 兜底轮询周期
15
15
  let enabled = true; // 配置开关(features.pinnedCollapse !== false)
16
16
  let syncTimer = 0;
17
17
 
18
- // 已置顶标题的识别不依赖文案(跟随界面语言):h3 的下一个兄弟是含任务行的列表
18
+ // 已置顶标题的识别不依赖文案(跟随界面语言):h3 的下一个兄弟是含任务行的
19
+ // 列表容器——3.15.x 是 UL,3.16.1 起是 DIV,不限定标签
19
20
  function findPinnedHeader() {
20
21
  for (const h3 of document.querySelectorAll('h3')) {
21
- const ul = h3.nextElementSibling;
22
- if (ul && ul.tagName === 'UL' && ul.querySelector('li[data-task-item-key]')) return h3;
22
+ const list = h3.nextElementSibling;
23
+ if (list && list.querySelector('li[data-task-item-key]')) return h3;
23
24
  }
24
25
  return null;
25
26
  }