From 880bf7fc570564d53749074c75107769b2e13a24 Mon Sep 17 00:00:00 2001 From: cruldra Date: Mon, 12 Oct 2026 15:48:48 +1100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E3=80=8C=E4=BA=86=E8=A7=A3=E4=B8=80=E5=9D=97=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E3=80=8D=EF=BC=9AAI=20=E6=89=BE=E5=8A=9F=E8=83=BD=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=8D=A0=E4=B8=80=E6=A0=8F=EF=BC=8C=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E9=87=8C=E6=8E=A5=E7=9D=80=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主栏标题栏的放大镜问要了解哪一块,占一个栏位建一栏(和收藏共用 30 个栏位,视图改名 slot1..30,按 slotKindN 分收藏 / 功能挂标题栏按钮),同时开对话页让 Claude 只读地在全部工作区文件夹里找,按 --json-schema 交回环节 → 文件 + 符号 + 一句话。 对话复用预览顶部的聊天(CodeQa.askTop:会话续接、进行中的一轮、记录存 workspaceState);结构不合格算这一轮没答成、留着能再画出来。 条目每次画、点时按眼下的代码找:有语言插件的解析成代码块按「类名.方法名」找,没有的问 VS Code 文档符号;找不到画成灰的。 --- vscode/package.json | 1700 +++++++++++++++-- vscode/src/cc/claudeProgress.ts | 3 + vscode/src/codeReader/blockView.ts | 2 +- .../src/codeReader/editor/documentSymbols.ts | 21 + vscode/src/codeReader/editor/messages.ts | 4 +- vscode/src/codeReader/editor/pathPreview.ts | 50 +- .../codeReader/editor/previewEditor.test.ts | 3 +- vscode/src/codeReader/editor/previewEditor.ts | 48 +- vscode/src/codeReader/editor/repoPath.test.ts | 13 + vscode/src/codeReader/editor/repoPath.ts | 37 +- .../src/codeReader/explorer/rowMenus.test.ts | 87 +- .../favorites/favoriteSlots.test.ts | 38 +- .../src/codeReader/favorites/favoriteSlots.ts | 26 +- .../src/codeReader/favorites/favoriteViews.ts | 63 +- .../codeReader/features/featureLocate.test.ts | 109 ++ .../src/codeReader/features/featureLocate.ts | 59 + .../codeReader/features/featureResult.test.ts | 132 ++ .../src/codeReader/features/featureResult.ts | 166 ++ .../src/codeReader/features/featureViews.ts | 476 +++++ .../src/codeReader/features/features.test.ts | 80 + vscode/src/codeReader/features/features.ts | 114 ++ vscode/src/codeReader/index.ts | 73 +- vscode/src/codeReader/qa/answerStream.test.ts | 13 + vscode/src/codeReader/qa/answerStream.ts | 12 +- vscode/src/codeReader/qa/asks.test.ts | 22 + vscode/src/codeReader/qa/asks.ts | 15 +- vscode/src/codeReader/qa/claudeAsk.test.ts | 20 + vscode/src/codeReader/qa/claudeAsk.ts | 27 +- vscode/src/codeReader/qa/codeQa.test.ts | 79 +- vscode/src/codeReader/qa/codeQa.ts | 115 +- vscode/src/codeReader/qa/pathQa.ts | 18 +- vscode/src/codeReader/slots/slotPool.ts | 49 + vscode/src/codeReader/slots/slots.test.ts | 29 + vscode/src/codeReader/slots/slots.ts | 35 + vscode/webview-ui/src/codeReader/App.tsx | 5 +- .../webview-ui/src/codeReader/AskThread.tsx | 13 +- vscode/webview-ui/src/codeReader/qa.ts | 4 +- vscode/webview-ui/src/codeReader/types.ts | 4 +- 38 files changed, 3386 insertions(+), 378 deletions(-) create mode 100644 vscode/src/codeReader/editor/documentSymbols.ts create mode 100644 vscode/src/codeReader/features/featureLocate.test.ts create mode 100644 vscode/src/codeReader/features/featureLocate.ts create mode 100644 vscode/src/codeReader/features/featureResult.test.ts create mode 100644 vscode/src/codeReader/features/featureResult.ts create mode 100644 vscode/src/codeReader/features/featureViews.ts create mode 100644 vscode/src/codeReader/features/features.test.ts create mode 100644 vscode/src/codeReader/features/features.ts create mode 100644 vscode/src/codeReader/slots/slotPool.ts create mode 100644 vscode/src/codeReader/slots/slots.test.ts create mode 100644 vscode/src/codeReader/slots/slots.ts diff --git a/vscode/package.json b/vscode/package.json index 1cb9a69..ba3510f 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -74,154 +74,154 @@ "name": "未提交的更改" }, { - "id": "superpowers.codeReader.favorite1", - "name": "收藏 1", - "when": "superpowers.codeReader.hasFavorite1" + "id": "superpowers.codeReader.slot1", + "name": "栏位 1", + "when": "superpowers.codeReader.slotKind1" }, { - "id": "superpowers.codeReader.favorite2", - "name": "收藏 2", - "when": "superpowers.codeReader.hasFavorite2" + "id": "superpowers.codeReader.slot2", + "name": "栏位 2", + "when": "superpowers.codeReader.slotKind2" }, { - "id": "superpowers.codeReader.favorite3", - "name": "收藏 3", - "when": "superpowers.codeReader.hasFavorite3" + "id": "superpowers.codeReader.slot3", + "name": "栏位 3", + "when": "superpowers.codeReader.slotKind3" }, { - "id": "superpowers.codeReader.favorite4", - "name": "收藏 4", - "when": "superpowers.codeReader.hasFavorite4" + "id": "superpowers.codeReader.slot4", + "name": "栏位 4", + "when": "superpowers.codeReader.slotKind4" }, { - "id": "superpowers.codeReader.favorite5", - "name": "收藏 5", - "when": "superpowers.codeReader.hasFavorite5" + "id": "superpowers.codeReader.slot5", + "name": "栏位 5", + "when": "superpowers.codeReader.slotKind5" }, { - "id": "superpowers.codeReader.favorite6", - "name": "收藏 6", - "when": "superpowers.codeReader.hasFavorite6" + "id": "superpowers.codeReader.slot6", + "name": "栏位 6", + "when": "superpowers.codeReader.slotKind6" }, { - "id": "superpowers.codeReader.favorite7", - "name": "收藏 7", - "when": "superpowers.codeReader.hasFavorite7" + "id": "superpowers.codeReader.slot7", + "name": "栏位 7", + "when": "superpowers.codeReader.slotKind7" }, { - "id": "superpowers.codeReader.favorite8", - "name": "收藏 8", - "when": "superpowers.codeReader.hasFavorite8" + "id": "superpowers.codeReader.slot8", + "name": "栏位 8", + "when": "superpowers.codeReader.slotKind8" }, { - "id": "superpowers.codeReader.favorite9", - "name": "收藏 9", - "when": "superpowers.codeReader.hasFavorite9" + "id": "superpowers.codeReader.slot9", + "name": "栏位 9", + "when": "superpowers.codeReader.slotKind9" }, { - "id": "superpowers.codeReader.favorite10", - "name": "收藏 10", - "when": "superpowers.codeReader.hasFavorite10" + "id": "superpowers.codeReader.slot10", + "name": "栏位 10", + "when": "superpowers.codeReader.slotKind10" }, { - "id": "superpowers.codeReader.favorite11", - "name": "收藏 11", - "when": "superpowers.codeReader.hasFavorite11" + "id": "superpowers.codeReader.slot11", + "name": "栏位 11", + "when": "superpowers.codeReader.slotKind11" }, { - "id": "superpowers.codeReader.favorite12", - "name": "收藏 12", - "when": "superpowers.codeReader.hasFavorite12" + "id": "superpowers.codeReader.slot12", + "name": "栏位 12", + "when": "superpowers.codeReader.slotKind12" }, { - "id": "superpowers.codeReader.favorite13", - "name": "收藏 13", - "when": "superpowers.codeReader.hasFavorite13" + "id": "superpowers.codeReader.slot13", + "name": "栏位 13", + "when": "superpowers.codeReader.slotKind13" }, { - "id": "superpowers.codeReader.favorite14", - "name": "收藏 14", - "when": "superpowers.codeReader.hasFavorite14" + "id": "superpowers.codeReader.slot14", + "name": "栏位 14", + "when": "superpowers.codeReader.slotKind14" }, { - "id": "superpowers.codeReader.favorite15", - "name": "收藏 15", - "when": "superpowers.codeReader.hasFavorite15" + "id": "superpowers.codeReader.slot15", + "name": "栏位 15", + "when": "superpowers.codeReader.slotKind15" }, { - "id": "superpowers.codeReader.favorite16", - "name": "收藏 16", - "when": "superpowers.codeReader.hasFavorite16" + "id": "superpowers.codeReader.slot16", + "name": "栏位 16", + "when": "superpowers.codeReader.slotKind16" }, { - "id": "superpowers.codeReader.favorite17", - "name": "收藏 17", - "when": "superpowers.codeReader.hasFavorite17" + "id": "superpowers.codeReader.slot17", + "name": "栏位 17", + "when": "superpowers.codeReader.slotKind17" }, { - "id": "superpowers.codeReader.favorite18", - "name": "收藏 18", - "when": "superpowers.codeReader.hasFavorite18" + "id": "superpowers.codeReader.slot18", + "name": "栏位 18", + "when": "superpowers.codeReader.slotKind18" }, { - "id": "superpowers.codeReader.favorite19", - "name": "收藏 19", - "when": "superpowers.codeReader.hasFavorite19" + "id": "superpowers.codeReader.slot19", + "name": "栏位 19", + "when": "superpowers.codeReader.slotKind19" }, { - "id": "superpowers.codeReader.favorite20", - "name": "收藏 20", - "when": "superpowers.codeReader.hasFavorite20" + "id": "superpowers.codeReader.slot20", + "name": "栏位 20", + "when": "superpowers.codeReader.slotKind20" }, { - "id": "superpowers.codeReader.favorite21", - "name": "收藏 21", - "when": "superpowers.codeReader.hasFavorite21" + "id": "superpowers.codeReader.slot21", + "name": "栏位 21", + "when": "superpowers.codeReader.slotKind21" }, { - "id": "superpowers.codeReader.favorite22", - "name": "收藏 22", - "when": "superpowers.codeReader.hasFavorite22" + "id": "superpowers.codeReader.slot22", + "name": "栏位 22", + "when": "superpowers.codeReader.slotKind22" }, { - "id": "superpowers.codeReader.favorite23", - "name": "收藏 23", - "when": "superpowers.codeReader.hasFavorite23" + "id": "superpowers.codeReader.slot23", + "name": "栏位 23", + "when": "superpowers.codeReader.slotKind23" }, { - "id": "superpowers.codeReader.favorite24", - "name": "收藏 24", - "when": "superpowers.codeReader.hasFavorite24" + "id": "superpowers.codeReader.slot24", + "name": "栏位 24", + "when": "superpowers.codeReader.slotKind24" }, { - "id": "superpowers.codeReader.favorite25", - "name": "收藏 25", - "when": "superpowers.codeReader.hasFavorite25" + "id": "superpowers.codeReader.slot25", + "name": "栏位 25", + "when": "superpowers.codeReader.slotKind25" }, { - "id": "superpowers.codeReader.favorite26", - "name": "收藏 26", - "when": "superpowers.codeReader.hasFavorite26" + "id": "superpowers.codeReader.slot26", + "name": "栏位 26", + "when": "superpowers.codeReader.slotKind26" }, { - "id": "superpowers.codeReader.favorite27", - "name": "收藏 27", - "when": "superpowers.codeReader.hasFavorite27" + "id": "superpowers.codeReader.slot27", + "name": "栏位 27", + "when": "superpowers.codeReader.slotKind27" }, { - "id": "superpowers.codeReader.favorite28", - "name": "收藏 28", - "when": "superpowers.codeReader.hasFavorite28" + "id": "superpowers.codeReader.slot28", + "name": "栏位 28", + "when": "superpowers.codeReader.slotKind28" }, { - "id": "superpowers.codeReader.favorite29", - "name": "收藏 29", - "when": "superpowers.codeReader.hasFavorite29" + "id": "superpowers.codeReader.slot29", + "name": "栏位 29", + "when": "superpowers.codeReader.slotKind29" }, { - "id": "superpowers.codeReader.favorite30", - "name": "收藏 30", - "when": "superpowers.codeReader.hasFavorite30" + "id": "superpowers.codeReader.slot30", + "name": "栏位 30", + "when": "superpowers.codeReader.slotKind30" } ] }, @@ -328,6 +328,17 @@ "category": "代码阅读", "icon": "$(star-empty)" }, + { + "command": "superpowers.codeReader.findFeature", + "title": "了解一块代码", + "category": "代码阅读", + "icon": "$(search)" + }, + { + "command": "superpowers.codeReader.openFeatureItem", + "title": "打开", + "category": "代码阅读" + }, { "command": "superpowers.codeReader.renameFavorite1", "title": "重命名", @@ -340,6 +351,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat1", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature1", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature1", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite2", "title": "重命名", @@ -352,6 +381,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat2", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature2", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature2", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite3", "title": "重命名", @@ -364,6 +411,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat3", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature3", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature3", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite4", "title": "重命名", @@ -376,6 +441,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat4", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature4", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature4", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite5", "title": "重命名", @@ -388,6 +471,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat5", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature5", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature5", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite6", "title": "重命名", @@ -400,6 +501,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat6", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature6", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature6", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite7", "title": "重命名", @@ -412,6 +531,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat7", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature7", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature7", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite8", "title": "重命名", @@ -424,6 +561,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat8", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature8", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature8", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite9", "title": "重命名", @@ -436,6 +591,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat9", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature9", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature9", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite10", "title": "重命名", @@ -448,6 +621,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat10", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature10", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature10", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite11", "title": "重命名", @@ -460,6 +651,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat11", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature11", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature11", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite12", "title": "重命名", @@ -472,6 +681,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat12", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature12", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature12", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite13", "title": "重命名", @@ -484,6 +711,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat13", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature13", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature13", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite14", "title": "重命名", @@ -496,6 +741,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat14", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature14", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature14", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite15", "title": "重命名", @@ -508,6 +771,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat15", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature15", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature15", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite16", "title": "重命名", @@ -520,6 +801,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat16", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature16", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature16", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite17", "title": "重命名", @@ -532,6 +831,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat17", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature17", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature17", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite18", "title": "重命名", @@ -544,6 +861,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat18", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature18", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature18", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite19", "title": "重命名", @@ -556,6 +891,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat19", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature19", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature19", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite20", "title": "重命名", @@ -568,6 +921,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat20", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature20", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature20", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite21", "title": "重命名", @@ -580,6 +951,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat21", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature21", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature21", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite22", "title": "重命名", @@ -592,6 +981,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat22", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature22", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature22", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite23", "title": "重命名", @@ -604,6 +1011,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat23", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature23", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature23", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite24", "title": "重命名", @@ -616,6 +1041,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat24", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature24", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature24", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite25", "title": "重命名", @@ -628,6 +1071,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat25", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature25", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature25", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite26", "title": "重命名", @@ -640,6 +1101,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat26", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature26", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature26", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite27", "title": "重命名", @@ -652,6 +1131,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat27", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature27", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature27", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite28", "title": "重命名", @@ -664,6 +1161,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat28", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature28", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature28", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite29", "title": "重命名", @@ -676,6 +1191,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat29", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature29", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature29", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.renameFavorite30", "title": "重命名", @@ -688,6 +1221,24 @@ "category": "代码阅读", "icon": "$(star-full)" }, + { + "command": "superpowers.codeReader.openFeatureChat30", + "title": "打开对话", + "category": "代码阅读", + "icon": "$(comment-discussion)" + }, + { + "command": "superpowers.codeReader.renameFeature30", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFeature30", + "title": "删除", + "category": "代码阅读", + "icon": "$(trash)" + }, { "command": "superpowers.codeReader.open", "title": "打开", @@ -797,7 +1348,7 @@ "superpowers.codeReader.copy": [ { "command": "superpowers.codeReader.copyFileName", - "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^file( unstaged)?( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^file( unstaged| feature)?( noted)?$/", "group": "copy@1" }, { @@ -807,17 +1358,17 @@ }, { "command": "superpowers.codeReader.copyFileStem", - "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^file( unstaged)?( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^file( unstaged| feature)?( noted)?$/", "group": "copy@2" }, { "command": "superpowers.codeReader.copyRelativePath", - "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^(file( unstaged| feature)?|folder)( noted)?$/", "group": "copy@3" }, { "command": "superpowers.codeReader.copyAbsolutePath", - "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^(file( unstaged| feature)?|folder)( noted)?$/", "group": "copy@4" } ], @@ -837,6 +1388,11 @@ "when": "view == superpowers.kanban", "group": "navigation" }, + { + "command": "superpowers.codeReader.findFeature", + "when": "view == superpowers.codeReader.files", + "group": "navigation@0" + }, { "command": "superpowers.codeReader.pickReviewIssue", "when": "view == superpowers.codeReader.files", @@ -879,314 +1435,764 @@ }, { "command": "superpowers.codeReader.renameFavorite1", - "when": "view == superpowers.codeReader.favorite1", + "when": "view == superpowers.codeReader.slot1 && superpowers.codeReader.slotKind1 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite1", - "when": "view == superpowers.codeReader.favorite1", + "when": "view == superpowers.codeReader.slot1 && superpowers.codeReader.slotKind1 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat1", + "when": "view == superpowers.codeReader.slot1 && superpowers.codeReader.slotKind1 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature1", + "when": "view == superpowers.codeReader.slot1 && superpowers.codeReader.slotKind1 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature1", + "when": "view == superpowers.codeReader.slot1 && superpowers.codeReader.slotKind1 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite2", - "when": "view == superpowers.codeReader.favorite2", + "when": "view == superpowers.codeReader.slot2 && superpowers.codeReader.slotKind2 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite2", - "when": "view == superpowers.codeReader.favorite2", + "when": "view == superpowers.codeReader.slot2 && superpowers.codeReader.slotKind2 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat2", + "when": "view == superpowers.codeReader.slot2 && superpowers.codeReader.slotKind2 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature2", + "when": "view == superpowers.codeReader.slot2 && superpowers.codeReader.slotKind2 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature2", + "when": "view == superpowers.codeReader.slot2 && superpowers.codeReader.slotKind2 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite3", - "when": "view == superpowers.codeReader.favorite3", + "when": "view == superpowers.codeReader.slot3 && superpowers.codeReader.slotKind3 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite3", - "when": "view == superpowers.codeReader.favorite3", + "when": "view == superpowers.codeReader.slot3 && superpowers.codeReader.slotKind3 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat3", + "when": "view == superpowers.codeReader.slot3 && superpowers.codeReader.slotKind3 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature3", + "when": "view == superpowers.codeReader.slot3 && superpowers.codeReader.slotKind3 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature3", + "when": "view == superpowers.codeReader.slot3 && superpowers.codeReader.slotKind3 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite4", - "when": "view == superpowers.codeReader.favorite4", + "when": "view == superpowers.codeReader.slot4 && superpowers.codeReader.slotKind4 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite4", - "when": "view == superpowers.codeReader.favorite4", + "when": "view == superpowers.codeReader.slot4 && superpowers.codeReader.slotKind4 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat4", + "when": "view == superpowers.codeReader.slot4 && superpowers.codeReader.slotKind4 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature4", + "when": "view == superpowers.codeReader.slot4 && superpowers.codeReader.slotKind4 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature4", + "when": "view == superpowers.codeReader.slot4 && superpowers.codeReader.slotKind4 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite5", - "when": "view == superpowers.codeReader.favorite5", + "when": "view == superpowers.codeReader.slot5 && superpowers.codeReader.slotKind5 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite5", - "when": "view == superpowers.codeReader.favorite5", + "when": "view == superpowers.codeReader.slot5 && superpowers.codeReader.slotKind5 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat5", + "when": "view == superpowers.codeReader.slot5 && superpowers.codeReader.slotKind5 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature5", + "when": "view == superpowers.codeReader.slot5 && superpowers.codeReader.slotKind5 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature5", + "when": "view == superpowers.codeReader.slot5 && superpowers.codeReader.slotKind5 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite6", - "when": "view == superpowers.codeReader.favorite6", + "when": "view == superpowers.codeReader.slot6 && superpowers.codeReader.slotKind6 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite6", - "when": "view == superpowers.codeReader.favorite6", + "when": "view == superpowers.codeReader.slot6 && superpowers.codeReader.slotKind6 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat6", + "when": "view == superpowers.codeReader.slot6 && superpowers.codeReader.slotKind6 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature6", + "when": "view == superpowers.codeReader.slot6 && superpowers.codeReader.slotKind6 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature6", + "when": "view == superpowers.codeReader.slot6 && superpowers.codeReader.slotKind6 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite7", - "when": "view == superpowers.codeReader.favorite7", + "when": "view == superpowers.codeReader.slot7 && superpowers.codeReader.slotKind7 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite7", - "when": "view == superpowers.codeReader.favorite7", + "when": "view == superpowers.codeReader.slot7 && superpowers.codeReader.slotKind7 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat7", + "when": "view == superpowers.codeReader.slot7 && superpowers.codeReader.slotKind7 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature7", + "when": "view == superpowers.codeReader.slot7 && superpowers.codeReader.slotKind7 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature7", + "when": "view == superpowers.codeReader.slot7 && superpowers.codeReader.slotKind7 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite8", - "when": "view == superpowers.codeReader.favorite8", + "when": "view == superpowers.codeReader.slot8 && superpowers.codeReader.slotKind8 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite8", - "when": "view == superpowers.codeReader.favorite8", + "when": "view == superpowers.codeReader.slot8 && superpowers.codeReader.slotKind8 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat8", + "when": "view == superpowers.codeReader.slot8 && superpowers.codeReader.slotKind8 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature8", + "when": "view == superpowers.codeReader.slot8 && superpowers.codeReader.slotKind8 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature8", + "when": "view == superpowers.codeReader.slot8 && superpowers.codeReader.slotKind8 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite9", - "when": "view == superpowers.codeReader.favorite9", + "when": "view == superpowers.codeReader.slot9 && superpowers.codeReader.slotKind9 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite9", - "when": "view == superpowers.codeReader.favorite9", + "when": "view == superpowers.codeReader.slot9 && superpowers.codeReader.slotKind9 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat9", + "when": "view == superpowers.codeReader.slot9 && superpowers.codeReader.slotKind9 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature9", + "when": "view == superpowers.codeReader.slot9 && superpowers.codeReader.slotKind9 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature9", + "when": "view == superpowers.codeReader.slot9 && superpowers.codeReader.slotKind9 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite10", - "when": "view == superpowers.codeReader.favorite10", + "when": "view == superpowers.codeReader.slot10 && superpowers.codeReader.slotKind10 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite10", - "when": "view == superpowers.codeReader.favorite10", + "when": "view == superpowers.codeReader.slot10 && superpowers.codeReader.slotKind10 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat10", + "when": "view == superpowers.codeReader.slot10 && superpowers.codeReader.slotKind10 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature10", + "when": "view == superpowers.codeReader.slot10 && superpowers.codeReader.slotKind10 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature10", + "when": "view == superpowers.codeReader.slot10 && superpowers.codeReader.slotKind10 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite11", - "when": "view == superpowers.codeReader.favorite11", + "when": "view == superpowers.codeReader.slot11 && superpowers.codeReader.slotKind11 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite11", - "when": "view == superpowers.codeReader.favorite11", + "when": "view == superpowers.codeReader.slot11 && superpowers.codeReader.slotKind11 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat11", + "when": "view == superpowers.codeReader.slot11 && superpowers.codeReader.slotKind11 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature11", + "when": "view == superpowers.codeReader.slot11 && superpowers.codeReader.slotKind11 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature11", + "when": "view == superpowers.codeReader.slot11 && superpowers.codeReader.slotKind11 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite12", - "when": "view == superpowers.codeReader.favorite12", + "when": "view == superpowers.codeReader.slot12 && superpowers.codeReader.slotKind12 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite12", - "when": "view == superpowers.codeReader.favorite12", + "when": "view == superpowers.codeReader.slot12 && superpowers.codeReader.slotKind12 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat12", + "when": "view == superpowers.codeReader.slot12 && superpowers.codeReader.slotKind12 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature12", + "when": "view == superpowers.codeReader.slot12 && superpowers.codeReader.slotKind12 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature12", + "when": "view == superpowers.codeReader.slot12 && superpowers.codeReader.slotKind12 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite13", - "when": "view == superpowers.codeReader.favorite13", + "when": "view == superpowers.codeReader.slot13 && superpowers.codeReader.slotKind13 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite13", - "when": "view == superpowers.codeReader.favorite13", + "when": "view == superpowers.codeReader.slot13 && superpowers.codeReader.slotKind13 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat13", + "when": "view == superpowers.codeReader.slot13 && superpowers.codeReader.slotKind13 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature13", + "when": "view == superpowers.codeReader.slot13 && superpowers.codeReader.slotKind13 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature13", + "when": "view == superpowers.codeReader.slot13 && superpowers.codeReader.slotKind13 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite14", - "when": "view == superpowers.codeReader.favorite14", + "when": "view == superpowers.codeReader.slot14 && superpowers.codeReader.slotKind14 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite14", - "when": "view == superpowers.codeReader.favorite14", + "when": "view == superpowers.codeReader.slot14 && superpowers.codeReader.slotKind14 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat14", + "when": "view == superpowers.codeReader.slot14 && superpowers.codeReader.slotKind14 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature14", + "when": "view == superpowers.codeReader.slot14 && superpowers.codeReader.slotKind14 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature14", + "when": "view == superpowers.codeReader.slot14 && superpowers.codeReader.slotKind14 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite15", - "when": "view == superpowers.codeReader.favorite15", + "when": "view == superpowers.codeReader.slot15 && superpowers.codeReader.slotKind15 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite15", - "when": "view == superpowers.codeReader.favorite15", + "when": "view == superpowers.codeReader.slot15 && superpowers.codeReader.slotKind15 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat15", + "when": "view == superpowers.codeReader.slot15 && superpowers.codeReader.slotKind15 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature15", + "when": "view == superpowers.codeReader.slot15 && superpowers.codeReader.slotKind15 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature15", + "when": "view == superpowers.codeReader.slot15 && superpowers.codeReader.slotKind15 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite16", - "when": "view == superpowers.codeReader.favorite16", + "when": "view == superpowers.codeReader.slot16 && superpowers.codeReader.slotKind16 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite16", - "when": "view == superpowers.codeReader.favorite16", + "when": "view == superpowers.codeReader.slot16 && superpowers.codeReader.slotKind16 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat16", + "when": "view == superpowers.codeReader.slot16 && superpowers.codeReader.slotKind16 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature16", + "when": "view == superpowers.codeReader.slot16 && superpowers.codeReader.slotKind16 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature16", + "when": "view == superpowers.codeReader.slot16 && superpowers.codeReader.slotKind16 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite17", - "when": "view == superpowers.codeReader.favorite17", + "when": "view == superpowers.codeReader.slot17 && superpowers.codeReader.slotKind17 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite17", - "when": "view == superpowers.codeReader.favorite17", + "when": "view == superpowers.codeReader.slot17 && superpowers.codeReader.slotKind17 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat17", + "when": "view == superpowers.codeReader.slot17 && superpowers.codeReader.slotKind17 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature17", + "when": "view == superpowers.codeReader.slot17 && superpowers.codeReader.slotKind17 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature17", + "when": "view == superpowers.codeReader.slot17 && superpowers.codeReader.slotKind17 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite18", - "when": "view == superpowers.codeReader.favorite18", + "when": "view == superpowers.codeReader.slot18 && superpowers.codeReader.slotKind18 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite18", - "when": "view == superpowers.codeReader.favorite18", + "when": "view == superpowers.codeReader.slot18 && superpowers.codeReader.slotKind18 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat18", + "when": "view == superpowers.codeReader.slot18 && superpowers.codeReader.slotKind18 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature18", + "when": "view == superpowers.codeReader.slot18 && superpowers.codeReader.slotKind18 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature18", + "when": "view == superpowers.codeReader.slot18 && superpowers.codeReader.slotKind18 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite19", - "when": "view == superpowers.codeReader.favorite19", + "when": "view == superpowers.codeReader.slot19 && superpowers.codeReader.slotKind19 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite19", - "when": "view == superpowers.codeReader.favorite19", + "when": "view == superpowers.codeReader.slot19 && superpowers.codeReader.slotKind19 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat19", + "when": "view == superpowers.codeReader.slot19 && superpowers.codeReader.slotKind19 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature19", + "when": "view == superpowers.codeReader.slot19 && superpowers.codeReader.slotKind19 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature19", + "when": "view == superpowers.codeReader.slot19 && superpowers.codeReader.slotKind19 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite20", - "when": "view == superpowers.codeReader.favorite20", + "when": "view == superpowers.codeReader.slot20 && superpowers.codeReader.slotKind20 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite20", - "when": "view == superpowers.codeReader.favorite20", + "when": "view == superpowers.codeReader.slot20 && superpowers.codeReader.slotKind20 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat20", + "when": "view == superpowers.codeReader.slot20 && superpowers.codeReader.slotKind20 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature20", + "when": "view == superpowers.codeReader.slot20 && superpowers.codeReader.slotKind20 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature20", + "when": "view == superpowers.codeReader.slot20 && superpowers.codeReader.slotKind20 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite21", - "when": "view == superpowers.codeReader.favorite21", + "when": "view == superpowers.codeReader.slot21 && superpowers.codeReader.slotKind21 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite21", - "when": "view == superpowers.codeReader.favorite21", + "when": "view == superpowers.codeReader.slot21 && superpowers.codeReader.slotKind21 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat21", + "when": "view == superpowers.codeReader.slot21 && superpowers.codeReader.slotKind21 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature21", + "when": "view == superpowers.codeReader.slot21 && superpowers.codeReader.slotKind21 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature21", + "when": "view == superpowers.codeReader.slot21 && superpowers.codeReader.slotKind21 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite22", - "when": "view == superpowers.codeReader.favorite22", + "when": "view == superpowers.codeReader.slot22 && superpowers.codeReader.slotKind22 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite22", - "when": "view == superpowers.codeReader.favorite22", + "when": "view == superpowers.codeReader.slot22 && superpowers.codeReader.slotKind22 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat22", + "when": "view == superpowers.codeReader.slot22 && superpowers.codeReader.slotKind22 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature22", + "when": "view == superpowers.codeReader.slot22 && superpowers.codeReader.slotKind22 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature22", + "when": "view == superpowers.codeReader.slot22 && superpowers.codeReader.slotKind22 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite23", - "when": "view == superpowers.codeReader.favorite23", + "when": "view == superpowers.codeReader.slot23 && superpowers.codeReader.slotKind23 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite23", - "when": "view == superpowers.codeReader.favorite23", + "when": "view == superpowers.codeReader.slot23 && superpowers.codeReader.slotKind23 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat23", + "when": "view == superpowers.codeReader.slot23 && superpowers.codeReader.slotKind23 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature23", + "when": "view == superpowers.codeReader.slot23 && superpowers.codeReader.slotKind23 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature23", + "when": "view == superpowers.codeReader.slot23 && superpowers.codeReader.slotKind23 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite24", - "when": "view == superpowers.codeReader.favorite24", + "when": "view == superpowers.codeReader.slot24 && superpowers.codeReader.slotKind24 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite24", - "when": "view == superpowers.codeReader.favorite24", + "when": "view == superpowers.codeReader.slot24 && superpowers.codeReader.slotKind24 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat24", + "when": "view == superpowers.codeReader.slot24 && superpowers.codeReader.slotKind24 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature24", + "when": "view == superpowers.codeReader.slot24 && superpowers.codeReader.slotKind24 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature24", + "when": "view == superpowers.codeReader.slot24 && superpowers.codeReader.slotKind24 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite25", - "when": "view == superpowers.codeReader.favorite25", + "when": "view == superpowers.codeReader.slot25 && superpowers.codeReader.slotKind25 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite25", - "when": "view == superpowers.codeReader.favorite25", + "when": "view == superpowers.codeReader.slot25 && superpowers.codeReader.slotKind25 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat25", + "when": "view == superpowers.codeReader.slot25 && superpowers.codeReader.slotKind25 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature25", + "when": "view == superpowers.codeReader.slot25 && superpowers.codeReader.slotKind25 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature25", + "when": "view == superpowers.codeReader.slot25 && superpowers.codeReader.slotKind25 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite26", - "when": "view == superpowers.codeReader.favorite26", + "when": "view == superpowers.codeReader.slot26 && superpowers.codeReader.slotKind26 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite26", - "when": "view == superpowers.codeReader.favorite26", + "when": "view == superpowers.codeReader.slot26 && superpowers.codeReader.slotKind26 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat26", + "when": "view == superpowers.codeReader.slot26 && superpowers.codeReader.slotKind26 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature26", + "when": "view == superpowers.codeReader.slot26 && superpowers.codeReader.slotKind26 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature26", + "when": "view == superpowers.codeReader.slot26 && superpowers.codeReader.slotKind26 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite27", - "when": "view == superpowers.codeReader.favorite27", + "when": "view == superpowers.codeReader.slot27 && superpowers.codeReader.slotKind27 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite27", - "when": "view == superpowers.codeReader.favorite27", + "when": "view == superpowers.codeReader.slot27 && superpowers.codeReader.slotKind27 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat27", + "when": "view == superpowers.codeReader.slot27 && superpowers.codeReader.slotKind27 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature27", + "when": "view == superpowers.codeReader.slot27 && superpowers.codeReader.slotKind27 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature27", + "when": "view == superpowers.codeReader.slot27 && superpowers.codeReader.slotKind27 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite28", - "when": "view == superpowers.codeReader.favorite28", + "when": "view == superpowers.codeReader.slot28 && superpowers.codeReader.slotKind28 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite28", - "when": "view == superpowers.codeReader.favorite28", + "when": "view == superpowers.codeReader.slot28 && superpowers.codeReader.slotKind28 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat28", + "when": "view == superpowers.codeReader.slot28 && superpowers.codeReader.slotKind28 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature28", + "when": "view == superpowers.codeReader.slot28 && superpowers.codeReader.slotKind28 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature28", + "when": "view == superpowers.codeReader.slot28 && superpowers.codeReader.slotKind28 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite29", - "when": "view == superpowers.codeReader.favorite29", + "when": "view == superpowers.codeReader.slot29 && superpowers.codeReader.slotKind29 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite29", - "when": "view == superpowers.codeReader.favorite29", + "when": "view == superpowers.codeReader.slot29 && superpowers.codeReader.slotKind29 == favorite", "group": "navigation@2" }, + { + "command": "superpowers.codeReader.openFeatureChat29", + "when": "view == superpowers.codeReader.slot29 && superpowers.codeReader.slotKind29 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature29", + "when": "view == superpowers.codeReader.slot29 && superpowers.codeReader.slotKind29 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature29", + "when": "view == superpowers.codeReader.slot29 && superpowers.codeReader.slotKind29 == feature", + "group": "navigation@3" + }, { "command": "superpowers.codeReader.renameFavorite30", - "when": "view == superpowers.codeReader.favorite30", + "when": "view == superpowers.codeReader.slot30 && superpowers.codeReader.slotKind30 == favorite", "group": "navigation@1" }, { "command": "superpowers.codeReader.removeFavorite30", - "when": "view == superpowers.codeReader.favorite30", + "when": "view == superpowers.codeReader.slot30 && superpowers.codeReader.slotKind30 == favorite", "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.openFeatureChat30", + "when": "view == superpowers.codeReader.slot30 && superpowers.codeReader.slotKind30 == feature", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.renameFeature30", + "when": "view == superpowers.codeReader.slot30 && superpowers.codeReader.slotKind30 == feature", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.removeFeature30", + "when": "view == superpowers.codeReader.slot30 && superpowers.codeReader.slotKind30 == feature", + "group": "navigation@3" } ], "view/item/context": [ { "command": "superpowers.codeReader.askPath", - "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^(file( unstaged| feature)?|folder)( noted)?$/", "group": "inline@0" }, { "submenu": "superpowers.codeReader.copy", - "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^(file( unstaged| feature)?|folder)( noted)?$/", "group": "inline@1" }, { @@ -1206,12 +2212,12 @@ }, { "command": "superpowers.codeReader.addFavorite", - "when": "view =~ /^superpowers\\.codeReader\\.(files|favorite\\d+)$/ && !(view == superpowers.codeReader.files && superpowers.codeReader.reviewing) && viewItem =~ /^folder( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\.(files|slot\\d+)$/ && !(view == superpowers.codeReader.files && superpowers.codeReader.reviewing) && viewItem =~ /^folder( noted)?$/", "group": "inline@3" }, { "command": "superpowers.codeReader.copyFileName", - "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^file( unstaged)?( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^file( unstaged| feature)?( noted)?$/", "group": "copy@1" }, { @@ -1221,32 +2227,32 @@ }, { "command": "superpowers.codeReader.copyFileStem", - "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^file( unstaged)?( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^file( unstaged| feature)?( noted)?$/", "group": "copy@2" }, { "command": "superpowers.codeReader.copyRelativePath", - "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^(file( unstaged| feature)?|folder)( noted)?$/", "group": "copy@3" }, { "command": "superpowers.codeReader.copyAbsolutePath", - "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^(file( unstaged| feature)?|folder)( noted)?$/", "group": "copy@4" }, { "command": "superpowers.codeReader.revealInFinder", - "when": "view =~ /^superpowers\\.codeReader\\./ && isMac && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && isMac && viewItem =~ /^(file( unstaged| feature)?|folder)( noted)?$/", "group": "fileManager" }, { "command": "superpowers.codeReader.revealInFileExplorer", - "when": "view =~ /^superpowers\\.codeReader\\./ && isWindows && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && isWindows && viewItem =~ /^(file( unstaged| feature)?|folder)( noted)?$/", "group": "fileManager" }, { "command": "superpowers.codeReader.openContainingFolder", - "when": "view =~ /^superpowers\\.codeReader\\./ && isLinux && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && isLinux && viewItem =~ /^(file( unstaged| feature)?|folder)( noted)?$/", "group": "fileManager" }, { @@ -1261,7 +2267,7 @@ }, { "command": "superpowers.codeReader.addFavorite", - "when": "view =~ /^superpowers\\.codeReader\\.(files|favorite\\d+)$/ && !(view == superpowers.codeReader.files && superpowers.codeReader.reviewing) && viewItem =~ /^folder( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\.(files|slot\\d+)$/ && !(view == superpowers.codeReader.files && superpowers.codeReader.reviewing) && viewItem =~ /^folder( noted)?$/", "group": "star" } ], @@ -1314,6 +2320,10 @@ "command": "superpowers.codeReader.addFavorite", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureItem", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite1", "when": "false" @@ -1322,6 +2332,18 @@ "command": "superpowers.codeReader.removeFavorite1", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat1", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature1", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature1", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite2", "when": "false" @@ -1330,6 +2352,18 @@ "command": "superpowers.codeReader.removeFavorite2", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat2", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature2", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature2", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite3", "when": "false" @@ -1338,6 +2372,18 @@ "command": "superpowers.codeReader.removeFavorite3", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat3", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature3", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature3", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite4", "when": "false" @@ -1346,6 +2392,18 @@ "command": "superpowers.codeReader.removeFavorite4", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat4", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature4", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature4", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite5", "when": "false" @@ -1354,6 +2412,18 @@ "command": "superpowers.codeReader.removeFavorite5", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat5", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature5", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature5", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite6", "when": "false" @@ -1362,6 +2432,18 @@ "command": "superpowers.codeReader.removeFavorite6", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat6", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature6", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature6", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite7", "when": "false" @@ -1370,6 +2452,18 @@ "command": "superpowers.codeReader.removeFavorite7", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat7", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature7", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature7", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite8", "when": "false" @@ -1378,6 +2472,18 @@ "command": "superpowers.codeReader.removeFavorite8", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat8", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature8", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature8", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite9", "when": "false" @@ -1386,6 +2492,18 @@ "command": "superpowers.codeReader.removeFavorite9", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat9", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature9", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature9", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite10", "when": "false" @@ -1394,6 +2512,18 @@ "command": "superpowers.codeReader.removeFavorite10", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat10", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature10", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature10", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite11", "when": "false" @@ -1402,6 +2532,18 @@ "command": "superpowers.codeReader.removeFavorite11", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat11", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature11", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature11", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite12", "when": "false" @@ -1410,6 +2552,18 @@ "command": "superpowers.codeReader.removeFavorite12", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat12", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature12", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature12", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite13", "when": "false" @@ -1418,6 +2572,18 @@ "command": "superpowers.codeReader.removeFavorite13", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat13", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature13", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature13", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite14", "when": "false" @@ -1426,6 +2592,18 @@ "command": "superpowers.codeReader.removeFavorite14", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat14", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature14", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature14", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite15", "when": "false" @@ -1434,6 +2612,18 @@ "command": "superpowers.codeReader.removeFavorite15", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat15", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature15", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature15", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite16", "when": "false" @@ -1442,6 +2632,18 @@ "command": "superpowers.codeReader.removeFavorite16", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat16", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature16", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature16", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite17", "when": "false" @@ -1450,6 +2652,18 @@ "command": "superpowers.codeReader.removeFavorite17", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat17", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature17", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature17", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite18", "when": "false" @@ -1458,6 +2672,18 @@ "command": "superpowers.codeReader.removeFavorite18", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat18", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature18", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature18", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite19", "when": "false" @@ -1466,6 +2692,18 @@ "command": "superpowers.codeReader.removeFavorite19", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat19", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature19", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature19", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite20", "when": "false" @@ -1474,6 +2712,18 @@ "command": "superpowers.codeReader.removeFavorite20", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat20", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature20", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature20", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite21", "when": "false" @@ -1482,6 +2732,18 @@ "command": "superpowers.codeReader.removeFavorite21", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat21", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature21", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature21", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite22", "when": "false" @@ -1490,6 +2752,18 @@ "command": "superpowers.codeReader.removeFavorite22", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat22", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature22", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature22", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite23", "when": "false" @@ -1498,6 +2772,18 @@ "command": "superpowers.codeReader.removeFavorite23", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat23", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature23", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature23", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite24", "when": "false" @@ -1506,6 +2792,18 @@ "command": "superpowers.codeReader.removeFavorite24", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat24", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature24", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature24", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite25", "when": "false" @@ -1514,6 +2812,18 @@ "command": "superpowers.codeReader.removeFavorite25", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat25", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature25", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature25", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite26", "when": "false" @@ -1522,6 +2832,18 @@ "command": "superpowers.codeReader.removeFavorite26", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat26", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature26", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature26", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite27", "when": "false" @@ -1530,6 +2852,18 @@ "command": "superpowers.codeReader.removeFavorite27", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat27", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature27", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature27", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite28", "when": "false" @@ -1538,6 +2872,18 @@ "command": "superpowers.codeReader.removeFavorite28", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat28", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature28", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature28", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite29", "when": "false" @@ -1546,6 +2892,18 @@ "command": "superpowers.codeReader.removeFavorite29", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat29", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature29", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature29", + "when": "false" + }, { "command": "superpowers.codeReader.renameFavorite30", "when": "false" @@ -1554,6 +2912,18 @@ "command": "superpowers.codeReader.removeFavorite30", "when": "false" }, + { + "command": "superpowers.codeReader.openFeatureChat30", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFeature30", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFeature30", + "when": "false" + }, { "command": "superpowers.codeReader.showPreview", "when": "activeEditor == workbench.editors.files.textFileEditor && (resourceLangId =~ /^(typescript|typescriptreact|javascript|javascriptreact|python|shellscript|dockerfile|rust)$/ || resourceLangId in superpowers.codeReader.languageIds)" diff --git a/vscode/src/cc/claudeProgress.ts b/vscode/src/cc/claudeProgress.ts index 802fd1f..cf4b44f 100644 --- a/vscode/src/cc/claudeProgress.ts +++ b/vscode/src/cc/claudeProgress.ts @@ -41,6 +41,9 @@ export function describeToolUse(name: string, input: Record): s case 'Grep': case 'Glob': return `搜索 ${truncate(asString(input.pattern))}` + // 要了结构化结果(--json-schema)时,模型调这个工具把结果交回来 + case 'StructuredOutput': + return '整理结果' default: return `调用 ${name}` } diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index 5a0f873..c80478b 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -157,7 +157,7 @@ function colorLines({ owner, roles }: Coloring, source: string, lines: Line[], s * 这块本身就是哪个名字的定义:函数名、类名,只声明一个变量(或一个解构模式)时的变量名。 * 导入、一条语句声明几个变量时没有,它们的名字各是属性表里的一行(PropertyRow.defines)。 */ -function ownName(block: CodeBlock): Node | undefined { +export function ownName(block: CodeBlock): Node | undefined { switch (block.kind) { case 'function': case 'class': diff --git a/vscode/src/codeReader/editor/documentSymbols.ts b/vscode/src/codeReader/editor/documentSymbols.ts new file mode 100644 index 0000000..1c97994 --- /dev/null +++ b/vscode/src/codeReader/editor/documentSymbols.ts @@ -0,0 +1,21 @@ +import type { DocumentSymbol, Range } from 'vscode' +import type { SourceRange } from '../contract' +import type { SymbolNode } from '../symbolLinks' +import { commands, Uri, workspace } from 'vscode' + +/** + * 一个文件的文档符号(`vscode.executeDocumentSymbolProvider`,由这种语言的扩展给):先把文件读进来(不显示),范围换成那个文件里的下标。 + * 没装这种语言的扩展时是空的。注释里的仓库链接、「了解一块代码」在没有语言插件的文件里找符号都用它。 + */ +export async function documentSymbolNodes(uri: string): Promise { + const doc = await workspace.openTextDocument(Uri.parse(uri)) + const symbols = await commands.executeCommand('vscode.executeDocumentSymbolProvider', doc.uri) + const span = (range: Range): SourceRange => ({ startIndex: doc.offsetAt(range.start), endIndex: doc.offsetAt(range.end) }) + const node = (symbol: DocumentSymbol): SymbolNode => ({ + name: symbol.name, + range: span(symbol.range), + selection: span(symbol.selectionRange), + children: (symbol.children ?? []).map(node), + }) + return Array.isArray(symbols) ? symbols.map(node) : [] +} diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 4f6adbf..8ddcb2b 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -98,8 +98,8 @@ export interface QaMessage { threads: QaThreadView[] /** 这一页顶部、按路径记的问答;不挂在块上 */ path: QaEntryView[] - /** 顶部问的是这个文件还是这个文件夹 */ - subject: 'file' | 'folder' + /** 顶部问的是这个文件、这个文件夹,还是「了解一块代码」找的一个功能 */ + subject: 'file' | 'folder' | 'feature' current: AskRound[] /** 块下面的重写记录,按块的记号 */ rewrites: QaThreadView[] diff --git a/vscode/src/codeReader/editor/pathPreview.ts b/vscode/src/codeReader/editor/pathPreview.ts index b4ee289..e880a96 100644 --- a/vscode/src/codeReader/editor/pathPreview.ts +++ b/vscode/src/codeReader/editor/pathPreview.ts @@ -1,17 +1,39 @@ /** * 文件夹、以及代码阅读不认识的文件,用这个协议打开代码阅读预览: * 文档是空的,页面上没有代码块,只在顶部聊天。源码文件直接开它自己的预览。 + * 「了解一块代码」找的功能的对话页也用这个协议,只是对的不是路径,是功能。 */ -import type { Uri, WorkspaceFolder } from 'vscode' +import type { WorkspaceFolder } from 'vscode' import * as path from 'node:path' -import { workspace } from 'vscode' -import { relativeQaKey } from '../qa/pathQa' +import { Uri, workspace } from 'vscode' +import { featureQaKey, pathQaKey, relativeQaKey } from '../qa/pathQa' export const PATH_PREVIEW_SCHEME = 'code-reader-path' export type PathSubject = 'file' | 'folder' +/** 预览顶部的聊天问的是什么:文件、文件夹,或「了解一块代码」找的一个功能 */ +export type TopSubject = PathSubject | 'feature' + +/** 功能对话页的查询串:后面跟着功能的 id */ +const FEATURE_QUERY = 'feature=' + +/** 「了解一块代码」一个功能的对话页:路径是问的那句话(标签页上显示它,`/` 换成全角的免得被当成目录),查询串带着功能的 id。 */ +export function featurePageUri(id: string, question: string): Uri { + return Uri.from({ scheme: PATH_PREVIEW_SCHEME, path: `/${question.replaceAll('/', '/')}`, query: `${FEATURE_QUERY}${id}` }) +} + +/** 一个功能的问答按这个 Uri 记、按它对事件:对话页标签上叫什么都是它。 */ +export function featureSubjectUri(id: string): Uri { + return Uri.from({ scheme: PATH_PREVIEW_SCHEME, path: '/', query: `${FEATURE_QUERY}${id}` }) +} + +/** 功能对话页(或 featureSubjectUri)对的功能 id;别的 Uri 没有。 */ +export function featureIdOf(uri: Uri): string | undefined { + return uri.scheme === PATH_PREVIEW_SCHEME && uri.query.startsWith(FEATURE_QUERY) ? uri.query.slice(FEATURE_QUERY.length) : undefined +} + export function pathPreviewUri(target: Uri, kind: PathSubject): Uri { return target.with({ scheme: PATH_PREVIEW_SCHEME, query: kind, fragment: '' }) } @@ -25,6 +47,14 @@ export function pathPreviewOf(uri: Uri): { uri: Uri, kind: PathSubject } | undef return { uri: uri.with({ scheme: 'file', query: '', fragment: '' }), kind: uri.query } } +/** 预览这一页顶部问的是什么:功能对话页是那个功能(featureSubjectUri),路径页还原成真实文件或文件夹,源码预览就是文档自己。 */ +export function topSubjectOf(uri: Uri): { uri: Uri, kind: TopSubject } { + const feature = featureIdOf(uri) + if (feature !== undefined) + return { uri: featureSubjectUri(feature), kind: 'feature' } + return pathPreviewOf(uri) ?? { uri, kind: 'file' } +} + /** * 这条路径在哪个工作区文件夹、相对路径是什么。 * 工作区根本身的相对路径是空字符串(locate 不认根,顶部问答要认)。 @@ -36,3 +66,17 @@ export function pathQaLocation(uri: Uri): { folder: WorkspaceFolder, key: string const key = relativeQaKey(path.posix.normalize(folder.uri.path), path.posix.normalize(uri.path)) return key === undefined ? undefined : { folder, key } } + +/** + * 顶部问答记在哪:在哪个工作区文件夹(claude 在它里面跑,带的图片存在它的 `.spx/code-qa/`)、记录表里的键(见 pathQa.ts)。 + * 文件、文件夹按「工作区文件夹 + 相对路径」;功能对话页按功能的 id,记在第一个工作区文件夹。不在工作区里、没打开文件夹时没有。 + */ +export function topQaLocation(uri: Uri): { folder: WorkspaceFolder, key: string } | undefined { + const feature = featureIdOf(uri) + if (feature !== undefined) { + const folder = workspace.workspaceFolders?.[0] + return folder && { folder, key: featureQaKey(feature) } + } + const at = pathQaLocation(uri) + return at && { folder: at.folder, key: pathQaKey(at.folder.uri.toString(), at.key) } +} diff --git a/vscode/src/codeReader/editor/previewEditor.test.ts b/vscode/src/codeReader/editor/previewEditor.test.ts index 898768f..453b8eb 100644 --- a/vscode/src/codeReader/editor/previewEditor.test.ts +++ b/vscode/src/codeReader/editor/previewEditor.test.ts @@ -26,7 +26,7 @@ vi.mock('vscode', () => ({ vi.mock('../../logging/logger', () => ({ logger: { add: vi.fn() } })) vi.mock('../../webview/html', () => ({ webviewHtml: () => '', webviewUiRoot: () => ({}) })) vi.mock('../folderStore', () => ({ locate: () => ({ folder: { uri: { fsPath: '/workspace' } }, key: 'web/a.ts' }) })) -vi.mock('./pathPreview', () => ({ pathPreviewOf: () => undefined })) +vi.mock('./pathPreview', () => ({ PATH_PREVIEW_SCHEME: 'code-reader-path', topSubjectOf: (uri: unknown) => ({ uri, kind: 'file' }) })) vi.mock('./occurrences', () => ({ occurrencesOf: vi.fn() })) const SOURCE = ['// 旧说明', 'export function load() {}'] @@ -51,6 +51,7 @@ function preview(rewrite: (uri: unknown, request: RewriteRequest) => Promise false, + async () => undefined, ] as unknown as ConstructorParameters) provider.resolveCustomTextEditor(...[ document, diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 4360d5b..cbdd3a7 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -1,13 +1,13 @@ -import type { CustomTextEditorProvider, Disposable, DocumentSymbol, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode' +import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode' import type { ClaudeImage } from '../../cc/spawnClaude' import type { IndexFind } from '../callTree/endpoint' import type { CommentEdit, CommentJob, WriteResult } from '../commentRewrite' -import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, SourceRange } from '../contract' +import type { BlockKind, LocalSymbol, RuleLocation, RuleReference } from '../contract' import type { DiffBaseSource } from '../diff/diffBase' import type { OldDocument, SentDocument } from '../diff/previewDiff' import type { WorkspaceEntries } from '../entries/workspaceEntries' import type { DefinitionStart, IndexedName, NameSources } from '../nameResolution' -import type { CodeQa } from '../qa/codeQa' +import type { CodeQa, TopRequest } from '../qa/codeQa' import type { CodeReaderRegistry } from '../registry' import type { LookupSources } from '../ruleLookups' import type { TreeSitterRuntime } from '../runtime' @@ -16,6 +16,7 @@ import type { BlockFilter } from './blockFilter' import type { FoundLocation } from './definitions' import type { DefinitionPeek, ExtensionToWebview, Occurrence, Reveal, WebviewToExtension } from './messages' import type { TargetSpot } from './openTarget' +import type { TopSubject } from './pathPreview' import { realpath } from 'node:fs/promises' import { commands, EndOfLine, Position, Range, Uri, window, workspace, WorkspaceEdit } from 'vscode' import { logger } from '../../logging/logger' @@ -38,10 +39,11 @@ import { definitionSiteNames, namesOnCard, peekBlock } from './definitionPeek' import { definitionKind, findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions' import { DefinitionSources } from './definitionSources' import { documentMessage } from './documentMessage' +import { documentSymbolNodes } from './documentSymbols' import { NavigationContext, OCCURRENCES_CONTEXT } from './occurrenceNavigation' import { occurrencesOf } from './occurrences' import { languageOf, openingFor } from './openTarget' -import { pathPreviewOf } from './pathPreview' +import { PATH_PREVIEW_SCHEME, topSubjectOf } from './pathPreview' import { findRepoSymbol, isRepoImagePath, repoRootOf, resolveRepoEntry } from './repoPath' export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview' @@ -81,6 +83,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { readonly entries: Pick, /** 在代码阅读侧栏的树上定位一个目录(仓库链接指到目录时);树上没有它时返回 false */ readonly revealInTree: (uri: Uri) => Promise, + /** 「了解一块代码」的对话页里接着说(subject 是那个功能,见 featureSubjectUri);说不了时返回给人看的原因 */ + readonly askFeature: (subject: Uri, request: TopRequest) => Promise, ) {} /** 侧栏「问 AI」打开预览,并把光标放进顶部输入框。已经开着的就揭到前面再聚焦。 */ @@ -103,7 +107,7 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void { // 问答带的图片(工作区文件夹的 .spx/code-qa/)也要能读,历史里才画得出缩略图 - const imageRoot = this.qa.imageRoot(pathPreviewOf(document.uri)?.uri ?? document.uri) + const imageRoot = this.qa.imageRoot(topSubjectOf(document.uri).uri) panel.webview.options = { enableScripts: true, localResourceRoots: [webviewUiRoot(this.extensionUri), ...(imageRoot ? [imageRoot] : [])] } panel.webview.html = webviewHtml(panel.webview, this.extensionUri, 'code-reader.html') const key = document.uri.toString() @@ -339,7 +343,8 @@ class Preview { return const request = ++this.latest this.callTrees.cancel() - if (pathPreviewOf(this.document.uri)) { + // 路径页、功能对话页:文档是空的,只在顶部聊天 + if (this.document.uri.scheme === PATH_PREVIEW_SCHEME) { this.sent = undefined const reveal = this.pendingReveal this.pendingReveal = undefined @@ -497,9 +502,9 @@ class Preview { this.post(this.owner.qa.view(subject.uri, file => this.panel.webview.asWebviewUri(file).toString(), subject.kind)) } - /** 这一页问的是哪个文件或文件夹。路径页还原成真实路径,源码预览就是文档自己。 */ - private subject(): { uri: Uri, kind: 'file' | 'folder' } { - return pathPreviewOf(this.document.uri) ?? { uri: this.document.uri, kind: 'file' } + /** 这一页顶部问的是什么(见 topSubjectOf)。 */ + private subject(): { uri: Uri, kind: TopSubject } { + return topSubjectOf(this.document.uri) } /** 把光标放进顶部输入框;页面还没准备好就等 ready。 */ @@ -526,7 +531,10 @@ class Preview { private async ask(askId: string, anchor: string, question: string, images: ClaudeImage[]): Promise { const subject = this.subject() if (anchor === PATH_ASK_ANCHOR) { - const refused = await this.owner.qa.askPath(subject.uri, subject.kind, { askId, question, images }) + const request = { askId, question, images } + const refused = subject.kind === 'feature' + ? await this.owner.askFeature(subject.uri, request) + : await this.owner.qa.askPath(subject.uri, subject.kind, request) if (refused !== undefined) this.post({ type: 'answerEnd', askId, status: 'failed', error: refused }) else @@ -808,7 +816,7 @@ class Preview { const root = await repoRootOf(this.document.uri.fsPath) const asked = new Map>() const symbolsOf = (uri: string): Promise => { - const known = asked.get(uri) ?? this.symbolNodes(uri) + const known = asked.get(uri) ?? documentSymbolNodes(uri) asked.set(uri, known) return known } @@ -919,29 +927,15 @@ class Preview { /** 用文档符号按名字找(`Agent`、`Agent.owner_id`);没有这个名字就没有,不打开到文件顶上。 */ private async locateRepoSymbol(uri: Uri, symbol: string): Promise { - const found = findRepoSymbol(await this.symbolNodes(uri.toString()), symbol) + const found = findRepoSymbol(await documentSymbolNodes(uri.toString()), symbol) return found && { uri: uri.toString(), selection: found.selection } } - /** 一个文件的文档符号(vscode.executeDocumentSymbolProvider):先把文件读进来(不显示),范围换成那个文件里的下标。 */ - private async symbolNodes(uri: string): Promise { - const doc = await workspace.openTextDocument(Uri.parse(uri)) - const symbols = await commands.executeCommand('vscode.executeDocumentSymbolProvider', doc.uri) - const span = (range: Range): SourceRange => ({ startIndex: doc.offsetAt(range.start), endIndex: doc.offsetAt(range.end) }) - const node = (symbol: DocumentSymbol): SymbolNode => ({ - name: symbol.name, - range: span(symbol.range), - selection: span(symbol.selectionRange), - children: (symbol.children ?? []).map(node), - }) - return Array.isArray(symbols) ? symbols.map(node) : [] - } - /** * 注释里不带路径的引用(`[Name]`、`[Class.member]`)指到哪:本文件定义的优先,再是导入进来的(见 symbolLinks.ts)。 * 文档符号同仓库链接找符号那一套(symbols,一批引用一起问时共用一份),导入的名字问语言扩展它的定义(同跳到定义),定义几处取第一处。 */ - private async resolveSymbolLink(symbol: string, symbols: (uri: string) => Promise = uri => this.symbolNodes(uri)): Promise { + private async resolveSymbolLink(symbol: string, symbols: (uri: string) => Promise = documentSymbolNodes): Promise { const { document } = this try { return await resolveReference(symbol, this.imports, { diff --git a/vscode/src/codeReader/editor/repoPath.test.ts b/vscode/src/codeReader/editor/repoPath.test.ts index 633ae63..e91c3d3 100644 --- a/vscode/src/codeReader/editor/repoPath.test.ts +++ b/vscode/src/codeReader/editor/repoPath.test.ts @@ -56,6 +56,19 @@ describe('findRepoSymbol', () => { expect(findRepoSymbol(tree, 'Agent.missing')).toBeUndefined() expect(findRepoSymbol(tree, 'Missing')).toBeUndefined() }) + + it('几层点开的一层层往里找;有一段是空的不算', () => { + expect(findRepoSymbol(tree, 'Agent.Nested.inner')?.name).toBe('inner') + expect(findRepoSymbol(tree, 'Agent.owner_id.inner')).toBeUndefined() + expect(findRepoSymbol(tree, 'Agent..inner')).toBeUndefined() + expect(findRepoSymbol(tree, 'Agent.')).toBeUndefined() + }) + + it('同名的有几个时挨个试:第一个里面没有就找下一个', () => { + const rust = [{ name: 'Order' }, { name: 'Order', children: [{ name: 'pay' }] }] + expect(findRepoSymbol(rust, 'Order.pay')).toBe(rust[1].children?.[0]) + expect(findRepoSymbol(rust, 'Order')).toBe(rust[0]) + }) }) describe('resolveRepoEntry', () => { diff --git a/vscode/src/codeReader/editor/repoPath.ts b/vscode/src/codeReader/editor/repoPath.ts index 54f9544..3f430fc 100644 --- a/vscode/src/codeReader/editor/repoPath.ts +++ b/vscode/src/codeReader/editor/repoPath.ts @@ -88,32 +88,33 @@ export async function resolveRepoEntry(root: string, rel: string): Promise(symbols: readonly T[], symbol: string): T | undefined { - const dot = symbol.indexOf('.') - const head = dot === -1 ? symbol : symbol.slice(0, dot) - const member = dot === -1 ? undefined : symbol.slice(dot + 1) - if (head === '' || member === '') - return undefined - const found = findNamed(symbols, head) - if (!found || member === undefined) - return found - return findNamed((found.children ?? []) as readonly T[], member) + const names = symbol.split('.') + return names.includes('') ? undefined : findPath(symbols, names) } -function findNamed(symbols: readonly T[], name: string): T | undefined { - for (const symbol of symbols) { - if (symbol.name === name) - return symbol - const nested = findNamed((symbol.children ?? []) as readonly T[], name) - if (nested) - return nested +/** 第一段按名字找(照先后、先往里再往后),每个同名的都在它里面接着找后面几段。 */ +function findPath(symbols: readonly T[], [name, ...rest]: readonly string[]): T | undefined { + for (const found of named(symbols, name)) { + const target = rest.length === 0 ? found : findPath((found.children ?? []) as readonly T[], rest) + if (target) + return target } return undefined } +function* named(symbols: readonly T[], name: string): Generator { + for (const symbol of symbols) { + if (symbol.name === name) + yield symbol + yield* named((symbol.children ?? []) as readonly T[], name) + } +} + function hasControl(text: string): boolean { for (const char of text) { if (char.charCodeAt(0) <= 31) diff --git a/vscode/src/codeReader/explorer/rowMenus.test.ts b/vscode/src/codeReader/explorer/rowMenus.test.ts index b04280a..18fb04b 100644 --- a/vscode/src/codeReader/explorer/rowMenus.test.ts +++ b/vscode/src/codeReader/explorer/rowMenus.test.ts @@ -1,7 +1,9 @@ import { readFileSync } from 'node:fs' import * as path from 'node:path' import { describe, expect, it } from 'vitest' -import { FAVORITE_SLOTS, favoriteCommand, favoriteContext, favoriteViewId } from '../favorites/favoriteSlots' +import { favoriteCommand } from '../favorites/favoriteSlots' +import { FEATURE_MARKER, featureCommand } from '../features/features' +import { slotContext, SLOTS, slotViewId } from '../slots/slots' import { UNSTAGED_MARKER } from '../uncommitted/uncommittedFiles' import { COPY_COMMANDS } from './copyText' import { entryContext, GROUP_CONTEXT } from './fileEntries' @@ -95,8 +97,9 @@ function evaluate(when: string, context: Context): boolean { const FILES = 'superpowers.codeReader.files' const UNCOMMITTED = 'superpowers.codeReader.uncommitted' -const FAVORITE = favoriteViewId(7) -const READER_VIEWS = [FILES, UNCOMMITTED, ...FAVORITE_SLOTS.map(favoriteViewId)] +const FAVORITE = slotViewId(7) +const FEATURE = slotViewId(8) +const READER_VIEWS = [FILES, UNCOMMITTED, ...SLOTS.map(slotViewId)] const REVIEWING = 'superpowers.codeReader.reviewing' const COPY_MENU = 'superpowers.codeReader.copy' @@ -277,6 +280,7 @@ describe('右键菜单的「在系统文件管理器中显示」', () => { }) describe('主栏的审查工单', () => { + const FIND = 'superpowers.codeReader.findFeature' const PICK = 'superpowers.codeReader.pickReviewIssue' const HIDE_TESTS = 'superpowers.codeReader.hideReviewTests' const SHOW_TESTS = 'superpowers.codeReader.showReviewTests' @@ -284,10 +288,10 @@ describe('主栏的审查工单', () => { const REFRESH = 'superpowers.codeReader.refresh' const TESTS_HIDDEN = 'superpowers.codeReader.reviewTestsHidden' - it('标题栏:平时是选工单、刷新;审查时多出隐藏 / 显示测试文件(按开关换着显示)和「显示全部」', () => { - expect(titleBar(FILES)).toEqual([PICK, REFRESH]) - expect(titleBar(FILES, { [REVIEWING]: true })).toEqual([PICK, HIDE_TESTS, SHOW_ALL, REFRESH]) - expect(titleBar(FILES, { [REVIEWING]: true, [TESTS_HIDDEN]: true })).toEqual([PICK, SHOW_TESTS, SHOW_ALL, REFRESH]) + it('标题栏:平时是了解一块代码、选工单、刷新;审查时多出隐藏 / 显示测试文件(按开关换着显示)和「显示全部」', () => { + expect(titleBar(FILES)).toEqual([FIND, PICK, REFRESH]) + expect(titleBar(FILES, { [REVIEWING]: true })).toEqual([FIND, PICK, HIDE_TESTS, SHOW_ALL, REFRESH]) + expect(titleBar(FILES, { [REVIEWING]: true, [TESTS_HIDDEN]: true })).toEqual([FIND, PICK, SHOW_TESTS, SHOW_ALL, REFRESH]) expect(menus['view/title'].filter(item => item.when.includes(FILES)).every(item => item.group?.startsWith('navigation'))).toBe(true) }) @@ -347,22 +351,34 @@ describe('「未提交的更改」一栏', () => { }) }) -describe('收藏栏', () => { - it(`预先声明 ${FAVORITE_SLOTS.length} 个栏位,排在「未提交的更改」后面,各自有收藏占着时才显示`, () => { - expect(views['superpowers-reader'].slice(2)).toEqual(FAVORITE_SLOTS.map(slot => ({ id: favoriteViewId(slot), name: `收藏 ${slot}`, when: favoriteContext(slot) }))) +describe('收藏、「了解一块代码」共用的栏位', () => { + it(`预先声明 ${SLOTS.length} 个栏位,排在「未提交的更改」后面,各自有收藏或功能占着时才显示`, () => { + expect(views['superpowers-reader'].slice(2)).toEqual(SLOTS.map(slot => ({ id: slotViewId(slot), name: `栏位 ${slot}`, when: slotContext(slot) }))) }) - it('每一栏的标题栏:重命名、取消收藏,只对这一栏', () => { - for (const slot of FAVORITE_SLOTS) - expect(titleBar(favoriteViewId(slot))).toEqual([favoriteCommand('rename', slot), favoriteCommand('remove', slot)]) + it('收藏占着的栏:标题栏是重命名、取消收藏,只对这一栏', () => { + for (const slot of SLOTS) + expect(titleBar(slotViewId(slot), { [slotContext(slot)]: 'favorite' })).toEqual([favoriteCommand('rename', slot), favoriteCommand('remove', slot)]) }) - it('每个栏位的命令都登记了、字和图标一样,命令面板里藏起来', () => { - for (const slot of FAVORITE_SLOTS) { + it('功能占着的栏:标题栏是打开对话、重命名、删除,只对这一栏', () => { + for (const slot of SLOTS) + expect(titleBar(slotViewId(slot), { [slotContext(slot)]: 'feature' })).toEqual([featureCommand('openChat', slot), featureCommand('rename', slot), featureCommand('remove', slot)]) + }) + + it('别的栏被占着不影响这一栏的按钮', () => { + expect(titleBar(slotViewId(3), { [slotContext(4)]: 'feature' })).toEqual([]) + }) + + it('每个栏位的命令都登记了、字和图标,命令面板里藏起来', () => { + for (const slot of SLOTS) { expect(titleOf(favoriteCommand('rename', slot))).toMatchObject({ title: '重命名', icon: '$(edit)' }) expect(titleOf(favoriteCommand('remove', slot))).toMatchObject({ title: '取消收藏', icon: '$(star-full)' }) - expect(palette.get(favoriteCommand('rename', slot))).toBe('false') - expect(palette.get(favoriteCommand('remove', slot))).toBe('false') + expect(titleOf(featureCommand('openChat', slot))).toMatchObject({ title: '打开对话', icon: '$(comment-discussion)' }) + expect(titleOf(featureCommand('rename', slot))).toMatchObject({ title: '重命名', icon: '$(edit)' }) + expect(titleOf(featureCommand('remove', slot))).toMatchObject({ title: '删除', icon: '$(trash)' }) + for (const id of [favoriteCommand('rename', slot), favoriteCommand('remove', slot), featureCommand('openChat', slot), featureCommand('rename', slot), featureCommand('remove', slot)]) + expect(palette.get(id)).toBe('false') } }) @@ -378,6 +394,43 @@ describe('收藏栏', () => { }) }) +describe('了解一块代码', () => { + const FIND = 'superpowers.codeReader.findFeature' + const OPEN_ITEM = 'superpowers.codeReader.openFeatureItem' + /** 功能栏里找得到的条目行(符号、只有文件的都是它) */ + const item = entryContext({ isDirectory: false, isRoot: false }, FEATURE_MARKER) + + it('入口只在主栏标题栏:放大镜「了解一块代码」,排在最前;别的栏没有', () => { + expect(titleBar(FILES)[0]).toBe(FIND) + expect(titleBar(FILES, { [REVIEWING]: true })[0]).toBe(FIND) + for (const view of [UNCOMMITTED, FEATURE]) + expect(titleBar(view, { [slotContext(8)]: 'feature' })).not.toContain(FIND) + expect(titleOf(FIND)).toMatchObject({ title: '了解一块代码', icon: '$(search)' }) + }) + + it('条目行:「问 AI」「复制」、在系统文件管理器中显示照常有;注释、收藏、暂存不挂', () => { + expect(itemsFor('view/item/context', 'inline', item, FEATURE)).toEqual([ASK, COPY_MENU]) + expect(itemsFor('view/item/context', 'copy', item, FEATURE)).toEqual(FILE_COPIES) + expect(itemsFor(COPY_MENU, 'copy', item, FEATURE)).toEqual(FILE_COPIES) + expect(itemsFor('view/item/context', 'fileManager', item, FEATURE)).toEqual(['superpowers.codeReader.revealInFinder']) + for (const group of ['notes', 'star']) + expect(itemsFor('view/item/context', group, item, FEATURE)).toEqual([]) + }) + + it('分组行、找不到的条目行什么按钮、菜单都没有', () => { + for (const context of [GROUP_CONTEXT, `${FEATURE_MARKER} missing`]) { + for (const group of ['inline', 'copy', 'fileManager', 'notes', 'star']) + expect(itemsFor('view/item/context', group, context, FEATURE)).toEqual([]) + } + }) + + it('点条目的命令只给侧栏行用,命令面板里藏起来;「了解一块代码」命令面板里能用', () => { + expect(titleOf(OPEN_ITEM)).toBeDefined() + expect(palette.get(OPEN_ITEM)).toBe('false') + expect(palette.has(FIND)).toBe(false) + }) +}) + describe('菜单里用到的', () => { it('命令都登记了;旧的筛选按钮、上下文键不再出现', () => { const ids = new Set(commands.map(item => item.command)) diff --git a/vscode/src/codeReader/favorites/favoriteSlots.test.ts b/vscode/src/codeReader/favorites/favoriteSlots.test.ts index 0aebcca..1403e17 100644 --- a/vscode/src/codeReader/favorites/favoriteSlots.test.ts +++ b/vscode/src/codeReader/favorites/favoriteSlots.test.ts @@ -1,40 +1,48 @@ import type { Favorite } from './favoriteSlots' import { describe, expect, it } from 'vitest' -import { FAVORITE_SLOTS, favoriteCommand, favoriteContext, favoriteViewId, placeFavorite, removeFavorite, renameFavorite } from './favoriteSlots' +import { SLOTS } from '../slots/slots' +import { favoriteCommand, placeFavorite, removeFavorite, renameFavorite } from './favoriteSlots' const favorite = (slot: number, name = `收藏${slot}`): Favorite => ({ uri: `file:///ws/dir${slot}`, name, slot }) /** 栏位 1…count 全占着 */ const filled = (count: number): Favorite[] => Array.from({ length: count }, (_, index) => favorite(index + 1)) +const slotsOf = (favorites: readonly Favorite[]): number[] => favorites.map(item => item.slot) + describe('新收藏占哪个栏位', () => { it('没有收藏时占第 1 个', () => { - expect(placeFavorite([], 'file:///ws/a')).toEqual({ slot: 1 }) + expect(placeFavorite([], 'file:///ws/a', [])).toEqual({ slot: 1 }) }) it('占没被占的里面最小的:中间空出来的先用', () => { - expect(placeFavorite([favorite(1), favorite(2)], 'file:///ws/a')).toEqual({ slot: 3 }) - expect(placeFavorite([favorite(1), favorite(3), favorite(4)], 'file:///ws/a')).toEqual({ slot: 2 }) - expect(placeFavorite([favorite(5)], 'file:///ws/a')).toEqual({ slot: 1 }) + expect(placeFavorite([favorite(1), favorite(2)], 'file:///ws/a', [1, 2])).toEqual({ slot: 3 }) + expect(placeFavorite([favorite(1), favorite(3), favorite(4)], 'file:///ws/a', [1, 3, 4])).toEqual({ slot: 2 }) + }) + + it('和「了解一块代码」共用栏位:功能占着的跳过,功能删掉空出来的收藏照样能用', () => { + const favorites = [favorite(1)] + expect(placeFavorite(favorites, 'file:///ws/a', [1, 2, 3])).toEqual({ slot: 4 }) + expect(placeFavorite(favorites, 'file:///ws/a', [1, 3])).toEqual({ slot: 2 }) }) it('取消收藏后空出来的栏位给下一个收藏用,别的收藏不挪位置', () => { const left = removeFavorite([favorite(1), favorite(2), favorite(3)], 2) expect(left).toEqual([favorite(1), favorite(3)]) - expect(placeFavorite(left, 'file:///ws/a')).toEqual({ slot: 2 }) + expect(placeFavorite(left, 'file:///ws/a', slotsOf(left))).toEqual({ slot: 2 }) }) - it(`${FAVORITE_SLOTS.length} 个栏位都占满了:满了`, () => { - expect(FAVORITE_SLOTS).toEqual(Array.from({ length: 30 }, (_, index) => index + 1)) - expect(placeFavorite(filled(FAVORITE_SLOTS.length), 'file:///ws/a')).toBe('full') - expect(placeFavorite(filled(FAVORITE_SLOTS.length - 1), 'file:///ws/a')).toEqual({ slot: FAVORITE_SLOTS.length }) + it('栏位都被收藏和功能占满了:满了', () => { + const favorites = filled(10) + expect(placeFavorite(favorites, 'file:///ws/a', SLOTS)).toBe('full') + expect(placeFavorite(favorites, 'file:///ws/a', SLOTS.slice(0, -1))).toEqual({ slot: SLOTS.length }) }) it('同一个目录收藏过:给出原来那个,不再占栏位;满了也先认出收藏过', () => { const favorites = [favorite(1), favorite(2)] - expect(placeFavorite(favorites, 'file:///ws/dir2')).toEqual({ existing: favorite(2) }) - expect(placeFavorite(filled(FAVORITE_SLOTS.length), 'file:///ws/dir7')).toEqual({ existing: favorite(7) }) + expect(placeFavorite(favorites, 'file:///ws/dir2', [1, 2])).toEqual({ existing: favorite(2) }) + expect(placeFavorite(filled(SLOTS.length), 'file:///ws/dir7', SLOTS)).toEqual({ existing: favorite(7) }) }) }) @@ -49,10 +57,8 @@ describe('重命名、取消收藏', () => { }) }) -describe('栏位对应的视图、上下文键、命令', () => { - it('第 n 个栏位各有自己的视图 id、显示不显示的上下文键、标题栏上重命名 / 取消收藏的命令', () => { - expect(favoriteViewId(3)).toBe('superpowers.codeReader.favorite3') - expect(favoriteContext(3)).toBe('superpowers.codeReader.hasFavorite3') +describe('收藏栏标题栏上的命令', () => { + it('每个栏位各有重命名 / 取消收藏的命令', () => { expect(favoriteCommand('rename', 3)).toBe('superpowers.codeReader.renameFavorite3') expect(favoriteCommand('remove', 30)).toBe('superpowers.codeReader.removeFavorite30') }) diff --git a/vscode/src/codeReader/favorites/favoriteSlots.ts b/vscode/src/codeReader/favorites/favoriteSlots.ts index e05d422..663a762 100644 --- a/vscode/src/codeReader/favorites/favoriteSlots.ts +++ b/vscode/src/codeReader/favorites/favoriteSlots.ts @@ -1,40 +1,26 @@ -/** - * 收藏栏的栏位:VS Code 的视图要在 package.json 里预先声明,所以声明了固定这么多个收藏栏(superpowers.codeReader.favorite1 … favorite30), - * 有收藏占着的才显示。这里是栏位号 1、2、3… - */ -export const FAVORITE_SLOTS: readonly number[] = Array.from({ length: 30 }, (_, index) => index + 1) +import { freeSlot } from '../slots/slots' -/** 一个收藏:目录(Uri 字符串)、栏标题上的名字、占着第几个栏位。 */ +/** 一个收藏:目录(Uri 字符串)、栏标题上的名字、占着第几个栏位(和「了解一块代码」共用,见 slots.ts)。 */ export interface Favorite { uri: string name: string slot: number } -/** 第 slot 个收藏栏的视图 id */ -export function favoriteViewId(slot: number): string { - return `superpowers.codeReader.favorite${slot}` -} - -/** 上下文键:第 slot 个栏位有收藏占着,这一栏才显示(package.json 里视图的 when 按它) */ -export function favoriteContext(slot: number): string { - return `superpowers.codeReader.hasFavorite${slot}` -} - /** 第 slot 个收藏栏标题栏上的按钮命令:rename 重命名、remove 取消收藏。标题栏按钮不知道点的是哪一栏,所以每个栏位各一条。 */ export function favoriteCommand(action: 'rename' | 'remove', slot: number): string { return `superpowers.codeReader.${action}Favorite${slot}` } /** - * 收藏 uri 这个目录要占哪个栏位:收藏过的给出原来那个(不重复收藏),栏位都占满了是 full,别的占没被占的里面最小的。 - * 一个收藏一直占着它的栏位、删掉才空出来,用户拖动排过的位置 VS Code 按栏位记着,所以不会错乱。 + * 收藏 uri 这个目录要占哪个栏位:收藏过的给出原来那个(不重复收藏),栏位都被收藏和功能占满了是 full,别的占空着的里面最小的。 + * taken 是收藏、功能眼下占着的全部栏位。 */ -export function placeFavorite(favorites: readonly Favorite[], uri: string): { existing: Favorite } | { slot: number } | 'full' { +export function placeFavorite(favorites: readonly Favorite[], uri: string, taken: Iterable): { existing: Favorite } | { slot: number } | 'full' { const existing = favorites.find(favorite => favorite.uri === uri) if (existing) return { existing } - const slot = FAVORITE_SLOTS.find(slot => !favorites.some(favorite => favorite.slot === slot)) + const slot = freeSlot(taken) return slot === undefined ? 'full' : { slot } } diff --git a/vscode/src/codeReader/favorites/favoriteViews.ts b/vscode/src/codeReader/favorites/favoriteViews.ts index f41a20d..01692a2 100644 --- a/vscode/src/codeReader/favorites/favoriteViews.ts +++ b/vscode/src/codeReader/favorites/favoriteViews.ts @@ -1,31 +1,18 @@ import type { Memento, TreeView } from 'vscode' import type { FileNode, FileTreeDecorator, TreeNode } from '../explorer/fileTree' +import type { SlotPool } from '../slots/slotPool' import type { Favorite } from './favoriteSlots' import * as path from 'node:path' -import { commands, FileType, Uri, window, workspace } from 'vscode' +import { FileType, Uri, window, workspace } from 'vscode' import { FileTreeProvider } from '../explorer/fileTree' import { TreeLocator } from '../explorer/treeLocator' -import { FAVORITE_SLOTS, favoriteContext, favoriteViewId, placeFavorite, removeFavorite, renameFavorite } from './favoriteSlots' +import { askSlotName, focusSlot } from '../slots/slotPool' +import { SLOTS, slotTitle, slotViewId } from '../slots/slots' +import { placeFavorite, removeFavorite, renameFavorite } from './favoriteSlots' /** workspaceState:收藏的目录(Favorite 的数组),按工作区记。 */ const FAVORITES_KEY = 'superpowers.codeReader.favorites' -/** 弹顶部的输入框问收藏栏叫什么(原值整段选中);Esc 或点别处是不改。 */ -async function askName(title: string, value: string): Promise { - const name = await window.showInputBox({ - title, - value, - prompt: '收藏栏标题上显示的名字', - validateInput: text => text.trim() ? undefined : '名字不能为空', - }) - return name?.trim() -} - -/** 亮出第 slot 个收藏栏(VS Code 给每个视图自带一条 `<视图 id>.focus` 命令)。 */ -async function focus(slot: number): Promise { - await commands.executeCommand(`${favoriteViewId(slot)}.focus`) -} - /** * 一个收藏栏:这个目录下的完整文件树(自己的文件树和视图),标题是起的名字、后面淡色写目录相对工作区的路径; * 当前文件在这个目录下、这一栏露着时跟过去定位。目录被删了时栏里说一句目录不在了。收藏时建、取消收藏时销毁。 @@ -40,8 +27,8 @@ class FavoritePane { constructor(favorite: Favorite, openCommand: string, decorator: FileTreeDecorator) { this.uri = Uri.parse(favorite.uri) const files = new FileTreeProvider(openCommand, decorator, { kind: 'directory', uri: this.uri }) - this.view = window.createTreeView(favoriteViewId(favorite.slot), { treeDataProvider: files, showCollapseAll: true }) - this.view.title = favorite.name + this.view = window.createTreeView(slotViewId(favorite.slot), { treeDataProvider: files, showCollapseAll: true }) + this.view.title = slotTitle('favorite', favorite.name) this.view.description = workspace.asRelativePath(this.uri) this.disposables = [ files, @@ -57,7 +44,7 @@ class FavoritePane { } rename(name: string): void { - this.view.title = name + this.view.title = slotTitle('favorite', name) } dispose(): void { @@ -74,7 +61,7 @@ class FavoritePane { } /** - * 侧栏的收藏栏:收藏一个目录占一个栏位,栏里是这个目录下的完整文件树,规则同主栏的工作区文件树、不受审查影响(栏位怎么分见 placeFavorite)。 + * 侧栏的收藏栏:收藏一个目录占一个栏位(和「了解一块代码」共用,见 SlotPool),栏里是这个目录下的完整文件树,规则同主栏的工作区文件树、不受审查影响(栏位怎么分见 placeFavorite)。 * 只给有收藏占着的栏位建视图,上下文键让这一栏显示出来;收藏按工作区记住,打开窗口时照记着的建回来。 */ export class FavoriteViews { @@ -84,14 +71,16 @@ export class FavoriteViews { /** * @param state workspaceState + * @param pool 和「了解一块代码」共用的栏位 * @param openCommand 点文件时执行的命令,参数是文件 Uri * @param decorator 给行添信息(文件注释),和别的栏共用 */ - constructor(private readonly state: Memento, private readonly openCommand: string, private readonly decorator: FileTreeDecorator) { + constructor(private readonly state: Memento, private readonly pool: SlotPool, private readonly openCommand: string, private readonly decorator: FileTreeDecorator) { this.favorites = state.get(FAVORITES_KEY) ?? [] - for (const favorite of this.favorites) + for (const favorite of this.favorites) { this.open(favorite) - void Promise.all(this.favorites.map(favorite => commands.executeCommand('setContext', favoriteContext(favorite.slot), true))) + void pool.occupy(favorite.slot, 'favorite') + } } /** 目录行上的「收藏目录」:起个名字(默认是目录名)占一个空栏位并亮出那一栏;收藏过的说一声、亮出原来那一栏,满了说一声。 */ @@ -99,26 +88,23 @@ export class FavoriteViews { if (!node) return // 只挂在侧栏的目录行上,别处调用没有目标 const uri = node.uri.toString() - const placement = placeFavorite(this.favorites, uri) + const placement = placeFavorite(this.favorites, uri, this.pool.taken()) if (placement === 'full') { - void window.showWarningMessage(`收藏栏满了(最多 ${FAVORITE_SLOTS.length} 个),先取消一个再收藏`) + void window.showWarningMessage(`栏位满了(收藏和「了解一块代码」共用,最多 ${SLOTS.length} 个),先取消一个收藏或删掉一个功能再收藏`) return } if ('existing' in placement) { void window.showInformationMessage(`这个目录已经收藏过了:「${placement.existing.name}」`) - await focus(placement.existing.slot) + await focusSlot(placement.existing.slot) return } - const name = await askName('收藏目录', path.posix.basename(node.uri.path)) + const name = await askSlotName('收藏目录', path.posix.basename(node.uri.path)) if (name === undefined) return const favorite: Favorite = { uri, name, slot: placement.slot } this.open(favorite) - await Promise.all([ - this.save([...this.favorites, favorite]), - commands.executeCommand('setContext', favoriteContext(favorite.slot), true), - ]) - await focus(favorite.slot) + await Promise.all([this.save([...this.favorites, favorite]), this.pool.occupy(favorite.slot, 'favorite')]) + await focusSlot(favorite.slot) } /** 收藏栏标题栏的「重命名」:改第 slot 栏的标题。 */ @@ -126,7 +112,7 @@ export class FavoriteViews { const favorite = this.favorites.find(item => item.slot === slot) if (!favorite) return - const name = await askName('重命名收藏', favorite.name) + const name = await askSlotName('重命名收藏', favorite.name) if (name === undefined) return this.panes.get(slot)?.rename(name) @@ -135,12 +121,11 @@ export class FavoriteViews { /** 收藏栏标题栏的「取消收藏」:藏起第 slot 栏、空出栏位,再销毁它的视图(免得露着的栏里闪一下没有数据)。 */ async remove(slot: number): Promise { + if (!this.panes.has(slot)) + return const pane = this.panes.get(slot) this.panes.delete(slot) - await Promise.all([ - this.save(removeFavorite(this.favorites, slot)), - commands.executeCommand('setContext', favoriteContext(slot), false), - ]) + await Promise.all([this.save(removeFavorite(this.favorites, slot)), this.pool.release(slot)]) pane?.dispose() } diff --git a/vscode/src/codeReader/features/featureLocate.test.ts b/vscode/src/codeReader/features/featureLocate.test.ts new file mode 100644 index 0000000..d23c691 --- /dev/null +++ b/vscode/src/codeReader/features/featureLocate.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { createBashAdapter } from '../languages/bash' +import { createPythonAdapter } from '../languages/python' +import { createRustAdapter } from '../languages/rust' +import { createTypeScriptAdapter } from '../languages/typescript' +import { runtime, WASM_DIR } from '../testSupport' +import { locateInSource } from './featureLocate' + +const typescript = createTypeScriptAdapter(WASM_DIR) +const python = createPythonAdapter(WASM_DIR) +const rust = createRustAdapter(WASM_DIR) +const bash = createBashAdapter(WASM_DIR) + +/** 找到的那块的名字在源码里是哪几个字、种类;找不到是 undefined */ +async function find(adapter: typeof typescript, languageId: string, source: string, symbol: string): Promise<{ text: string, kind: string } | undefined> { + const found = await locateInSource(runtime, adapter, languageId, source, symbol) + return found && { text: source.slice(found.range.startIndex, found.range.endIndex), kind: found.kind } +} + +const TS = ` +import { api } from './api' + +export function login(name: string) { + return api.post('/login', { name }) +} + +export class AuthService { + private token = '' + + async login(name: string) { + return login(name) + } + + static create() { + return new AuthService() + } +} + +export const LoginPage = () => { + const submit = () => login('a') + return submit +} +` + +const PY = ` +class UserService: + def renew(self, user): + return self._charge(user) + + class Retry: + def schedule(self): + pass + + +def renew_all(): + pass +` + +const RS = ` +pub struct Order; + +impl Order { + pub fn pay(&self) {} +} + +fn main() {} +` + +describe('按符号名在源码里找(语言插件解析出的块)', () => { + it('函数、类、组件按名字找;同名时取先出现的', async () => { + expect(await find(typescript, 'typescript', TS, 'login')).toEqual({ text: 'login', kind: 'function' }) + expect(await find(typescript, 'typescript', TS, 'AuthService')).toEqual({ text: 'AuthService', kind: 'class' }) + expect(await find(typescript, 'typescriptreact', TS, 'LoginPage')).toEqual({ text: 'LoginPage', kind: 'function' }) + }) + + it('方法写成「类名.方法名」:先找类,再在里面找方法', async () => { + const found = await locateInSource(runtime, typescript, 'typescript', TS, 'AuthService.login') + expect(found?.kind).toBe('function') + // 是类里的那个 login,不是文件开头的函数 + expect(found!.range.startIndex).toBeGreaterThan(TS.indexOf('class AuthService')) + expect(await find(typescript, 'typescript', TS, 'AuthService.create')).toEqual({ text: 'create', kind: 'function' }) + expect(await find(typescript, 'typescript', TS, 'LoginPage.submit')).toEqual({ text: 'submit', kind: 'variable' }) + }) + + it('类里的方法、嵌套类里的方法(Python);impl 里的方法按类型名找(Rust)', async () => { + expect(await find(python, 'python', PY, 'UserService.renew')).toEqual({ text: 'renew', kind: 'function' }) + expect(await find(python, 'python', PY, 'UserService.Retry.schedule')).toEqual({ text: 'schedule', kind: 'function' }) + expect(await find(python, 'python', PY, 'renew_all')).toEqual({ text: 'renew_all', kind: 'function' }) + expect(await find(rust, 'rust', RS, 'Order.pay')).toEqual({ text: 'pay', kind: 'function' }) + }) + + it('类型(interface、type)和它们的成员按声明的名字找;shell 命令不算名字', async () => { + const types = 'export interface Favorite {\n uri: string\n}\ntype Slot = number\n' + expect(await find(typescript, 'typescript', types, 'Favorite')).toEqual({ text: 'Favorite', kind: 'type' }) + expect(await find(typescript, 'typescript', types, 'Favorite.uri')).toEqual({ text: 'uri', kind: 'field' }) + expect(await find(typescript, 'typescript', types, 'Slot')).toEqual({ text: 'Slot', kind: 'type' }) + expect(await find(rust, 'rust', 'pub type Id = u32;\n', 'Id')).toEqual({ text: 'Id', kind: 'statement' }) + expect(await find(bash, 'shellscript', 'deploy() {\n echo hi\n}\n', 'deploy')).toEqual({ text: 'deploy', kind: 'function' }) + expect(await find(bash, 'shellscript', 'deploy() {\n echo hi\n}\n', 'deploy.echo')).toBeUndefined() + }) + + it('找不到:没有这个名字、类里没有这个方法、写法不对', async () => { + expect(await find(typescript, 'typescript', TS, 'logout')).toBeUndefined() + expect(await find(typescript, 'typescript', TS, 'AuthService.logout')).toBeUndefined() + expect(await find(typescript, 'typescript', TS, 'Missing.login')).toBeUndefined() + expect(await find(typescript, 'typescript', TS, 'AuthService.')).toBeUndefined() + expect(await find(python, 'python', PY, 'UserService.schedule.renew')).toBeUndefined() + }) +}) diff --git a/vscode/src/codeReader/features/featureLocate.ts b/vscode/src/codeReader/features/featureLocate.ts new file mode 100644 index 0000000..f285760 --- /dev/null +++ b/vscode/src/codeReader/features/featureLocate.ts @@ -0,0 +1,59 @@ +/** + * 「了解一块代码」的条目按符号名找位置:用语言插件把源码解析成代码块(同预览),在有名字的块里按 `类名.方法名` 一层层找。 + * 每次画栏、点条目时按眼下的代码现找,代码改过也找得到。不碰 VS Code;语言没有插件时用 VS Code 的文档符号(见 featureViews.ts)。 + */ + +import type { BlockKind, CodeBlock, LanguageAdapter, Node, SourceRange } from '../contract' +import type { TreeSitterRuntime } from '../runtime' +import { ownName } from '../blockView' +import { findRepoSymbol } from '../editor/repoPath' + +/** 块树上有名字的一块:名字、种类、名字在源码里的范围;子孙里有名字的挂在下面。 */ +export interface NamedBlock { + name: string + kind: BlockKind + range: SourceRange + children: NamedBlock[] +} + +/** 语言插件不给名字、按语法树里的声明找名字的块:类型(TypeScript 的 interface、type)、类型里的成员、别的声明语句(Rust 的 type)。 */ +const DECLARATIONS = new Set(['type', 'field', 'statement']) + +/** + * 块的名字:函数、类、组件、变量(见 ownName),Rust 的 impl、trait(`impl Order` 里的方法写成 `Order.pay`); + * DECLARATIONS 那几种按语法树里声明的 name 字段找,export 语句先进到它导出的声明里。 + */ +function nameOf(block: CodeBlock): Node | undefined { + if (block.kind === 'impl' || block.kind === 'trait') + return block.facts.name + if (!DECLARATIONS.has(block.kind)) + return ownName(block) + const declaration = block.node.childForFieldName('declaration') ?? block.node + return declaration.childForFieldName('name') ?? undefined +} + +/** 块树里有名字的那些;没名字的块(条件、调用语句)跳过,它里面有名字的往上提一层。 */ +export function namedBlocks(blocks: readonly CodeBlock[]): NamedBlock[] { + return blocks.flatMap((block): NamedBlock[] => { + const children = namedBlocks(block.children) + const name = nameOf(block) + return name ? [{ name: name.text, kind: block.kind, range: { startIndex: name.startIndex, endIndex: name.endIndex }, children }] : children + }) +} + +/** 用语言插件解析 source,按 symbol(`login`、`AuthService.login`)找那一块;找不到没有。语法树用完即释放。 */ +export async function locateInSource( + runtime: Pick, + adapter: LanguageAdapter, + languageId: string, + source: string, + symbol: string, +): Promise { + const tree = await runtime.parse(adapter, languageId, source) + try { + return findRepoSymbol(namedBlocks(adapter.toBlocks(tree.rootNode, source, languageId)), symbol) + } + finally { + tree.delete() + } +} diff --git a/vscode/src/codeReader/features/featureResult.test.ts b/vscode/src/codeReader/features/featureResult.test.ts new file mode 100644 index 0000000..b8c7e08 --- /dev/null +++ b/vscode/src/codeReader/features/featureResult.test.ts @@ -0,0 +1,132 @@ +import type { FeatureResult } from './featureResult' +import { describe, expect, it } from 'vitest' +import { featureAnswer, featureFollowUp, featurePrompt, featureSchema, itemLabel, parseFeatureResult } from './featureResult' + +const FOLDERS = ['web', 'server'] + +const LOGIN: FeatureResult = { + groups: [ + { + name: '登录页面', + items: [ + { folder: 'web', path: 'src/pages/Login.tsx', symbol: 'LoginPage', role: '登录表单,提交账号密码' }, + { folder: 'web', path: '.env', role: '登录接口的地址' }, + ], + }, + { + name: '校验与发令牌', + items: [{ folder: 'server', path: 'app/auth.py', symbol: 'AuthService.login', role: '核对密码、发令牌' }], + }, + ], +} + +describe('交回的结构(--json-schema)', () => { + const schema = JSON.parse(featureSchema(FOLDERS)) as { + properties: { groups: { items: { properties: Record, required: string[] } } } + required: string[] + additionalProperties: boolean + } + const group = schema.properties.groups.items + const item = (group.properties.items as { items: { properties: Record, required: string[], additionalProperties: boolean } }).items + + it('若干环节,每个环节有名字和条目;只能交这几项', () => { + expect(schema.required).toEqual(['groups']) + expect(schema.additionalProperties).toBe(false) + expect(group.required).toEqual(['name', 'items']) + }) + + it('条目:工作区文件夹只能是现有的几个,路径和一句话必填,符号可以不写,不让给行号', () => { + expect(item.properties.folder.enum).toEqual(FOLDERS) + expect(item.required).toEqual(['folder', 'path', 'role']) + expect(Object.keys(item.properties).sort()).toEqual(['folder', 'path', 'role', 'symbol']) + expect(item.additionalProperties).toBe(false) + }) +}) + +describe('校验 AI 交回的结果', () => { + it('合格的原样收下,按交回的先后排', () => { + expect(parseFeatureResult(LOGIN, FOLDERS)).toEqual({ result: LOGIN }) + }) + + it('去掉首尾空白;符号是空的当没写', () => { + const output = { groups: [{ name: ' 登录页面 ', items: [{ folder: 'web', path: ' src/a.ts ', symbol: ' ', role: ' 入口 ' }] }] } + expect(parseFeatureResult(output, FOLDERS)).toEqual({ result: { groups: [{ name: '登录页面', items: [{ folder: 'web', path: 'src/a.ts', role: '入口' }] }] } }) + }) + + it('一个环节都没有也收下:说明没找到相关的代码', () => { + expect(parseFeatureResult({ groups: [] }, FOLDERS)).toEqual({ result: { groups: [] } }) + }) + + it('没交结构化结果、结构不对:说清哪里不对', () => { + expect(parseFeatureResult(undefined, FOLDERS)).toEqual({ error: 'AI 没按要求的结构交回结果(只回了一段话)。' }) + expect(parseFeatureResult({ groups: 'x' }, FOLDERS)).toEqual({ error: '交回的结果不对:没有环节列表。' }) + expect(parseFeatureResult({ groups: [{ name: '', items: [] }] }, FOLDERS)).toEqual({ error: '交回的结果不对:第 1 个环节没有名字。' }) + expect(parseFeatureResult({ groups: [{ name: '页面', items: [] }] }, FOLDERS)).toEqual({ error: '交回的结果不对:「页面」下面一条都没有。' }) + expect(parseFeatureResult({ groups: [{ name: '页面', items: [{ folder: 'web', path: 'a.ts', role: '' }] }] }, FOLDERS)) + .toEqual({ error: '交回的结果不对:「页面」第 1 条没说它干什么。' }) + }) + + it('路径必须相对工作区文件夹、不能跑出去;工作区文件夹必须是现有的', () => { + const one = (item: Record): unknown => ({ groups: [{ name: '页面', items: [{ role: '入口', ...item }] }] }) + expect(parseFeatureResult(one({ folder: 'web', path: '/etc/passwd' }), FOLDERS)).toEqual({ error: '交回的结果不对:「页面」第 1 条的路径「/etc/passwd」不是相对工作区文件夹的路径。' }) + expect(parseFeatureResult(one({ folder: 'web', path: '../x.ts' }), FOLDERS)).toEqual({ error: '交回的结果不对:「页面」第 1 条的路径「../x.ts」不是相对工作区文件夹的路径。' }) + expect(parseFeatureResult(one({ folder: 'other', path: 'a.ts' }), FOLDERS)).toEqual({ error: '交回的结果不对:「页面」第 1 条的工作区文件夹「other」不存在。' }) + }) + + it('读存着的结果不查工作区文件夹:文件夹后来变了,那几条到时候画成找不到', () => { + const output = { groups: [{ name: '页面', items: [{ folder: 'gone', path: 'a.ts', role: '入口' }] }] } + expect(parseFeatureResult(output)).toEqual({ result: output }) + }) +}) + +describe('发给 AI 的话', () => { + const folders = [{ name: 'web', path: '/ws/web' }, { name: 'server', path: '/ws/server' }] + + it('开新会话:要找的功能、在哪些工作区文件夹里找、交回什么、只读不改、不给行号、只列直接相关的、中文', () => { + const prompt = featurePrompt('用户登录', folders) + expect(prompt).toContain('「用户登录」') + expect(prompt).toContain('- web:/ws/web') + expect(prompt).toContain('- server:/ws/server') + expect(prompt).toMatch(/类名\.方法名/) + expect(prompt).toContain('不要写行号') + expect(prompt).toContain('只列和这个功能直接相关的') + expect(prompt).toContain('中文') + expect(prompt).not.toContain('补充') + }) + + it('会话找不到了重新开时,用户接着说的话跟在后面', () => { + expect(featurePrompt('用户登录', folders, '还漏了找回密码')).toMatch(/「用户登录」[\s\S]*\n\n用户补充:还漏了找回密码$/) + }) + + it('接着上次的会话说:用户的话,再要完整的新结果', () => { + expect(featureFollowUp('XX 不相关')).toBe('XX 不相关\n\n按这个改,交回改完之后完整的结果(全部环节和条目,不是只交改动的部分)。') + }) +}) + +describe('对话里显示的回答', () => { + it('按环节列出条目:符号、文件、一句话', () => { + expect(featureAnswer(LOGIN, false)).toBe([ + '**1. 登录页面**', + '', + '- `LoginPage`(`src/pages/Login.tsx`):登录表单,提交账号密码', + '- `.env`:登录接口的地址', + '', + '**2. 校验与发令牌**', + '', + '- `AuthService.login`(`app/auth.py`):核对密码、发令牌', + ].join('\n')) + }) + + it('几个工作区文件夹时路径前带上文件夹名;一个环节都没有时说没找到', () => { + expect(featureAnswer({ groups: [LOGIN.groups[1]] }, true)).toBe('**1. 校验与发令牌**\n\n- `AuthService.login`(`server/app/auth.py`):核对密码、发令牌') + expect(featureAnswer({ groups: [] }, false)).toBe('没找到和这个功能直接相关的代码。') + }) +}) + +describe('栏里条目的名字', () => { + it('有符号写符号,只有文件的写文件名', () => { + expect(itemLabel(LOGIN.groups[0].items[0])).toBe('LoginPage') + expect(itemLabel(LOGIN.groups[0].items[1])).toBe('.env') + expect(itemLabel({ folder: 'web', path: 'config/app.yaml', role: '配置' })).toBe('app.yaml') + }) +}) diff --git a/vscode/src/codeReader/features/featureResult.ts b/vscode/src/codeReader/features/featureResult.ts new file mode 100644 index 0000000..3183e06 --- /dev/null +++ b/vscode/src/codeReader/features/featureResult.ts @@ -0,0 +1,166 @@ +/** + * 「了解一块代码」:AI 在整个工作区里找一个功能的代码,按 JSON Schema 交回若干「环节」,每个环节下面是文件 + 符号 + 一句话。 + * 这里是交回的结构、怎么校验、发给 AI 的话、对话里怎么显示。不碰 VS Code。 + */ + +import * as path from 'node:path' +import { isSafeRepoPath } from '../editor/repoPath' + +/** + * 一条:在哪个工作区文件夹的哪个文件(相对这个文件夹),哪个函数 / 方法 / 类 / 组件(方法写成 `类名.方法名`,配置文件这类没有函数的不写), + * 和一句话说它在这个功能里干什么。不带行号:每次按眼下的代码现找(见 featureLocate.ts),代码改过也找得到。 + */ +export interface FeatureItem { + folder: string + path: string + symbol?: string + role: string +} + +/** 一个环节:按功能起的名字(「登录页面」「校验与发令牌」)和下面的条目。 */ +export interface FeatureGroup { + name: string + items: FeatureItem[] +} + +/** AI 交回的一次结果:各环节按功能流程的先后排。一个环节都没有是没找到相关的代码。 */ +export interface FeatureResult { + groups: FeatureGroup[] +} + +/** 给 `--json-schema` 的结构:folders 是工作区文件夹的名字,条目的 folder 只能是其中之一。 */ +export function featureSchema(folders: readonly string[]): string { + return JSON.stringify({ + type: 'object', + properties: { + groups: { + type: 'array', + description: '这个功能的各个环节,按功能流程的先后排', + items: { + type: 'object', + properties: { + name: { type: 'string', description: '按功能起的环节名,如「登录页面」「登录接口」「校验与发令牌」「用户表」' }, + items: { + type: 'array', + minItems: 1, + items: { + type: 'object', + properties: { + folder: { type: 'string', enum: [...new Set(folders)], description: '文件所在的工作区文件夹的名字' }, + path: { type: 'string', description: '相对这个工作区文件夹的路径,用 / 分隔' }, + symbol: { type: 'string', description: '函数、方法、类或组件的名字,方法写成「类名.方法名」,要能在文件里唯一找到;配置文件这类没有函数的不写' }, + role: { type: 'string', description: '一句中文:它在这个功能里干什么' }, + }, + required: ['folder', 'path', 'role'], + additionalProperties: false, + }, + }, + }, + required: ['name', 'items'], + additionalProperties: false, + }, + }, + }, + required: ['groups'], + additionalProperties: false, + }) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** 去掉首尾空白的字;不是字或是空的没有。 */ +function text(value: unknown): string | undefined { + const trimmed = typeof value === 'string' ? value.trim() : '' + return trimmed === '' ? undefined : trimmed +} + +/** + * 校验交回的结果,合格的去掉首尾空白收下,不合格的说清哪里不对(给人看的话)。 + * 给了 folders 时(AI 刚交回的)条目的工作区文件夹必须是其中之一;读存着的不给:工作区文件夹后来变了,那几条到时候画成找不到。 + */ +export function parseFeatureResult(output: unknown, folders?: readonly string[]): { result: FeatureResult } | { error: string } { + if (output === undefined) + return { error: 'AI 没按要求的结构交回结果(只回了一段话)。' } + const wrong = (detail: string): { error: string } => ({ error: `交回的结果不对:${detail}` }) + if (!isRecord(output) || !Array.isArray(output.groups)) + return wrong('没有环节列表。') + const groups: FeatureGroup[] = [] + for (const [index, group] of output.groups.entries()) { + const name = isRecord(group) ? text(group.name) : undefined + if (name === undefined) + return wrong(`第 ${index + 1} 个环节没有名字。`) + const items = isRecord(group) && Array.isArray(group.items) ? group.items : [] + if (items.length === 0) + return wrong(`「${name}」下面一条都没有。`) + const parsed: FeatureItem[] = [] + for (const [at, raw] of items.entries()) { + const where = `「${name}」第 ${at + 1} 条` + const item = isRecord(raw) ? raw : {} + const folder = text(item.folder) + const file = text(item.path) + const role = text(item.role) + const symbol = text(item.symbol) + if (folder === undefined || (folders && !folders.includes(folder))) + return wrong(`${where}的工作区文件夹「${folder ?? ''}」不存在。`) + if (file === undefined || !isSafeRepoPath(file)) + return wrong(`${where}的路径「${file ?? ''}」不是相对工作区文件夹的路径。`) + if (role === undefined) + return wrong(`${where}没说它干什么。`) + parsed.push({ folder, path: file, ...(symbol !== undefined && { symbol }), role }) + } + groups.push({ name, items: parsed }) + } + return { result: { groups } } +} + +/** 发给 AI 的工作区文件夹:名字和磁盘上的路径。 */ +export interface PromptFolder { + name: string + path: string +} + +/** + * 开新会话时发给 AI 的话:要找哪个功能、在哪些工作区文件夹里找、交回什么样的结果。 + * said 是会话找不到了重新开时用户接着说的话,跟在最后。 + */ +export function featurePrompt(question: string, folders: readonly PromptFolder[], said?: string): string { + const lines = [ + `在下面这些工作区文件夹里找出「${question}」这个功能的代码:`, + ...folders.map(folder => `- ${folder.name}:${folder.path}`), + '', + '只读、搜文件,不要改任何东西。找完按给定的结构交回:', + '- 把这个功能分成几个环节,按功能流程的先后排,每个环节起一个按功能叫的名字(如「登录页面」「登录接口」「校验与发令牌」「用户表」)。', + '- 每个环节下面列出相关的代码:folder 是文件所在的工作区文件夹的名字,path 是相对这个文件夹的路径;' + + 'symbol 是函数、方法、类或组件的名字,方法写成「类名.方法名」这样能在文件里唯一找到的写法;配置文件这类没有函数的条目不写 symbol。', + '- role 用一句中文说它在这个功能里干什么。', + '- 不要写行号。只列和这个功能直接相关的代码,顺带用到的通用工具、框架代码不要列。', + ] + const prompt = lines.join('\n') + return said === undefined ? prompt : `${prompt}\n\n用户补充:${said}` +} + +/** 接着上次的会话说时发的:用户的话,再要改完之后完整的结果。 */ +export function featureFollowUp(said: string): string { + return `${said}\n\n按这个改,交回改完之后完整的结果(全部环节和条目,不是只交改动的部分)。` +} + +/** 栏里、对话里一条的名字:有符号写符号,只有文件的写文件名。 */ +export function itemLabel(item: FeatureItem): string { + return item.symbol ?? path.posix.basename(item.path) +} + +/** + * 对话里显示的这一轮回答(Markdown):按环节列出各条(符号、文件、一句话),和栏里的一样。 + * showFolder:几个工作区文件夹时路径前带上文件夹名。 + */ +export function featureAnswer(result: FeatureResult, showFolder: boolean): string { + if (result.groups.length === 0) + return '没找到和这个功能直接相关的代码。' + const where = (item: FeatureItem): string => showFolder ? `${item.folder}/${item.path}` : item.path + const line = (item: FeatureItem): string => item.symbol === undefined + ? `- \`${where(item)}\`:${item.role}` + : `- \`${item.symbol}\`(\`${where(item)}\`):${item.role}` + return result.groups.map((group, index) => [`**${index + 1}. ${group.name}**`, '', ...group.items.map(line)].join('\n')).join('\n\n') +} diff --git a/vscode/src/codeReader/features/featureViews.ts b/vscode/src/codeReader/features/featureViews.ts new file mode 100644 index 0000000..7a9795b --- /dev/null +++ b/vscode/src/codeReader/features/featureViews.ts @@ -0,0 +1,476 @@ +/** + * 「了解一块代码」的侧栏:主栏标题栏的放大镜问要了解哪一块,占一个栏位(和收藏共用)建一栏,同时让 AI 在整个工作区里找; + * 对话放在这个功能的对话页上(预览顶部的聊天,记录、进行中的那一轮都归 CodeQa 管),接着说就按上次的会话改、整栏换成新结果。 + * 栏里是 AI 交回的各个环节和条目;条目每次画、每次点时按眼下的代码找位置,找不到的画成灰的。 + */ + +import type { Disposable, FileDecorationProvider, Memento, TreeDataProvider, TreeView, WorkspaceFolder } from 'vscode' +import type { LanguageAdapter, SourceRange } from '../contract' +import type { TargetSpot } from '../editor/openTarget' +import type { FileNode } from '../explorer/fileTree' +import type { Answer } from '../qa/claudeAsk' +import type { AskEvent, CodeQa, TopRequest } from '../qa/codeQa' +import type { TreeSitterRuntime } from '../runtime' +import type { SlotPool } from '../slots/slotPool' +import type { FeatureGroup, FeatureItem, FeatureResult } from './featureResult' +import type { Feature, FeatureRun } from './features' +import { randomUUID } from 'node:crypto' +import { EventEmitter, FileDecoration, MarkdownString, Range, TabInputCustom, ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri, window, workspace } from 'vscode' +import { documentSymbolNodes } from '../editor/documentSymbols' +import { featureIdOf, featurePageUri, featureSubjectUri } from '../editor/pathPreview' +import { findRepoSymbol } from '../editor/repoPath' +import { entryContext, GROUP_CONTEXT } from '../explorer/fileEntries' +import { PATH_ASK_ANCHOR } from '../qa/pathQa' +import { askSlotName, focusSlot } from '../slots/slotPool' +import { SLOTS, slotTitle, slotViewId } from '../slots/slots' +import { locateInSource } from './featureLocate' +import { featureAnswer, featureFollowUp, featurePrompt, featureSchema, parseFeatureResult } from './featureResult' +import { FEATURE_MARKER, featureMessage, itemRow, MISSING_CONTEXT, parseFeatures, removeFeature, renameFeature, withResult } from './features' + +/** workspaceState:「了解一块代码」找过的功能(Feature 的数组),按工作区记。 */ +const FEATURES_KEY = 'superpowers.codeReader.features' + +/** 点栏里一条的命令:参数是这一行。 */ +export const OPEN_FEATURE_ITEM = 'superpowers.codeReader.openFeatureItem' + +/** 找不到的条目行挂的 Uri 的协议:MissingRows 把它的字画成灰的,真文件在别处的颜色不受影响。 */ +const MISSING_SCHEME = 'code-reader-feature-missing' + +/** 文件改了、增删了,合并成一次重找。 */ +const REFRESH_DELAY_MS = 300 + +/** 栏位满了时说的话 */ +const FULL_MESSAGE = `栏位满了(收藏和「了解一块代码」共用,最多 ${SLOTS.length} 个),先取消一个收藏或删掉一个功能再找` + +/** 符号行的图标:按语言插件认出的块的种类;问 VS Code 文档符号找到的不知道种类,用方法的图标。 */ +const SYMBOL_ICONS: Record = { + class: 'symbol-class', + exception: 'symbol-class', + impl: 'symbol-class', + trait: 'symbol-interface', + enum: 'symbol-enum', + module: 'symbol-module', + const: 'symbol-constant', + variable: 'symbol-variable', + function: 'symbol-method', +} + +/** 一条按眼下的代码找到的地方:文件,有符号时再加符号名字的位置(spot)和块的种类(kind,语言插件认出来的才有)。 */ +export interface FoundItem { + uri: Uri + folder: WorkspaceFolder + spot?: TargetSpot + kind?: string +} + +/** 找条目用的语言插件:这种语言的插件(没有时 undefined)和解析器。 */ +export interface ItemLanguages { + adapterFor: (languageId: string) => LanguageAdapter | undefined + runtime: Pick +} + +/** 分组行(一个环节) */ +interface GroupRow { + slot: number + group: FeatureGroup +} + +/** 找得到的条目行:同侧栏文件行一样带着文件(FileNode),行菜单(问 AI、复制、在系统文件管理器中显示)照常用。 */ +type FoundRow = FileNode & { slot: number, item: FeatureItem, kind?: string } + +/** 找不到的条目行 */ +interface MissingRow { + slot: number + item: FeatureItem + missing: true +} + +export type FeatureRow = GroupRow | FoundRow | MissingRow + +/** 条目在磁盘上是哪个文件:工作区文件夹按名字找;那个文件夹不在了时没有。 */ +function itemUri(item: FeatureItem): { uri: Uri, folder: WorkspaceFolder } | undefined { + const folder = workspace.workspaceFolders?.find(candidate => candidate.name === item.folder) + return folder && { uri: Uri.joinPath(folder.uri, ...item.path.split('/')), folder } +} + +/** + * 按眼下的代码找一条在哪:只有文件的看文件还在不在;有符号的,这种语言有语言插件就解析成代码块按 `类名.方法名` 找(见 locateInSource), + * 没有插件就问 VS Code 的文档符号。文件、符号没有了时没有。 + */ +export async function locateItem(item: FeatureItem, languages: ItemLanguages): Promise { + const at = itemUri(item) + if (!at || !await workspace.fs.stat(at.uri).then(() => true, () => false)) + return undefined + if (item.symbol === undefined) + return at + const { symbol } = item + try { + const doc = await workspace.openTextDocument(at.uri) + const adapter = languages.adapterFor(doc.languageId) + let found: { range: SourceRange, kind?: string } | undefined + if (adapter) { + const block = await locateInSource(languages.runtime, adapter, doc.languageId, doc.getText(), symbol) + found = block && { range: block.range, kind: block.kind } + } + else { + const node = findRepoSymbol(await documentSymbolNodes(at.uri.toString()), symbol) + found = node && { range: node.selection } + } + if (!found) + return undefined + const range = new Range(doc.positionAt(found.range.startIndex), doc.positionAt(found.range.endIndex)) + return { ...at, spot: { range, offset: found.range.startIndex }, ...(found.kind !== undefined && { kind: found.kind }) } + } + catch { + // 打不开成文本(二进制文件) + return undefined + } +} + +/** 找不到的条目行画成灰字:只认 MISSING_SCHEME 的 Uri。 */ +class MissingRows implements FileDecorationProvider { + provideFileDecoration(uri: Uri): FileDecoration | undefined { + return uri.scheme === MISSING_SCHEME ? new FileDecoration(undefined, '找不到了', new ThemeColor('disabledForeground')) : undefined + } +} + +/** 一个功能栏里的树:环节 → 条目。条目每次画时按眼下的代码找位置。 */ +class FeatureTree implements TreeDataProvider { + private readonly changed = new EventEmitter() + readonly onDidChangeTreeData = this.changed.event + + constructor(private readonly slot: number, private result: FeatureResult | undefined, private readonly locate: (item: FeatureItem) => Promise) {} + + /** 换成这次的结果,整棵树重画。 */ + show(result: FeatureResult): void { + this.result = result + this.changed.fire() + } + + /** 按眼下的代码重找各条,整棵树重画。 */ + refresh(): void { + this.changed.fire() + } + + /** 有一条是不是这个文件 */ + covers(uri: Uri): boolean { + const key = uri.toString() + return this.result?.groups.some(group => group.items.some(item => itemUri(item)?.uri.toString() === key)) ?? false + } + + async getChildren(row?: FeatureRow): Promise { + if (!row) + return this.result?.groups.map(group => ({ slot: this.slot, group })) ?? [] + if (!('group' in row)) + return [] + return Promise.all(row.group.items.map(async (item): Promise => { + const found = await this.locate(item) + if (!found) + return { slot: this.slot, item, missing: true } + return { slot: this.slot, item, uri: found.uri, folder: found.folder, isDirectory: false, isRoot: false, ...(found.kind !== undefined && { kind: found.kind }) } + })) + } + + getTreeItem(row: FeatureRow): TreeItem { + if ('group' in row) { + const item = new TreeItem(row.group.name, TreeItemCollapsibleState.Expanded) + item.contextValue = GROUP_CONTEXT + return item + } + const found = !('missing' in row) + const { label, description, tooltip } = itemRow(row.item, found) + const item = new TreeItem(label, TreeItemCollapsibleState.None) + item.description = description + item.tooltip = new MarkdownString(tooltip) + item.command = { command: OPEN_FEATURE_ITEM, title: '打开', arguments: [row] } + if (!found) { + item.contextValue = MISSING_CONTEXT + item.resourceUri = Uri.from({ scheme: MISSING_SCHEME, path: `/${row.item.path}` }) + item.iconPath = new ThemeIcon('circle-slash', new ThemeColor('disabledForeground')) + return item + } + item.contextValue = entryContext(row, FEATURE_MARKER) + if (row.item.symbol === undefined) { + // 只有文件的条目:文件图标、git 改动的颜色由 VS Code 按文件加 + item.resourceUri = row.uri + item.iconPath = ThemeIcon.File + } + else { + item.iconPath = new ThemeIcon(SYMBOL_ICONS[row.kind ?? 'function'] ?? 'symbol-method') + } + return item + } + + dispose(): void { + this.changed.dispose() + } +} + +/** 一个功能栏:标题是放大镜加名字,栏上方一句话说正在找、没找成、没有结果(见 featureMessage)。占上栏位时建、删掉时销毁。 */ +class FeaturePane { + private readonly tree: FeatureTree + private readonly view: TreeView + private result: FeatureResult | undefined + private run: FeatureRun | undefined + + constructor(feature: Feature, locate: (item: FeatureItem) => Promise) { + this.result = feature.result + this.tree = new FeatureTree(feature.slot, feature.result, locate) + this.view = window.createTreeView(slotViewId(feature.slot), { treeDataProvider: this.tree, showCollapseAll: true }) + this.view.title = slotTitle('feature', feature.name) + this.view.message = featureMessage(this.result, this.run) + } + + rename(name: string): void { + this.view.title = slotTitle('feature', name) + } + + /** AI 交回了新结果:整栏换成它。 */ + show(result: FeatureResult): void { + this.result = result + this.tree.show(result) + this.view.message = featureMessage(this.result, this.run) + } + + /** 正在找、没找成、停下了、没在找(undefined)。 */ + setRun(run: FeatureRun | undefined): void { + this.run = run + this.view.message = featureMessage(this.result, this.run) + } + + covers(uri: Uri): boolean { + return this.tree.covers(uri) + } + + refresh(): void { + this.tree.refresh() + } + + dispose(): void { + this.view.dispose() + this.tree.dispose() + } +} + +/** + * 「了解一块代码」:找过的功能各占一栏(和收藏共用栏位,见 SlotPool),记在 workspaceState 里,重开窗口栏还在。 + * 找、接着说都交给 CodeQa 的顶部聊天(askTop):会话、进行中的那一轮、对话记录都在那里,关掉对话页也不中断; + * 这里拼发给 AI 的话、校验交回的结果、换栏里的树,跟着 CodeQa 报的进展改栏上方那句话。 + * 打开对话页、打开文件不在这里做(要用预览),只给出要打开什么(chatPage、target)。 + */ +export class FeatureViews implements Disposable { + private features: Feature[] + /** 栏位号 → 那一栏 */ + private readonly panes = new Map() + private readonly disposables: Disposable[] + private timer: NodeJS.Timeout | undefined + /** 等着重找的文件 */ + private readonly touched = new Map() + + /** + * @param state workspaceState + * @param pool 和收藏共用的栏位 + * @param qa 对话:顶部聊天的一轮怎么问、记录怎么存 + * @param locate 按眼下的代码找一条在哪(见 locateItem) + */ + constructor( + private readonly state: Memento, + private readonly pool: SlotPool, + private readonly qa: CodeQa, + private readonly locate: (item: FeatureItem) => Promise, + ) { + this.features = parseFeatures(state.get(FEATURES_KEY)) + for (const feature of this.features) { + this.open(feature) + void pool.occupy(feature.slot, 'feature') + } + const watcher = workspace.createFileSystemWatcher('**/*') + this.disposables = [ + watcher, + watcher.onDidChange(uri => this.touch(uri)), + watcher.onDidCreate(uri => this.touch(uri)), + watcher.onDidDelete(uri => this.touch(uri)), + workspace.onDidChangeWorkspaceFolders(() => this.refreshAll()), + window.registerFileDecorationProvider(new MissingRows()), + qa.onDidAnswer(event => this.answered(event)), + ] + } + + /** + * 主栏标题栏的「了解一块代码」:问要了解哪一块,占最小的空栏位建一栏(栏名就是问的话)、亮出来,让 AI 开始找。 + * 返回这个功能的对话页(要打开它看找的过程);没问、栏位满了、没打开工作区文件夹时没有。 + */ + async find(): Promise { + if (!workspace.workspaceFolders?.length) { + void window.showWarningMessage('先打开工作区文件夹,再了解里面的代码') + return undefined + } + if (this.pool.free() === undefined) { + void window.showWarningMessage(FULL_MESSAGE) + return undefined + } + const question = (await window.showInputBox({ + title: '了解一块代码', + prompt: '你想要了解哪一块的代码', + placeHolder: '例如:用户登录、会员自动续费', + validateInput: text => text.trim() ? undefined : '说说想了解哪一块', + }))?.trim() + if (!question) + return undefined + const slot = this.pool.free() + if (slot === undefined) { + void window.showWarningMessage(FULL_MESSAGE) + return undefined + } + const feature: Feature = { id: randomUUID(), question, name: question, slot } + this.open(feature) + await Promise.all([this.save([...this.features, feature]), this.pool.occupy(slot, 'feature')]) + await focusSlot(slot) + await this.start(feature, { askId: randomUUID(), question, images: [] }) + return featurePageUri(feature.id, feature.question) + } + + /** 功能对话页里接着说(subject 见 featureSubjectUri):AI 接着上次的会话改,交回完整的新结果。说不了时返回给人看的原因。 */ + async ask(subject: Uri, request: TopRequest): Promise { + const feature = this.features.find(item => item.id === featureIdOf(subject)) + if (!feature) + return '这个功能已经删掉了。' + return this.start(feature, request, request.question) + } + + /** 第 slot 栏的对话页;这一栏不是功能时没有。 */ + chatPage(slot: number): Uri | undefined { + const feature = this.features.find(item => item.slot === slot) + return feature && featurePageUri(feature.id, feature.question) + } + + /** 点栏里一条:按眼下的代码找它在哪;找不到了说一声、这一栏重画(画成灰的)。 */ + async target(row: FeatureRow | undefined): Promise { + if (!row || 'group' in row) + return undefined + const found = await this.locate(row.item) + if (!found) { + void window.showInformationMessage(`找不到了:${row.item.path}${row.item.symbol === undefined ? '' : ` 里的 ${row.item.symbol}`}`) + this.panes.get(row.slot)?.refresh() + } + return found + } + + /** 功能栏标题栏的「重命名」:改第 slot 栏的标题。 */ + async rename(slot: number): Promise { + const feature = this.features.find(item => item.slot === slot) + if (!feature) + return + const name = await askSlotName('重命名', feature.name) + if (name === undefined) + return + this.panes.get(slot)?.rename(name) + await this.save(renameFeature(this.features, slot, name)) + } + + /** 功能栏标题栏的「删除」:删栏(空出栏位)、删结果、删对话记录,正在找的叫停,开着的对话页关掉。 */ + async remove(slot: number): Promise { + const feature = this.features.find(item => item.slot === slot) + if (!feature) + return + const pane = this.panes.get(slot) + this.panes.delete(slot) + const pages = window.tabGroups.all.flatMap(group => group.tabs).filter(tab => tab.input instanceof TabInputCustom && featureIdOf(tab.input.uri) === feature.id) + await Promise.all([ + this.save(removeFeature(this.features, slot)), + this.pool.release(slot), + this.qa.deleteTop(featureSubjectUri(feature.id)), + ...(pages.length > 0 ? [window.tabGroups.close(pages)] : []), + ]) + pane?.dispose() + } + + dispose(): void { + clearTimeout(this.timer) + for (const disposable of this.disposables) + disposable.dispose() + for (const pane of this.panes.values()) + pane.dispose() + } + + /** + * 让 AI 找一轮:只读、按 featureSchema 交结果,在第一个工作区文件夹里跑、别的加进来读。 + * 接着上次的会话说时发 request.question 再要完整的新结果;没有会话(第一次、会话找不到了)发完整的要求,said 是用户接着说的话。 + */ + private async start(feature: Feature, request: TopRequest, said?: string): Promise { + const folders = workspace.workspaceFolders ?? [] + if (folders.length === 0) + return '没打开工作区文件夹,没地方找。' + const names = folders.map(folder => folder.name) + const refused = await this.qa.askTop(featureSubjectUri(feature.id), request, { + prompt: featurePrompt(feature.question, folders.map(folder => ({ name: folder.name, path: folder.uri.fsPath })), said), + followUp: featureFollowUp(request.question), + schema: featureSchema(names), + addDirs: folders.slice(1).map(folder => folder.uri.fsPath), + }, answer => this.settle(feature.id, answer, names)) + if (refused !== undefined) + this.panes.get(feature.slot)?.setRun({ status: 'failed', error: refused }) + return refused + } + + /** AI 答完了:交回的结构合格就存下、整栏换成它,返回对话里显示的回答;不合格说清哪里不对。 */ + private async settle(id: string, answer: Answer, folders: readonly string[]): Promise<{ text: string } | { error: string }> { + const parsed = parseFeatureResult(answer.output, folders) + if ('error' in parsed) + return parsed + const feature = this.features.find(item => item.id === id) + if (!feature) + return { error: '这个功能已经删掉了。' } + await this.save(withResult(this.features, id, parsed.result)) + this.panes.get(feature.slot)?.show(parsed.result) + return { text: featureAnswer(parsed.result, folders.length > 1) } + } + + /** CodeQa 报的进展:功能对话页上的那一轮在找、找完、没找成、停下了,改那一栏上方的话。删掉了的功能那一轮停下后不再留着。 */ + private answered(event: AskEvent): void { + const id = featureIdOf(Uri.parse(event.owner)) + if (id === undefined) + return + const feature = this.features.find(item => item.id === id) + if (!feature) { + if (event.kind === 'end') + this.qa.forgetAsk(featureSubjectUri(id), PATH_ASK_ANCHOR) + return + } + const pane = this.panes.get(feature.slot) + if (event.kind === 'update') { + pane?.setRun({ status: 'running', ...(event.ask.activity !== undefined && { activity: event.ask.activity }) }) + return + } + const { end } = event + pane?.setRun(end.status === 'done' ? undefined : end.status === 'failed' ? { status: 'failed', error: end.error } : { status: 'stopped' }) + } + + /** 文件改了、增删了:等一会儿,把有条目在这些文件里的栏重找一遍。 */ + private touch(uri: Uri): void { + this.touched.set(uri.toString(), uri) + clearTimeout(this.timer) + this.timer = setTimeout(() => { + const uris = [...this.touched.values()] + this.touched.clear() + for (const pane of this.panes.values()) { + if (uris.some(touched => pane.covers(touched))) + pane.refresh() + } + }, REFRESH_DELAY_MS) + } + + private refreshAll(): void { + for (const pane of this.panes.values()) + pane.refresh() + } + + private open(feature: Feature): void { + this.panes.set(feature.slot, new FeaturePane(feature, this.locate)) + } + + private async save(features: Feature[]): Promise { + this.features = features + await this.state.update(FEATURES_KEY, features.length > 0 ? features : undefined) + } +} diff --git a/vscode/src/codeReader/features/features.test.ts b/vscode/src/codeReader/features/features.test.ts new file mode 100644 index 0000000..a25db68 --- /dev/null +++ b/vscode/src/codeReader/features/features.test.ts @@ -0,0 +1,80 @@ +import type { FeatureResult } from './featureResult' +import type { Feature } from './features' +import { describe, expect, it } from 'vitest' +import { featureCommand, featureMessage, itemRow, parseFeatures, placeFeature, removeFeature, renameFeature, withResult } from './features' + +const RESULT: FeatureResult = { groups: [{ name: '登录页面', items: [{ folder: 'web', path: 'src/Login.tsx', symbol: 'LoginPage', role: '登录表单' }] }] } +const feature = (slot: number, overrides: Partial = {}): Feature => ({ id: `f${slot}`, question: `问题${slot}`, name: `问题${slot}`, slot, ...overrides }) + +describe('存着的功能', () => { + it('认得出的留下,坏的丢掉;结果读不懂的当还没有结果', () => { + const stored = [ + feature(1, { result: RESULT }), + { id: 'x', question: 'q', name: 'q' }, + 'bad', + feature(2, { result: { groups: 'bad' } as unknown as FeatureResult }), + ] + expect(parseFeatures(stored)).toEqual([feature(1, { result: RESULT }), feature(2)]) + expect(parseFeatures(undefined)).toEqual([]) + }) + + it('同一个栏位存了两个时只认第一个', () => { + expect(parseFeatures([feature(1), { ...feature(1), id: 'other' }])).toEqual([feature(1)]) + }) +}) + +describe('新功能占栏位、重命名、换结果、删除', () => { + it('和收藏共用栏位:占没被占的里面最小的,满了没有', () => { + expect(placeFeature([1, 2, 4])).toBe(3) + expect(placeFeature(Array.from({ length: 30 }, (_, index) => index + 1))).toBeUndefined() + }) + + it('重命名只改那一栏的名字,换结果只换那一个,删除只去掉那一个', () => { + const features = [feature(1), feature(4)] + expect(renameFeature(features, 4, '续费')).toEqual([feature(1), feature(4, { name: '续费' })]) + expect(withResult(features, 'f1', RESULT)).toEqual([feature(1, { result: RESULT }), feature(4)]) + expect(removeFeature(features, 1)).toEqual([feature(4)]) + }) +}) + +describe('功能栏标题栏上的命令', () => { + it('每个栏位各有打开对话、重命名、删除', () => { + expect(featureCommand('openChat', 3)).toBe('superpowers.codeReader.openFeatureChat3') + expect(featureCommand('rename', 3)).toBe('superpowers.codeReader.renameFeature3') + expect(featureCommand('remove', 30)).toBe('superpowers.codeReader.removeFeature30') + }) +}) + +describe('栏里的一行条目', () => { + const item = RESULT.groups[0].items[0] + + it('名字后面淡色的是那一句话,悬停看文件、符号和那句话', () => { + expect(itemRow(item, true)).toEqual({ label: 'LoginPage', description: '登录表单', tooltip: '**src\\/Login\\.tsx** \nLoginPage \n登录表单' }) + expect(itemRow({ folder: 'web', path: '.env', role: '接口地址' }, true)).toEqual({ label: '.env', description: '接口地址', tooltip: '**\\.env** \n接口地址' }) + }) + + it('找不到的悬停先说找不到了', () => { + expect(itemRow(item, false).tooltip).toBe('找不到了 \n**src\\/Login\\.tsx** \nLoginPage \n登录表单') + }) +}) + +describe('栏上方的一句话', () => { + it('正在找:第一次是「正在找…」,有结果后再找是按你说的改;带上正在做的事', () => { + expect(featureMessage(undefined, { status: 'running' })).toBe('正在找…') + expect(featureMessage(undefined, { status: 'running', activity: '读取 a.ts' })).toBe('正在找…(读取 a.ts)') + expect(featureMessage(RESULT, { status: 'running' })).toBe('正在按你说的改…') + }) + + it('找失败了:说原因、可以在对话里接着说重试;有旧结果时旧的留着', () => { + expect(featureMessage(undefined, { status: 'failed', error: '没找到 Claude Code' })).toBe('没找成:没找到 Claude Code。可以在对话里接着说,让它再找一次。') + expect(featureMessage(RESULT, { status: 'failed', error: '等太久,已经停掉。' })).toBe('这次没改成:等太久,已经停掉。下面还是上一次的结果,可以在对话里接着说,让它再改一次。') + }) + + it('没在找:有结果不说话;一个环节都没有说没找到;还没有结果(停下了、重开了窗口)说可以接着说', () => { + expect(featureMessage(RESULT, undefined)).toBeUndefined() + expect(featureMessage(RESULT, { status: 'stopped' })).toBeUndefined() + expect(featureMessage({ groups: [] }, undefined)).toBe('没找到和这个功能直接相关的代码,可以在对话里换个说法接着说。') + expect(featureMessage(undefined, { status: 'stopped' })).toBe('还没有结果,可以在对话里接着说,让它再找一次。') + expect(featureMessage(undefined, undefined)).toBe('还没有结果,可以在对话里接着说,让它再找一次。') + }) +}) diff --git a/vscode/src/codeReader/features/features.ts b/vscode/src/codeReader/features/features.ts new file mode 100644 index 0000000..50d6680 --- /dev/null +++ b/vscode/src/codeReader/features/features.ts @@ -0,0 +1,114 @@ +/** + * 「了解一块代码」找过的功能:问的话、栏标题、占哪个栏位、AI 最近一次交回的结果,按工作区存着。 + * 这里是记录怎么读、怎么改,栏里一行、栏上方一句话怎么写。不碰 VS Code。 + */ + +import type { FeatureItem, FeatureResult } from './featureResult' +import { escapeMarkdown } from '../explorer/rowDecoration' +import { freeSlot, SLOTS } from '../slots/slots' +import { itemLabel, parseFeatureResult } from './featureResult' + +/** 一个功能:question 是用户问的那句话(对话页标签上的名字,开新会话时发给 AI),name 是栏标题(开始时就是问题),result 是 AI 最近一次交回的结果(还没交回过时没有)。 */ +export interface Feature { + id: string + question: string + name: string + slot: number + result?: FeatureResult +} + +/** 功能栏里找得到的条目行的 contextValue 带的标记(见 entryContext):行菜单只挂文件能用的那几样(问 AI、复制、在系统文件管理器中显示)。 */ +export const FEATURE_MARKER = 'feature' + +/** 找不到的条目行的 contextValue:什么菜单都不挂。 */ +export const MISSING_CONTEXT = `${FEATURE_MARKER} missing` + +/** 第 slot 个功能栏标题栏上的按钮命令:openChat 打开对话、rename 重命名、remove 删除。标题栏按钮不知道点的是哪一栏,所以每个栏位各一条。 */ +export function featureCommand(action: 'openChat' | 'rename' | 'remove', slot: number): string { + const name = { openChat: 'openFeatureChat', rename: 'renameFeature', remove: 'removeFeature' }[action] + return `superpowers.codeReader.${name}${slot}` +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** 读 workspaceState 里存的功能;认不出的丢掉,同一个栏位存了两个时只认第一个,结果读不懂的当还没有结果。 */ +export function parseFeatures(value: unknown): Feature[] { + if (!Array.isArray(value)) + return [] + const features: Feature[] = [] + for (const raw of value) { + if (!isRecord(raw)) + continue + const { id, question, name, slot, result } = raw + if (typeof id !== 'string' || typeof question !== 'string' || typeof name !== 'string' || typeof slot !== 'number' || !SLOTS.includes(slot)) + continue + if (features.some(feature => feature.slot === slot)) + continue + const parsed = result === undefined ? undefined : parseFeatureResult(result) + features.push({ id, question, name, slot, ...(parsed && 'result' in parsed && { result: parsed.result }) }) + } + return features +} + +/** 新功能占哪个栏位:和收藏共用,taken 是两者眼下占着的全部栏位;满了没有。 */ +export function placeFeature(taken: Iterable): number | undefined { + return freeSlot(taken) +} + +/** 第 slot 栏的功能改叫 name,栏位不动。 */ +export function renameFeature(features: readonly Feature[], slot: number, name: string): Feature[] { + return features.map(feature => feature.slot === slot ? { ...feature, name } : feature) +} + +/** id 那个功能换成这次交回的结果。 */ +export function withResult(features: readonly Feature[], id: string, result: FeatureResult): Feature[] { + return features.map(feature => feature.id === id ? { ...feature, result } : feature) +} + +/** 删掉第 slot 栏的功能,空出这个栏位。 */ +export function removeFeature(features: readonly Feature[], slot: number): Feature[] { + return features.filter(feature => feature.slot !== slot) +} + +/** + * 栏里一行条目怎么画:名字(符号名,只有文件的写文件名)、后面淡色的那一句话、悬停提示(文件、符号、那句话)。 + * found 是按眼下的代码找没找到;找不到的悬停先说「找不到了」。 + */ +export function itemRow(item: FeatureItem, found: boolean): { label: string, description: string, tooltip: string } { + const lines = [`**${escapeMarkdown(item.path)}**`, ...(item.symbol === undefined ? [] : [escapeMarkdown(item.symbol)]), escapeMarkdown(item.role)] + return { label: itemLabel(item), description: item.role, tooltip: [...(found ? [] : ['找不到了']), ...lines].join(' \n') } +} + +/** 一个功能眼下在不在找:running 还在找(activity 是正在做的事),failed 没找成(error 是原因),stopped 被叫停了;没在找时没有。 */ +export type FeatureRun + = | { status: 'running', activity?: string } + | { status: 'failed', error: string } + | { status: 'stopped' } + +/** 句子没有句末标点时补个句号,后面好接下一句。 */ +function sentence(text: string): string { + return /[。!?.!?]$/.test(text) ? text : `${text}。` +} + +/** + * 栏上方的一句话(TreeView.message):正在找、没找成(可以在对话里接着说重试)、没找到相关的代码、还没有结果; + * 有结果、又没在找时不说话。有旧结果时再找没成,旧结果留着。 + */ +export function featureMessage(result: FeatureResult | undefined, run: FeatureRun | undefined): string | undefined { + if (run?.status === 'running') { + const doing = result ? '正在按你说的改…' : '正在找…' + return run.activity ? `${doing}(${run.activity})` : doing + } + if (run?.status === 'failed') { + return result + ? `这次没改成:${sentence(run.error)}下面还是上一次的结果,可以在对话里接着说,让它再改一次。` + : `没找成:${sentence(run.error)}可以在对话里接着说,让它再找一次。` + } + if (!result) + return '还没有结果,可以在对话里接着说,让它再找一次。' + if (result.groups.length === 0) + return '没找到和这个功能直接相关的代码,可以在对话里换个说法接着说。' + return undefined +} diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index 6151a79..d51b1f5 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -1,6 +1,8 @@ import type { ExtensionContext, Uri } from 'vscode' import type { CodeReaderApi, RuleLocation, RuleReference } from './contract' +import type { TargetSpot } from './editor/openTarget' import type { FileNode } from './explorer/fileTree' +import type { FeatureRow } from './features/featureViews' import type { IndexedName } from './nameResolution' import { commands, window, workspace } from 'vscode' import { logger } from '../logging/logger' @@ -21,8 +23,10 @@ import { registerPathAsk } from './explorer/pathAskPanel' import { registerRevealCommands } from './explorer/revealCommands' import { RowDecorator } from './explorer/rowDecorator' import { TreeLocator } from './explorer/treeLocator' -import { FAVORITE_SLOTS, favoriteCommand } from './favorites/favoriteSlots' +import { favoriteCommand } from './favorites/favoriteSlots' import { FavoriteViews } from './favorites/favoriteViews' +import { featureCommand } from './features/features' +import { FeatureViews, locateItem, OPEN_FEATURE_ITEM } from './features/featureViews' import { createBashAdapter } from './languages/bash' import { createDockerfileAdapter } from './languages/dockerfile' import { createPythonAdapter } from './languages/python' @@ -49,6 +53,8 @@ import { sqlxRule } from './rules/sqlx' import { tauriRule } from './rules/tauri' import { thiserrorRule } from './rules/thiserror' import { bundledWasmDir, indexGrammar, TreeSitterRuntime } from './runtime' +import { SlotPool } from './slots/slotPool' +import { SLOTS } from './slots/slots' import { bashZhTranslator } from './translators/bashZh' import { dockerfileZhTranslator } from './translators/dockerfileZh' import { pythonZhTranslator } from './translators/pythonZh' @@ -64,18 +70,26 @@ const LANGUAGE_IDS_CONTEXT = 'superpowers.codeReader.languageIds' const FILES_VIEW = 'superpowers.codeReader.files' const OPEN_COMMAND = 'superpowers.codeReader.open' -/** 侧栏点文件:语言受支持就用预览打开,否则用 VS Code 默认的打开方式(见 openingFor)。 */ -async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise { - const opening = openingFor(await languageOf(uri), languageId => registry.languageFor(languageId) !== undefined) - if (opening.kind === 'preview') - // 和 vscode.open 一样按「预览标签」打开(用户关了 workbench.editor.enablePreview 时 VS Code 自会固定) - await commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, { preview: true }) +/** + * 侧栏点文件、点「了解一块代码」栏里的一条:语言受支持就用预览打开,否则用 VS Code 默认的打开方式(见 openingFor)。 + * 给了 spot(条目的符号在哪)就跳过去:预览滚到那一块、闪一下,普通编辑器选中那个名字。 + */ +async function openFile(uri: Uri, registry: CodeReaderRegistry, previews: PreviewEditorProvider, spot?: TargetSpot): Promise { + const opening = openingFor(await languageOf(uri), languageId => registry.languageFor(languageId) !== undefined, spot) + if (opening.kind === 'default') { + await commands.executeCommand('vscode.open', uri, opening.selection && { selection: opening.selection }) + return + } + // 和 vscode.open 一样按「预览标签」打开(用户关了 workbench.editor.enablePreview 时 VS Code 自会固定) + const open = (): Thenable => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, { preview: true }) + if (opening.reveal) + await previews.openAt(uri, opening.reveal, open) else - await commands.executeCommand('vscode.open', uri) + await open() } /** - * 接入代码阅读:注册表、侧栏的几栏(主栏的文件树可以按审查工单只看 PR 改过的文件,常驻的「未提交的更改」,每个收藏目录一栏; + * 接入代码阅读:注册表、侧栏的几栏(主栏的文件树可以按审查工单只看 PR 改过的文件,常驻的「未提交的更改」,每个收藏目录、每个「了解一块代码」找的功能各一栏; * 行上有问路径、复制、在系统文件管理器里显示、文件注释、收藏目录)、预览编辑器(含问 AI,按文件所在位置标差异)、编辑器右上角的「预览 / 编辑」切换。 * 内置的语言插件、翻译插件、库规则和命令规则与其他扩展一样通过返回的 API 注册。 */ @@ -104,11 +118,31 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { const locator = new TreeLocator(files, view) const review = new ReviewMode(context, FILES_VIEW, view, files) const uncommitted = new UncommittedView(context, OPEN_COMMAND, decorator) - const favorites = new FavoriteViews(context.workspaceState, OPEN_COMMAND, decorator) + // 收藏目录、「了解一块代码」共用一池栏位 + const slots = new SlotPool() + const favorites = new FavoriteViews(context.workspaceState, slots, OPEN_COMMAND, decorator) + const features = new FeatureViews(context.workspaceState, slots, qa, item => locateItem(item, { adapterFor: languageId => registry.languageFor(languageId), runtime })) const heads = new HeadBases() // 预览对比什么按文件所在的位置定:审查工作副本里的对比 PR 的目标分支,工作区仓库里有改动的对比上一次提交 const diffBases = locatedDiffBases([review, heads]) - const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, namesIn, (index, key) => indexes.find(index, key), diffBases, entries, uri => locator.reveal(uri)) + const previews = new PreviewEditorProvider( + context.extensionUri, + registry, + runtime, + new BlockFilter(context.workspaceState), + resolve, + qa, + namesIn, + (index, key) => indexes.find(index, key), + diffBases, + entries, + uri => locator.reveal(uri), + (subject, request) => features.ask(subject, request), + ) + const openChat = async (page: Uri | undefined): Promise => { + if (page) + await previews.openAsking(page) + } context.subscriptions.push( qa, @@ -139,6 +173,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { review, uncommitted, favorites, + features, heads, // 主栏审查时刷新是重读 PR 改过的文件,平时是重读工作区文件树 commands.registerCommand('superpowers.codeReader.refresh', () => review.reviewing ? review.reload() : files.refresh()), @@ -151,13 +186,23 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { commands.registerCommand('superpowers.codeReader.showUncommittedTests', () => uncommitted.setTestsHidden(false)), // 「更改」组文件行上的按钮传来的是这一行的节点 commands.registerCommand('superpowers.codeReader.stage', (node?: FileNode) => uncommitted.stage(node)), - // 目录行上的按钮传来的是这一行的节点;收藏栏标题栏上的按钮不带是哪一栏,所以每个栏位各一条命令 + // 目录行上的按钮传来的是这一行的节点;栏标题栏上的按钮不带是哪一栏,所以每个栏位各一套命令 commands.registerCommand('superpowers.codeReader.addFavorite', (node?: FileNode) => favorites.add(node)), - ...FAVORITE_SLOTS.flatMap(slot => [ + commands.registerCommand('superpowers.codeReader.findFeature', async () => openChat(await features.find())), + ...SLOTS.flatMap(slot => [ commands.registerCommand(favoriteCommand('rename', slot), () => favorites.rename(slot)), commands.registerCommand(favoriteCommand('remove', slot), () => favorites.remove(slot)), + commands.registerCommand(featureCommand('openChat', slot), () => openChat(features.chatPage(slot))), + commands.registerCommand(featureCommand('rename', slot), () => features.rename(slot)), + commands.registerCommand(featureCommand('remove', slot), () => features.remove(slot)), ]), - commands.registerCommand(OPEN_COMMAND, (uri: Uri) => openFile(uri, registry)), + commands.registerCommand(OPEN_COMMAND, (uri: Uri) => openFile(uri, registry, previews)), + // 「了解一块代码」栏里的条目行传来的是这一行:按眼下的代码找到它再打开 + commands.registerCommand(OPEN_FEATURE_ITEM, async (row?: FeatureRow) => { + const found = await features.target(row) + if (found) + await openFile(found.uri, registry, previews, found.spot) + }), // 标题栏按钮传来的是 (文件 Uri, { groupId }),原样转给 VS Code 定位是哪一组的哪个标签 commands.registerCommand('superpowers.codeReader.showPreview', (...args: unknown[]) => showPreview(previews, args)), commands.registerCommand('superpowers.codeReader.showSource', (...args: unknown[]) => showSource(previews, args)), diff --git a/vscode/src/codeReader/qa/answerStream.test.ts b/vscode/src/codeReader/qa/answerStream.test.ts index 1340427..94db1f6 100644 --- a/vscode/src/codeReader/qa/answerStream.test.ts +++ b/vscode/src/codeReader/qa/answerStream.test.ts @@ -76,6 +76,19 @@ describe('stream-json → 问答的进展', () => { .toEqual({ kind: 'result', text: '答案', sessionId: SESSION }) }) + it('要了结构化结果(--json-schema):交结果的那次工具调用是「整理结果」,result 行带上交回的结构;result 里那份 JSON 不当回答的字', () => { + const output = { groups: [{ name: '页面', items: [] }] } + expect(run([ + line({ type: 'assistant', message: { id: 'm', content: [{ type: 'tool_use', id: 't', name: 'StructuredOutput', input: output }] }, parent_tool_use_id: null }), + line({ type: 'result', subtype: 'success', is_error: false, result: JSON.stringify(output), structured_output: output, session_id: SESSION }), + ])).toEqual([ + { kind: 'activity', label: '整理结果' }, + { kind: 'result', text: '', sessionId: SESSION, output }, + ]) + expect(run([delta('找到了。'), line({ type: 'result', is_error: false, result: '{}', structured_output: {}, session_id: SESSION })]).at(-1)) + .toEqual({ kind: 'result', text: '找到了。', sessionId: SESSION, output: {} }) + }) + it('claude 自己报的失败:取它说的原因', () => { expect(run([line({ type: 'result', subtype: 'success', is_error: true, result: 'Invalid API key · Please run /login' })])) .toEqual([{ kind: 'error', message: 'Invalid API key · Please run /login' }]) diff --git a/vscode/src/codeReader/qa/answerStream.ts b/vscode/src/codeReader/qa/answerStream.ts index 33a5a4d..1a6e74f 100644 --- a/vscode/src/codeReader/qa/answerStream.ts +++ b/vscode/src/codeReader/qa/answerStream.ts @@ -14,7 +14,8 @@ export type AnswerEvent | { kind: 'text', text: string } /** 正在做的事:「读取 routes.py」「搜索 get_user」 */ | { kind: 'activity', label: string } - | { kind: 'result', text: string, sessionId: string } + /** 答完了;要了结构化结果(--json-schema)时 output 是交回的结构(result 行的 structured_output),没要、没交时没有 */ + | { kind: 'result', text: string, sessionId: string, output?: unknown } /** Claude Code 自己报的失败(result 行 is_error),进程多半也会以非零退出 */ | { kind: 'error', message: string } @@ -85,7 +86,14 @@ export function createAnswerParser(): (line: string) => AnswerEvent[] { const errors = Array.isArray(line.errors) ? line.errors.filter(item => typeof item === 'string').join(';') : '' return [{ kind: 'error', message: str(line.result).trim() || errors || str(line.subtype) || '没说原因' }] } - return [{ kind: 'result', text: str(line.result).trim() || text, sessionId: str(line.session_id) || sessionId }] + // 要了结构化结果时 result 是那份结构的 JSON,不是说的话:回答的字取眼下这条回复 + const output = line.structured_output + return [{ + kind: 'result', + text: output === undefined ? str(line.result).trim() || text : text, + sessionId: str(line.session_id) || sessionId, + ...(output !== undefined && { output }), + }] } return (raw) => { diff --git a/vscode/src/codeReader/qa/asks.test.ts b/vscode/src/codeReader/qa/asks.test.ts index 97dcf80..c1a8f97 100644 --- a/vscode/src/codeReader/qa/asks.test.ts +++ b/vscode/src/codeReader/qa/asks.test.ts @@ -81,6 +81,28 @@ describe('正在答的问题', () => { expect(events.ends[0][1]).toEqual({ status: 'failed', error: 'Claude Code 没答成:Claude 退出码非零 (1): 网络断了' }) }) + it('答成了、listener 却不收(交回的结构不合格):这一轮当出错留着,同报错一样能再画出来,这块能再问', async () => { + const { runner, runs } = fakeRunner() + const asks = new QaAsks(runner) + let ended: () => void = () => {} + const done = new Promise((resolve) => { + ended = resolve + }) + asks.start(start('1'), { + update: () => {}, + end: async () => { + ended() + return { status: 'failed', error: '交回的结果不对:没有环节列表。' } + }, + }) + runs[0].answer({ text: '', sessionId: 's' }) + await done + await Promise.resolve() + expect(asks.currentFor('file:///a.py')).toEqual([{ askId: '1', anchor: '1', question: '问 1', text: '', status: 'failed', error: '交回的结果不对:没有环节列表。' }]) + expect(asks.start(start('2', '1'), listener())).toBeUndefined() + expect(asks.currentFor('file:///a.py').map(round => round.askId)).toEqual(['2']) + }) + it('同一块同时只答一个;别的文件里同样记号的块不算同一块', () => { const { runner } = fakeRunner() const asks = new QaAsks(runner) diff --git a/vscode/src/codeReader/qa/asks.ts b/vscode/src/codeReader/qa/asks.ts index f2d340f..8263993 100644 --- a/vscode/src/codeReader/qa/asks.ts +++ b/vscode/src/codeReader/qa/asks.ts @@ -48,7 +48,14 @@ export interface AskStart> { export interface AskListener { update: (ask: RunningAsk) => void - end: (ask: RunningAsk, end: AskEnd) => void | Promise + /** 结束了怎么存、怎么告诉界面。答成了、交回的却不能用(结构不合格)时返回没答成的原因:这一轮当出错留着,同 Claude Code 报错 */ + end: (ask: RunningAsk, end: AskEnd) => void | Refusal | Promise +} + +/** listener 不收这次答成的:给人看的原因 */ +export interface Refusal { + status: 'failed' + error: string } /** 一种对话怎么跑:同时最多几轮(每轮一个 claude 进程),满了是不开始(refuse,回给人看的话)还是排在后面(queue),以及给人看的话。 */ @@ -127,7 +134,11 @@ export class QaAsks { expect(args).not.toContain('--settings') expect(args.slice(args.indexOf('--resume'), args.indexOf('--resume') + 3)).toEqual(['--resume', 'session-1', '-p']) }) + + it('要结构化结果时带 --json-schema;别的工作区文件夹用 --add-dir 加进来读;照样只读、能接着会话', () => { + const args = askArgs({ resume: 'session-1', schema: '{"type":"object"}', addDirs: ['/ws/server', '/ws/docs'] }) + expect(args.slice(0, 4)).toEqual(['--tools', 'Read,Grep,Glob', '--allowedTools', 'Read,Grep,Glob']) + expect(args.slice(args.indexOf('--permission-mode'), args.indexOf('--permission-mode') + 2)).toEqual(['--permission-mode', 'dontAsk']) + expect(args).toContain('--strict-mcp-config') + expect(args.slice(args.indexOf('--add-dir'), args.indexOf('--add-dir') + 3)).toEqual(['--add-dir', '/ws/server', '/ws/docs']) + expect(args.slice(args.indexOf('--resume'), args.indexOf('--resume') + 3)).toEqual(['--resume', 'session-1', '-p']) + expect(args.slice(-2)).toEqual(['--json-schema', '{"type":"object"}']) + expect(args.join(' ')).not.toMatch(/dangerously|bypassPermissions|Edit|Write|Bash/) + expect(askArgs({ addDirs: [] })).not.toContain('--add-dir') + }) }) describe('问题连同图片从 stdin 发', () => { @@ -122,6 +134,14 @@ describe('问一次', () => { expect(sent(calls[0]).text).toBe('完整的问题') }) + it('要结构化结果:参数带上 schema 和加进来的目录,交回的结构化结果一并返回;只交了结构化结果、没说话也算答了', async () => { + const output = { groups: [] } + const { deps, calls } = fakeClaude([{ lines: [init('s1'), line({ type: 'result', subtype: 'success', is_error: false, result: '', structured_output: output, session_id: 's1' })] }]) + expect(await askClaude(request({ schema: '{"type":"object"}', addDirs: ['/ws/b'] }), deps)).toEqual({ text: '', sessionId: 's1', output }) + expect(calls[0].args.slice(-2)).toEqual(['--json-schema', '{"type":"object"}']) + expect(calls[0].args).toContain('/ws/b') + }) + it('选的是「默认」(没给 profile):不带 --settings', async () => { const { deps, calls } = fakeClaude([{ lines: [result('答案', 's1')] }]) await askClaude(request(), deps) diff --git a/vscode/src/codeReader/qa/claudeAsk.ts b/vscode/src/codeReader/qa/claudeAsk.ts index b47148e..4ab279e 100644 --- a/vscode/src/codeReader/qa/claudeAsk.ts +++ b/vscode/src/codeReader/qa/claudeAsk.ts @@ -28,10 +28,12 @@ const PROTOTYPE_DIR = 'docs/prototype/*/openpencil' * 一次提问的命令行参数;问题连同贴的图片从 stdin 按 stream-json 发(见 askClaude),没贴图也一样。只读靠三层:可用的内置工具只剩读、搜(`--tools`);这几个不用再批(`--allowedTools`), * 别的一律拒绝、不等人批(`--permission-mode dontAsk`,盖过 profile 里的 bypassPermissions); * 不加载任何 MCP 服务(`--strict-mcp-config` 又不给 `--mcp-config`),免得 MCP 工具能改东西。 + * addDirs 是工作目录以外还能读的目录(`--add-dir`);给了 schema 就按它交结构化结果(`--json-schema`)。 */ -export function askArgs(options: { profilePath?: string, resume?: string } = {}): string[] { +export function askArgs(options: { profilePath?: string, resume?: string, schema?: string, addDirs?: readonly string[] } = {}): string[] { + const { profilePath, resume, schema, addDirs = [] } = options return [ - ...(options.profilePath ? ['--settings', options.profilePath] : []), + ...(profilePath ? ['--settings', profilePath] : []), '--tools', READ_ONLY_TOOLS, '--allowedTools', @@ -39,7 +41,8 @@ export function askArgs(options: { profilePath?: string, resume?: string } = {}) '--permission-mode', 'dontAsk', '--strict-mcp-config', - ...(options.resume ? ['--resume', options.resume] : []), + ...(addDirs.length > 0 ? ['--add-dir', ...addDirs] : []), + ...(resume ? ['--resume', resume] : []), '-p', '--input-format', 'stream-json', @@ -47,6 +50,7 @@ export function askArgs(options: { profilePath?: string, resume?: string } = {}) 'stream-json', '--verbose', '--include-partial-messages', + ...(schema ? ['--json-schema', schema] : []), ] } @@ -100,6 +104,10 @@ export interface AskRun { /** 追问:接着 sessionId 那次会话问 prompt;会话找不到了(过期、被清理)就按完整问题重新开一次 */ followUp?: { sessionId: string, prompt: string } cwd: string + /** cwd 以外还要读的目录(别的工作区文件夹) */ + addDirs?: string[] + /** 按这个 JSON Schema 交结构化结果(`--json-schema`);只要一段话回答时没有 */ + schema?: string signal: AbortSignal /** 答的过程中:会话 id、回答长出来的字、正在读什么 */ onEvent: (event: Exclude) => void @@ -108,6 +116,8 @@ export interface AskRun { export interface Answer { text: string sessionId: string + /** 要了结构化结果时交回的结构;没要、没交时没有 */ + output?: unknown } export type AskRunner = (run: AskRun) => Promise @@ -122,14 +132,17 @@ function missingSession(err: unknown): boolean { return err instanceof ClaudeError && !(err instanceof ClaudeCancelledError) && /no conversation found/i.test(`${err.message}\n${err.stderr}`) } -/** 问一次,答完返回最终回答;失败、超时、被叫停时抛 ClaudeError(及其子类),用 askFailure 换成给人看的话。 */ +/** + * 问一次,答完返回最终回答(要了结构化结果的连同交回的结构;只交了结构、没说话也算答了); + * 失败、超时、被叫停时抛 ClaudeError(及其子类),用 askFailure 换成给人看的话。 + */ export async function askClaude(run: AskRun, deps: AskDeps = DEFAULT_DEPS): Promise { const once = async (prompt: string, resume?: string): Promise => { const parse = createAnswerParser() let answer: Answer | undefined let failure: string | undefined await deps.runLines({ - args: askArgs({ profilePath: run.profilePath, resume }), + args: askArgs({ profilePath: run.profilePath, resume, schema: run.schema, addDirs: run.addDirs }), input: buildStreamJsonLine(prompt, run.images), cwd: run.cwd, signal: run.signal, @@ -137,7 +150,7 @@ export async function askClaude(run: AskRun, deps: AskDeps = DEFAULT_DEPS): Prom onLine: (line) => { for (const event of parse(line)) { if (event.kind === 'result') - answer = { text: event.text, sessionId: event.sessionId } + answer = { text: event.text, sessionId: event.sessionId, ...(event.output !== undefined && { output: event.output }) } else if (event.kind === 'error') failure = event.message else @@ -147,7 +160,7 @@ export async function askClaude(run: AskRun, deps: AskDeps = DEFAULT_DEPS): Prom }) if (failure !== undefined) throw new ClaudeError(failure, '') - if (!answer?.text) + if (!answer || (!answer.text && answer.output === undefined)) throw new ClaudeError('Claude Code 结束了,但没给出回答', '') return answer } diff --git a/vscode/src/codeReader/qa/codeQa.test.ts b/vscode/src/codeReader/qa/codeQa.test.ts index ded96c3..9ac07cc 100644 --- a/vscode/src/codeReader/qa/codeQa.test.ts +++ b/vscode/src/codeReader/qa/codeQa.test.ts @@ -1,6 +1,6 @@ import type { ExtensionContext, Memento, Uri } from 'vscode' import type { AskRunner, CommentAnswer, CommentAsk } from './claudeAsk' -import type { RewriteEvent, RewriteRequest } from './codeQa' +import type { AskEvent, RewriteEvent, RewriteRequest } from './codeQa' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' @@ -45,6 +45,7 @@ vi.mock('../folderStore', () => ({ })) vi.mock('../editor/pathPreview', () => ({ pathQaLocation: () => ({ folder: { uri: { fsPath: '/workspace', toString: () => 'file:///workspace' } }, key: 'a.ts' }), + topQaLocation: () => ({ folder: { uri: { fsPath: '/workspace', toString: () => 'file:///workspace' } }, key: 'file:///workspace\na.ts' }), })) vi.mock('./pathAskDescribe', () => ({ describePath: async () => ({ kind: 'file', relativePath: 'a.ts', head: { unavailable: '测试' } }) })) vi.mock('../../cc/profiles', () => ({ @@ -262,3 +263,79 @@ describe('重写注释的记录', () => { expect(writeBack).toHaveBeenCalledWith('第二段。') }) }) + +describe('顶部聊天要结构化结果的一轮(了解一块代码)', () => { + /** 记在内存里的 workspaceState:顶部问答记在这里 */ + function memento(): Pick { + const values = new Map() + return { get: (key: string) => values.get(key) as T, update: async (key: string, value: unknown) => void values.set(key, value) } + } + + function setup() { + const runner = vi.fn() + const qa = new CodeQa(context(), memento(), runner) + services.push(qa) + return { qa, runner } + } + + /** 等这一轮结束的事件 */ + function ended(qa: CodeQa, askId: string): Promise> { + return new Promise((resolve) => { + qa.onDidAnswer((event) => { + if (event.kind === 'end' && event.ask.askId === askId) + resolve(event) + }) + }) + } + + const run = { prompt: '完整的话', followUp: '接着说的话', schema: '{"type":"object"}', addDirs: ['/ws/server'] } + const historyOf = (qa: CodeQa) => qa.view(uri, file => file.fsPath, 'feature').path + + it('带上 schema 和加进来的目录;答完交给 settle,记下它给的回答;下一轮接着最近一次的会话说', async () => { + const { qa, runner } = setup() + const output = { groups: [] } + runner.mockResolvedValueOnce({ text: '找到了。', sessionId: 's1', output }) + const settle = vi.fn(async () => ({ text: '整理好的回答' })) + const first = ended(qa, '1') + expect(await qa.askTop(uri, { askId: '1', question: '用户登录', images: [] }, run, settle)).toBeUndefined() + expect(runner.mock.calls[0][0]).toMatchObject({ prompt: '完整的话', schema: '{"type":"object"}', addDirs: ['/ws/server'], cwd: '/workspace' }) + expect(runner.mock.calls[0][0]).not.toHaveProperty('followUp') + expect((await first).end).toEqual({ status: 'done', answer: { text: '整理好的回答', sessionId: 's1', output } }) + expect(settle).toHaveBeenCalledWith({ text: '找到了。', sessionId: 's1', output }) + expect(historyOf(qa)).toEqual([expect.objectContaining({ question: '用户登录', answer: '整理好的回答' })]) + + runner.mockResolvedValueOnce({ text: '', sessionId: 's1', output }) + const second = ended(qa, '2') + await qa.askTop(uri, { askId: '2', question: '还漏了续费失败重试', images: [] }, run, settle) + await second + expect(runner.mock.calls[1][0].followUp).toEqual({ sessionId: 's1', prompt: '接着说的话' }) + expect(historyOf(qa).map(entry => entry.question)).toEqual(['用户登录', '还漏了续费失败重试']) + }) + + it('settle 说结构不合格:这一轮算失败、说清原因,不记', async () => { + const { qa, runner } = setup() + runner.mockResolvedValueOnce({ text: '只说了话', sessionId: 's1' }) + const failed = ended(qa, '1') + await qa.askTop(uri, { askId: '1', question: '用户登录', images: [] }, run, async () => ({ error: 'AI 没按要求的结构交回结果(只回了一段话)。' })) + expect((await failed).end).toEqual({ status: 'failed', error: 'AI 没按要求的结构交回结果(只回了一段话)。' }) + expect(historyOf(qa)).toEqual([]) + // 没答成的这一轮留着,页面重新加载后照样画出来 + await vi.waitFor(() => expect(qa.view(uri, file => file.fsPath, 'feature').current).toMatchObject([{ askId: '1', status: 'failed', error: 'AI 没按要求的结构交回结果(只回了一段话)。' }])) + }) + + it('删掉这一页的全部问答:记下的没了,正在问的叫停', async () => { + const { qa, runner } = setup() + runner.mockResolvedValueOnce({ text: '', sessionId: 's1', output: {} }) + const first = ended(qa, '1') + await qa.askTop(uri, { askId: '1', question: '用户登录', images: [] }, run, async () => ({ text: '回答' })) + await first + runner.mockImplementationOnce(ask => new Promise((_resolve, reject) => { + ask.signal.addEventListener('abort', () => reject(new ClaudeCancelledError(''))) + })) + const stopped = ended(qa, '2') + await qa.askTop(uri, { askId: '2', question: '接着找', images: [] }, run, async () => ({ text: '回答' })) + await qa.deleteTop(uri) + expect((await stopped).end).toEqual({ status: 'stopped' }) + expect(historyOf(qa)).toEqual([]) + }) +}) diff --git a/vscode/src/codeReader/qa/codeQa.ts b/vscode/src/codeReader/qa/codeQa.ts index 3c45c0e..dee67b5 100644 --- a/vscode/src/codeReader/qa/codeQa.ts +++ b/vscode/src/codeReader/qa/codeQa.ts @@ -2,22 +2,22 @@ import type { Event, ExtensionContext, Memento } from 'vscode' import type { ClaudeProfile } from '../../cc/profiles' import type { ClaudeImage } from '../../cc/spawnClaude' import type { QaEntryView, QaMessage } from '../editor/messages' -import type { PathSubject } from '../editor/pathPreview' +import type { PathSubject, TopSubject } from '../editor/pathPreview' import type { PathLocation } from '../folderStore' import type { AskEnd, RunningAsk } from './asks' -import type { AskRunner, CommentAnswer, CommentAsk } from './claudeAsk' +import type { Answer, AskRunner, CommentAnswer, CommentAsk } from './claudeAsk' import type { ChatEntry, QaEntry, RewriteEntry } from './qaMap' import { randomUUID } from 'node:crypto' import { Disposable, EventEmitter, Uri, window } from 'vscode' import { getSettings, onDidChangeSettings } from '../../settings/store' -import { pathQaLocation } from '../editor/pathPreview' +import { pathQaLocation, topQaLocation } from '../editor/pathPreview' import { FolderStore, locate } from '../folderStore' import { AskProfiles } from './askProfiles' import { QaAsks, REWRITE_POLICY } from './asks' import { askClaude, askCommentBody } from './claudeAsk' import { describePath } from './pathAskDescribe' import { pathAskFollowUp, pathAskPrompt } from './pathAskPrompt' -import { parsePathQa, PATH_ASK_ANCHOR, PATH_QA_STATE_KEY, pathQaEntries, pathQaKey, withoutPathQa, withPathQa } from './pathQa' +import { parsePathQa, PATH_ASK_ANCHOR, PATH_QA_STATE_KEY, pathQaEntries, withoutPathQa, withoutPathQaKey, withPathQa } from './pathQa' import { isQaImageType, QA_IMAGE_DIR, removeQaImages, writeQaImages } from './qaImages' import { latestSession, QA_FILE, REWRITE_FILE, withEntry, withoutEntry } from './qaMap' @@ -48,6 +48,21 @@ export interface AskRequest { images: ClaudeImage[] } +/** 在页面顶部问的一轮:question 是记在这一轮上的话,images 是贴进输入框的图片(答成了存进 `.spx/code-qa/`)。 */ +export interface TopRequest { + askId: string + question: string + images: ClaudeImage[] +} + +/** 顶部这一轮怎么问 claude:开新会话时发的完整的话、接着上次的会话说时发的话,要结构化结果时的 JSON Schema,工作目录以外还要读的目录。 */ +export interface TopRun { + prompt: string + followUp: string + schema?: string + addDirs?: string[] +} + /** 重写一段注释:question 是记在这一轮上的话(点按钮是技能命令,之后是用户在输入框里说的)。 */ export interface RewriteRequest { askId: string @@ -92,7 +107,7 @@ async function chatEntry(rootPath: string, question: string, sessionId: string, /** * 块下面的对话:问 AI 和重写注释,各自记在各工作区文件夹的一份文件里(`.spx/code-qa.json`、`.spx/comment-rewrites.json`; * 改名、删文件、外部改动的跟随同文件注释,见 FolderStore),挂在块的位置记号上,各自接着自己最近一次的会话。 - * 预览顶部问的是整个文件或文件夹,按路径记在这个窗口的 workspaceState 里,不进那两份文件。 + * 预览顶部问的是整个文件、文件夹或「了解一块代码」找的一个功能,记在这个窗口的 workspaceState 里(见 topQaLocation),不进那两份文件。 */ export class CodeQa implements Disposable { private readonly store = new FolderStore(QA_FILE) @@ -140,7 +155,7 @@ export class CodeQa implements Disposable { /** uri 这份文件或文件夹的问答带的图片放在哪个目录(预览要能读到它才画得出缩略图);不在工作区文件夹里时没有。 */ imageRoot(uri: Uri): Uri | undefined { - const at = pathQaLocation(uri) + const at = topQaLocation(uri) return at && Uri.joinPath(at.folder.uri, ...QA_IMAGE_DIR.split('/')) } @@ -149,18 +164,18 @@ export class CodeQa implements Disposable { * 不在工作区文件夹里时没地方存,问 AI、重写注释都不可用。 * 带的图片换成预览里能用的地址(imageUrl,即 webview.asWebviewUri)。重写记录的回答是交回的正文,没交回时是空的。 */ - view(uri: Uri, imageUrl: (file: Uri) => string, subject: PathSubject): QaMessage { + view(uri: Uri, imageUrl: (file: Uri) => string, subject: TopSubject): QaMessage { const at = locate(uri) - const pathAt = pathQaLocation(uri) + const top = topQaLocation(uri) const threads = at ? this.store.get(at.folder, at.key) ?? [] : [] const rewrites = at ? this.rewriteStore.get(at.folder, at.key) ?? [] : [] - const pathEntries = pathAt ? pathQaEntries(this.pathMap(), pathQaKey(pathAt.folder.uri.toString(), pathAt.key)) : [] + const pathEntries = top ? pathQaEntries(this.pathMap(), top.key) : [] const root = this.imageRoot(uri) const show = (entry: QaEntry): QaEntryView => ({ ...viewEntry(root, imageUrl, entry), answer: entry.answer }) const showRewrite = (entry: RewriteEntry): QaEntryView => ({ ...viewEntry(root, imageUrl, entry), answer: entry.body ?? '', ...(entry.error && { error: entry.error }) }) return { type: 'qa', - available: pathAt !== undefined, + available: top !== undefined, subject, threads: threads.map(({ anchor, entries }) => ({ anchor, entries: entries.map(show) })), path: pathEntries.map(show), @@ -296,19 +311,28 @@ export class CodeQa implements Disposable { } } - /** - * 问这一整个文件或文件夹。材料是路径(文件再加开头若干行,文件夹再加这一层的名字), - * 答成了记在 workspaceState 里,不进按块存的那份文件。没答成不记。 - * 问过的接着最近一次的会话;进行中再问、问不了时返回给人看的原因。 - */ - async askPath(uri: Uri, kind: PathSubject, request: { askId: string, question: string, images: ClaudeImage[] }): Promise { + /** 问这一整个文件或文件夹:材料是路径(文件再加开头若干行,文件夹再加这一层的名字),怎么接着会话、怎么记同 askTop。 */ + async askPath(uri: Uri, kind: PathSubject, request: TopRequest): Promise { const at = pathQaLocation(uri) if (!at) return '这个路径不在工作区文件夹里,问答没地方存。' + const described = await describePath({ uri, folder: at.folder, isDirectory: kind === 'folder', relativePath: at.key }) + const run = { prompt: pathAskPrompt(described, request.question), followUp: pathAskFollowUp(described.relativePath, kind, request.question) } + return this.askTop(uri, request, run, async answer => ({ text: answer.text })) + } + + /** + * 在 subject 这一页的顶部问一轮(文件、文件夹、「了解一块代码」的功能,记在哪见 topQaLocation);问不了、进行中再问时返回给人看的原因。 + * 问过的接着最近一次的会话说 run.followUp,没问过、会话找不到了发 run.prompt;要结构化结果时带上 run.schema,别的目录要读时带上 run.addDirs。 + * 答完交给 settle:它给出这一轮记下的回答,或说交回的不合格(这一轮算没答成)。答成了记在 workspaceState 里,带的图片写进 `.spx/code-qa/`;没答成不记。 + */ + async askTop(subject: Uri, request: TopRequest, run: TopRun, settle: (answer: Answer) => Promise<{ text: string } | { error: string }>): Promise { + const top = topQaLocation(subject) + if (!top) + return '不在工作区文件夹里,问答没地方存。' const { askId, question, images } = request if (!images.every(image => isQaImageType(image.mediaType))) return '只能带 PNG、JPEG、GIF、WebP 图片。' - const described = await describePath({ uri, folder: at.folder, isDirectory: kind === 'folder', relativePath: at.key }) let profile: ClaudeProfile | undefined try { profile = await this.profiles.current() @@ -316,22 +340,21 @@ export class CodeQa implements Disposable { catch (err) { return err instanceof Error ? err.message : String(err) } - const owner = uri.toString() - const key = pathQaKey(at.folder.uri.toString(), at.key) - const sessionId = pathQaEntries(this.pathMap(), key).at(-1)?.sessionId - const prompt = pathAskPrompt(described, question) - const followUp = pathAskFollowUp(described.relativePath, kind, question) + const owner = subject.toString() + const sessionId = pathQaEntries(this.pathMap(), top.key).at(-1)?.sessionId return this.asks.start({ owner, askId, anchor: PATH_ASK_ANCHOR, question, run: { - prompt, + prompt: run.prompt, images, - ...(sessionId && { followUp: { sessionId, prompt: followUp } }), + ...(sessionId && { followUp: { sessionId, prompt: run.followUp } }), ...(profile && { profilePath: profile.path }), - cwd: at.folder.uri.fsPath, + cwd: top.folder.uri.fsPath, + ...(run.schema !== undefined && { schema: run.schema }), + ...(run.addDirs && run.addDirs.length > 0 && { addDirs: run.addDirs }), }, }, { update: ask => this.answered.fire({ owner, kind: 'update', ask }), @@ -340,10 +363,16 @@ export class CodeQa implements Disposable { this.answered.fire({ owner, kind: 'end', ask, end }) return } + const settled = await settle(end.answer) + if ('error' in settled) { + const refusal = { status: 'failed', error: settled.error } as const + this.answered.fire({ owner, kind: 'end', ask, end: refusal }) + return refusal + } let entry: QaEntry try { - const { id, question: asked, ...rest } = await chatEntry(at.folder.uri.fsPath, question, end.answer.sessionId, profile, images) - entry = { id, question: asked, answer: end.answer.text, ...rest } + const { id, question: asked, ...rest } = await chatEntry(top.folder.uri.fsPath, question, end.answer.sessionId, profile, images) + entry = { id, question: asked, answer: settled.text, ...rest } } catch (err) { const error = `回答没存上:图片写不进 ${QA_IMAGE_DIR}(${err instanceof Error ? err.message : String(err)})` @@ -351,10 +380,10 @@ export class CodeQa implements Disposable { return } await this.enqueuePath(async () => { - await this.paths.update(PATH_QA_STATE_KEY, withPathQa(this.pathMap(), key, entry)) + await this.paths.update(PATH_QA_STATE_KEY, withPathQa(this.pathMap(), top.key, entry)) }) - this.answered.fire({ owner, kind: 'end', ask, end, entryId: entry.id }) - this.pathChanged.fire([uri]) + this.answered.fire({ owner, kind: 'end', ask, end: { status: 'done', answer: { ...end.answer, text: settled.text } }, entryId: entry.id }) + this.pathChanged.fire([subject]) }, }) } @@ -374,6 +403,25 @@ export class CodeQa implements Disposable { await this.store.update(at.folder, map => withoutEntry(map, at.key, anchor, entryId)) } + /** 删掉 subject 这一页顶部的全部问答和它们带的图片,正在问的叫停(「了解一块代码」删掉一个功能时)。 */ + async deleteTop(subject: Uri): Promise { + const owner = subject.toString() + for (const round of this.asks.currentFor(owner)) + this.asks.stop(round.askId) + this.asks.forget(owner, PATH_ASK_ANCHOR) + const at = topQaLocation(subject) + if (!at) + return + let images: string[] = [] + await this.enqueuePath(async () => { + const map = this.pathMap() + images = pathQaEntries(map, at.key).flatMap(entry => entry.images ?? []) + await this.paths.update(PATH_QA_STATE_KEY, withoutPathQaKey(map, at.key)) + }) + await removeQaImages(at.folder.uri.fsPath, images) + this.pathChanged.fire([subject]) + } + dispose(): void { this.settingsSubscription.dispose() this.asks.stopAll() @@ -400,15 +448,14 @@ export class CodeQa implements Disposable { /** 删顶部的一次问答和它带的图片,然后告诉开着的预览。 */ private async deletePath(uri: Uri, entryId: string): Promise { - const at = pathQaLocation(uri) + const at = topQaLocation(uri) if (!at) return - const key = pathQaKey(at.folder.uri.toString(), at.key) let images: string[] = [] await this.enqueuePath(async () => { const map = this.pathMap() - images = pathQaEntries(map, key).find(entry => entry.id === entryId)?.images ?? [] - await this.paths.update(PATH_QA_STATE_KEY, withoutPathQa(map, key, entryId)) + images = pathQaEntries(map, at.key).find(entry => entry.id === entryId)?.images ?? [] + await this.paths.update(PATH_QA_STATE_KEY, withoutPathQa(map, at.key, entryId)) }) await removeQaImages(at.folder.uri.fsPath, images) this.pathChanged.fire([uri]) diff --git a/vscode/src/codeReader/qa/pathQa.ts b/vscode/src/codeReader/qa/pathQa.ts index 95adeab..71779da 100644 --- a/vscode/src/codeReader/qa/pathQa.ts +++ b/vscode/src/codeReader/qa/pathQa.ts @@ -1,6 +1,6 @@ /** - * 预览顶部的问答:按「工作区文件夹 + 相对路径」记在这个窗口的 workspaceState 里。 - * 文件和文件夹各用各的路径;不写进按块存的 `.spx/code-qa.json`。 + * 预览顶部的问答:文件、文件夹按「工作区文件夹 + 相对路径」,「了解一块代码」的功能对话页按功能的 id,记在这个窗口的 workspaceState 里。 + * 不写进按块存的 `.spx/code-qa.json`。 */ import type { QaEntry } from './qaMap' @@ -32,6 +32,11 @@ export function pathQaKey(folderUri: string, relativePath: string): string { return `${folderUri}\n${relativePath}` } +/** 「了解一块代码」一个功能的对话记录的键。工作区文件夹的 Uri 带协议名(`file://`),和它撞不上。 */ +export function featureQaKey(id: string): string { + return `feature\n${id}` +} + /** 给人看的相对路径;工作区根写成 `.`。 */ export function displayRelativePath(relativePath: string): string { return relativePath === '' ? '.' : relativePath @@ -60,6 +65,15 @@ export function withoutPathQa(map: PathQaMap, key: string, entryId: string): Pat return { ...map, [key]: kept } } +/** 去掉这个键下的全部问答;没有这个键时原样返回。 */ +export function withoutPathQaKey(map: PathQaMap, key: string): PathQaMap { + if (!(key in map)) + return map + const next = { ...map } + delete next[key] + return next +} + /** 读 workspaceState 里存的表;认不出的丢掉,不让一条坏数据把整份弄丢。 */ export function parsePathQa(value: unknown): PathQaMap { if (!isRecord(value)) diff --git a/vscode/src/codeReader/slots/slotPool.ts b/vscode/src/codeReader/slots/slotPool.ts new file mode 100644 index 0000000..391ebe1 --- /dev/null +++ b/vscode/src/codeReader/slots/slotPool.ts @@ -0,0 +1,49 @@ +import type { SlotKind } from './slots' +import { commands, window } from 'vscode' +import { freeSlot, slotContext, slotViewId } from './slots' + +/** + * 收藏和功能共用的那些栏位眼下被谁占着。各自的记录(收藏的目录、找到的功能)各存各的,占哪一栏都记在这里: + * 新占时挑最小的空位(见 freeSlot),占上、放开时跟着设上下文键,那一栏和它标题栏上的按钮才显示、藏起。 + */ +export class SlotPool { + private readonly kinds = new Map() + + /** 眼下没被占的里面最小的栏位;都占满了没有。 */ + free(): number | undefined { + return freeSlot(this.kinds.keys()) + } + + /** 眼下被占着的全部栏位 */ + taken(): number[] { + return [...this.kinds.keys()] + } + + /** kind 占上第 slot 栏(开窗口时各自存着的,和新占的)。马上记下,同时再占的不会挑到同一栏。 */ + async occupy(slot: number, kind: SlotKind): Promise { + this.kinds.set(slot, kind) + await commands.executeCommand('setContext', slotContext(slot), kind) + } + + /** 放开第 slot 栏:这一栏藏起来,栏位给下一个用。 */ + async release(slot: number): Promise { + this.kinds.delete(slot) + await commands.executeCommand('setContext', slotContext(slot), false) + } +} + +/** 弹顶部的输入框问栏标题叫什么(原值整段选中);Esc 或点别处是不改。 */ +export async function askSlotName(title: string, value: string): Promise { + const name = await window.showInputBox({ + title, + value, + prompt: '栏标题上显示的名字', + validateInput: text => text.trim() ? undefined : '名字不能为空', + }) + return name?.trim() +} + +/** 亮出第 slot 栏(VS Code 给每个视图自带一条 `<视图 id>.focus` 命令)。 */ +export async function focusSlot(slot: number): Promise { + await commands.executeCommand(`${slotViewId(slot)}.focus`) +} diff --git a/vscode/src/codeReader/slots/slots.test.ts b/vscode/src/codeReader/slots/slots.test.ts new file mode 100644 index 0000000..534c787 --- /dev/null +++ b/vscode/src/codeReader/slots/slots.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { freeSlot, slotContext, SLOTS, slotTitle, slotViewId } from './slots' + +describe('新占栏位占哪个', () => { + it('没被占的里面最小的:中间空出来的先用', () => { + expect(freeSlot([])).toBe(1) + expect(freeSlot([1, 2])).toBe(3) + expect(freeSlot([1, 3, 4])).toBe(2) + expect(freeSlot([5])).toBe(1) + }) + + it(`${SLOTS.length} 个栏位都占满了:没有`, () => { + expect(SLOTS).toEqual(Array.from({ length: 30 }, (_, index) => index + 1)) + expect(freeSlot(SLOTS)).toBeUndefined() + expect(freeSlot(SLOTS.slice(0, -1))).toBe(SLOTS.length) + }) +}) + +describe('栏位对应的视图、上下文键、标题', () => { + it('第 n 个栏位有自己的视图 id 和记着被谁占着的上下文键', () => { + expect(slotViewId(3)).toBe('superpowers.codeReader.slot3') + expect(slotContext(3)).toBe('superpowers.codeReader.slotKind3') + }) + + it('标题前的记号分开收藏(文件夹)和功能(放大镜)', () => { + expect(slotTitle('favorite', '后端接口')).toBe('📁 后端接口') + expect(slotTitle('feature', '用户登录')).toBe('🔍 用户登录') + }) +}) diff --git a/vscode/src/codeReader/slots/slots.ts b/vscode/src/codeReader/slots/slots.ts new file mode 100644 index 0000000..65e37fd --- /dev/null +++ b/vscode/src/codeReader/slots/slots.ts @@ -0,0 +1,35 @@ +/** + * 代码阅读侧栏里收藏目录、「了解一块代码」找到的功能共用的栏位:VS Code 的视图要在 package.json 里预先声明, + * 所以声明了固定这么多个栏(superpowers.codeReader.slot1 … slot30),有收藏或功能占着的才显示。这里是栏位号 1、2、3… + */ +export const SLOTS: readonly number[] = Array.from({ length: 30 }, (_, index) => index + 1) + +/** 一个栏位被谁占着:favorite 是收藏的目录,feature 是「了解一块代码」找到的一个功能。 */ +export type SlotKind = 'favorite' | 'feature' + +/** 第 slot 个栏的视图 id */ +export function slotViewId(slot: number): string { + return `superpowers.codeReader.slot${slot}` +} + +/** 上下文键:第 slot 个栏位被哪种占着(值是 SlotKind),这一栏才显示、标题栏上挂这种的按钮(package.json 的 when 按它) */ +export function slotContext(slot: number): string { + return `superpowers.codeReader.slotKind${slot}` +} + +/** 栏标题前的记号:收藏是文件夹,功能是放大镜。视图标题只能是字,图标字体画不进去,所以用 emoji。 */ +const SLOT_MARKS: Record = { favorite: '📁', feature: '🔍' } + +/** 栏标题:记号加名字。 */ +export function slotTitle(kind: SlotKind, name: string): string { + return `${SLOT_MARKS[kind]} ${name}` +} + +/** + * 新占一个栏位占哪个:没被占的里面最小的,都占满了没有。taken 是收藏、功能眼下占着的全部栏位。 + * 一个栏位一直被占着、删掉才空出来,用户拖动排过的位置 VS Code 按栏位记着,所以不会错乱。 + */ +export function freeSlot(taken: Iterable): number | undefined { + const used = new Set(taken) + return SLOTS.find(slot => !used.has(slot)) +} diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index 75cbb11..7545558 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -6,7 +6,7 @@ import type { OccurrencePage } from './useOccurrences' import type { ViewState } from './viewState' import { TriangleAlert } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { AskThread } from './AskThread' +import { AskThread, FEATURE_THREAD } from './AskThread' import { BlockLevel } from './BlockTree' import { CallTreeDialog } from './CallTreeDialog' import { DefinitionPeekPopup } from './DefinitionPeekPopup' @@ -268,6 +268,7 @@ export function App(): ReactElement | null { asks={asksOf(qa.state, PATH_ASK_ANCHOR)} open hint={qa.state.subject === 'folder' ? '这个文件夹' : '这个文件'} + {...(qa.state.subject === 'feature' && { wording: FEATURE_THREAD })} autoFocus={false} focusToken={pathFocus} closable={false} @@ -294,7 +295,7 @@ export function App(): ReactElement | null { )} {doc.blocks.length === 0 - ? doc.error === undefined && qa.state.subject !== 'folder' &&
这个文件里没有代码。
+ ? doc.error === undefined && qa.state.subject === 'file' &&
这个文件里没有代码。
: doc.blocks.some(block => !filter.hidden.has(block.id)) ? :
所有块都被筛选隐藏了。
} diff --git a/vscode/webview-ui/src/codeReader/AskThread.tsx b/vscode/webview-ui/src/codeReader/AskThread.tsx index 8d01d5e..144de88 100644 --- a/vscode/webview-ui/src/codeReader/AskThread.tsx +++ b/vscode/webview-ui/src/codeReader/AskThread.tsx @@ -3,7 +3,7 @@ import type { ClipboardEvent, DragEvent, KeyboardEvent, ReactElement } from 'rea import type { Ask } from './qa' import type { PendingImage } from './qaImages' import type { ProfileChoice, QaEntryView } from './types' -import { ChevronDown, ChevronRight, CircleStop, Hourglass, LoaderCircle, MessageSquareText, SendHorizontal, Sparkles, Trash2, TriangleAlert, X } from 'lucide-react' +import { ChevronDown, ChevronRight, CircleStop, Hourglass, LoaderCircle, MessageSquareText, Search, SendHorizontal, Sparkles, Trash2, TriangleAlert, X } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { SelectMenu } from '@/components/ui/select-menu' @@ -33,6 +33,17 @@ export const ASK_THREAD: ThreadWording = { answering: '正在回答…', } +/** 「了解一块代码」的对话页:第一轮是侧栏里问的那句话,输入框里接着说哪里漏了、哪些不相关,AI 改完整栏换成新结果 */ +export const FEATURE_THREAD: ThreadWording = { + label: '了解一块代码', + icon: Search, + summary: count => `说过 ${count} 次`, + deleteLabel: '删除这一轮', + placeholder: () => '接着说:还漏了什么、哪些不相关…', + inputLabel: '接着说的话', + answering: '正在找…', +} + /** 重写注释:第一轮由块右上角的按钮发技能命令,输入框里说的是对这段注释的修改意见 */ export const REWRITE_THREAD: ThreadWording = { label: '按注释规范重写', diff --git a/vscode/webview-ui/src/codeReader/qa.ts b/vscode/webview-ui/src/codeReader/qa.ts index b553ee2..738acaf 100644 --- a/vscode/webview-ui/src/codeReader/qa.ts +++ b/vscode/webview-ui/src/codeReader/qa.ts @@ -42,8 +42,8 @@ export interface ChatState { export interface QaState extends ChatState { /** 这一页顶部按路径记的问答 */ path: QaEntryView[] - /** 顶部问的是这个文件还是这个文件夹 */ - subject: 'file' | 'folder' + /** 顶部问的是这个文件、这个文件夹,还是「了解一块代码」找的一个功能 */ + subject: 'file' | 'folder' | 'feature' /** 输入框旁边的 profile 下拉,所有块共用 */ profiles: ProfileChoice } diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index f3daad9..75a5db8 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -308,8 +308,8 @@ export interface QaMessage { threads: QaThreadView[] /** 这一页顶部、按路径记的问答;不挂在块上 */ path: QaEntryView[] - /** 顶部问的是这个文件还是这个文件夹 */ - subject: 'file' | 'folder' + /** 顶部问的是这个文件、这个文件夹,还是「了解一块代码」找的一个功能 */ + subject: 'file' | 'folder' | 'feature' current: AskRound[] /** 块下面的重写记录,按块的记号 */ rewrites: QaThreadView[]