nx-kv 0.12.0 → 0.15.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +154 -1
  2. package/assets/nx-kv/references/repo-registry.md +56 -3
  3. package/package.json +1 -1
  4. package/src/modules/auth/index.js +2 -1
  5. package/src/modules/repos/boards.js +8 -3
  6. package/src/modules/repos/detail.jsx +289 -0
  7. package/src/modules/repos/index.js +61 -4
  8. package/src/modules/repos/keys.js +6 -1
  9. package/src/modules/repos/remote-panel.jsx +218 -0
  10. package/src/modules/repos/remote.js +111 -10
  11. package/src/modules/repos/service.js +183 -19
  12. package/src/modules/repos/transfer.js +29 -12
  13. package/src/modules/repos/view.jsx +74 -150
  14. package/src/modules/todo/view.jsx +71 -3
  15. package/src/web/frontend/App.jsx +50 -10
  16. package/src/web/frontend/components/icons.jsx +7 -0
  17. package/src/web/frontend/components/layout/tabs.jsx +2 -0
  18. package/src/web/frontend/components/sidebar/drag.js +74 -46
  19. package/src/web/frontend/components/sidebar/index.jsx +137 -88
  20. package/src/web/frontend/components/sidebar/model.js +199 -66
  21. package/src/web/frontend/components/sidebar/row-menu.jsx +167 -35
  22. package/src/web/frontend/components/sidebar/task-children.jsx +17 -7
  23. package/src/web/frontend/modules/md-workspace/md-edit-ops.js +340 -0
  24. package/src/web/frontend/modules/md-workspace/md-history.js +78 -0
  25. package/src/web/frontend/modules/md-workspace/md-panes.jsx +1109 -68
  26. package/src/web/frontend/modules/md-workspace/md-parse.js +38 -8
  27. package/src/web/frontend/modules/md-workspace/sel-menu.jsx +55 -8
  28. package/src/web/frontend/modules/md-workspace/shortcut-sheet.jsx +90 -0
  29. package/src/web/frontend/modules/md-workspace/style.css +272 -18
  30. package/src/web/frontend/modules/md-workspace/tool-bar.jsx +216 -19
  31. package/src/web/frontend/modules/md-workspace/view.jsx +131 -14
  32. package/src/web/frontend/prefs.js +94 -10
  33. package/src/web/frontend/registry.js +2 -1
  34. package/src/web/frontend/style-v2.css +185 -0
  35. package/src/web/frontend/style.css +1 -0
  36. package/src/web/public/assets/{CliHints-Br4yygbI.js → CliHints-ClCH9hMc.js} +1 -1
  37. package/src/web/public/assets/detail-DLLVTDvq.js +2 -0
  38. package/src/web/public/assets/index-DAAwgr0O.js +11 -0
  39. package/src/web/public/assets/index-DUWdqYzy.css +1 -0
  40. package/src/web/public/assets/md-parse-BAglkG0y.js +18 -0
  41. package/src/web/public/assets/view-BI8jWJma.js +10 -0
  42. package/src/web/public/assets/view-BVRdFSDe.js +26 -0
  43. package/src/web/public/assets/{view-B4fbTb4n.js → view-Bd2rRJ3_.js} +3 -3
  44. package/src/web/public/assets/view-CD0wpGx_.css +1 -0
  45. package/src/web/public/assets/{view-overview-tr3l0EUh.js → view-overview-BzsxIsw5.js} +1 -1
  46. package/src/web/public/index.html +2 -2
  47. package/src/modules/auth/view.jsx +0 -78
  48. package/src/web/public/assets/index-D4KgmRkn.js +0 -11
  49. package/src/web/public/assets/index-D4kU6s0M.css +0 -1
  50. package/src/web/public/assets/view-BcGdYDG2.js +0 -21
  51. package/src/web/public/assets/view-BjbVZ-n0.js +0 -19
  52. package/src/web/public/assets/view-DvCKCWZ8.js +0 -1
  53. package/src/web/public/assets/view-DxSQKWVx.css +0 -1
package/CHANGELOG.md CHANGED
@@ -2,10 +2,144 @@
2
2
 
3
3
  本文件记录对外可见的变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
4
4
 
5
- ## [0.12.0] - 2026-10-30 仓库身份改用全局唯一 key + 导入导出重做
5
+ ## [0.15.0] - 2026-10-31 仓库详情页 + desc 升级成 md 列表
6
+
7
+ ### Added
8
+
9
+ - **仓库详情页 `#/repo/<key>`**:一个仓库的信息(key / 远程 / 路径 / 备注 / 项目组)、
10
+ **md 描述文档**(按份渲染,可切 tab)、**相关任务**(本机队列按 cwd 匹配到它,
11
+ 待领取 / 已领取分列,可一键跳工作台)。侧边栏点项目、列表点 md chip 都落这一页。
12
+ - **`docs` 字段:仓库内 md 的相对路径列表**。desc 从此只是摘要,详细说明写 md:
13
+ `repo add/update --docs README.md,docs/arch.md`、`repo doc <id> --path …` 读正文。
14
+ 只收仓库内相对路径(绝对路径 / `..` / 非 md 拒收),读正文有越界、不存在、
15
+ 512KB 上限三类显式错误;导出布局不带 md 正文。
16
+ - 仓库列表行的 desc 下面多一排 `md` chip(点一下直达详情页那一份)。
17
+
18
+ ### Changed
19
+
20
+ - **侧边栏的项目行点击 = 进仓库详情页**。以前是「展开任务子行」,于是**没有队列的
21
+ 仓库点了没反应**(它唯一去处是工作台,而空队列没有 topicKey)。现在行点击一律有去处,
22
+ 展开任务子行改点左边的箭头(有队列才画箭头);详情页里也能看到任务并跳工作台。
23
+ - 侧边栏行的 tooltip 会列出该仓库的 md 文档。
24
+
25
+ ### 证据
26
+
27
+ `tests/unit/repos-docs.test.mjs`(7 条:路径清洗、增改查口径、三类读错误、导出不带正文)。
28
+ 全量:lint 干净 / build 通过 / unit 303 / smoke 158。CLI 与面板都实测(无头 Chrome
29
+ 点侧边栏 → `#/repo/…`,md 渲染、任务列表、md chip 均正常)。
30
+
31
+ ## [0.14.0] - 2026-10-31 仓库身份只有两个来源:远程地址 / 自定义 key;都没有就冻结
32
+
33
+ ### Added
34
+
35
+ - **编辑仓库弹窗可自定义 key**(原来只有 CLI 能设)。留空 = 跟随远程地址,填了 =
36
+ 自定义身份。表单里实时说明当前这条的身份来自哪儿。
37
+ - **冻结**:既没有远程地址、也没有自定义 key 的仓库被明确标成「无 key · 冻结」
38
+ (仓库表格徽标 + `repo list` / `repo add` 的 CLI 输出)。它照常能在本机用
39
+ (侧边栏、本地任务、绑定、进项目组),但**不参与导出导入**:导出时仓库本体被跳过,
40
+ 项目组里的冻结成员也从导出的成员表里剔除,并如实告警——因为没有 key,
41
+ 映射到「另一台机器上的同一个仓库」这件事本身就无意义。
42
+ - CLI:`repo key refresh` 对没有远程地址的仓库不再建议 `local/<名称>` 兜底,
43
+ 改为说明「无法派生 key:用 repo key set 自定义或补 remote,否则冻结」。
44
+
45
+ ### Changed
46
+
47
+ - **`local/<名称>` 不再是身份**:它退化为纯内部锚点(本机关系要有落点),
48
+ 不再冒充 key 参与导出、也不再参与导入匹配。
49
+ - **改 key = 换身份,关系跟着搬**:项目组成员、组内描述、参考绑定会在 key 变化时
50
+ 一起重映射。以前改 key(自定义或换 remote 派生)会把关系断在旧 key 上,
51
+ 表现为「仓库莫名从项目组里掉了」。
52
+ - 冻结的仓库不参与 key 唯一性检查:两份同名副本(如两个 `nx-kv` 目录)可以各自登记。
53
+ - **仓库页去掉「扫描」按钮**(连同它的弹窗与结果卡片):登记入口统一走「添加仓库」。
54
+ CLI 的 `nx-kv repo scan` 仍然保留(`--dry-run` 只发现不登记),需要时用命令跑。
55
+
56
+ ### 证据
57
+
58
+ `tests/unit/repos-frozen-key.test.mjs`(11 条)钉住身份判定、冻结的导出行为、
59
+ 自定义 key 往返、改 key 搬关系、同名不冲突。全量:lint 干净 / build 通过 /
60
+ unit 296 / smoke 158。
61
+
62
+ ## [0.13.0] - 2026-10-30 项目 / 项目组是资产,收纳组只是显示;remote 批量对齐
63
+
64
+ ### Changed
65
+
66
+ - **侧边栏的层级错了,改正:项目组(看板)不是仓库的父目录。**
67
+ 项目(仓库)与项目组(看板)是**并列的资产**——两者都能被编排、都能挂参考,
68
+ 项目组内部装的是「开发仓库 + 参考仓库」(那是数据),但侧边栏不再把它画成
69
+ 装仓库的文件夹:一条仓库属于某个项目组,仍然在顶层自己出现。
70
+ 以前 board 行里嵌 repo 行,读起来就是「仓库属于它、移动就改归属」。
71
+ - **左侧的「组」回到侧边栏,但只是显示层(收纳组)**:把项目/项目组摆在一起的
72
+ 格子(类似压缩包),存在 `prefs.groups`(浏览器本地),不写 store、不进导出。
73
+ 一条资产最多在一个盒子里;解散盒子只拆格子,资产一个都不少。
74
+ 与项目组的分工写死在菜单措辞里:**「归入项目组 X」= 数据**(store 里的成员表,
75
+ 会导出、跨机可见),**「放进收纳组 X」= 显示**(只影响我怎么看)。
76
+ - **「对齐 remote」从一个仓库一个按钮改成一次扫全部**:判断该换哪条 remote 的依据
77
+ 全在数据里(`git remote -v` + 登记采纳值 + key 派生),所以仓库页右上给一个
78
+ 「对齐 remote」按钮:一屏列出 remote 现状(可替换 / 无 remote / 登记悬空 / 目录不可用),
79
+ 带建议与「全部按建议对齐」。行内的 remote 按钮已去掉。
80
+ **清单只含开发仓库**:参考仓库的 remote 就是它 clone 的来源兼 key,没有「换一条」的
81
+ 余地,列进来只是几十行噪音(它们的健康度看 `repo ref check`)。要看参考仓库得
82
+ 显式 `--type ref`,两类一起是 `--type all`。
83
+ - 侧边栏点项目组 = 去仓库页筛选到这一组(`#/repos?board=<id>`)——项目组没有独立
84
+ 页面,它的成员、组级参考仓库、组内描述都长在仓库页上。
85
+
86
+ ### Fixed
87
+
88
+ - **侧边栏拖拽在稀疏排序下「拖了没反应」**:`prefs.order` 只存用户动过的条目,
89
+ 拖到一个从没排过序的目标上时 `order.indexOf(target)` 是 -1,旧实现直接放弃。
90
+ 现在以「当前渲染序」为落点基准,结果写回完整序列,顺序完全确定。
91
+ - 拖进收纳组会自动展开盒子(否则拖完像是东西消失了)。
6
92
 
7
93
  ### Added
8
94
 
95
+ - `repo remote scan [--type dev|ref]`(HTTP `GET /api/repos/remote/scan`):
96
+ 批量扫描全部仓库的 remote 现状并给建议。**只读本地 git 配置、不发网络请求**,
97
+ 所以够快;远端存在性仍是按需的 `repo remote check`。
98
+ - `repo remote align`(HTTP `POST /api/repos/remote/align`,body `items`):
99
+ 整批落盘(自定义 key 只改 remote,不覆盖 key)。
100
+ - 测试:`tests/unit/sidebar-model.test.mjs`(15 条,钉住「资产 vs 收纳组」与拖拽落点)、
101
+ `tests/unit/repos-remote-scan.test.mjs`(10 条,钉住 scan 的判定与批量对齐)。
102
+ 全量:lint 干净、build 通过、unit 285、smoke 158。
103
+
104
+ ## [0.12.1] - 2026-10-30 key 归一化修复 + remote 对齐反馈
105
+
106
+ ### Fixed
107
+
108
+ - **keyFromRemote 在 Linux 上多一个前导斜杠**:同一地址在「派生」(keyFromRemote)与
109
+ 「归一」(normalizeKey)两条路上算出的 key 必须一致。Linux 的 `file:///tmp/x` 剥协议后
110
+ 带前导斜杠,于是「派生」得 `/tmp/x`、「归一」得 `tmp/x` —— 同一份数据算出两个 key,
111
+ 导入时参考关系直接连不上(「参考仓库不存在」)。Windows 上是盘符所以走不到这个分支,
112
+ 只有 CI 红。keyFromRemote 的结果现在就规范化(它是「key 的来源」,不该产出非规范形态)。
113
+ - **remote 对齐弹窗的「检查」点了像没反应**:git ls-remote 要几秒,期间界面毫无变化;
114
+ 出错又被 toast 吞掉(一闪而过)。改成点击即显示「检查中…」,错误原因直接写进弹窗
115
+ (错误信息不该只活 3 秒)。
116
+
117
+ ### Changed
118
+
119
+ - 并入三个工作树的成果:md-desc(关系改 key + 导入占位 + remote 对齐)、
120
+ sidebar-repo-boards(侧边栏改用仓库登记 + 看板)、fix-checkbox-width
121
+ (勾选框被 width:100% 撑满 → 选项列表看着一片空白)。
122
+
123
+ ## [0.12.0] - 2026-10-30 仓库身份改用全局唯一 key + 导入导出重做
124
+
125
+ ### Changed
126
+
127
+ - **侧边栏不再自建一套「目录 + 分组」,改为直接用仓库登记与看板**。
128
+ 以前侧边栏的「目录」是本地任务队列按 cwd 自动生成的主题,「分组」存在浏览器
129
+ localStorage 里(`prefs.groups` / `prefs.dirGroups`)——后者与仓库页的看板是
130
+ **两套互不相干的数据**:换浏览器分组就没了,改看板侧边栏也不知道。
131
+ 现在:
132
+ - 目录行 = **仓库登记**里的开发仓库(带全局唯一 key、描述、路径);
133
+ - 分组行 = **看板**(展开看成员),归属关系就是看板的成员表;
134
+ - 侧边栏只保留纯 UI 偏好:排序、隐藏、展开态、收起态(`prefs` 里的
135
+ `groups` / `dirGroups` 已废弃并在读取时从 localStorage 抹掉);
136
+ - 行菜单改操作真实数据:「归入/移出看板」走 `board assign`,
137
+ 「+」菜单变成「登记仓库 / 新建看板」(原来建的是任务队列主题,不是仓库登记);
138
+ - 拖拽只改顺序(显示态),归属一律走行菜单——拖拽不再写本地分组。
139
+ - 仓库模块:看板成员从随机 id(`repoIds`)迁到 **key**(`repoKeys`),读时自动
140
+ 迁移并落盘;绑定目标同样记 key;`keyFromRemote` 剥 `file://` URL 的盘符冒号
141
+ (否则 `normalizeKey` 会把 `:` 压成 `-`,导致「登记的是 A、成员表里是 B」)。
142
+
9
143
  - **内部关系全面改用 key(不再是随机 id)+ 导入分组的「本地未拥有」占位**:
10
144
  - **关系一律用 key**:看板成员存 `repoKeys`(不是 `repoIds`)、组内描述按 key、
11
145
  绑定关系(linked,ref 侧与 dev 侧双向记录)也按 key。`repo.id` / `board.id`
@@ -22,6 +156,25 @@
22
156
  `repo remote check`(`git ls-remote` 检查远端是否存在,**存在 / 不存在 /
23
157
  不可达**三种结论分清)。面板上每行有「remote」按钮,弹窗里下拉切换 + 存在性徽标。
24
158
 
159
+ - **MD 工作台编辑器专业对齐(10 轮 UI/UX 循环,纪要见 `plan/LOOP-MD-EDITOR.md`)**:
160
+ - **标题不再截断**:live 与源码两态的标题改为自动增高 textarea,长标题按宽度软换行;
161
+ Enter 不产生换行、直接把焦点交给正文(live 进首块 / 源码回正文 0 位)
162
+ - **工具栏分层**:模式 | 格式 | 插入三簇 hairline 分组,格式组去灰底容器(hover 才显底),
163
+ 次级动作去描边转文本态,视觉重量留给主按钮
164
+ - **自适应改容器查询**:`.mdw-main` 声明 container,工具栏/纸面按「编辑列」实际宽度三级折叠
165
+ (1280 收转变组 / 960 收进溢出菜单 / 640 纸面紧凑)——修掉右侧进程 rail 一开就静默裁按钮的缺陷
166
+ - **溢出菜单 portal 到 body**:修 `···`/`更多` 菜单被工具栏 `overflow:hidden` 整条裁掉的缺陷
167
+ - **阅读态补题头**:三态标题呈现统一,题头与正文同轴同宽
168
+ - **空文档幽灵引导**:空文档渲染 Notion 式引导条,点击直接聚焦首块
169
+ - **撤销/重做**:新增文档级历史栈——受控 textarea 的程序化改写会冲掉浏览器原生 undo 栈,
170
+ 原先「Ctrl+B 加粗 → Ctrl+Z」毫无反应;现在格式操作、输入段均可撤销/重做,
171
+ 且撤销后光标回到改动处(源码精确,live 跨块自动跳转并接管焦点)
172
+ - **Ctrl/⌘S 保存 + 页签未保存圆点**:改动即在页签挂一颗点,保存/提交后摘除
173
+ - **快捷键速查面板**:Ctrl/⌘ / 或工具栏问号唤起,15 条键位分三组
174
+ - **实时预览块级查找**:Ctrl/⌘F 在预览态可用,n/m 计数 + 命中块高亮 + 滚动定位
175
+ - **无障碍**:编辑区 aria-label 补齐、状态栏小字对比度提到 WCAG AA
176
+
177
+
25
178
  - **仓库获得全局唯一 key,导入导出改以 key 为身份(v3)+ 全屏导入导出面板**:
26
179
  - **key**:归一化远程地址派生(`git@c:o/r.git`、`https://c/o/r`、
27
180
  `ssh://git@ssh.github.com:443/o/r.git`、带端口、带 `.git` 都是同一个
@@ -54,9 +54,25 @@ git -C <path> pull --no-edit
54
54
  - 路径不必是 git 仓库:登记不校验目录内容,也不要求目录存在
55
55
  - **key 是身份,全局唯一**(2026-10-28):来自归一化远程地址(`git@c:o/r.git`、
56
56
  `https://c/o/r`、`ssh://git@ssh.github.com:443/o/r.git` 归一到同一个
57
- `c/o/r`),或无 remote 时 `local/<名称>` 兜底,也可 `repo key set` 自定义。
58
- 自定义 key 不跟随 remote 变化;`repo key refresh --all` 扫描 `git remote -v`
59
- (**SSH 优先**)提示可更新的 key/远程地址,`--apply` 才落盘
57
+ `c/o/r`)。自定义 key 不跟随 remote 变化;`repo key refresh --all` 扫描
58
+ `git remote -v`(**SSH 优先**)提示可更新的 key/远程地址,`--apply` 才落盘
59
+ - **身份只有两个来源:远程地址,或人显式自定义**(2026-10-31)。两者都没有的仓库
60
+ **冻结**:它照常能在本机用(侧边栏、本地任务、绑定、进项目组),但
61
+ - **不导出**(导出靠 key 映射到「另一台机器上的同一个仓库」,`local/<名称>` 在
62
+ 别人那里映射不到任何东西),项目组里的冻结成员也会被从导出的成员表里剔除;
63
+ - 不参与导入匹配(不会被别人的布局「命中」)。
64
+
65
+ 这里**不再有 `local/<名称>` 兜底身份**——那个兜底只是本机关系的内部锚点,
66
+ 绝不外发。要给一个没有远程地址的仓库身份,就显式指定 key:
67
+
68
+ ```bash
69
+ nx-kv repo key set <id|路径> 团队/nx-rh # 自定义身份(也可 repo update <id> --key …)
70
+ nx-kv repo update <id> --key "" # 清空 = 回到跟随远程地址;没有远程地址就回到冻结
71
+ ```
72
+
73
+ 面板上就是「编辑仓库」弹窗里那个 **key** 字段:留空 = 跟随远程地址,填了 = 自定义。
74
+ **改 key 等于换身份**,服务层会把项目组成员、组内描述、绑定一起搬过去
75
+ (不搬的话关系会断在旧 key 上,表现为「仓库莫名从组里掉了」)。
60
76
  - **所有内部关系用 key,不用随机 id**(2026-10-28):看板成员存 `repoKeys`、
61
77
  组内描述按 key、绑定关系(linked)按 key。id 只是本机行主键,不再参与任何关系引用
62
78
  ——id 换机即失效,key 才能精确映射
@@ -64,6 +80,21 @@ git -C <path> pull --no-edit
64
80
  两者要对齐但方向由人定(SSH 优先只是默认值):
65
81
  `repo remote list`(列候选)、`repo remote pick --url`(人工换一条)、
66
82
  `repo remote check`(存在 / 不存在 / 不可达,三种结论分清)
83
+ - **批量对齐走 scan**:判断「该换哪条 remote」的依据全在数据里(`git remote -v`
84
+ + 登记采纳值 + key 派生),所以有一条批量命令,别一个仓库一个仓库点:
85
+
86
+ ```bash
87
+ nx-kv repo remote scan # 缺省只扫开发仓库:ok / 可替换 / 空 / 登记悬空 / 目录不可用 + 建议
88
+ nx-kv repo remote scan --type ref|all # 要看参考仓库才显式指定
89
+ # 面板上就是「仓库」页右上那个「对齐 remote」按钮(不是每行一个 remote 按钮)
90
+ ```
91
+
92
+ **范围默认是开发仓库**:参考仓库的 remote 就是它 clone 的来源兼 key,没有「换一条」
93
+ 的余地,列进来只是几十行噪音;它们的健康度看 `repo ref check`。
94
+
95
+ `scan` **只读本地 git 配置、不发网络请求**,所以够快;远端存在性(`git ls-remote`)
96
+ 是按需的 `remote check`。整批落盘用 `repo.remote.align`(HTTP:`POST
97
+ /api/repos/remote/align`,body `items = [{ id|key, url }]`)。
67
98
  - **没有标签**:分组只走看板一条路(2026-10-24 废除仓库 tags,两套平行分组只会打架)
68
99
  - **type 双态**:`dev` = 开发仓库(默认),`ref` = 参考仓库(见下节)
69
100
  - **remote 是稳定 key**:参考仓库必有;开发仓库也建议记(`repo remote <id|path>` 一条命令
@@ -71,6 +102,28 @@ git -C <path> pull --no-edit
71
102
  - **看板的 desc 是领域描述**:写该组仓库的侧重、配合关系(谁是中心/终端)、
72
103
  关键路径与搜索关键词——它是给人和 agent 读的上下文,写法约定见 [[kv-repo]]
73
104
 
105
+ ## 仓库的描述:desc 是摘要,docs 才是正文
106
+
107
+ `desc` 一句话不够用(用户的原话:desc 不该是一句话,而是 **md 列表**)。所以一个仓库带:
108
+
109
+ - `desc` —— 一句话摘要。列表行、搜索、导出都读它,必须短。
110
+ - `docs` —— **仓库内 md 的相对路径数组**(最多 24 条)。详细说明、设计、约定写在这些
111
+ md 里,谁写谁维护;面板的仓库详情页把它们渲染出来。
112
+
113
+ ```bash
114
+ nx-kv repo add <path> --docs README.md,docs/arch.md # 登记时一起挂
115
+ nx-kv repo update <id> --docs README.md,docs/arch.md # 改(传空数组 = 清空)
116
+ nx-kv repo doc <id> --path docs/arch.md # 读正文(面板也走这条)
117
+ ```
118
+
119
+ 只认**仓库内**的相对路径:绝对路径、`..` 越界、非 `.md` 一律拒收(`sanitizeDocs`)。
120
+ 读正文时不存在 / 越界 / 超过 512KB 都是显式错误,不静默返回空。导出布局**不带 md 正文**
121
+ (本机文件不入布局,只带登记)。
122
+
123
+ 面板:`#/repo/<key>` 是仓库详情页(信息 + md 描述 + 项目组 + 相关任务);侧边栏点项目
124
+ 就落这儿——**空任务的仓库也点得开**(以前它只能去工作台,没队列就没去处)。
125
+ 列表行里 desc 下面那排 `md` chip 就是 docs,点一下直达详情页对应的那一份。
126
+
74
127
  ## 参考仓库(type=ref):搜索并克隆外部项目
75
128
 
76
129
  mmx-research-gitclone 的整体吸纳(2026-10-27):远程 clone 下来学习/参考的仓库与
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-kv",
3
- "version": "0.12.0",
3
+ "version": "0.15.0",
4
4
  "description": "nx-kv — KV 清单管理中枢(远程)+ 本机本地任务队列(localtask):serve 驱动 CLI 与 Web 面板,同源同构,附带可安装 skill 供 agent 提交 / 领取 / 阻塞接收任务",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,7 +32,8 @@ export default {
32
32
  id: 'auth',
33
33
  title: '认证(登录态)',
34
34
  order: 10,
35
- view: () => import('./view.jsx'),
35
+ // 登录 UI 已合并进云端页(frontend todo view),此处只保留 CLI/HTTP action;
36
+ // 独立面板视图已下线(2026-10-30)。
36
37
 
37
38
  actions: [
38
39
  {
@@ -16,7 +16,7 @@
16
16
  //
17
17
  // 本文件不含传输层概念(不认识 argv 与 HTTP)。
18
18
  import { resolve } from 'node:path';
19
- import { loadStore, mutateStore, newId, boardKeyOf, boardRepoKeys, deriveKey, normalizeKey } from './service.js';
19
+ import { loadStore, mutateStore, newId, boardKeyOf, boardRepoKeys, deriveKey, normalizeKey, isFrozen } from './service.js';
20
20
  import { badInput, notFound, conflict } from '../../core/errors.js';
21
21
 
22
22
  const cleanText = (v) => String(v || '').trim();
@@ -198,6 +198,8 @@ export async function assignRepo({ id, key, board, before, remove } = {}) {
198
198
  }
199
199
 
200
200
  if (!target) throw badInput('请给 --board <看板id>');
201
+ // 冻结(无身份)的仓库**允许**进项目组:本机的编排不该被「有没有远程地址」卡住。
202
+ // 代价由导出承担——它的 local/<名称> 不是身份,导出时会被剔除并告警(transfer.js)。
201
203
  if (!Array.isArray(target.repoKeys)) target.repoKeys = [];
202
204
  const at = target.repoKeys.indexOf(rkey);
203
205
  // 已在板内:摘出后按 before 回插(纯调位次);不在:追加。
@@ -257,9 +259,12 @@ export async function importBoards({ json } = {}) {
257
259
  const incoming = Array.isArray(data?.boards) ? data.boards : null;
258
260
  if (!incoming || !incoming.length) throw badInput('JSON 里没有 boards 数组');
259
261
  return mutateStore((s) => {
260
- const byName = new Map(s.repos.map((r) => [String(r.name).trim(), deriveKey(r)]));
262
+ // 冻结仓库不参与匹配:它的 key 是 local/<名称>,别人导出的文件里不会有这个 key,
263
+ // 拿它去对齐只会把成员错配到本机凑巧同名的目录上。
264
+ const ident = s.repos.filter((r) => !isFrozen(r));
265
+ const byName = new Map(ident.map((r) => [String(r.name).trim(), deriveKey(r)]));
261
266
  // path 匹配(大小写不敏感、分隔符归一)——老格式的导入文件按 path 引用
262
- const byPath = new Map(s.repos.map((r) => [String(r.path).toLowerCase().replace(/\\/g, '/'), deriveKey(r)]));
267
+ const byPath = new Map(ident.map((r) => [String(r.path).toLowerCase().replace(/\\/g, '/'), deriveKey(r)]));
263
268
  let created = 0;
264
269
  let merged = 0;
265
270
  let matched = 0;
@@ -0,0 +1,289 @@
1
+ // 仓库详情页(#/repo/<key>):**一个仓库的信息 + 它的 md 描述 + 它的任务**。
2
+ //
3
+ // 为什么要这一页(用户的原始诉求):
4
+ // 1. 侧边栏「所有资源都应该支持点击」——没有任务队列的仓库以前点了没反应,
5
+ // 因为它唯一的去处是工作台(#/dir/<topicKey>),而空队列没有 topicKey;
6
+ // 2. 点开要能看到**仓库信息**,并且 desc 不是一句话而是 **md 列表**
7
+ // ([]md 是 string desc 的上位替代,链到仓库里的本地 md 文件);
8
+ // 3. 顺带能看到这个仓库**相关的任务**(本机队列按 cwd 匹配到它)。
9
+ //
10
+ // 数据边界:仓库登记来自 /api/repos/:id,md 正文来自 /api/repos/doc,
11
+ // 任务来自 App 已经轮询着的 localtasks(rows),这一页不自己再拉一遍队列。
12
+ import { useCallback, useEffect, useMemo, useState } from 'react';
13
+ import { api } from '../../web/frontend/api/client.js';
14
+ import { useGuard, useToast, Copyable } from '../../web/frontend/components/ui.jsx';
15
+ import { Icon } from '../../web/frontend/components/icons.jsx';
16
+ import { renderMarkdownHtml } from '../../web/frontend/modules/md-workspace/md-parse.js';
17
+
18
+ /** 任务正文里抽一行标题(与侧边栏同一口径:`# 标题` 优先,否则首行) */
19
+ function taskTitle(t) {
20
+ const text = String(t?.text || '');
21
+ const m = text.match(/^#\s+(.+)$/m);
22
+ if (m) return m[1].trim();
23
+ const line = text.split('\n').find((l) => l.trim());
24
+ return (line || `#${t.id}`).trim().slice(0, 80);
25
+ }
26
+
27
+ const isDeleted = (t) => !!t.deletedAt;
28
+ const isPending = (t) => t.status !== 'consumed' && !t.deletedAt;
29
+
30
+ export default function RepoDetail({ dirKey, rows, onNavigate }) {
31
+ const guard = useGuard();
32
+ const toast = useToast();
33
+
34
+ const [repo, setRepo] = useState(null); // 仓库登记
35
+ const [err, setErr] = useState('');
36
+ const [active, setActive] = useState(''); // 当前 md(相对路径)
37
+ const [doc, setDoc] = useState(null); // { path, text, size }
38
+ const [docErr, setDocErr] = useState('');
39
+ const [adding, setAdding] = useState(''); // 「挂一个 md」输入框
40
+
41
+ const load = useCallback(async () => {
42
+ if (!dirKey) return;
43
+ setErr('');
44
+ try {
45
+ const d = await api('/api/repos/' + encodeURIComponent(dirKey));
46
+ setRepo(d);
47
+ // 选中哪份 md:?doc=<相对路径> 优先(列表里点具体某份直达),否则第一份
48
+ const want = (() => {
49
+ const m = /[?&]doc=([^&]+)/.exec(typeof location !== 'undefined' ? location.hash || '' : '');
50
+ return m ? decodeURIComponent(m[1]) : '';
51
+ })();
52
+ setActive((cur) => {
53
+ if (want && (d.docs || []).includes(want)) return want;
54
+ if (cur && (d.docs || []).includes(cur)) return cur;
55
+ return (d.docs || [])[0] || '';
56
+ });
57
+ } catch (e) {
58
+ setErr(String((e && e.message) || e));
59
+ }
60
+ }, [dirKey]);
61
+ useEffect(() => { load(); }, [load]);
62
+
63
+ // md 正文按需拉(一次一份,切换时再拉:md 可能很大,没必要全预取)
64
+ useEffect(() => {
65
+ if (!repo || !active) { setDoc(null); return; }
66
+ let alive = true;
67
+ setDocErr('');
68
+ api('/api/repos/doc?id=' + encodeURIComponent(repo.key) + '&path=' + encodeURIComponent(active))
69
+ .then((d) => { if (alive) setDoc(d); })
70
+ .catch((e) => { if (alive) { setDoc(null); setDocErr(String((e && e.message) || e)); } });
71
+ return () => { alive = false; };
72
+ }, [repo, active]);
73
+
74
+ // 这个仓库的本机队列:服务端按 cwd 匹配登记,topic.repo.id 就是它
75
+ const topic = useMemo(() => {
76
+ if (!repo) return null;
77
+ return (rows || []).find((t) => t.repo && t.repo.id === repo.id) || null;
78
+ }, [rows, repo]);
79
+
80
+ const tasks = useMemo(() => {
81
+ const list = (topic && topic.tasks) || [];
82
+ return {
83
+ pending: list.filter(isPending),
84
+ done: list.filter((t) => t.status === 'consumed' && !isDeleted(t)).slice().reverse(),
85
+ };
86
+ }, [topic]);
87
+
88
+ const addDoc = () => guard(async () => {
89
+ const p = adding.trim();
90
+ if (!p) return;
91
+ setAdding('');
92
+ if ((repo.docs || []).includes(p)) return;
93
+ await api('/api/repos/' + encodeURIComponent(repo.id), {
94
+ method: 'PATCH',
95
+ body: { docs: [...(repo.docs || []), p] },
96
+ });
97
+ await load();
98
+ setActive(p); // 挂上就直接看它(挂错了下一步就是「摘掉」)
99
+ toast('已挂上 ' + p);
100
+ });
101
+
102
+ const removeDoc = (p) => guard(async () => {
103
+ await api('/api/repos/' + encodeURIComponent(repo.id), {
104
+ method: 'PATCH',
105
+ body: { docs: (repo.docs || []).filter((x) => x !== p) },
106
+ });
107
+ if (active === p) setActive('');
108
+ await load();
109
+ });
110
+
111
+ const openFolder = () => guard(async () => {
112
+ await api('/api/repos/open', { method: 'POST', body: { id: repo.id } });
113
+ });
114
+
115
+ if (err) {
116
+ return (
117
+ <div className="rpd">
118
+ <div className="rpd-err">
119
+ 仓库读取失败:{err}
120
+ <button className="btn small ghost" onClick={load}>重试</button>
121
+ <button className="btn small ghost" onClick={() => onNavigate('#/repos')}>回仓库管理</button>
122
+ </div>
123
+ </div>
124
+ );
125
+ }
126
+ if (!repo) return <div className="rpd"><div className="muted" style={{ padding: 24 }}>读取中…</div></div>;
127
+
128
+ return (
129
+ <div className="rpd" data-testid="repo-detail">
130
+ <div className="rpd-head">
131
+ <div className="rpd-title">
132
+ <h2>{repo.name}</h2>
133
+ <span className="rpd-badge">{repo.type === 'ref' ? '参考仓库' : '开发仓库'}</span>
134
+ {repo.frozen ? (
135
+ <span className="rpd-badge warn" title="没有 key:既没有远程地址,也没有自定义 key。导出导入不包含它">无 key · 冻结</span>
136
+ ) : (
137
+ <span className="rpd-badge key" title="key 是身份,导出导入按它映射">
138
+ {repo.keyCustom ? '自定义 key' : 'key 跟随远程地址'}
139
+ </span>
140
+ )}
141
+ </div>
142
+ {repo.desc ? <div className="rpd-desc">{repo.desc}</div> : null}
143
+ <div className="rpd-acts">
144
+ {topic ? (
145
+ <button className="btn small" onClick={() => onNavigate('#/dir/' + encodeURIComponent(topic.key))}>
146
+ 打开工作台{topic.pending ? `(${topic.pending} 待领)` : ''}
147
+ </button>
148
+ ) : null}
149
+ <button className="btn small ghost" onClick={openFolder}>打开目录</button>
150
+ <button className="btn small ghost" onClick={() => onNavigate('#/repos')}>仓库管理</button>
151
+ </div>
152
+ </div>
153
+
154
+ <div className="rpd-cols">
155
+ {/* 左:md 描述列表(desc 的上位替代) */}
156
+ <section className="rpd-docs">
157
+ <div className="rpd-colhead">
158
+ <h3>描述文档</h3>
159
+ <span className="muted">仓库内的 md(相对路径),谁写谁维护</span>
160
+ </div>
161
+ <div className="rpd-tabs">
162
+ {(repo.docs || []).map((p) => (
163
+ <span key={p} className={'rpd-tab' + (active === p ? ' on' : '')}>
164
+ <button className="rpd-tab-name" onClick={() => setActive(p)} title={p}>{p}</button>
165
+ <button className="rpd-tab-x" title="从描述里摘掉(不删文件)" onClick={() => removeDoc(p)}>
166
+ <Icon name="x" size={10} />
167
+ </button>
168
+ </span>
169
+ ))}
170
+ <span className="rpd-add">
171
+ <input
172
+ value={adding}
173
+ placeholder="挂一个 md,如 README.md"
174
+ spellCheck="false"
175
+ onChange={(e) => setAdding(e.target.value)}
176
+ onKeyDown={(e) => { if (e.key === 'Enter') addDoc(); }}
177
+ />
178
+ <button className="btn small ghost" onClick={addDoc} disabled={!adding.trim()}>挂上</button>
179
+ </span>
180
+ </div>
181
+ {active ? (
182
+ docErr ? (
183
+ <div className="rpd-docerr">
184
+ {docErr}
185
+ <button className="btn small ghost" onClick={() => setActive('')}>关闭</button>
186
+ </div>
187
+ ) : doc ? (
188
+ <article
189
+ className="rpd-md"
190
+ // md 渲染用工作台同一份解析器:预览与真实内容不会各说各话
191
+ dangerouslySetInnerHTML={{ __html: renderMarkdownHtml(doc.text) }}
192
+ />
193
+ ) : (
194
+ <div className="muted" style={{ padding: 12 }}>读取中…</div>
195
+ )
196
+ ) : (
197
+ <div className="rpd-empty">
198
+ 还没有描述文档。{repo.desc ? '现在只有一句描述:' : ''}
199
+ {repo.desc ? <b>{repo.desc}</b> : null}
200
+ <div className="muted">
201
+ 在上面的输入框里填一个**仓库内**的 md 相对路径(<code>README.md</code>、
202
+ <code>docs/arch.md</code>),挂上就能在这里读。
203
+ </div>
204
+ </div>
205
+ )}
206
+ </section>
207
+
208
+ {/* 右:身份 + 项目组 + 相关任务 */}
209
+ <aside className="rpd-side">
210
+ <div className="rpd-card">
211
+ <div className="rpd-colhead"><h3>登记信息</h3></div>
212
+ <dl className="rpd-fields">
213
+ <dt>key</dt>
214
+ <dd><Copyable text={repo.key || '(无)'} title="点击复制">{repo.frozen ? '(无 · 冻结)' : repo.key}</Copyable></dd>
215
+ <dt>远程</dt>
216
+ <dd>{repo.remote ? <Copyable text={repo.remote} title="点击复制">{repo.remote}</Copyable> : <span className="muted">(无)</span>}</dd>
217
+ <dt>路径</dt>
218
+ <dd><Copyable text={repo.path} title="点击复制">{repo.path}</Copyable></dd>
219
+ {repo.notes ? (<><dt>备注</dt><dd>{repo.notes}</dd></>) : null}
220
+ </dl>
221
+ </div>
222
+
223
+ <div className="rpd-card">
224
+ <div className="rpd-colhead">
225
+ <h3>项目组</h3>
226
+ <span className="muted">{(repo.boards || []).length ? `${repo.boards.length} 个` : ''}</span>
227
+ </div>
228
+ {(repo.boards || []).length ? (
229
+ <div className="rpd-chips">
230
+ {repo.boards.map((b) => (
231
+ <button key={b.id} className="rpd-chip" title={b.desc || ''}
232
+ onClick={() => onNavigate('#/repos?board=' + encodeURIComponent(b.id))}>
233
+ {b.name}
234
+ </button>
235
+ ))}
236
+ </div>
237
+ ) : <div className="muted rpd-none">还没进任何项目组</div>}
238
+ </div>
239
+
240
+ <div className="rpd-card">
241
+ <div className="rpd-colhead">
242
+ <h3>相关任务</h3>
243
+ <span className="muted">
244
+ {topic ? `${tasks.pending.length} 待领 · ${tasks.done.length} 已领` : '本机队列未建立'}
245
+ </span>
246
+ </div>
247
+ {!topic ? (
248
+ <div className="rpd-empty small">
249
+ 这个目录还没有任务队列。往它投一条任务(在本目录下):
250
+ <code>nx-kv localtask add "要做的事" --cwd {repo.path}</code>
251
+ </div>
252
+ ) : (
253
+ <>
254
+ {tasks.pending.length || tasks.done.length ? (
255
+ <ul className="rpd-tasks">
256
+ {tasks.pending.map((t) => (
257
+ <li key={t.id} className="pending">
258
+ <span className="rpd-dot" />
259
+ <span className="rpd-task-title" title={t.text || ''}>{taskTitle(t)}</span>
260
+ <button className="rpd-task-go" title="在工作台打开"
261
+ onClick={() => onNavigate('#/dir/' + encodeURIComponent(topic.key) + '?task=' + encodeURIComponent(t.id))}>
262
+ <Icon name="jump" size={11} />
263
+ </button>
264
+ </li>
265
+ ))}
266
+ {tasks.done.map((t) => (
267
+ <li key={t.id} className="done">
268
+ <span className="rpd-dot" />
269
+ <span className="rpd-task-title" title={t.text || ''}>{taskTitle(t)}</span>
270
+ </li>
271
+ ))}
272
+ </ul>
273
+ ) : (
274
+ <div className="rpd-empty small">
275
+ 队列是空的——没有待领取的任务。
276
+ <code>nx-kv localtask add "要做的事"</code>
277
+ </div>
278
+ )}
279
+ <button className="btn small ghost rpd-open-dir" onClick={() => onNavigate('#/dir/' + encodeURIComponent(topic.key))}>
280
+ 去工作台
281
+ </button>
282
+ </>
283
+ )}
284
+ </div>
285
+ </aside>
286
+ </div>
287
+ </div>
288
+ );
289
+ }