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,16 +1,23 @@
1
1
  // 会话快捷切换:alt+z 立即切到上次使用的会话(再按切回);按住 alt 按 x/c 打开
2
- // MRU 弹窗前后移动高亮(x 更早 / c 更新,两端循环),松开 alt 切换,Esc / 点弹窗外
3
- // 取消,点行即切。MRU(最近使用序)从注入起记录(不回填历史库,上限 50 条),
2
+ // MRU 弹窗前后移动高亮(x 更早 / c 更新,c 到底且还有更多时加载 5 条并选中新
3
+ // 加载的第一条),alt+e 直接打开同一弹窗(初始显示最近 10 条),alt+r 打开
4
+ // “运行中 / 运行完未查看”任务弹窗,松开 alt 切换,Esc / 点弹窗外取消,点行即切。
5
+ // MRU(最近使用序)从注入起记录(不回填历史库,上限 50 条),
4
6
  // 与侧栏展示顺序无关;当前会话靠侧栏条目的 bg-selected 类识别(实测标记),
5
7
  // 事件驱动扫描(会话行增删/选中标记移动才触发)+ 低频兜底轮询——
6
8
  // 点击、新建任务自动跳转等任何方式的切换都能捕捉,聊天区流式输出不产生扫描。
7
9
  // 目标条目在折叠项目下时先展开项目行再点击(展开先例见 pinned-expand.js)。
8
- import { h, t, getConfig } from '../core.js';
10
+ import { h, t, getConfig, DEFAULT_SHORTCUTS } from '../core.js';
9
11
  import { ensureStyle, showToast } from '../ui.js';
12
+ import { findProjectRow } from './ws-rows.js';
13
+ import { wsRunningKeys } from './ws-running.js';
10
14
 
11
15
  const MRU_LIMIT = 50;
12
16
  const SCAN_DEBOUNCE_MS = 150; // 相关 DOM 变动后的去抖,合并连续变动
13
17
  const SCAN_FALLBACK_MS = 5000; // 兜底轮询周期:仅补观察器覆盖不到的路径
18
+ const INITIAL_COUNT = 10; // 弹窗初始渲染条数(alt+e“最近 10 条”同此)
19
+ const PAGE_SIZE = 5; // c 到底再按时加载的条数
20
+ const UNSEEN_LIMIT = 50;
14
21
 
15
22
  let mru = []; // 会话键(data-task-item-key),最近使用的在最前
16
23
  let current = null; // 当前会话键
@@ -19,6 +26,24 @@ let popupEl = null; // 打开的切换弹窗(null = 未打开)
19
26
  let aliasesCache = {}; // 项目路径 → 别名(打开弹窗时预取,renderPopup 同步使用)
20
27
  let highlight = 0;
21
28
  let scanDebounce = 0;
29
+ let popupMode = 'mru'; // 'mru':最近使用(x/c/e);'attention':运行中 + 未查看(r)
30
+ let visibleCount = INITIAL_COUNT;
31
+ let unseen = new Set(); // 运行结束以来未再切入查看的会话键
32
+ let lastRunKeys = null; // 上次采样的运行键集合(用于识别“运行→结束”的转变)
33
+ let movedSinceOpen = false; // 打开弹窗后是否移动过高亮(决定松开 alt 是提交还是保持)
34
+ // 键位(alt+字母/数字):默认见 core.js,可在设置 → 快捷键修改;
35
+ // 设置保存时会派发 zcodepro:config-changed,这里刷新缓存
36
+ let hotkeys = { ...DEFAULT_SHORTCUTS };
37
+ function loadHotkeys() {
38
+ return getConfig(true).then((cfg) => {
39
+ const sc = cfg && cfg.shortcuts;
40
+ if (sc && typeof sc === 'object') {
41
+ for (const k of Object.keys(DEFAULT_SHORTCUTS)) {
42
+ if (typeof sc[k] === 'string' && /^[a-z0-9]$/.test(sc[k])) hotkeys[k] = sc[k];
43
+ }
44
+ }
45
+ }).catch(() => {});
46
+ }
22
47
 
23
48
  function firstLineOf(item) {
24
49
  // 条目文本含相对时间等杂项,取首个截断类标题元素,兜底 innerText 首行
@@ -40,8 +65,9 @@ function findCurrent() {
40
65
  return null;
41
66
  }
42
67
 
43
- // 记录一次“切到 key”;顺带刷新在场条目的标题缓存
68
+ // 记录一次“切到 key”;顺带刷新在场条目的标题缓存。切入即视为已查看
44
69
  function record(key) {
70
+ if (key) unseen.delete(key);
45
71
  if (key && key !== current) {
46
72
  current = key;
47
73
  mru = mru.filter((k) => k !== key);
@@ -93,7 +119,9 @@ async function switchTo(key) {
93
119
  let item = find();
94
120
  if (!item) {
95
121
  const ws = workspaceOf(key);
96
- const header = document.querySelector(`[data-testid="workspace-item-${CSS.escape(ws)}"]`);
122
+ // 项目行按路径定位:旧结构查 testid,新结构(3.16.1 起)按 data-project-id
123
+ // 分区匹配(分区与路径的关联需该项目本次会话展开过一次,见 ws-rows.js)
124
+ const header = findProjectRow(ws);
97
125
  const expander = header && header.querySelector('[aria-expanded]');
98
126
  if (expander && expander.getAttribute('aria-expanded') === 'false') {
99
127
  expander.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
@@ -108,13 +136,35 @@ async function switchTo(key) {
108
136
 
109
137
  // —— 切换弹窗(类 alt+tab):居中列表卡,x/c 移动高亮,松开 alt / 点击提交 ——
110
138
 
139
+ // attention 弹窗的列表:正在运行或运行完未查看的任务。MRU 只记录切入过的
140
+ // 会话,而“未查看”的任务可能从未切入,不在 MRU 里——这部分(先运行中、
141
+ // 再未查看,各按发现序)追加在 MRU 命中项之后
142
+ function attentionList() {
143
+ const run = wsRunningKeys();
144
+ const listed = new Set();
145
+ const head = [];
146
+ for (const k of mru) {
147
+ if (run.has(k) || unseen.has(k)) { head.push(k); listed.add(k); }
148
+ }
149
+ const tail = [];
150
+ for (const k of run) if (!listed.has(k)) { tail.push(k); listed.add(k); }
151
+ for (const k of unseen) if (!listed.has(k)) { tail.push(k); listed.add(k); }
152
+ return [...head, ...tail];
153
+ }
154
+
155
+ // 弹窗当前列表:普通模式为 MRU 全量;attention 模式为运行中 + 未查看
156
+ function activeList() {
157
+ return popupMode === 'attention' ? attentionList() : mru;
158
+ }
159
+
111
160
  function closePopup(commit) {
112
161
  if (!popupEl) return;
113
162
  const idx = highlight;
163
+ const list = activeList();
114
164
  popupEl.remove();
115
165
  popupEl = null;
116
- if (commit && idx > 0 && mru[idx] && mru[idx] !== current) {
117
- void switchTo(mru[idx]).then((ok) => { if (!ok) showToast(t().switcherFailed, 'error'); });
166
+ if (commit && list[idx] && list[idx] !== current) {
167
+ void switchTo(list[idx]).then((ok) => { if (!ok) showToast(t().switcherFailed, 'error'); });
118
168
  }
119
169
  }
120
170
 
@@ -136,51 +186,94 @@ function renderPopup() {
136
186
  if (!popupEl) return;
137
187
  const L = t();
138
188
  const list = popupEl.querySelector('[data-zcodepro-switcher-list]');
139
- list.replaceChildren(...mru.map((key, i) => {
189
+ const full = activeList();
190
+ const shown = full.slice(0, visibleCount);
191
+ // attention 模式每行标状态:运行中(绿)/ 未查看(琥珀);普通模式不加
192
+ const run = popupMode === 'attention' ? wsRunningKeys() : null;
193
+ list.replaceChildren(...shown.map((key) => {
140
194
  const proj = projLabelOf(key, aliasesCache);
141
195
  return h('div', {
142
196
  class: 'zcodepro-switcher-row',
143
- 'data-active': i === highlight ? '' : undefined,
144
- onClick: () => { highlight = i; closePopup(true); },
197
+ 'data-active': full[highlight] === key ? '' : undefined,
198
+ onClick: () => { highlight = full.indexOf(key); closePopup(true); },
145
199
  },
146
200
  // 标题文本包 span:截断省略作用在文本节点上(flex 容器自身 ellipsis 无效)
147
201
  h('div', { class: 'zcodepro-switcher-title' },
148
- i === 0 ? h('span', { class: 'zcodepro-switcher-tag' }, L.switcherCurrent) : null,
202
+ key === current ? h('span', { class: 'zcodepro-switcher-tag' }, L.switcherCurrent) : null,
203
+ run && run.has(key) ? h('span', { class: 'zcodepro-switcher-tag zcodepro-switcher-tag-run' }, L.switcherRunning) : null,
204
+ run && !run.has(key) ? h('span', { class: 'zcodepro-switcher-tag zcodepro-switcher-tag-new' }, L.switcherUnseen) : null,
149
205
  h('span', null, findTitle(key))),
150
206
  // 无项目归属(如置顶)时不渲染空行
151
207
  proj ? h('div', { class: 'zcodepro-switcher-ws' }, proj) : null);
152
208
  }));
209
+ if (full.length > shown.length) {
210
+ list.append(h('div', { class: 'zcodepro-switcher-more' },
211
+ L.switcherMore.replaceAll('{n}', String(full.length - shown.length))));
212
+ }
153
213
  }
154
214
 
155
- async function openPopup() {
215
+ async function openPopup(mode = 'mru') {
156
216
  if (popupEl) return;
217
+ popupMode = mode;
218
+ visibleCount = INITIAL_COUNT;
219
+ highlight = 0;
220
+ movedSinceOpen = false;
157
221
  const cfg = await getConfig().catch(() => null);
158
222
  aliasesCache = (cfg && cfg.aliases && typeof cfg.aliases === 'object') ? cfg.aliases : {};
159
223
  ensureStyle();
160
224
  const L = t();
161
- highlight = 0;
162
225
  popupEl = h('div', {
163
226
  class: 'zcodepro-switcher-overlay',
164
227
  // 点弹窗外 = 取消(不切)
165
228
  onMousedown: (e) => { if (e.target === popupEl) closePopup(false); },
166
229
  },
167
230
  h('div', { class: 'zcodepro-switcher' },
168
- h('div', { class: 'zcodepro-switcher-hint' }, L.switcherHint),
231
+ // 提示行键位跟随当前配置(改键后新开的弹窗即用新键位)
232
+ h('div', { class: 'zcodepro-switcher-hint' },
233
+ (mode === 'attention' ? L.switcherAttentionHint : L.switcherHint)
234
+ .replaceAll('{prev}', hotkeys.switchPrev)
235
+ .replaceAll('{next}', hotkeys.switchNext)),
169
236
  h('div', { 'data-zcodepro-switcher-list': '1', class: 'zcodepro-switcher-list' })));
170
237
  document.body.append(popupEl);
171
238
  renderPopup();
172
239
  }
173
240
 
174
- function moveHighlight(delta) {
175
- if (!popupEl || mru.length < 2) return;
176
- highlight = (highlight + delta + mru.length) % mru.length;
177
- renderPopup();
178
- const row = popupEl.querySelector('[data-active]');
241
+ function scrollHighlight() {
242
+ const row = popupEl && popupEl.querySelector('[data-active]');
179
243
  if (row) row.scrollIntoView({ block: 'nearest' });
180
244
  }
181
245
 
246
+ function moveUp() {
247
+ const len = Math.min(activeList().length, visibleCount);
248
+ if (len < 2) return;
249
+ highlight = highlight > 0 ? highlight - 1 : len - 1; // 到顶循环回已加载的末尾
250
+ movedSinceOpen = true;
251
+ renderPopup();
252
+ scrollHighlight();
253
+ }
254
+
255
+ function moveDown() {
256
+ const full = activeList();
257
+ const len = Math.min(full.length, visibleCount);
258
+ if (len < 2) return;
259
+ if (highlight < len - 1) {
260
+ highlight += 1;
261
+ } else if (visibleCount < full.length) {
262
+ // 到底且还有更多:加载 5 条,高亮落在新加载的第一条
263
+ visibleCount = Math.min(visibleCount + PAGE_SIZE, full.length);
264
+ highlight = len;
265
+ } else {
266
+ highlight = 0; // 全部加载完后再到底:循环回顶部
267
+ }
268
+ movedSinceOpen = true;
269
+ renderPopup();
270
+ scrollHighlight();
271
+ }
272
+
182
273
  export function startSessionSwitch() {
183
274
  scan();
275
+ void loadHotkeys();
276
+ window.addEventListener('zcodepro:config-changed', () => { void loadHotkeys(); });
184
277
  // 事件驱动为主:任务条目相关变动(去抖 150ms)立即重扫
185
278
  new MutationObserver((muts) => { if (taskRowMutations(muts)) scheduleScan(); })
186
279
  .observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
@@ -188,6 +281,21 @@ export function startSessionSwitch() {
188
281
  setInterval(() => { if (!document.hidden) scan(); }, SCAN_FALLBACK_MS);
189
282
  document.addEventListener('visibilitychange', () => { if (!document.hidden) scan(); });
190
283
 
284
+ // “运行完未查看”跟踪:周期采样运行键集合(ws-running 维护的实时状态),
285
+ // 从运行集合消失(转终态)且结束时不是当前会话的记为未查看;
286
+ // 用户切入该会话(record)即清除。注入前已结束的任务无从判定,不追溯
287
+ setInterval(() => {
288
+ if (document.hidden) return;
289
+ const now = wsRunningKeys();
290
+ if (lastRunKeys) {
291
+ for (const k of lastRunKeys) {
292
+ if (!now.has(k) && k !== current) unseen.add(k);
293
+ }
294
+ }
295
+ lastRunKeys = now;
296
+ if (unseen.size > UNSEEN_LIMIT) unseen = new Set([...unseen].slice(-UNSEEN_LIMIT));
297
+ }, 10_000);
298
+
191
299
  // 点击兜底:bg-selected 标记不可用/未出现时,点条目(含本功能派发的合成点击)即记录
192
300
  document.addEventListener('click', (e) => {
193
301
  const item = e.target instanceof Element && e.target.closest('[data-task-item-key]');
@@ -202,17 +310,29 @@ export function startSessionSwitch() {
202
310
  closePopup(false);
203
311
  return;
204
312
  }
313
+ // 弹窗开着时方向键 / Enter 也可操作(此时用户往往已松开 alt):
314
+ // ↑/↓ 与 x/c 同规则(下到底加载 5 条),Enter 提交高亮项
315
+ if (popupEl && (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter')) {
316
+ e.preventDefault();
317
+ e.stopImmediatePropagation();
318
+ if (e.key === 'ArrowUp') moveUp();
319
+ else if (e.key === 'ArrowDown') moveDown();
320
+ else if (!e.repeat) closePopup(true);
321
+ return;
322
+ }
205
323
  if (!e.altKey || e.ctrlKey || e.metaKey || e.isComposing) return;
206
324
  // 我们的设置弹窗开着时不抢键(它有自己的 Esc/焦点逻辑)
207
325
  if (document.querySelector('.zcodepro-overlay')) return;
208
326
  const k = e.key.toLowerCase();
209
- if (k !== 'z' && k !== 'x' && k !== 'c') return;
327
+ const isHot = k === hotkeys.switchLast || k === hotkeys.switchPrev || k === hotkeys.switchNext
328
+ || k === hotkeys.switchRecent || k === hotkeys.switchAttention;
329
+ if (!isHot) return;
210
330
  e.preventDefault();
211
331
  e.stopImmediatePropagation();
212
332
  void (async () => {
213
333
  const cfg = await getConfig(); // 带 3s 缓存
214
334
  if (cfg.features && cfg.features.sessionSwitch === false) return;
215
- if (k === 'z') {
335
+ if (k === hotkeys.switchLast) {
216
336
  if (e.repeat) return;
217
337
  // 即时切换:关掉可能开着的弹窗,切到最近使用的会话(上次使用的会话)
218
338
  closePopup(false);
@@ -228,15 +348,34 @@ export function startSessionSwitch() {
228
348
  void switchTo(target).then((ok) => { if (!ok) showToast(t().switcherFailed, 'error'); });
229
349
  return;
230
350
  }
231
- // x/c:弹窗导航(无可导航会话时与 z 一致轻提示)
351
+ // r:运行中 / 运行完未查看的任务(attention 弹窗)
352
+ if (k === hotkeys.switchAttention) {
353
+ if (e.repeat) return;
354
+ closePopup(false);
355
+ if (!attentionList().length) {
356
+ showToast(t().switcherAttentionEmpty);
357
+ return;
358
+ }
359
+ await openPopup('attention');
360
+ return;
361
+ }
362
+ // x/c/e:最近使用弹窗(无可导航会话时与 z 一致轻提示)
232
363
  if (mru.length < 2) { showToast(t().switcherEmpty); return; }
233
- if (!popupEl) await openPopup();
234
- moveHighlight(k === 'x' ? 1 : -1);
364
+ // e 只负责打开(初始显示最近 10 条,c 可继续加载更早的)
365
+ if (k === hotkeys.switchRecent) {
366
+ if (!e.repeat && !popupEl) await openPopup('mru');
367
+ return;
368
+ }
369
+ if (!popupEl) await openPopup('mru');
370
+ // 键位对调:alt+x 上移一位(更靠上/更近的键管向上),alt+c 下移
371
+ if (k === hotkeys.switchPrev) moveUp(); else moveDown();
235
372
  })();
236
373
  }, true);
237
374
 
238
375
  document.addEventListener('keyup', (e) => {
239
- if (e.key === 'Alt' && popupEl) closePopup(true); // 松开 alt:提交高亮项
376
+ // 松开 alt:移动过高亮则提交(alt+tab 流);从未移动则保持弹窗打开,
377
+ // 让用户继续用方向键选择(alt+e / alt+r 打开后松手查看的场景)
378
+ if (e.key === 'Alt' && popupEl && movedSinceOpen) closePopup(true);
240
379
  }, true);
241
380
 
242
381
  // 窗口失焦(alt+tab 到别的应用等)视同取消