nx-kv 0.12.0 → 0.12.1

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/CHANGELOG.md CHANGED
@@ -2,9 +2,44 @@
2
2
 
3
3
  本文件记录对外可见的变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
4
4
 
5
+ ## [0.12.1] - 2026-10-30 key 归一化修复 + remote 对齐反馈
6
+
7
+ ### Fixed
8
+
9
+ - **keyFromRemote 在 Linux 上多一个前导斜杠**:同一地址在「派生」(keyFromRemote)与
10
+ 「归一」(normalizeKey)两条路上算出的 key 必须一致。Linux 的 `file:///tmp/x` 剥协议后
11
+ 带前导斜杠,于是「派生」得 `/tmp/x`、「归一」得 `tmp/x` —— 同一份数据算出两个 key,
12
+ 导入时参考关系直接连不上(「参考仓库不存在」)。Windows 上是盘符所以走不到这个分支,
13
+ 只有 CI 红。keyFromRemote 的结果现在就规范化(它是「key 的来源」,不该产出非规范形态)。
14
+ - **remote 对齐弹窗的「检查」点了像没反应**:git ls-remote 要几秒,期间界面毫无变化;
15
+ 出错又被 toast 吞掉(一闪而过)。改成点击即显示「检查中…」,错误原因直接写进弹窗
16
+ (错误信息不该只活 3 秒)。
17
+
18
+ ### Changed
19
+
20
+ - 并入三个工作树的成果:md-desc(关系改 key + 导入占位 + remote 对齐)、
21
+ sidebar-repo-boards(侧边栏改用仓库登记 + 看板)、fix-checkbox-width
22
+ (勾选框被 width:100% 撑满 → 选项列表看着一片空白)。
23
+
5
24
  ## [0.12.0] - 2026-10-30 仓库身份改用全局唯一 key + 导入导出重做
6
25
 
7
- ### Added
26
+ ### Changed
27
+
28
+ - **侧边栏不再自建一套「目录 + 分组」,改为直接用仓库登记与看板**。
29
+ 以前侧边栏的「目录」是本地任务队列按 cwd 自动生成的主题,「分组」存在浏览器
30
+ localStorage 里(`prefs.groups` / `prefs.dirGroups`)——后者与仓库页的看板是
31
+ **两套互不相干的数据**:换浏览器分组就没了,改看板侧边栏也不知道。
32
+ 现在:
33
+ - 目录行 = **仓库登记**里的开发仓库(带全局唯一 key、描述、路径);
34
+ - 分组行 = **看板**(展开看成员),归属关系就是看板的成员表;
35
+ - 侧边栏只保留纯 UI 偏好:排序、隐藏、展开态、收起态(`prefs` 里的
36
+ `groups` / `dirGroups` 已废弃并在读取时从 localStorage 抹掉);
37
+ - 行菜单改操作真实数据:「归入/移出看板」走 `board assign`,
38
+ 「+」菜单变成「登记仓库 / 新建看板」(原来建的是任务队列主题,不是仓库登记);
39
+ - 拖拽只改顺序(显示态),归属一律走行菜单——拖拽不再写本地分组。
40
+ - 仓库模块:看板成员从随机 id(`repoIds`)迁到 **key**(`repoKeys`),读时自动
41
+ 迁移并落盘;绑定目标同样记 key;`keyFromRemote` 剥 `file://` URL 的盘符冒号
42
+ (否则 `normalizeKey` 会把 `:` 压成 `-`,导致「登记的是 A、成员表里是 B」)。
8
43
 
9
44
  - **内部关系全面改用 key(不再是随机 id)+ 导入分组的「本地未拥有」占位**:
10
45
  - **关系一律用 key**:看板成员存 `repoKeys`(不是 `repoIds`)、组内描述按 key、
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-kv",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "nx-kv — KV 清单管理中枢(远程)+ 本机本地任务队列(localtask):serve 驱动 CLI 与 Web 面板,同源同构,附带可安装 skill 供 agent 提交 / 领取 / 阻塞接收任务",
5
5
  "type": "module",
6
6
  "bin": {
@@ -57,11 +57,17 @@ export async function loadStore() {
57
57
  const raw = await fsp.readFile(storePath(), 'utf8');
58
58
  const data = JSON.parse(raw);
59
59
  if (!data || typeof data !== 'object') return emptyStore();
60
- return {
60
+ const store = {
61
61
  version: 1,
62
62
  repos: Array.isArray(data.repos) ? data.repos : [],
63
63
  boards: Array.isArray(data.boards) ? data.boards : [],
64
64
  };
65
+ // 老结构(看板成员存随机 id)→ 新结构(存 key):读时改写并落盘。
66
+ // 放这里 = 唯一入口,任何读路径都自动看到迁移后的数据。
67
+ if (migrateStoreKeys(store)) {
68
+ await saveStore(store).catch(() => {});
69
+ }
70
+ return store;
65
71
  } catch {
66
72
  /* 缺失或损坏 → 空 store */
67
73
  return emptyStore();
@@ -178,6 +184,9 @@ export function keyFromRemote(url) {
178
184
  s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
179
185
  s = s.replace(/^[^/]*@/, ''); // 丢 user@
180
186
  s = s.replace(/^([^/]+?):\d+(\/|$)/, '$1$2'); // 丢 :port(只认紧跟 host 的那个冒号)
187
+ // file:///D:/path 这类本地 URL:盘符冒号也要丢(key 里不允许 `:`——
188
+ // normalizeKey 会把它压成 -,两边不一致就会「登记的是 A、成员表里是 B」)
189
+ s = s.replace(/^([a-z]):/i, '$1');
181
190
  }
182
191
  s = s.replace(/\.git$/i, '');
183
192
  s = s.replace(/\/{2,}/g, '/');
@@ -187,6 +196,10 @@ export function keyFromRemote(url) {
187
196
  const host = s.slice(0, slash).toLowerCase();
188
197
  s = (hostAlias[host] || host) + s.slice(slash);
189
198
  }
199
+ // 去首尾斜杠:file:///tmp/x 剥协议后是 "/tmp/x",带前导斜杠的话同一份数据在
200
+ // 「派生」与「归一」两条路上会算出不同的 key(/tmp/x vs tmp/x)——
201
+ // 表现为「登记的是 A、成员表里是 B」,关系连不上。
202
+ s = s.replace(/^\/+|\/+$/g, '');
190
203
  return s.toLowerCase();
191
204
  }
192
205
 
@@ -264,6 +277,26 @@ export function boardRepoKeys(board, store) {
264
277
  return out;
265
278
  }
266
279
 
280
+ /**
281
+ * 老结构(board.repoIds)→ 新结构(board.repoKeys)的一次性**落盘**迁移。
282
+ *
283
+ * boardRepoKeys 的兼容读解决「迁移前也能看清单」,但盘上还是老结构:
284
+ * 每次读都现算,而且仓库登记一变(改名/换 remote),key 就跟着变,
285
+ * 与别处按 key 存的关系对不上。所以读的时候顺手把新结构写回去(幂等)。
286
+ */
287
+ export function migrateStoreKeys(store) {
288
+ if (!store || !Array.isArray(store.boards)) return false;
289
+ let changed = false;
290
+ for (const b of store.boards) {
291
+ if (Array.isArray(b.repoKeys)) continue; // 已是新结构
292
+ const ids = Array.isArray(b.repoIds) ? b.repoIds : [];
293
+ b.repoKeys = ids.length ? boardRepoKeys(b, store) : [];
294
+ delete b.repoIds;
295
+ changed = true;
296
+ }
297
+ return changed;
298
+ }
299
+
267
300
  /**
268
301
  * **中心清单**:一份扁平的仓库列表(按名称排序),每条带它所属的看板。
269
302
  *
@@ -310,11 +310,27 @@ export default function ReposView() {
310
310
  toast('已对齐 remote');
311
311
  await Promise.all([loadRepos(), loadRefCheck()]);
312
312
  });
313
- const checkRemote = (url) => guard(async () => {
314
- const d = await api('/api/repos/remote/check?id=' + encodeURIComponent(remoteInfo.key)
315
- + (url ? '&url=' + encodeURIComponent(url) : ''));
316
- setRemoteCheck(d);
317
- });
313
+ // 存在性检查:git ls-remote 要几秒,期间不给任何反馈会像「点了没反应」。
314
+ // 错误直接写进弹窗(remoteCheck.detail),不靠 toast —— toast 会一闪而过。
315
+ const checkRemote = async (url) => {
316
+ const key = remoteInfo ? remoteInfo.key : '';
317
+ if (!key) {
318
+ setRemoteCheck({ url: '', status: 'unreachable', detail: '没有仓库标识' });
319
+ return;
320
+ }
321
+ setRemoteCheck({ url: url || remoteInfo.current || '', status: 'checking', detail: '检查中…' });
322
+ try {
323
+ const d = await api('/api/repos/remote/check?id=' + encodeURIComponent(key)
324
+ + (url ? '&url=' + encodeURIComponent(url) : ''));
325
+ setRemoteCheck(d);
326
+ } catch (e) {
327
+ setRemoteCheck({
328
+ url: url || remoteInfo.current || '',
329
+ status: 'unreachable',
330
+ detail: String((e && e.message) || e),
331
+ });
332
+ }
333
+ };
318
334
 
319
335
 
320
336
  // 组内描述保存:note 为空 = 清除
@@ -1291,8 +1307,11 @@ export default function ReposView() {
1291
1307
  <div className="form-field">
1292
1308
  <label>存在性检查</label>
1293
1309
  <div className="rp-remote-check">
1294
- <span className={'rp-status ' + (remoteCheck.status === 'exists' ? 'same' : remoteCheck.status === 'missing' ? 'new' : 'missing')}>
1295
- {remoteCheck.status === 'exists' ? '存在' : remoteCheck.status === 'missing' ? '不存在' : '不可达'}
1310
+ <span className={'rp-status ' + (remoteCheck.status === 'exists' ? 'same' : remoteCheck.status === 'missing' ? 'new' : remoteCheck.status === 'checking' ? 'checking' : 'missing')}>
1311
+ {remoteCheck.status === 'exists' ? '存在'
1312
+ : remoteCheck.status === 'missing' ? '不存在'
1313
+ : remoteCheck.status === 'checking' ? '检查中'
1314
+ : '不可达'}
1296
1315
  </span>
1297
1316
  <span className="mono rp-remote-url">{remoteCheck.url}</span>
1298
1317
  <span className="muted">{remoteCheck.detail}</span>
@@ -100,6 +100,27 @@ export default function App() {
100
100
  return () => clearInterval(t);
101
101
  }, [refreshLocal]);
102
102
 
103
+ // ─── 仓库与看板:侧边栏的**唯一数据源** ───
104
+ // 侧边栏的「目录」= 仓库登记,「分组」= 看板。以前侧边栏自己维护一套
105
+ // localStorage 分组,与仓库页的看板互不相干——那才是两套数据。
106
+ const [repoData, setRepoData] = useState({ repos: [], boards: [] });
107
+ const refreshRepos = useCallback(async () => {
108
+ try {
109
+ const [repos, boards] = await Promise.all([api('/api/repos'), api('/api/boards')]);
110
+ setRepoData({
111
+ repos: (repos || []).filter((r) => (r.type || 'dev') !== 'ref'), // 参考仓库不进侧边栏
112
+ boards: (boards && boards.boards) || [],
113
+ });
114
+ } catch {
115
+ /* 读失败:保留旧数据,侧边栏仍可用 */
116
+ }
117
+ }, []);
118
+ useEffect(() => {
119
+ refreshRepos();
120
+ const t = setInterval(refreshRepos, 30_000);
121
+ return () => clearInterval(t);
122
+ }, [refreshRepos]);
123
+
103
124
  // ─── 等待回填提醒(沿用 v1:presence 轮询,用户停在任意页都要知道)───
104
125
  useEffect(() => {
105
126
  let stopped = false;
@@ -139,16 +160,18 @@ export default function App() {
139
160
  {prefs.folded ? (
140
161
  <Rail
141
162
  route={route} onNavigate={onNavigate} prefs={prefs} patchPrefs={patchPrefs}
142
- rows={rows} waitingCount={waitingCount} boot={boot}
163
+ repos={repoData.repos} rows={rows} waitingCount={waitingCount} boot={boot}
143
164
  />
144
165
  ) : (
145
166
  <>
146
167
  {/* 窄屏(≤900px)侧栏浮层化后的点外收起遮罩;宽屏 display:none 不参与 */}
147
168
  <div className="side-scrim" onClick={() => patchPrefs({ folded: true })} aria-hidden="true" />
148
169
  <Sidebar
149
- topics={rows} route={route} onNavigate={onNavigate}
170
+ repos={repoData.repos} boards={repoData.boards} topics={rows}
171
+ route={route} onNavigate={onNavigate}
150
172
  boot={boot} waitingCount={waitingCount}
151
- prefs={prefs} patchPrefs={patchPrefs} refreshLocal={refreshLocal}
173
+ prefs={prefs} patchPrefs={patchPrefs}
174
+ refreshRepos={refreshRepos}
152
175
  />
153
176
  </>
154
177
  )}
@@ -196,8 +219,21 @@ function GlobalEntry({ rows, refreshLocal, boot, onNavigate }) {
196
219
  }
197
220
 
198
221
  // 收起态 rail:52px 图标条,悬停气泡显示名称,徽标仍在(原型 §2/§3)
199
- function Rail({ route, onNavigate, patchPrefs, rows, waitingCount, boot }) {
200
- const dirs = rows.filter((r) => !r.isGlobal).slice(0, 9);
222
+ function Rail({ route, onNavigate, patchPrefs, repos, rows, waitingCount, boot }) {
223
+ // 目录 = 仓库登记(与展开态同一份数据);待领取数从队列按仓库汇总
224
+ const pendingOf = new Map();
225
+ const topicKeyOf = new Map();
226
+ for (const t of rows) {
227
+ if (t.isGlobal || !t.repo) continue;
228
+ pendingOf.set(t.repo.id, (pendingOf.get(t.repo.id) || 0) + (t.pending || 0));
229
+ if (!topicKeyOf.has(t.repo.id)) topicKeyOf.set(t.repo.id, t.key);
230
+ }
231
+ const dirs = (repos || []).slice(0, 9).map((r) => ({
232
+ key: topicKeyOf.get(r.id) || r.key || r.id,
233
+ name: r.name,
234
+ pending: pendingOf.get(r.id) || 0,
235
+ waiting: (rows || []).some((t) => t.repo && t.repo.id === r.id && t.waiting),
236
+ }));
201
237
  const globalRow = rows.find((r) => r.isGlobal);
202
238
  const btn = (active, tip, onClick, icon, badge, dot, key) => (
203
239
  <button
@@ -1,11 +1,13 @@
1
1
  // 侧边栏拖拽落点判定(纯函数)——三段式落点,吸收自 nx-ft:
2
2
  // 行上缘 30% = before(排到它前面,指示线)
3
3
  // 行下缘 30% = after (排到它后面,指示线)
4
- // 中段 = into (移入该分组,仅分组行接受,描边;目录行中段不作落点——
5
- // 没有「目录套目录」,不给假提示)
4
+ // 中段 = into (移入该看板,仅看板行接受,描边;仓库行中段不作落点——
5
+ // 没有「仓库套仓库」,不给假提示)
6
6
  // 树空白处 = tail (拖到末尾)
7
- // canDropInto 守卫:不能把分组放进自己的子树(这里没有子树,守卫退化为
8
- // 「不能放进自己」+ 目标类型检查)。
7
+ //
8
+ // **拖拽只改顺序,不改归属**:归属是数据(看板成员表),由行菜单里的
9
+ // 「归入/移出看板」改;拖拽是显示态(prefs.order)。以前拖进「分组」会写
10
+ // prefs.dirGroups —— 那套本地分组已经废弃,看板才是唯一归属。
9
11
 
10
12
  export function zoneOf(e, rowEl) {
11
13
  const r = rowEl.getBoundingClientRect();
@@ -18,21 +20,21 @@ export function zoneOf(e, rowEl) {
18
20
 
19
21
  export function canDrop(drag, target, zone) {
20
22
  if (!drag) return false;
21
- if (drag.type === 'dir') {
22
- if (target.type === 'group') return zone === 'into'; // 目录→分组:仅入组
23
- if (target.type === 'dir') return zone === 'before' || zone === 'after'; // 排序
23
+ if (drag.type === 'repo') {
24
+ if (target.type === 'board') return zone === 'into'; // 仓库→看板:仅入组(拖拽仅排序,不改归属,见文件头)
25
+ if (target.type === 'repo') return zone === 'before' || zone === 'after'; // 排序
24
26
  return false;
25
27
  }
26
- if (drag.type === 'group') {
27
- if (target.type === 'group') return target.id !== drag.id && (zone === 'before' || zone === 'after');
28
- return false; // 分组不进目录、不进树尾之外的位置
28
+ if (drag.type === 'board') {
29
+ if (target.type === 'board') return target.id !== drag.id && (zone === 'before' || zone === 'after');
30
+ return false; // 看板不进仓库行
29
31
  }
30
32
  return false;
31
33
  }
32
34
 
33
35
  // 拖拽文案(跟随提示)
34
36
  export function dropLabel(drag, target, zone) {
35
- if (target.type === 'group' && zone === 'into') return `移入分组「${target.name}」`;
37
+ if (target.type === 'board' && zone === 'into') return `归入看板「${target.name}」`;
36
38
  const name = target.name || '';
37
39
  if (zone === 'before') return `排到「${name}」之前`;
38
40
  if (zone === 'after') return `排到「${name}」之后`;
@@ -42,40 +44,27 @@ export function dropLabel(drag, target, zone) {
42
44
 
43
45
  /**
44
46
  * 把落点应用到偏好(纯函数,返回 patch 片段)。
45
- * @returns {{order?, groups?, dirGroups?}} 需要合并进 prefs 的字段
47
+ * 只回 {order} —— 排序是显示态;归属不动(那是数据,走行菜单)。
46
48
  */
47
49
  export function applyDrop(prefs, drag, target, zone) {
48
- const dirGroupOf = (key) => (prefs.dirGroups || {})[key] || '';
50
+ const order = [...(prefs.order || [])];
51
+ const keyOf = (r) => (r.type === 'board' ? 'board:' + r.id : r.key);
49
52
 
50
- if (drag.type === 'dir') {
51
- const dirGroups = { ...(prefs.dirGroups || {}) };
52
- if (target.type === 'group' && zone === 'into') {
53
- dirGroups[drag.key] = target.id;
54
- return { dirGroups };
55
- }
56
- // 排序:重建顶层 order(组内序由 order 里的相对位置决定,分组归属不变)
57
- if (target.type === 'dir') {
58
- dirGroups[drag.key] = target.groupId ?? dirGroupOf(target.key);
59
- const others = collectDirKeys(prefs).filter((k) => k !== drag.key);
60
- const at = others.indexOf(target.key);
61
- const order = others.slice();
62
- order.splice(zone === 'before' ? Math.max(0, at) : at + 1, 0, drag.key);
63
- return { order, dirGroups };
64
- }
53
+ if (drag.type === 'repo' && target.type === 'repo') {
54
+ const others = order.filter((k) => k !== keyOf(drag));
55
+ const at = others.indexOf(keyOf(target));
56
+ if (at < 0) return { order };
57
+ others.splice(zone === 'before' ? at : at + 1, 0, keyOf(drag));
58
+ return { order: others };
65
59
  }
66
60
 
67
- if (drag.type === 'group' && target.type === 'group') {
68
- const ids = (prefs.groups || []).map((g) => g.id).filter((id) => id !== drag.id);
69
- const at = ids.indexOf(target.key || target.id);
70
- ids.splice(zone === 'before' ? Math.max(0, at) : at + 1, 0, drag.id);
71
- const byId = new Map((prefs.groups || []).map((g) => [g.id, g]));
72
- return { groups: ids.map((id) => byId.get(id)).filter(Boolean) };
61
+ if (drag.type === 'board' && target.type === 'board') {
62
+ const others = order.filter((k) => k !== keyOf(drag));
63
+ const at = others.indexOf(keyOf(target));
64
+ if (at < 0) return { order };
65
+ others.splice(zone === 'before' ? at : at + 1, 0, keyOf(drag));
66
+ return { order: others };
73
67
  }
74
- return {};
75
- }
76
68
 
77
- // 顶层编排序里的全部目录 key(按当前分组展开序)
78
- function collectDirKeys(prefs) {
79
- // order 只存目录 key(applyOrder 用);分组归属不影响相对顺序的重建
80
- return [...(prefs.order || [])];
69
+ return {};
81
70
  }
@@ -1,6 +1,9 @@
1
- // 侧边栏(应用壳的左栏):品牌区 + 搜索 + 树(全局池恒顶 → 分组 → 目录)+ 底部入口。
1
+ // 侧边栏(应用壳的左栏):品牌区 + 搜索 + 树(全局池恒顶 → 看板 → 仓库)+ 底部入口。
2
2
  // 收起态渲染为 52px 图标 rail(悬停气泡、徽标仍在)。
3
3
  // 拖拽编排 = 三段式落点(drag.js);可见序 = 渲染序,键盘导航走同一份扁平序。
4
+ //
5
+ // **数据源**:仓库登记 + 看板(真实数据,见 model.js)。侧边栏不持有自己的分组体系,
6
+ // 也不持有自己的目录清单——「目录」就是仓库登记里的开发仓库。
4
7
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
5
8
  import { Icon } from '../icons.jsx';
6
9
  import { PopMenu } from '../overlay.jsx';
@@ -11,37 +14,39 @@ import { rowMenuItems, addMenuItems, cfgMenuItems } from './row-menu.jsx';
11
14
  import TaskChildren from './task-children.jsx';
12
15
 
13
16
  export default function Sidebar({
14
- topics, // mergeDirectoryRows+withGlobalRow 产物(含 isGlobal 行)
17
+ repos, // 开发仓库(type != ref)
18
+ boards, // 看板
19
+ topics, // 本机任务队列(按 cwd 匹配仓库)
15
20
  route, // {view, key}
16
21
  onNavigate, // (hash) => void
17
22
  boot,
18
23
  waitingCount,
19
24
  prefs,
20
25
  patchPrefs, // (patch) => void(已持久化)
21
- refreshLocal, // 添加目录后刷新数据
26
+ refreshRepos, // 仓库/看板刷新(登记、归属变更后)
22
27
  }) {
23
28
  const toast = useToast();
24
29
  const { dialog, node: dialogNode } = useDialog();
25
30
  const [kw, setKw] = useState('');
26
31
  const [menu, setMenu] = useState(null); // {x,y,items}
27
32
  const [kbIndex, setKbIndex] = useState(-1); // 键盘焦点行
28
- const [expanded, setExpanded] = useState(() => new Set()); // 展开子行的目录 key
33
+ const [expanded, setExpanded] = useState(() => new Set()); // 展开子行的仓库 key
29
34
  const dragRef = useRef(null); // {type,key|id,name}
30
35
  const [dropHint, setDropHint] = useState(null); // {key|id, zone}
31
36
  const treeRef = useRef(null);
32
37
 
33
38
  const { rows, matchCount } = useMemo(
34
- () => buildTree(topics, prefs, kw),
35
- [topics, prefs, kw]
39
+ () => buildTree(repos, boards, topics, prefs, kw),
40
+ [repos, boards, topics, prefs, kw]
36
41
  );
37
42
 
38
43
  const isActive = (row) => {
39
44
  if (row.type === 'global') return route.view === 'global';
40
- if (row.type === 'dir') return route.view === 'dir' && route.key === row.key;
45
+ if (row.type === 'repo') return route.view === 'dir' && route.key === row.topicKey;
41
46
  return false;
42
47
  };
43
48
 
44
- // 当前路由所在目录自动展开子行(深链 / 总览跳转进来也能看到层级)
49
+ // 当前路由所在仓库自动展开子行(深链 / 总览跳转进来也能看到层级)
45
50
  useEffect(() => {
46
51
  if (route.view !== 'dir' || !route.key) return;
47
52
  setExpanded((prev) => {
@@ -61,15 +66,16 @@ export default function Sidebar({
61
66
  });
62
67
  }, []);
63
68
 
64
- // 点击目录 = 展开/收起子行(不再跳工作台);打开页面走子行,工作台全屏编辑只在具体页面上
69
+ // 点击:看板 = 展开/收起成员;仓库 = 展开/收起子行(任务);全局 = 进全局池。
70
+ // 仓库行没有任务队列时(topicKey 为空)点击不导航——没有可打开的工作台。
65
71
  const openRow = useCallback(
66
72
  (row) => {
67
- if (row.type === 'group') {
73
+ if (row.type === 'board') {
68
74
  patchPrefs((p) => ({ groupOpen: { ...(p.groupOpen || {}), [row.id]: !row.open } }));
69
75
  } else if (row.type === 'global') {
70
76
  onNavigate('#/global');
71
77
  } else {
72
- toggleExpanded(row.key);
78
+ toggleExpanded(row.topicKey || row.key);
73
79
  }
74
80
  },
75
81
  [onNavigate, patchPrefs, toggleExpanded]
@@ -135,10 +141,11 @@ export default function Sidebar({
135
141
  const drag = dragRef.current;
136
142
  dragRef.current = null;
137
143
  setDropHint(null);
138
- if (!drag || drag.type !== 'dir') return;
144
+ if (!drag || drag.type !== 'repo') return;
139
145
  e.preventDefault();
140
- const order = (prefs.order || []).filter((k) => k !== drag.key);
141
- order.push(drag.key);
146
+ const key = drag.key;
147
+ const order = (prefs.order || []).filter((k) => k !== key);
148
+ order.push(key);
142
149
  patchPrefs({ order });
143
150
  };
144
151
 
@@ -146,22 +153,16 @@ export default function Sidebar({
146
153
  const openRowMenu = (e, row) => {
147
154
  e.preventDefault();
148
155
  e.stopPropagation();
149
- setMenu({ x: e.clientX, y: e.clientY, items: rowMenuItems({ row, prefs, patchPrefs, onNavigate, dialog, newGroup }) });
156
+ setMenu({
157
+ x: e.clientX,
158
+ y: e.clientY,
159
+ items: rowMenuItems({ row, prefs, patchPrefs, onNavigate, boards, onDataChanged: refreshRepos }),
160
+ });
150
161
  };
151
162
 
152
- // ─── 添加目录 / 新建分组 ───
163
+ // ─── 登记仓库 / 新建看板 ───
153
164
  const addMenu = (e) => {
154
- setMenu({ x: e.clientX, y: e.clientY, items: addMenuItems({ dialog, toast, refreshLocal, newGroup }) });
155
- };
156
- const newGroup = async (moveKey) => {
157
- const name = await dialog({ title: '新建分组', input: true, placeholder: '分组名' });
158
- if (!name) return;
159
- const id = 'g' + Date.now().toString(36);
160
- patchPrefs((p) => {
161
- const patch = { groups: [...(p.groups || []), { id, name }] };
162
- if (moveKey) patch.dirGroups = { ...(p.dirGroups || {}), [moveKey]: id };
163
- return patch;
164
- });
165
+ setMenu({ x: e.clientX, y: e.clientY, items: addMenuItems({ dialog, toast, onDataChanged: refreshRepos }) });
165
166
  };
166
167
 
167
168
  // ─── 设置气泡 ───
@@ -204,7 +205,7 @@ export default function Sidebar({
204
205
  <Icon name="search" />
205
206
  <input
206
207
  value={kw}
207
- placeholder="搜索目录(命中自动展开)"
208
+ placeholder="搜索仓库(命中自动展开)"
208
209
  onChange={(e) => setKw(e.target.value)}
209
210
  onKeyDown={(e) => { if (e.key === 'Escape') setKw(''); }}
210
211
  />
@@ -221,13 +222,13 @@ export default function Sidebar({
221
222
  ref={treeRef}
222
223
  tabIndex={0}
223
224
  onKeyDown={onTreeKey}
224
- onDragOver={(e) => { if (dragRef.current?.type === 'dir') e.preventDefault(); }}
225
+ onDragOver={(e) => { if (dragRef.current?.type === 'repo') e.preventDefault(); }}
225
226
  onDrop={onTreeDropTail}
226
227
  >
227
228
  {rows.map((row, i) => (
228
- row.type === 'group' ? (
229
+ row.type === 'board' ? (
229
230
  <button
230
- key={'g:' + row.id}
231
+ key={'b:' + row.id}
231
232
  className={'trow group' + (row.open ? ' open' : '') + (kbIndex === i ? ' kb-focus' : '')}
232
233
  onClick={() => openRow(row)}
233
234
  onContextMenu={(e) => openRowMenu(e, row)}
@@ -238,13 +239,14 @@ export default function Sidebar({
238
239
  onDragLeave={onDragLeave}
239
240
  onDrop={(e) => onDrop(e, row)}
240
241
  data-drop={dropHint?.id === row.id ? dropHint.zone : undefined}
242
+ title={row.desc || row.name}
241
243
  >
242
244
  <span className="tw"><Icon name="chevron" size={10} /></span>
243
245
  <span className="gico"><Icon name="folder" /></span>
244
246
  <span className="nm">{row.name}</span>
245
247
  <span className="cnt">{row.count}</span>
246
248
  <span
247
- className="more" role="button" tabIndex={-1} aria-label="分组菜单"
249
+ className="more" role="button" tabIndex={-1} aria-label="看板菜单"
248
250
  onClick={(e) => openRowMenu(e, { ...row, open: row.open })}
249
251
  >
250
252
  <Icon name="dots" size={13} />
@@ -254,15 +256,15 @@ export default function Sidebar({
254
256
  <div key={row.type + ':' + row.key} className="tblock">
255
257
  <button
256
258
  className={
257
- 'trow' + (row.type === 'dir' && row.groupId ? ' child' : '') +
258
- (row.type === 'dir' && expanded.has(row.key) ? ' open' : '') +
259
+ 'trow' + (row.type === 'repo' && row.groupId ? ' child' : '') +
260
+ (row.type === 'repo' && expanded.has(row.topicKey) ? ' open' : '') +
259
261
  (isActive(row) ? ' active' : '') +
260
262
  (row.pending > 0 ? ' pinned' : '') +
261
263
  (kbIndex === i ? ' kb-focus' : '')
262
264
  }
263
265
  onClick={() => openRow(row)}
264
266
  onContextMenu={(e) => openRowMenu(e, row)}
265
- title={row.cwd ? `${row.cwd}\n${row.summary || ''}` : row.name}
267
+ title={row.cwd ? `${row.name}\n${row.cwd}\n${row.desc || row.summary || ''}` : row.name}
266
268
  draggable
267
269
  onDragStart={(e) => onDragStart(e, row)}
268
270
  onDragEnd={onDragEnd}
@@ -278,7 +280,7 @@ export default function Sidebar({
278
280
  {row.type === 'global' && row.waiting ? <span className="wdot" title="有等待中的主题" /> : null}
279
281
  </span>
280
282
  {row.pending > 0 && <span className="cnt">{row.pending}</span>}
281
- {row.type === 'dir' && row.waiting && <span className="wdot" title="agent 等待回填中" />}
283
+ {row.type === 'repo' && row.waiting && <span className="wdot" title="agent 等待回填中" />}
282
284
  <span
283
285
  className="more" role="button" tabIndex={-1} aria-label="行菜单"
284
286
  onClick={(e) => openRowMenu(e, row)}
@@ -286,13 +288,13 @@ export default function Sidebar({
286
288
  <Icon name="dots" size={13} />
287
289
  </span>
288
290
  </button>
289
- {(row.type === 'global' ? isActive(row) : expanded.has(row.key)) && (
291
+ {(row.type === 'global' ? isActive(row) : expanded.has(row.topicKey)) && (
290
292
  <TaskChildren row={row} onNavigate={onNavigate} />
291
293
  )}
292
294
  </div>
293
295
  )
294
296
  ))}
295
- {!rows.length && <div className="tree-empty">{kw ? '没有匹配的目录' : '暂无目录:点右上 + 添加'}</div>}
297
+ {!rows.length && <div className="tree-empty">{kw ? '没有匹配的仓库' : '还没有登记仓库:点右上 + 登记,或去「仓库管理」'}</div>}
296
298
  <div className="droptail" aria-hidden="true" />
297
299
  </div>
298
300