diff --git a/vscode/package.json b/vscode/package.json index 395ca7c..2edd9a5 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -36,6 +36,7 @@ "onCommand:superpowers.configureHandoffStore", "onUri", "onView:superpowers.codeReader.files", + "onView:superpowers.codeReader.uncommitted", "onCustomEditor:superpowers.codeReader.preview", "onCommand:superpowers.codeReader.open", "onCommand:superpowers.codeReader.showPreview", @@ -67,6 +68,160 @@ { "id": "superpowers.codeReader.files", "name": "代码阅读" + }, + { + "id": "superpowers.codeReader.uncommitted", + "name": "未提交的更改" + }, + { + "id": "superpowers.codeReader.favorite1", + "name": "收藏 1", + "when": "superpowers.codeReader.hasFavorite1" + }, + { + "id": "superpowers.codeReader.favorite2", + "name": "收藏 2", + "when": "superpowers.codeReader.hasFavorite2" + }, + { + "id": "superpowers.codeReader.favorite3", + "name": "收藏 3", + "when": "superpowers.codeReader.hasFavorite3" + }, + { + "id": "superpowers.codeReader.favorite4", + "name": "收藏 4", + "when": "superpowers.codeReader.hasFavorite4" + }, + { + "id": "superpowers.codeReader.favorite5", + "name": "收藏 5", + "when": "superpowers.codeReader.hasFavorite5" + }, + { + "id": "superpowers.codeReader.favorite6", + "name": "收藏 6", + "when": "superpowers.codeReader.hasFavorite6" + }, + { + "id": "superpowers.codeReader.favorite7", + "name": "收藏 7", + "when": "superpowers.codeReader.hasFavorite7" + }, + { + "id": "superpowers.codeReader.favorite8", + "name": "收藏 8", + "when": "superpowers.codeReader.hasFavorite8" + }, + { + "id": "superpowers.codeReader.favorite9", + "name": "收藏 9", + "when": "superpowers.codeReader.hasFavorite9" + }, + { + "id": "superpowers.codeReader.favorite10", + "name": "收藏 10", + "when": "superpowers.codeReader.hasFavorite10" + }, + { + "id": "superpowers.codeReader.favorite11", + "name": "收藏 11", + "when": "superpowers.codeReader.hasFavorite11" + }, + { + "id": "superpowers.codeReader.favorite12", + "name": "收藏 12", + "when": "superpowers.codeReader.hasFavorite12" + }, + { + "id": "superpowers.codeReader.favorite13", + "name": "收藏 13", + "when": "superpowers.codeReader.hasFavorite13" + }, + { + "id": "superpowers.codeReader.favorite14", + "name": "收藏 14", + "when": "superpowers.codeReader.hasFavorite14" + }, + { + "id": "superpowers.codeReader.favorite15", + "name": "收藏 15", + "when": "superpowers.codeReader.hasFavorite15" + }, + { + "id": "superpowers.codeReader.favorite16", + "name": "收藏 16", + "when": "superpowers.codeReader.hasFavorite16" + }, + { + "id": "superpowers.codeReader.favorite17", + "name": "收藏 17", + "when": "superpowers.codeReader.hasFavorite17" + }, + { + "id": "superpowers.codeReader.favorite18", + "name": "收藏 18", + "when": "superpowers.codeReader.hasFavorite18" + }, + { + "id": "superpowers.codeReader.favorite19", + "name": "收藏 19", + "when": "superpowers.codeReader.hasFavorite19" + }, + { + "id": "superpowers.codeReader.favorite20", + "name": "收藏 20", + "when": "superpowers.codeReader.hasFavorite20" + }, + { + "id": "superpowers.codeReader.favorite21", + "name": "收藏 21", + "when": "superpowers.codeReader.hasFavorite21" + }, + { + "id": "superpowers.codeReader.favorite22", + "name": "收藏 22", + "when": "superpowers.codeReader.hasFavorite22" + }, + { + "id": "superpowers.codeReader.favorite23", + "name": "收藏 23", + "when": "superpowers.codeReader.hasFavorite23" + }, + { + "id": "superpowers.codeReader.favorite24", + "name": "收藏 24", + "when": "superpowers.codeReader.hasFavorite24" + }, + { + "id": "superpowers.codeReader.favorite25", + "name": "收藏 25", + "when": "superpowers.codeReader.hasFavorite25" + }, + { + "id": "superpowers.codeReader.favorite26", + "name": "收藏 26", + "when": "superpowers.codeReader.hasFavorite26" + }, + { + "id": "superpowers.codeReader.favorite27", + "name": "收藏 27", + "when": "superpowers.codeReader.hasFavorite27" + }, + { + "id": "superpowers.codeReader.favorite28", + "name": "收藏 28", + "when": "superpowers.codeReader.hasFavorite28" + }, + { + "id": "superpowers.codeReader.favorite29", + "name": "收藏 29", + "when": "superpowers.codeReader.hasFavorite29" + }, + { + "id": "superpowers.codeReader.favorite30", + "name": "收藏 30", + "when": "superpowers.codeReader.hasFavorite30" } ] }, @@ -120,19 +275,13 @@ "icon": "$(git-pull-request)" }, { - "command": "superpowers.codeReader.showUncommitted", - "title": "未提交的更改", - "category": "代码阅读", - "icon": "$(git-commit)" - }, - { - "command": "superpowers.codeReader.hideTests", + "command": "superpowers.codeReader.hideReviewTests", "title": "隐藏测试文件", "category": "代码阅读", "icon": "$(beaker-stop)" }, { - "command": "superpowers.codeReader.showTests", + "command": "superpowers.codeReader.showReviewTests", "title": "显示测试文件", "category": "代码阅读", "icon": "$(beaker)" @@ -155,6 +304,390 @@ "category": "代码阅读", "icon": "$(refresh)" }, + { + "command": "superpowers.codeReader.hideUncommittedTests", + "title": "隐藏测试文件", + "category": "代码阅读", + "icon": "$(beaker-stop)" + }, + { + "command": "superpowers.codeReader.showUncommittedTests", + "title": "显示测试文件", + "category": "代码阅读", + "icon": "$(beaker)" + }, + { + "command": "superpowers.codeReader.refreshUncommitted", + "title": "刷新", + "category": "代码阅读", + "icon": "$(refresh)" + }, + { + "command": "superpowers.codeReader.addFavorite", + "title": "收藏目录", + "category": "代码阅读", + "icon": "$(star-empty)" + }, + { + "command": "superpowers.codeReader.renameFavorite1", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite1", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite2", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite2", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite3", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite3", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite4", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite4", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite5", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite5", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite6", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite6", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite7", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite7", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite8", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite8", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite9", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite9", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite10", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite10", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite11", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite11", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite12", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite12", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite13", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite13", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite14", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite14", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite15", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite15", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite16", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite16", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite17", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite17", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite18", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite18", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite19", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite19", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite20", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite20", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite21", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite21", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite22", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite22", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite23", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite23", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite24", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite24", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite25", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite25", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite26", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite26", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite27", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite27", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite28", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite28", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite29", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite29", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, + { + "command": "superpowers.codeReader.renameFavorite30", + "title": "重命名", + "category": "代码阅读", + "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.removeFavorite30", + "title": "取消收藏", + "category": "代码阅读", + "icon": "$(star-full)" + }, { "command": "superpowers.codeReader.open", "title": "打开", @@ -264,27 +797,27 @@ "superpowers.codeReader.copy": [ { "command": "superpowers.codeReader.copyFileName", - "when": "view == superpowers.codeReader.files && viewItem =~ /^file( unstaged)?( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^file( unstaged)?( noted)?$/", "group": "copy@1" }, { "command": "superpowers.codeReader.copyFolderName", - "when": "view == superpowers.codeReader.files && viewItem =~ /^folder( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^folder( noted)?$/", "group": "copy@1" }, { "command": "superpowers.codeReader.copyFileStem", - "when": "view == superpowers.codeReader.files && viewItem =~ /^file( unstaged)?( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^file( unstaged)?( noted)?$/", "group": "copy@2" }, { "command": "superpowers.codeReader.copyRelativePath", - "when": "view == superpowers.codeReader.files && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", "group": "copy@3" }, { "command": "superpowers.codeReader.copyAbsolutePath", - "when": "view == superpowers.codeReader.files && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", "group": "copy@4" } ], @@ -310,106 +843,426 @@ "group": "navigation@1" }, { - "command": "superpowers.codeReader.showUncommitted", - "when": "view == superpowers.codeReader.files", + "command": "superpowers.codeReader.hideReviewTests", + "when": "view == superpowers.codeReader.files && superpowers.codeReader.reviewing && !superpowers.codeReader.reviewTestsHidden", "group": "navigation@2" }, { - "command": "superpowers.codeReader.hideTests", - "when": "view == superpowers.codeReader.files && (superpowers.codeReader.filter == uncommitted || superpowers.codeReader.filter == review) && !superpowers.codeReader.testsHidden", - "group": "navigation@3" - }, - { - "command": "superpowers.codeReader.showTests", - "when": "view == superpowers.codeReader.files && (superpowers.codeReader.filter == uncommitted || superpowers.codeReader.filter == review) && superpowers.codeReader.testsHidden", - "group": "navigation@3" + "command": "superpowers.codeReader.showReviewTests", + "when": "view == superpowers.codeReader.files && superpowers.codeReader.reviewing && superpowers.codeReader.reviewTestsHidden", + "group": "navigation@2" }, { "command": "superpowers.codeReader.showAllFiles", - "when": "view == superpowers.codeReader.files && superpowers.codeReader.filter", - "group": "navigation@4" + "when": "view == superpowers.codeReader.files && superpowers.codeReader.reviewing", + "group": "navigation@3" }, { "command": "superpowers.codeReader.refresh", "when": "view == superpowers.codeReader.files", - "group": "navigation@5" + "group": "navigation@4" + }, + { + "command": "superpowers.codeReader.hideUncommittedTests", + "when": "view == superpowers.codeReader.uncommitted && !superpowers.codeReader.uncommittedTestsHidden", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.showUncommittedTests", + "when": "view == superpowers.codeReader.uncommitted && superpowers.codeReader.uncommittedTestsHidden", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.refreshUncommitted", + "when": "view == superpowers.codeReader.uncommitted", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite1", + "when": "view == superpowers.codeReader.favorite1", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite1", + "when": "view == superpowers.codeReader.favorite1", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite2", + "when": "view == superpowers.codeReader.favorite2", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite2", + "when": "view == superpowers.codeReader.favorite2", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite3", + "when": "view == superpowers.codeReader.favorite3", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite3", + "when": "view == superpowers.codeReader.favorite3", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite4", + "when": "view == superpowers.codeReader.favorite4", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite4", + "when": "view == superpowers.codeReader.favorite4", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite5", + "when": "view == superpowers.codeReader.favorite5", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite5", + "when": "view == superpowers.codeReader.favorite5", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite6", + "when": "view == superpowers.codeReader.favorite6", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite6", + "when": "view == superpowers.codeReader.favorite6", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite7", + "when": "view == superpowers.codeReader.favorite7", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite7", + "when": "view == superpowers.codeReader.favorite7", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite8", + "when": "view == superpowers.codeReader.favorite8", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite8", + "when": "view == superpowers.codeReader.favorite8", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite9", + "when": "view == superpowers.codeReader.favorite9", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite9", + "when": "view == superpowers.codeReader.favorite9", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite10", + "when": "view == superpowers.codeReader.favorite10", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite10", + "when": "view == superpowers.codeReader.favorite10", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite11", + "when": "view == superpowers.codeReader.favorite11", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite11", + "when": "view == superpowers.codeReader.favorite11", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite12", + "when": "view == superpowers.codeReader.favorite12", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite12", + "when": "view == superpowers.codeReader.favorite12", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite13", + "when": "view == superpowers.codeReader.favorite13", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite13", + "when": "view == superpowers.codeReader.favorite13", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite14", + "when": "view == superpowers.codeReader.favorite14", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite14", + "when": "view == superpowers.codeReader.favorite14", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite15", + "when": "view == superpowers.codeReader.favorite15", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite15", + "when": "view == superpowers.codeReader.favorite15", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite16", + "when": "view == superpowers.codeReader.favorite16", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite16", + "when": "view == superpowers.codeReader.favorite16", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite17", + "when": "view == superpowers.codeReader.favorite17", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite17", + "when": "view == superpowers.codeReader.favorite17", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite18", + "when": "view == superpowers.codeReader.favorite18", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite18", + "when": "view == superpowers.codeReader.favorite18", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite19", + "when": "view == superpowers.codeReader.favorite19", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite19", + "when": "view == superpowers.codeReader.favorite19", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite20", + "when": "view == superpowers.codeReader.favorite20", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite20", + "when": "view == superpowers.codeReader.favorite20", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite21", + "when": "view == superpowers.codeReader.favorite21", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite21", + "when": "view == superpowers.codeReader.favorite21", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite22", + "when": "view == superpowers.codeReader.favorite22", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite22", + "when": "view == superpowers.codeReader.favorite22", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite23", + "when": "view == superpowers.codeReader.favorite23", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite23", + "when": "view == superpowers.codeReader.favorite23", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite24", + "when": "view == superpowers.codeReader.favorite24", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite24", + "when": "view == superpowers.codeReader.favorite24", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite25", + "when": "view == superpowers.codeReader.favorite25", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite25", + "when": "view == superpowers.codeReader.favorite25", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite26", + "when": "view == superpowers.codeReader.favorite26", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite26", + "when": "view == superpowers.codeReader.favorite26", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite27", + "when": "view == superpowers.codeReader.favorite27", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite27", + "when": "view == superpowers.codeReader.favorite27", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite28", + "when": "view == superpowers.codeReader.favorite28", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite28", + "when": "view == superpowers.codeReader.favorite28", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite29", + "when": "view == superpowers.codeReader.favorite29", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite29", + "when": "view == superpowers.codeReader.favorite29", + "group": "navigation@2" + }, + { + "command": "superpowers.codeReader.renameFavorite30", + "when": "view == superpowers.codeReader.favorite30", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.removeFavorite30", + "when": "view == superpowers.codeReader.favorite30", + "group": "navigation@2" } ], "view/item/context": [ { "command": "superpowers.codeReader.askPath", - "when": "view == superpowers.codeReader.files && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", "group": "inline@0" }, { "submenu": "superpowers.codeReader.copy", - "when": "view == superpowers.codeReader.files && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", "group": "inline@1" }, { "command": "superpowers.codeReader.addNote", - "when": "view == superpowers.codeReader.files && superpowers.codeReader.filter != review && viewItem =~ /^(file( unstaged)?|folder)$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && !(view == superpowers.codeReader.files && superpowers.codeReader.reviewing) && viewItem =~ /^(file( unstaged)?|folder)$/", "group": "inline@2" }, { "command": "superpowers.codeReader.editNote", - "when": "view == superpowers.codeReader.files && superpowers.codeReader.filter != review && viewItem =~ /^(file( unstaged)?|folder) noted$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && !(view == superpowers.codeReader.files && superpowers.codeReader.reviewing) && viewItem =~ /^(file( unstaged)?|folder) noted$/", "group": "inline@2" }, { "command": "superpowers.codeReader.stage", - "when": "view == superpowers.codeReader.files && superpowers.codeReader.filter == uncommitted && viewItem =~ /^file unstaged( noted)?$/", + "when": "view == superpowers.codeReader.uncommitted && viewItem =~ /^file unstaged( noted)?$/", + "group": "inline@3" + }, + { + "command": "superpowers.codeReader.addFavorite", + "when": "view =~ /^superpowers\\.codeReader\\.(files|favorite\\d+)$/ && !(view == superpowers.codeReader.files && superpowers.codeReader.reviewing) && viewItem =~ /^folder( noted)?$/", "group": "inline@3" }, { "command": "superpowers.codeReader.copyFileName", - "when": "view == superpowers.codeReader.files && viewItem =~ /^file( unstaged)?( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^file( unstaged)?( noted)?$/", "group": "copy@1" }, { "command": "superpowers.codeReader.copyFolderName", - "when": "view == superpowers.codeReader.files && viewItem =~ /^folder( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^folder( noted)?$/", "group": "copy@1" }, { "command": "superpowers.codeReader.copyFileStem", - "when": "view == superpowers.codeReader.files && viewItem =~ /^file( unstaged)?( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^file( unstaged)?( noted)?$/", "group": "copy@2" }, { "command": "superpowers.codeReader.copyRelativePath", - "when": "view == superpowers.codeReader.files && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", "group": "copy@3" }, { "command": "superpowers.codeReader.copyAbsolutePath", - "when": "view == superpowers.codeReader.files && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", "group": "copy@4" }, { "command": "superpowers.codeReader.revealInFinder", - "when": "view == superpowers.codeReader.files && isMac && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && isMac && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", "group": "fileManager" }, { "command": "superpowers.codeReader.revealInFileExplorer", - "when": "view == superpowers.codeReader.files && isWindows && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && isWindows && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", "group": "fileManager" }, { "command": "superpowers.codeReader.openContainingFolder", - "when": "view == superpowers.codeReader.files && isLinux && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && isLinux && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/", "group": "fileManager" }, { "command": "superpowers.codeReader.addNote", - "when": "view == superpowers.codeReader.files && superpowers.codeReader.filter != review && viewItem =~ /^(file( unstaged)?|folder)$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && !(view == superpowers.codeReader.files && superpowers.codeReader.reviewing) && viewItem =~ /^(file( unstaged)?|folder)$/", "group": "notes" }, { "command": "superpowers.codeReader.editNote", - "when": "view == superpowers.codeReader.files && superpowers.codeReader.filter != review && viewItem =~ /^(file( unstaged)?|folder) noted$/", + "when": "view =~ /^superpowers\\.codeReader\\./ && !(view == superpowers.codeReader.files && superpowers.codeReader.reviewing) && viewItem =~ /^(file( unstaged)?|folder) noted$/", "group": "notes" + }, + { + "command": "superpowers.codeReader.addFavorite", + "when": "view =~ /^superpowers\\.codeReader\\.(files|favorite\\d+)$/ && !(view == superpowers.codeReader.files && superpowers.codeReader.reviewing) && viewItem =~ /^folder( noted)?$/", + "group": "star" } ], "editor/title": [ @@ -431,20 +1284,276 @@ }, { "command": "superpowers.codeReader.showAllFiles", - "when": "superpowers.codeReader.filter" + "when": "superpowers.codeReader.reviewing" }, { - "command": "superpowers.codeReader.hideTests", - "when": "(superpowers.codeReader.filter == uncommitted || superpowers.codeReader.filter == review) && !superpowers.codeReader.testsHidden" + "command": "superpowers.codeReader.hideReviewTests", + "when": "superpowers.codeReader.reviewing && !superpowers.codeReader.reviewTestsHidden" }, { - "command": "superpowers.codeReader.showTests", - "when": "(superpowers.codeReader.filter == uncommitted || superpowers.codeReader.filter == review) && superpowers.codeReader.testsHidden" + "command": "superpowers.codeReader.showReviewTests", + "when": "superpowers.codeReader.reviewing && superpowers.codeReader.reviewTestsHidden" }, { "command": "superpowers.codeReader.stage", "when": "false" }, + { + "command": "superpowers.codeReader.hideUncommittedTests", + "when": "false" + }, + { + "command": "superpowers.codeReader.showUncommittedTests", + "when": "false" + }, + { + "command": "superpowers.codeReader.refreshUncommitted", + "when": "false" + }, + { + "command": "superpowers.codeReader.addFavorite", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite1", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite1", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite2", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite2", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite3", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite3", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite4", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite4", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite5", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite5", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite6", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite6", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite7", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite7", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite8", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite8", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite9", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite9", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite10", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite10", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite11", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite11", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite12", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite12", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite13", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite13", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite14", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite14", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite15", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite15", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite16", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite16", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite17", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite17", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite18", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite18", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite19", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite19", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite20", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite20", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite21", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite21", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite22", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite22", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite23", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite23", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite24", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite24", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite25", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite25", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite26", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite26", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite27", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite27", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite28", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite28", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite29", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite29", + "when": "false" + }, + { + "command": "superpowers.codeReader.renameFavorite30", + "when": "false" + }, + { + "command": "superpowers.codeReader.removeFavorite30", + "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/codeReader/diff/diffBase.test.ts b/vscode/src/codeReader/diff/diffBase.test.ts new file mode 100644 index 0000000..608d7e0 --- /dev/null +++ b/vscode/src/codeReader/diff/diffBase.test.ts @@ -0,0 +1,55 @@ +import type { Uri } from 'vscode' +import type { DiffBase, DiffBaseSource } from './diffBase' +import { describe, expect, it, vi } from 'vitest' +import { locatedDiffBases } from './diffBase' + +vi.mock('vscode', () => ({ + Disposable: { + from: (...items: { dispose: () => void }[]) => ({ dispose: () => items.forEach(item => item.dispose()) }), + }, +})) + +const uri = (path: string): Uri => ({ path }) as Uri +const base = (label: string): DiffBase => ({ label, key: label, read: async () => null, missing: '' }) + +/** 只管 root 下面的文件的一方;fire 模拟它的基准变了 */ +function source(root: string, label: string): DiffBaseSource & { fire: () => void } { + const listeners = new Set<() => void>() + return { + diffBase: file => file.path.startsWith(`${root}/`) ? base(label) : undefined, + onDidChange: (listener) => { + listeners.add(listener) + return { dispose: () => listeners.delete(listener) } + }, + fire: () => listeners.forEach(listener => listener()), + } +} + +describe('预览按文件所在的位置取对比基准', () => { + const review = source('/worktrees/issue-446', 'PR 目标分支 master') + const head = source('/ws', '上一次提交') + const bases = locatedDiffBases([review, head]) + + it('审查工作副本里的对比 PR 目标分支,工作区仓库里的对比上一次提交,都不管的不比', () => { + expect(bases.diffBase(uri('/worktrees/issue-446/src/a.ts'))?.label).toBe('PR 目标分支 master') + expect(bases.diffBase(uri('/ws/src/a.ts'))?.label).toBe('上一次提交') + expect(bases.diffBase(uri('/elsewhere/a.ts'))).toBeUndefined() + }) + + it('两方都管的文件按顺序取前面那方', () => { + const both = locatedDiffBases([source('/ws', '前面'), source('/ws', '后面')]) + expect(both.diffBase(uri('/ws/a.ts'))?.label).toBe('前面') + }) + + it('哪一方的基准变了都通知预览重新问;不听了就不再通知', () => { + const changed = vi.fn() + const listening = bases.onDidChange(changed) + review.fire() + head.fire() + expect(changed).toHaveBeenCalledTimes(2) + + listening.dispose() + head.fire() + expect(changed).toHaveBeenCalledTimes(2) + }) +}) diff --git a/vscode/src/codeReader/diff/diffBase.ts b/vscode/src/codeReader/diff/diffBase.ts index 0851b2f..d86c7e6 100644 --- a/vscode/src/codeReader/diff/diffBase.ts +++ b/vscode/src/codeReader/diff/diffBase.ts @@ -1,6 +1,9 @@ +import type { Event, Uri } from 'vscode' +import { Disposable } from 'vscode' + /** - * 对比基准:侧栏只列改动的文件时(「未提交的更改」对比上一次提交,「选工单」审查对比 PR 的目标分支), - * 预览拿它取这个文件改之前的样子,和现在的比出差异。由开着的那种侧栏筛选给(TreeFilter.diffBase)。 + * 对比基准:预览拿它取这个文件改之前的样子,和现在的比出差异。 + * 按文件所在的位置给(见 locatedDiffBases):审查工作副本里的对比 PR 的目标分支,工作区仓库里有改动的对比上一次提交。 */ export interface DiffBase { /** 筛选栏上「对比:」后面的字:「上一次提交」「PR 目标分支 master」 */ @@ -12,3 +15,31 @@ export interface DiffBase { /** 基准里没有这个文件时横幅上的一句说明 */ missing: string } + +/** 给对比基准的一方:它管的文件给基准,不管的没有;基准变了(HEAD 动了、换了审查的 PR)发 onDidChange,打开着的预览重新问。 */ +export interface DiffBaseSource { + diffBase: (uri: Uri) => DiffBase | undefined + readonly onDidChange: Event +} + +/** + * 预览对比什么按文件所在的位置定,不看是从哪一栏、点链接还是切标签打开的: + * 按顺序问各方(审查工作副本在前、工作区仓库在后),第一个管这个文件的说了算,都不管的不比;哪一方的基准变了都通知。 + */ +export function locatedDiffBases(sources: readonly DiffBaseSource[]): DiffBaseSource { + return { + diffBase: (uri) => { + for (const source of sources) { + const base = source.diffBase(uri) + if (base) + return base + } + return undefined + }, + onDidChange: (listener, thisArgs, disposables) => { + const all = Disposable.from(...sources.map(source => source.onDidChange(listener, thisArgs))) + disposables?.push(all) + return all + }, + } +} diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index ba0f57b..36ca8e1 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -133,8 +133,8 @@ export interface StickyScrollSettings { * failed 带给人看的原因,stopped 是被叫停,这两种都没记、文件没改 * moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现,由这两条命令发给活动的预览 * displayNames:version 这份文档里显示成中文名的名字,查到一步发一步(先本文件定义的,再别的文件定义的);不是页面上这个版本的不理 - * diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有,见 DiffBase):每发一份文档之后算好了补发, - * 换了筛选、基准挪了(提交后 HEAD 动了)再发;null 是不比了(回到全部文件) + * diff:这份文件相比对比基准改了什么(审查工作副本里的、工作区仓库里有改动的才有,见 DiffBase):每发一份文档之后算好了补发, + * 基准变了(提交后 HEAD 动了、换了审查的工单、文件有了改动)再发;null 是不比了(退出了审查、改动提交了) * repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径,unresolved 是找不到的不带路径的引用 * repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到 * repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了 diff --git a/vscode/src/codeReader/editor/previewEditor.test.ts b/vscode/src/codeReader/editor/previewEditor.test.ts index 687a074..898768f 100644 --- a/vscode/src/codeReader/editor/previewEditor.test.ts +++ b/vscode/src/codeReader/editor/previewEditor.test.ts @@ -48,7 +48,7 @@ function preview(rewrite: (uri: unknown, request: RewriteRequest) => Promise [], async () => [], - { onDidChangeDiffBase: subscribe }, + { onDidChange: subscribe }, { onDidChange: subscribe }, async () => false, ] as unknown as ConstructorParameters) diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 2179ea6..6216a2a 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -1,9 +1,9 @@ -import type { CustomTextEditorProvider, Disposable, DocumentSymbol, Event, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode' +import type { CustomTextEditorProvider, Disposable, DocumentSymbol, 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 { DiffBase } from '../diff/diffBase' +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' @@ -49,12 +49,6 @@ export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview' /** 连续输入时合并成一次重新解析。 */ const REFRESH_DELAY_MS = 150 -/** 预览对比什么:侧栏开着的筛选定(TreeFilters),换了筛选、基准变了发 onDidChangeDiffBase。 */ -export interface DiffBases { - diffBase: (uri: Uri) => DiffBase | undefined - onDidChangeDiffBase: Event -} - /** * 「代码阅读预览」自定义编辑器。预览和普通编辑器共用同一个 TextDocument: * 块内修改走 WorkspaceEdit,未保存标记、⌘S、⌘Z 都是 VS Code 原生行为。 @@ -81,8 +75,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { readonly indexedNames: (languageId: string, name: string) => Promise, /** 工作区索引里键是 key 的各项(规则查名字的定义时用,见 ruleLookups.ts;接口的调用树查契约和前端请求时用) */ readonly indexed: IndexFind, - /** 预览标差异时对比什么 */ - readonly diffBases: DiffBases, + /** 预览标差异时对比什么:按文件所在的位置定,基准变了发 onDidChange(见 locatedDiffBases) */ + readonly diffBases: DiffBaseSource, /** 哪些文件是项目写明的入口、预览里哪些名字的块标成入口 */ readonly entries: Pick, /** 在代码阅读侧栏的树上定位一个目录(仓库链接指到目录时);树上没有它时返回 false */ @@ -245,7 +239,7 @@ class Preview { text => logger.add({ level: 'warn', source: 'codeReader', message: `${document.uri.fsPath}:${text}` }), ) this.disposables = [ - owner.diffBases.onDidChangeDiffBase(() => { + owner.diffBases.onDidChange(() => { if (this.ready && this.sent) void this.diff.update(this.sent) }), diff --git a/vscode/src/codeReader/explorer/fileTree.test.ts b/vscode/src/codeReader/explorer/fileTree.test.ts index 88315db..9a69c16 100644 --- a/vscode/src/codeReader/explorer/fileTree.test.ts +++ b/vscode/src/codeReader/explorer/fileTree.test.ts @@ -51,6 +51,7 @@ vi.mock('vscode', async () => { get workspaceFolders() { return disk.folders }, + getWorkspaceFolder: (uri: FakeUri) => (disk.folders as { uri: FakeUri }[]).find(folder => uri.path === folder.uri.path || uri.path.startsWith(`${folder.uri.path}/`)), fs: { readDirectory: async (uri: FakeUri) => { const entries = disk.dirs.get(uri.path) @@ -164,7 +165,7 @@ describe('只摆改动的文件时:在树上就找到,不在就没有', () = it('找到那个分组里的那一行,上一层一直走到分组行', async () => { const files = new FileTreeProvider('open', decorator) const { staged, unstaged } = sections() - files.showChanges([staged, unstaged]) + files.show({ kind: 'changes', view: [staged, unstaged] }) const found = await files.nodeOf(Uri.file('/ws/backend/src/luffy/visibility.py')) expect(found?.section).toBe(unstaged) @@ -177,7 +178,7 @@ describe('只摆改动的文件时:在树上就找到,不在就没有', () = it('没摆出来的文件、目录找不到', async () => { const files = new FileTreeProvider('open', decorator) const { staged, unstaged } = sections() - files.showChanges([staged, unstaged]) + files.show({ kind: 'changes', view: [staged, unstaged] }) expect(await files.nodeOf(Uri.file('/ws/README.md'))).toBeUndefined() expect(await files.nodeOf(Uri.file('/ws/dist'))).toBeUndefined() @@ -187,14 +188,14 @@ describe('只摆改动的文件时:在树上就找到,不在就没有', () = it('同一个文件摆在两个分组里时取最先摆的', async () => { const files = new FileTreeProvider('open', decorator) const { staged, unstaged } = sections() - files.showChanges([staged, unstaged]) + files.show({ kind: 'changes', view: [staged, unstaged] }) expect((await files.nodeOf(Uri.file('/ws/backend/src/gate.py')))?.section).toBe(staged) }) it('分组没有名字时根就在最上面一层,根的上一层没有', async () => { const files = new FileTreeProvider('open', decorator) - files.showChanges([{ roots: [{ folder, tree: buildChangedTree([{ path: 'backend/src/gate.py', change: 'A' }]) }] }]) + files.show({ kind: 'changes', view: [{ roots: [{ folder, tree: buildChangedTree([{ path: 'backend/src/gate.py', change: 'A' }]) }] }] }) const found = await files.nodeOf(Uri.file('/ws/backend/src/gate.py')) const [root] = await files.getChildren() @@ -202,3 +203,46 @@ describe('只摆改动的文件时:在树上就找到,不在就没有', () = expect(topOf(files, found!)).toBe(root) }) }) + +describe('收藏栏:一个目录下的完整文件树', () => { + it('直接摆目录下的各项(不套一层根),规则同工作区文件树:目录在前、files.exclude 藏掉的不摆', async () => { + disk.dirs.set('/ws/backend', [['src', DIR], ['main.py', FILE], ['build', DIR]]) + disk.exclude = { '**/build': true } + const files = new FileTreeProvider('open', decorator, { kind: 'directory', uri: Uri.file('/ws/backend') }) + const top = await files.getChildren() + + expect(top.map(node => !isGroupNode(node) && [node.uri.path, node.isDirectory, node.isRoot])).toEqual([ + ['/ws/backend/src', true, false], + ['/ws/backend/main.py', false, false], + ]) + // 行的根是所在的工作区文件夹:复制相对路径、文件注释、files.exclude 都按它算 + expect(top.every(node => !isGroupNode(node) && node.folder === folder)).toBe(true) + }) + + it('最上面一层没有上一层;往下找得到,上一层走到收藏的目录下面为止', async () => { + const files = new FileTreeProvider('open', decorator, { kind: 'directory', uri: Uri.file('/ws/backend') }) + const found = await files.nodeOf(Uri.file('/ws/backend/src/luffy/visibility.py')) + + expect(chainUp(files, found)).toEqual(['/ws/backend/src/luffy/visibility.py', '/ws/backend/src/luffy', '/ws/backend/src']) + expect(topOf(files, found!)).toBe((await files.getChildren())[0]) + }) + + it('目录外面的找不到;目录被删了时树是空的', async () => { + const files = new FileTreeProvider('open', decorator, { kind: 'directory', uri: Uri.file('/ws/backend') }) + + expect(await files.nodeOf(Uri.file('/ws/README.md'))).toBeUndefined() + expect(await files.nodeOf(Uri.file('/ws/backend'))).toBeUndefined() + disk.dirs.delete('/ws/backend') + files.refresh() + expect(await files.getChildren()).toEqual([]) + }) + + it('目录不在任何工作区文件夹里(文件夹被移出工作区):拿目录自己当根', async () => { + disk.folders = [] + const files = new FileTreeProvider('open', decorator, { kind: 'directory', uri: Uri.file('/ws/backend') }) + const [src] = await files.getChildren() + + expect(!isGroupNode(src) && src.folder.uri.path).toBe('/ws/backend') + expect(files.getParent(src)).toBeUndefined() + }) +}) diff --git a/vscode/src/codeReader/explorer/fileTree.ts b/vscode/src/codeReader/explorer/fileTree.ts index 3949d57..617443f 100644 --- a/vscode/src/codeReader/explorer/fileTree.ts +++ b/vscode/src/codeReader/explorer/fileTree.ts @@ -8,7 +8,7 @@ import { pathRelation } from './treeReveal' export interface FileNode { uri: Uri - /** 这一行所在的根:工作区文件夹;只摆改动的文件时是那个根(仓库根、审查的工作副本) */ + /** 这一行所在的根:工作区文件夹(收藏栏里是收藏的目录所在的那个);只摆改动的文件时是那个根(仓库根、审查的工作副本) */ folder: WorkspaceFolder isDirectory: boolean isRoot: boolean @@ -36,6 +36,19 @@ export interface ChangedSection { /** 只摆改动的文件时树上摆什么:一个或几个分组,每组下面每个根一个根节点,再往下只有改动的文件和通往它们的目录。 */ export type ChangedFilesView = readonly ChangedSection[] +/** + * 一棵树上摆什么: + * workspace 是工作区文件树,每个工作区文件夹一个根节点(主栏); + * directory 是这个目录下的完整文件树,直接摆它下面的各项、不再套一层根(收藏栏); + * changes 是只摆改动的文件(主栏审查工单时 PR 改过的,「未提交的更改」一栏分「暂存的更改」「更改」两组),目录全部展开,删掉的文件列出来但打不开。 + */ +export type TreeContent + = | { kind: 'workspace' } + | { kind: 'directory', uri: Uri } + | { kind: 'changes', view: ChangedFilesView } + +export const WORKSPACE_TREE: TreeContent = { kind: 'workspace' } + /** 分组行(「暂存的更改」「更改」)。 */ export interface GroupNode { group: ChangedSection @@ -61,10 +74,8 @@ export interface FileTreeDecorator { const REFRESH_DELAY_MS = 300 /** - * 侧栏的工作区文件树:每个工作区文件夹一个根节点,只看和打开。 + * 侧栏的一棵文件树,只看和打开:主栏、「未提交的更改」、各收藏栏各用一个,摆什么见 TreeContent,可以整个换掉(show)。 * 节点用 resourceUri,图标、git 改动颜色都由 VS Code 按文件图标主题和 SCM 装饰自动加上。 - * 筛选时换成只摆改动的文件(showChanges:审查工单时 PR 改过的,未提交的更改时各仓库里没提交的,分「暂存的更改」「更改」两组): - * 目录全部展开,删掉的文件列出来但打不开。 * 能按 Uri 找到树上那一行和它的上一层(nodeOf、getParent),侧栏定位到某个文件 / 目录(TreeLocator)靠它。 */ export class FileTreeProvider implements TreeDataProvider { @@ -75,16 +86,18 @@ export class FileTreeProvider implements TreeDataProvider { /** 按分组、Uri 记下交给 VS Code 的节点:只重画几行时要交回同一个对象,VS Code 按对象认行;同一个文件可以在几个分组里各有一行。 */ private readonly nodes = new Map>() private timer: NodeJS.Timeout | undefined - /** 筛选时只摆的那些文件;undefined 是普通的工作区文件树 */ - private changes: ChangedFilesView | undefined - /** 筛选时每个分组的分组行 */ - private groups: GroupNode[] = [] + /** 只摆改动的文件时每个分组的分组行 */ + private groups: GroupNode[] + /** 摆一个目录时它所在的工作区文件夹;整棵重摆时重新找(工作区文件夹可能变了) */ + private directoryFolder: WorkspaceFolder | undefined /** * @param openCommand 点文件时执行的命令,参数是文件 Uri * @param decorator 给行添信息(文件注释) + * @param content 一开始摆什么,默认是工作区文件树 */ - constructor(private readonly openCommand: string, private readonly decorator: FileTreeDecorator) { + constructor(private readonly openCommand: string, private readonly decorator: FileTreeDecorator, private content: TreeContent = WORKSPACE_TREE) { + this.groups = groupsOf(content) // 只关心增删:文件内容变化不影响树的结构 const watcher = workspace.createFileSystemWatcher('**/*', false, true, false) this.disposables = [ @@ -105,6 +118,7 @@ export class FileTreeProvider implements TreeDataProvider { clearTimeout(this.timer) this.timer = undefined this.nodes.clear() + this.directoryFolder = undefined this.changed.fire(undefined) } @@ -117,39 +131,31 @@ export class FileTreeProvider implements TreeDataProvider { return item } - /** 换成只摆改动的文件(undefined 回到工作区文件树),整棵树重画。 */ - showChanges(view: ChangedFilesView | undefined): void { - this.changes = view - this.groups = (view ?? []).map(group => ({ group })) + /** 换成摆 content,整棵树重画。 */ + show(content: TreeContent): void { + this.content = content + this.groups = groupsOf(content) this.refresh() } async getChildren(node?: TreeNode): Promise { - if (this.changes) + const content = this.content + if (content.kind === 'changes') return this.changedChildren(node) - if (!node) - return (workspace.workspaceFolders ?? []).map(folder => this.nodeAt(folder.uri, folder, true, true)) - if (isGroupNode(node)) + if (node && isGroupNode(node)) return [] // 分组行只在只摆改动的文件时有 - - let entries: [string, FileType][] - try { - entries = await workspace.fs.readDirectory(node.uri) - } - catch { - return [] // 目录刚被删掉或没有权限 - } - const excluded = createExcludeMatcher(workspace.getConfiguration('files', node.folder).get('exclude')) - const names = new Set(entries.map(([name]) => name)) - const dir = path.posix.relative(node.folder.uri.path, node.uri.path) - return sortEntries(entries.map(([name, type]) => ({ name, isDirectory: (type & FileType.Directory) !== 0 }))) - .filter(entry => !excluded(dir ? `${dir}/${entry.name}` : entry.name, names)) - .map(entry => this.nodeAt(Uri.joinPath(node.uri, entry.name), node.folder, entry.isDirectory, false)) + if (node) + return this.entriesOf(node.uri, node.folder) + if (content.kind === 'workspace') + return (workspace.workspaceFolders ?? []).map(folder => this.nodeAt(folder.uri, folder, true, true)) + this.directoryFolder ??= workspace.getWorkspaceFolder(content.uri) ?? { uri: content.uri, name: path.posix.basename(content.uri.path), index: workspace.workspaceFolders?.length ?? 0 } + return this.entriesOf(content.uri, this.directoryFolder) } /** * 一行的上一层,交回 getChildren 给过的同一个对象(`TreeView.reveal` 按对象认行): - * 文件、目录的上一层是所在目录,根节点的上一层是它所在的分组行(只摆改动的文件、分组有名字时),别的没有。 + * 文件、目录的上一层是所在目录,根节点的上一层是它所在的分组行(只摆改动的文件、分组有名字时); + * 摆一个目录时最上面一层(这个目录下的各项)没有上一层。 */ getParent(node: TreeNode): TreeNode | undefined { if (isGroupNode(node)) @@ -157,13 +163,15 @@ export class FileTreeProvider implements TreeDataProvider { if (node.isRoot) return this.groups.find(({ group }) => group === node.section && group.label !== undefined) const parentPath = path.posix.dirname(node.uri.path) + if (this.content.kind === 'directory' && parentPath === this.content.uri.path) + return undefined const isRoot = path.posix.relative(node.folder.uri.path, parentPath) === '' return this.nodeAt(isRoot ? node.folder.uri : node.uri.with({ path: parentPath }), node.folder, true, isRoot, node.section) } /** * 树上 uri 这一行(文件或目录):从最上面一层层往下找,只进通往它的目录,路上的行按 getChildren 摆出来的记下。 - * 不在树上(工作区外、被 files.exclude 藏了、只摆改动的文件时没摆它)时没有;同一个文件摆在几个分组里时取最先摆的那行。 + * 不在树上(工作区外、收藏的目录外、被 files.exclude 藏了、只摆改动的文件时没摆它)时没有;同一个文件摆在几个分组里时取最先摆的那行。 */ async nodeOf(uri: Uri): Promise { return this.findBelow(undefined, uri) @@ -185,7 +193,7 @@ export class FileTreeProvider implements TreeDataProvider { item.command = { command: this.openCommand, title: '打开', arguments: [node.uri] } return item } - const expanded = node.isRoot || this.changes !== undefined + const expanded = node.isRoot || this.content.kind === 'changes' const item = new TreeItem(node.uri, expanded ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed) item.iconPath = ThemeIcon.Folder if (node.isRoot) @@ -214,6 +222,23 @@ export class FileTreeProvider implements TreeDataProvider { return node } + /** 目录 dir 下面一层:目录在前、按名字排,files.exclude 按所在根 folder 算相对路径藏掉的不摆。 */ + private async entriesOf(dir: Uri, folder: WorkspaceFolder): Promise { + let entries: [string, FileType][] + try { + entries = await workspace.fs.readDirectory(dir) + } + catch { + return [] // 目录刚被删掉或没有权限 + } + const excluded = createExcludeMatcher(workspace.getConfiguration('files', folder).get('exclude')) + const names = new Set(entries.map(([name]) => name)) + const relative = path.posix.relative(folder.uri.path, dir.path) + return sortEntries(entries.map(([name, type]) => ({ name, isDirectory: (type & FileType.Directory) !== 0 }))) + .filter(entry => !excluded(relative ? `${relative}/${entry.name}` : entry.name, names)) + .map(entry => this.nodeAt(Uri.joinPath(dir, entry.name), folder, entry.isDirectory, false)) + } + /** * 只摆改动的文件时的一层:最上面是分组行(没有名字的分组直接摆它的各个根),分组下面是各个根, * 再往下只有改动的文件和通往它们的目录。 @@ -261,3 +286,8 @@ export class FileTreeProvider implements TreeDataProvider { this.timer = setTimeout(() => this.refresh(), REFRESH_DELAY_MS) } } + +/** 只摆改动的文件时每个分组一行分组行,别的没有 */ +function groupsOf(content: TreeContent): GroupNode[] { + return content.kind === 'changes' ? content.view.map(group => ({ group })) : [] +} diff --git a/vscode/src/codeReader/explorer/rowMenus.test.ts b/vscode/src/codeReader/explorer/rowMenus.test.ts index a5d3c73..b04280a 100644 --- a/vscode/src/codeReader/explorer/rowMenus.test.ts +++ b/vscode/src/codeReader/explorer/rowMenus.test.ts @@ -1,6 +1,7 @@ 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 { UNSTAGED_MARKER } from '../uncommitted/uncommittedFiles' import { COPY_COMMANDS } from './copyText' import { entryContext, GROUP_CONTEXT } from './fileEntries' @@ -12,38 +13,126 @@ interface Manifest { commands: { command: string, title: string, icon?: string }[] submenus: { id: string, label: string, icon?: string }[] menus: Record + views: Record } } -const { commands, submenus, menus } = (JSON.parse(readFileSync(path.resolve(__dirname, '../../../package.json'), 'utf8')) as Manifest).contributes +const { commands, submenus, menus, views } = (JSON.parse(readFileSync(path.resolve(__dirname, '../../../package.json'), 'utf8')) as Manifest).contributes + +/** 上下文键的值:没设的是 undefined */ +type Context = Record + +/** + * 照 VS Code 的规则算 when 子句,够菜单里用的写法:`&&` `||` `!` 括号、`==` `!=`、`=~ /正则/`、`in 列表`、单独一个键(有值算真)。 + * `&&` 比 `||` 先算,和 VS Code 一样。 + */ +function evaluate(when: string, context: Context): boolean { + let at = 0 + const skip = (): void => { + while (when[at] === ' ') + at++ + } + const eat = (token: string): boolean => { + skip() + if (!when.startsWith(token, at)) + return false + at += token.length + return true + } + const read = (pattern: RegExp): RegExpExecArray => { + skip() + const match = pattern.exec(when.slice(at)) + if (!match) + throw new Error(`读不懂 when:${when}(在「${when.slice(at)}」)`) + at += match[0].length + return match + } + const primary = (): boolean => { + if (eat('(')) { + const value = or() + if (!eat(')')) + throw new Error(`括号没配对:${when}`) + return value + } + const key = read(/^[\w.]+/)[0] + if (eat('=~')) { + const [, source, flags] = read(/^\/((?:\\.|[^/\\])*)\/([a-z]*)/) + return new RegExp(source, flags).test(String(context[key] ?? '')) + } + if (eat('==')) + return String(context[key]) === read(/^[\w.]+/)[0] + if (eat('!=')) + return String(context[key]) !== read(/^[\w.]+/)[0] + if (eat('in ')) { + const list = context[read(/^[\w.]+/)[0]] + return Array.isArray(list) && list.includes(String(context[key])) + } + return Boolean(context[key]) + } + const unary = (): boolean => eat('!') ? !unary() : primary() + function and(): boolean { + let value = unary() + while (eat('&&')) { + const right = unary() + value = value && right + } + return value + } + function or(): boolean { + let value = and() + while (eat('||')) { + const right = and() + value = value || right + } + return value + } + const value = or() + skip() + if (at !== when.length) + throw new Error(`when 没读完:${when}`) + return value +} + +const FILES = 'superpowers.codeReader.files' +const UNCOMMITTED = 'superpowers.codeReader.uncommitted' +const FAVORITE = favoriteViewId(7) +const READER_VIEWS = [FILES, UNCOMMITTED, ...FAVORITE_SLOTS.map(favoriteViewId)] +const REVIEWING = 'superpowers.codeReader.reviewing' const COPY_MENU = 'superpowers.codeReader.copy' const ADD_NOTE = 'superpowers.codeReader.addNote' const EDIT_NOTE = 'superpowers.codeReader.editNote' +const STAGE = 'superpowers.codeReader.stage' +const ASK = 'superpowers.codeReader.askPath' +const ADD_FAVORITE = 'superpowers.codeReader.addFavorite' const file = entryContext({ isDirectory: false, isRoot: false }) const folder = entryContext({ isDirectory: true, isRoot: false }) const root = entryContext({ isDirectory: true, isRoot: true }) /** 「未提交的更改」里「更改」组的文件行 */ const unstaged = entryContext({ isDirectory: false, isRoot: false }, UNSTAGED_MARKER) -const STAGE = 'superpowers.codeReader.stage' -const ASK = 'superpowers.codeReader.askPath' const noted = (context: string): string => rowDecoration(context, 'x', '注释', undefined)!.contextValue const orderOf = (item: MenuItem): number => Number(item.group?.split('@')[1] ?? 0) +const palette = new Map(menus.commandPalette.map(item => [item.command, item.when])) +const titleOf = (id: string): { title: string, icon?: string } | undefined => commands.find(item => item.command === id) -/** 某个菜单的某一组(行尾按钮 inline / 右键菜单的 copy、notes)对这一行显示哪些命令或子菜单,照 `@序号` 排 */ -function itemsFor(menu: string, group: string, contextValue: string): string[] { +/** 在 view 这一栏(context 是别的上下文键)的某一行上,某个菜单的某一组(行尾按钮 inline / 右键菜单的 copy、notes…)显示哪些命令或子菜单,照 `@序号` 排 */ +function itemsFor(menu: string, group: string, row: string, view = FILES, context: Context = {}): string[] { return menus[menu] - .filter(item => item.group?.split('@')[0] === group && item.when.includes('view == superpowers.codeReader.files')) - .filter((item) => { - const pattern = /viewItem =~ \/(.+)\/$/.exec(item.when)?.[1] - return pattern !== undefined && new RegExp(pattern).test(contextValue) - }) + .filter(item => item.group?.split('@')[0] === group && evaluate(item.when, { isMac: true, ...context, view, viewItem: row })) .sort((a, b) => orderOf(a) - orderOf(b)) .map(item => item.command ?? item.submenu ?? '') } +/** view 这一栏标题栏上显示哪些按钮,照 `@序号` 排 */ +function titleBar(view: string, context: Context = {}): string[] { + return menus['view/title'] + .filter(item => evaluate(item.when, { ...context, view })) + .sort((a, b) => orderOf(a) - orderOf(b)) + .map(item => item.command ?? '') +} + const FILE_COPIES = [ 'superpowers.codeReader.copyFileName', 'superpowers.codeReader.copyFileStem', @@ -56,70 +145,84 @@ const FOLDER_COPIES = [ 'superpowers.codeReader.copyAbsolutePath', ] -describe('侧栏行尾按钮', () => { - it('先「问 AI」再「复制」再注释:没注释的是「添加注释」,有注释的是「编辑注释」;工作区根都没有', () => { - for (const context of [file, folder]) { - expect(itemsFor('view/item/context', 'inline', context)).toEqual([ASK, COPY_MENU, ADD_NOTE]) - expect(itemsFor('view/item/context', 'inline', noted(context))).toEqual([ASK, COPY_MENU, EDIT_NOTE]) - } - expect(itemsFor('view/item/context', 'inline', root)).toEqual([]) +describe('菜单的 when 子句', () => { + it('每一条都读得懂(括号配对、正则写全)', () => { + for (const item of Object.values(menus).flat()) + expect(() => evaluate(item.when, {})).not.toThrow() }) - it('「更改」组的文件行多一个「暂存」,排在最后;别的行都没有', () => { - expect(itemsFor('view/item/context', 'inline', unstaged)).toEqual([ASK, COPY_MENU, ADD_NOTE, STAGE]) - expect(itemsFor('view/item/context', 'inline', noted(unstaged))).toEqual([ASK, COPY_MENU, EDIT_NOTE, STAGE]) + it('行菜单只挂在代码阅读的各栏,别的视图(看板、资源管理器)上没有', () => { + for (const view of ['superpowers.kanban', 'workbench.explorer.fileView']) + expect(itemsFor('view/item/context', 'inline', file, view)).toEqual([]) + }) +}) + +describe('侧栏行尾按钮', () => { + it('每一栏都是先「问 AI」再「复制」再注释:没注释的是「添加注释」,有注释的是「编辑注释」;根都没有', () => { + for (const view of READER_VIEWS) { + expect(itemsFor('view/item/context', 'inline', file, view)).toEqual([ASK, COPY_MENU, ADD_NOTE]) + expect(itemsFor('view/item/context', 'inline', noted(file), view)).toEqual([ASK, COPY_MENU, EDIT_NOTE]) + expect(itemsFor('view/item/context', 'inline', root, view)).toEqual([]) + } + }) + + it('目录行在主栏、收藏栏多一个「收藏目录」,排在最后;「未提交的更改」里没有', () => { + for (const view of [FILES, FAVORITE]) { + expect(itemsFor('view/item/context', 'inline', folder, view)).toEqual([ASK, COPY_MENU, ADD_NOTE, ADD_FAVORITE]) + expect(itemsFor('view/item/context', 'inline', noted(folder), view)).toEqual([ASK, COPY_MENU, EDIT_NOTE, ADD_FAVORITE]) + } + expect(itemsFor('view/item/context', 'inline', folder, UNCOMMITTED)).toEqual([ASK, COPY_MENU, ADD_NOTE]) + }) + + it('「未提交的更改」里「更改」组的文件行多一个「暂存」,排在最后;别的行、别的栏都没有', () => { + expect(itemsFor('view/item/context', 'inline', unstaged, UNCOMMITTED)).toEqual([ASK, COPY_MENU, ADD_NOTE, STAGE]) + expect(itemsFor('view/item/context', 'inline', noted(unstaged), UNCOMMITTED)).toEqual([ASK, COPY_MENU, EDIT_NOTE, STAGE]) for (const context of [file, folder, root, noted(file), noted(folder)]) - expect(itemsFor('view/item/context', 'inline', context)).not.toContain(STAGE) + expect(itemsFor('view/item/context', 'inline', context, UNCOMMITTED)).not.toContain(STAGE) + for (const view of [FILES, FAVORITE]) + expect(itemsFor('view/item/context', 'inline', unstaged, view)).not.toContain(STAGE) }) it('分组行(「暂存的更改」「更改」)什么按钮、菜单都没有', () => { - for (const group of ['inline', 'copy', 'fileManager', 'notes']) - expect(itemsFor('view/item/context', group, GROUP_CONTEXT)).toEqual([]) - expect(itemsFor(COPY_MENU, 'copy', GROUP_CONTEXT)).toEqual([]) + for (const group of ['inline', 'copy', 'fileManager', 'notes', 'star']) + expect(itemsFor('view/item/context', group, GROUP_CONTEXT, UNCOMMITTED)).toEqual([]) + expect(itemsFor(COPY_MENU, 'copy', GROUP_CONTEXT, UNCOMMITTED)).toEqual([]) }) it('「复制」是带 copy 图标的子菜单(VS Code 画成点开的下拉按钮),注释按钮的图标是 note / edit', () => { expect(submenus.find(item => item.id === COPY_MENU)).toEqual({ id: COPY_MENU, label: '复制', icon: '$(copy)' }) - expect(commands.find(item => item.command === ADD_NOTE)?.icon).toBe('$(note)') - expect(commands.find(item => item.command === EDIT_NOTE)?.icon).toBe('$(edit)') + expect(titleOf(ADD_NOTE)?.icon).toBe('$(note)') + expect(titleOf(EDIT_NOTE)?.icon).toBe('$(edit)') }) - it('「问 AI」在复制左边,文件和文件夹(含未提交、有注释)都有,审查时也不藏,命令面板里没有', () => { - const ask = menus['view/item/context'].filter(item => item.command === ASK) - expect(ask).toEqual([{ - command: ASK, - when: 'view == superpowers.codeReader.files && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/', - group: 'inline@0', - }]) - expect(ask[0].when.includes('filter')).toBe(false) + it('「问 AI」文件和文件夹(含未提交、有注释)都有,审查时也不藏,命令面板里没有', () => { for (const context of [file, folder, unstaged, noted(file), noted(folder), noted(unstaged)]) - expect(itemsFor('view/item/context', 'inline', context)[0]).toBe(ASK) - expect(itemsFor('view/item/context', 'inline', root)).not.toContain(ASK) - expect(commands.find(item => item.command === ASK)).toMatchObject({ title: '问 AI', icon: '$(sparkle)' }) - expect(menus.commandPalette.find(item => item.command === ASK)?.when).toBe('false') + expect(itemsFor('view/item/context', 'inline', context, FILES, { [REVIEWING]: true })[0]).toBe(ASK) + expect(titleOf(ASK)).toMatchObject({ title: '问 AI', icon: '$(sparkle)' }) + expect(palette.get(ASK)).toBe('false') }) }) describe('复制菜单', () => { - it.each([COPY_MENU, 'view/item/context'])('%s:文件四项、文件夹没有「不带后缀」,有没有注释都一样', (menu) => { - for (const context of [file, noted(file), unstaged, noted(unstaged)]) - expect(itemsFor(menu, 'copy', context)).toEqual(FILE_COPIES) - for (const context of [folder, noted(folder)]) - expect(itemsFor(menu, 'copy', context)).toEqual(FOLDER_COPIES) - expect(itemsFor(menu, 'copy', root)).toEqual([]) + it.each([COPY_MENU, 'view/item/context'])('%s:每一栏都是文件四项、文件夹没有「不带后缀」,有没有注释都一样', (menu) => { + for (const view of READER_VIEWS) { + for (const context of [file, noted(file), unstaged, noted(unstaged)]) + expect(itemsFor(menu, 'copy', context, view)).toEqual(FILE_COPIES) + for (const context of [folder, noted(folder)]) + expect(itemsFor(menu, 'copy', context, view)).toEqual(FOLDER_COPIES) + expect(itemsFor(menu, 'copy', root, view)).toEqual([]) + } }) it('菜单上的字', () => { - const title = (id: string): string | undefined => commands.find(item => item.command === id)?.title - expect(FILE_COPIES.map(title)).toEqual(['复制文件名', '复制文件名(不带后缀)', '复制相对路径', '复制绝对路径']) - expect(title('superpowers.codeReader.copyFolderName')).toBe('复制文件夹名') + expect(FILE_COPIES.map(id => titleOf(id)?.title)).toEqual(['复制文件名', '复制文件名(不带后缀)', '复制相对路径', '复制绝对路径']) + expect(titleOf('superpowers.codeReader.copyFolderName')?.title).toBe('复制文件夹名') }) it('命令都登记了、都有注册,命令面板里藏起来', () => { - const palette = new Map(menus.commandPalette.map(item => [item.command, item.when])) for (const id of [...new Set([...FILE_COPIES, ...FOLDER_COPIES])]) { expect(id in COPY_COMMANDS).toBe(true) - expect(commands.some(item => item.command === id)).toBe(true) + expect(titleOf(id)).toBeDefined() expect(palette.get(id)).toBe('false') } expect(Object.keys(COPY_COMMANDS).sort()).toEqual([...new Set([...FILE_COPIES, ...FOLDER_COPIES])].sort()) @@ -127,12 +230,14 @@ describe('复制菜单', () => { }) describe('右键菜单的注释', () => { - it('没注释的目录 / 文件只有「添加注释」,有注释的只有「编辑注释」,工作区根没有', () => { - for (const context of [file, folder, unstaged]) { - expect(itemsFor('view/item/context', 'notes', context)).toEqual([ADD_NOTE]) - expect(itemsFor('view/item/context', 'notes', noted(context))).toEqual([EDIT_NOTE]) + it('每一栏没注释的目录 / 文件只有「添加注释」,有注释的只有「编辑注释」,根没有', () => { + for (const view of READER_VIEWS) { + for (const context of [file, folder, unstaged]) { + expect(itemsFor('view/item/context', 'notes', context, view)).toEqual([ADD_NOTE]) + expect(itemsFor('view/item/context', 'notes', noted(context), view)).toEqual([EDIT_NOTE]) + } + expect(itemsFor('view/item/context', 'notes', root, view)).toEqual([]) } - expect(itemsFor('view/item/context', 'notes', root)).toEqual([]) }) }) @@ -143,101 +248,143 @@ describe('右键菜单的「在系统文件管理器中显示」', () => { { platform: 'isWindows', command: 'superpowers.codeReader.revealInFileExplorer', title: '在文件资源管理器中显示' }, { platform: 'isLinux', command: 'superpowers.codeReader.openContainingFolder', title: '打开所在的文件夹' }, ] - const REVEAL_IDS = REVEALS.map(item => item.command) - const whenOf = (command: string): string | undefined => menus['view/item/context'].find(item => item.command === command)?.when - it('文件、文件夹(含未提交、有注释)都有,工作区根没有;审查时也不藏', () => { - for (const context of [file, folder, unstaged, noted(file), noted(folder), noted(unstaged)]) - expect(itemsFor('view/item/context', 'fileManager', context)).toEqual(REVEAL_IDS) - expect(itemsFor('view/item/context', 'fileManager', root)).toEqual([]) - expect(REVEAL_IDS.some(id => whenOf(id)?.includes('filter'))).toBe(false) - }) - - it('每个系统只出自己那一项,字和 VS Code 自带的一样', () => { - for (const { platform, command, title } of REVEALS) { - const when = whenOf(command) - expect(when).toContain(`&& ${platform} &&`) - for (const other of REVEALS.filter(item => item.platform !== platform)) - expect(when).not.toContain(other.platform) - expect(commands.find(item => item.command === command)?.title).toBe(title) + it('每个系统只出自己那一项:每一栏的文件、文件夹(含未提交、有注释)都有,根没有;审查时也不藏', () => { + for (const { platform, command } of REVEALS) { + const system = { isMac: false, [platform]: true, [REVIEWING]: true } + for (const view of READER_VIEWS) { + for (const context of [file, folder, unstaged, noted(file), noted(folder), noted(unstaged)]) + expect(itemsFor('view/item/context', 'fileManager', context, view, system)).toEqual([command]) + expect(itemsFor('view/item/context', 'fileManager', root, view, system)).toEqual([]) + } } }) - it('自成一组,排在复制后面、注释前面(VS Code 按组名的字母序排组,组和组之间画分隔线)', () => { + it('字和 VS Code 自带的一样,只在右键菜单里,不挂行尾按钮,命令面板里藏起来', () => { + for (const { command, title } of REVEALS) { + expect(titleOf(command)?.title).toBe(title) + expect(menus['view/item/context'].filter(item => item.command === command)).toHaveLength(1) + expect(palette.get(command)).toBe('false') + } + }) + + it('右键菜单分组:复制、在系统文件管理器中显示、注释、收藏目录(VS Code 按组名的字母序排组,组和组之间画分隔线)', () => { const groups = menus['view/item/context'] - .filter(item => item.when.includes('view == superpowers.codeReader.files') && item.group !== undefined && !item.group.startsWith('inline')) + .filter(item => item.group !== undefined && !item.group.startsWith('inline')) .map(item => item.group!.split('@')[0]) - expect([...new Set(groups)].sort((a, b) => a.localeCompare(b))).toEqual(['copy', 'fileManager', 'notes']) - }) - - it('只在右键菜单里,不挂行尾按钮,命令面板里藏起来', () => { - for (const id of REVEAL_IDS) { - expect(menus['view/item/context'].filter(item => item.command === id)).toHaveLength(1) - expect(menus.commandPalette.find(item => item.command === id)?.when).toBe('false') - } + expect([...new Set(groups)].sort((a, b) => a.localeCompare(b))).toEqual(['copy', 'fileManager', 'notes', 'star']) }) }) -describe('筛选(审查工单、未提交的更改)', () => { - const FILTER = 'superpowers.codeReader.filter' +describe('主栏的审查工单', () => { const PICK = 'superpowers.codeReader.pickReviewIssue' - const UNCOMMITTED = 'superpowers.codeReader.showUncommitted' - const HIDE_TESTS = 'superpowers.codeReader.hideTests' - const SHOW_TESTS = 'superpowers.codeReader.showTests' + const HIDE_TESTS = 'superpowers.codeReader.hideReviewTests' + const SHOW_TESTS = 'superpowers.codeReader.showReviewTests' const SHOW_ALL = 'superpowers.codeReader.showAllFiles' - const TESTS_HIDDEN = 'superpowers.codeReader.testsHidden' - const title = menus['view/title'] - .filter(item => item.when.startsWith('view == superpowers.codeReader.files')) - .sort((a, b) => orderOf(a) - orderOf(b)) - const whenOf = (command: string): string | undefined => title.find(item => item.command === command)?.when + const REFRESH = 'superpowers.codeReader.refresh' + const TESTS_HIDDEN = 'superpowers.codeReader.reviewTestsHidden' - it('标题栏:选工单、未提交的更改、隐藏 / 显示测试文件、开着筛选时才有的「显示全部」、刷新', () => { - expect(title.map(item => item.command)).toEqual([PICK, UNCOMMITTED, HIDE_TESTS, SHOW_TESTS, SHOW_ALL, 'superpowers.codeReader.refresh']) - expect(title.every(item => item.group?.startsWith('navigation'))).toBe(true) - expect(whenOf(SHOW_ALL)).toBe(`view == superpowers.codeReader.files && ${FILTER}`) - for (const command of [PICK, UNCOMMITTED]) - expect(whenOf(command)).not.toContain(FILTER) + 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]) + expect(menus['view/title'].filter(item => item.when.includes(FILES)).every(item => item.group?.startsWith('navigation'))).toBe(true) }) - it('隐藏 / 显示测试文件在未提交的更改和审查时都有(同一个开关),按是否隐藏着换着显示,整棵项目树上没有', () => { - const listing = `(${FILTER} == uncommitted || ${FILTER} == review)` - expect(whenOf(HIDE_TESTS)).toBe(`view == superpowers.codeReader.files && ${listing} && !${TESTS_HIDDEN}`) - expect(whenOf(SHOW_TESTS)).toBe(`view == superpowers.codeReader.files && ${listing} && ${TESTS_HIDDEN}`) - const palette = new Map(menus.commandPalette.map(item => [item.command, item.when])) - expect(palette.get(HIDE_TESTS)).toBe(`${listing} && !${TESTS_HIDDEN}`) - expect(palette.get(SHOW_TESTS)).toBe(`${listing} && ${TESTS_HIDDEN}`) + it('审查时主栏行上没有注释、收藏按钮(那些行是工作副本里的,不记注释、不收藏);别的栏照常有,复制照旧', () => { + const reviewing = { [REVIEWING]: true } + expect(itemsFor('view/item/context', 'inline', file, FILES, reviewing)).toEqual([ASK, COPY_MENU]) + expect(itemsFor('view/item/context', 'inline', folder, FILES, reviewing)).toEqual([ASK, COPY_MENU]) + for (const group of ['notes', 'star']) + expect(itemsFor('view/item/context', group, folder, FILES, reviewing)).toEqual([]) + expect(itemsFor('view/item/context', 'copy', file, FILES, reviewing)).toEqual(FILE_COPIES) + expect(itemsFor('view/item/context', 'inline', folder, FAVORITE, reviewing)).toEqual([ASK, COPY_MENU, ADD_NOTE, ADD_FAVORITE]) + expect(itemsFor('view/item/context', 'inline', file, UNCOMMITTED, reviewing)).toEqual([ASK, COPY_MENU, ADD_NOTE]) }) - it('「暂存」只在未提交的更改时挂在「更改」组的文件行上,命令面板里藏起来', () => { - const stage = menus['view/item/context'].filter(item => item.command === STAGE) - expect(stage).toHaveLength(1) - expect(stage[0].when).toContain(`${FILTER} == uncommitted`) - expect(menus.commandPalette.find(item => item.command === STAGE)?.when).toBe('false') + it('命令面板:「显示全部」、隐藏 / 显示测试文件只在审查时有', () => { + expect(evaluate(palette.get(SHOW_ALL)!, {})).toBe(false) + expect(evaluate(palette.get(SHOW_ALL)!, { [REVIEWING]: true })).toBe(true) + expect(evaluate(palette.get(HIDE_TESTS)!, { [REVIEWING]: true })).toBe(true) + expect(evaluate(palette.get(HIDE_TESTS)!, {})).toBe(false) + expect(evaluate(palette.get(SHOW_TESTS)!, { [REVIEWING]: true, [TESTS_HIDDEN]: true })).toBe(true) }) it('按钮的字和图标', () => { - expect(commands.find(item => item.command === PICK)).toMatchObject({ title: '选工单', icon: '$(git-pull-request)' }) - expect(commands.find(item => item.command === UNCOMMITTED)).toMatchObject({ title: '未提交的更改', icon: '$(git-commit)' }) - expect(commands.find(item => item.command === HIDE_TESTS)).toMatchObject({ title: '隐藏测试文件', icon: '$(beaker-stop)' }) - expect(commands.find(item => item.command === SHOW_TESTS)).toMatchObject({ title: '显示测试文件', icon: '$(beaker)' }) - expect(commands.find(item => item.command === STAGE)).toMatchObject({ title: '暂存', icon: '$(add)' }) - expect(commands.find(item => item.command === SHOW_ALL)).toMatchObject({ title: '显示全部', icon: '$(close)' }) + expect(titleOf(PICK)).toMatchObject({ title: '选工单', icon: '$(git-pull-request)' }) + expect(titleOf(HIDE_TESTS)).toMatchObject({ title: '隐藏测试文件', icon: '$(beaker-stop)' }) + expect(titleOf(SHOW_TESTS)).toMatchObject({ title: '显示测试文件', icon: '$(beaker)' }) + expect(titleOf(SHOW_ALL)).toMatchObject({ title: '显示全部', icon: '$(close)' }) }) +}) - it('审查时行上没有注释按钮(工作副本里不记注释),未提交的更改时照常有;复制照旧', () => { - const noteItems = menus['view/item/context'].filter(item => item.command === ADD_NOTE || item.command === EDIT_NOTE) - expect(noteItems.length).toBeGreaterThan(0) - expect(noteItems.every(item => item.when.includes(`${FILTER} != review`))).toBe(true) - const copyItems = menus['view/item/context'].filter(item => item.submenu === COPY_MENU || item.command?.startsWith('superpowers.codeReader.copy')) - expect(copyItems.some(item => item.when.includes(FILTER))).toBe(false) +describe('「未提交的更改」一栏', () => { + const HIDE_TESTS = 'superpowers.codeReader.hideUncommittedTests' + const SHOW_TESTS = 'superpowers.codeReader.showUncommittedTests' + const REFRESH = 'superpowers.codeReader.refreshUncommitted' + const TESTS_HIDDEN = 'superpowers.codeReader.uncommittedTestsHidden' + + it('是主栏下面常驻的一栏', () => { + expect(views['superpowers-reader'].slice(0, 2)).toEqual([ + { id: FILES, name: '代码阅读' }, + { id: UNCOMMITTED, name: '未提交的更改' }, + ]) + }) + + it('标题栏:隐藏 / 显示测试文件(这一栏自己的开关,和审查的分开)、刷新;不跟着审查变', () => { + expect(titleBar(UNCOMMITTED)).toEqual([HIDE_TESTS, REFRESH]) + expect(titleBar(UNCOMMITTED, { [TESTS_HIDDEN]: true })).toEqual([SHOW_TESTS, REFRESH]) + expect(titleBar(UNCOMMITTED, { [REVIEWING]: true, 'superpowers.codeReader.reviewTestsHidden': true })).toEqual([HIDE_TESTS, REFRESH]) + }) + + it('按钮的字和图标同主栏的,命令面板里藏起来(只对这一栏)', () => { + expect(titleOf(HIDE_TESTS)).toMatchObject({ title: '隐藏测试文件', icon: '$(beaker-stop)' }) + expect(titleOf(SHOW_TESTS)).toMatchObject({ title: '显示测试文件', icon: '$(beaker)' }) + expect(titleOf(REFRESH)).toMatchObject({ title: '刷新', icon: '$(refresh)' }) + expect(titleOf(STAGE)).toMatchObject({ title: '暂存', icon: '$(add)' }) + for (const id of [HIDE_TESTS, SHOW_TESTS, REFRESH, STAGE]) + expect(palette.get(id)).toBe('false') + }) +}) + +describe('收藏栏', () => { + it(`预先声明 ${FAVORITE_SLOTS.length} 个栏位,排在「未提交的更改」后面,各自有收藏占着时才显示`, () => { + expect(views['superpowers-reader'].slice(2)).toEqual(FAVORITE_SLOTS.map(slot => ({ id: favoriteViewId(slot), name: `收藏 ${slot}`, when: favoriteContext(slot) }))) + }) + + it('每一栏的标题栏:重命名、取消收藏,只对这一栏', () => { + for (const slot of FAVORITE_SLOTS) + expect(titleBar(favoriteViewId(slot))).toEqual([favoriteCommand('rename', slot), favoriteCommand('remove', slot)]) + }) + + it('每个栏位的命令都登记了、字和图标一样,命令面板里藏起来', () => { + for (const slot of FAVORITE_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') + } }) - it('命令面板里「显示全部」只在开着筛选时有', () => { - expect(menus.commandPalette.find(item => item.command === SHOW_ALL)?.when).toBe(FILTER) + it('「收藏目录」:行尾按钮和右键菜单同一项,只对目录行(不含根、文件),星标图标,命令面板里藏起来', () => { + for (const view of [FILES, FAVORITE]) { + expect(itemsFor('view/item/context', 'star', folder, view)).toEqual([ADD_FAVORITE]) + for (const context of [file, root, unstaged]) + expect(itemsFor('view/item/context', 'star', context, view)).toEqual([]) + } + expect(itemsFor('view/item/context', 'star', folder, UNCOMMITTED)).toEqual([]) + expect(titleOf(ADD_FAVORITE)).toMatchObject({ title: '收藏目录', icon: '$(star-empty)' }) + expect(palette.get(ADD_FAVORITE)).toBe('false') }) +}) - it('只用一个上下文键,旧的 superpowers.codeReader.reviewing 不再出现', () => { +describe('菜单里用到的', () => { + it('命令都登记了;旧的筛选按钮、上下文键不再出现', () => { + const ids = new Set(commands.map(item => item.command)) + for (const item of Object.values(menus).flat().filter(item => item.command?.startsWith('superpowers.codeReader.'))) + expect(ids.has(item.command!)).toBe(true) + expect(ids.has('superpowers.codeReader.showUncommitted')).toBe(false) const whens = Object.values(menus).flat().map(item => item.when) - expect(whens.some(when => when.includes('superpowers.codeReader.reviewing'))).toBe(false) + expect(whens.some(when => /superpowers\.codeReader\.(?:filter|testsHidden)\b/.test(when))).toBe(false) }) }) diff --git a/vscode/src/codeReader/explorer/testsToggle.ts b/vscode/src/codeReader/explorer/testsToggle.ts new file mode 100644 index 0000000..f7e22b4 --- /dev/null +++ b/vscode/src/codeReader/explorer/testsToggle.ts @@ -0,0 +1,41 @@ +import type { Memento } from 'vscode' +import { commands } from 'vscode' + +/** 开关存在 workspaceState 的哪个键、标题栏按哪个上下文键换着显示「隐藏 / 显示测试文件」。 */ +export interface TestsToggleKeys { + state: string + context: string +} + +/** + * 一栏标题栏上「隐藏 / 显示测试文件」的开关:各栏各一个(主栏的审查、「未提交的更改」),按工作区记住; + * 上下文键跟着开关走,标题栏按它换着显示两个按钮。 + */ +export class TestsToggle { + private value: boolean + + /** + * @param state workspaceState + * @param keys 开关记在哪、上下文键叫什么 + * @param changed 开关换了:这一栏按新开关重摆 + */ + constructor(private readonly state: Memento, private readonly keys: TestsToggleKeys, private readonly changed: () => void) { + this.value = state.get(keys.state) ?? false + void commands.executeCommand('setContext', keys.context, this.value) + } + + /** 隐藏着测试文件:摆树时把测试文件剔掉 */ + get hidden(): boolean { + return this.value + } + + /** 标题栏的「隐藏 / 显示测试文件」:换开关、按工作区记住,这一栏马上按新开关重摆。 */ + async set(hidden: boolean): Promise { + this.value = hidden + this.changed() + await Promise.all([ + commands.executeCommand('setContext', this.keys.context, hidden), + this.state.update(this.keys.state, hidden || undefined), + ]) + } +} diff --git a/vscode/src/codeReader/explorer/treeFilters.ts b/vscode/src/codeReader/explorer/treeFilters.ts deleted file mode 100644 index 308353a..0000000 --- a/vscode/src/codeReader/explorer/treeFilters.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { Event, Memento, TreeView, Uri } from 'vscode' -import type { DiffBase } from '../diff/diffBase' -import type { FileTreeProvider, TreeNode } from './fileTree' -import { commands, Disposable, EventEmitter } from 'vscode' - -/** 上下文键:侧栏树开着哪种筛选,没设是全部文件。标题栏的「显示全部」、行上的注释按钮按它显示 / 藏起来。 */ -const FILTER_CONTEXT = 'superpowers.codeReader.filter' -/** workspaceState:隐藏测试文件。几种筛选共用这一个开关。 */ -const HIDE_TESTS_KEY = 'superpowers.codeReader.hideTests' -/** 上下文键:隐藏着测试文件。标题栏的「隐藏测试文件 / 显示测试文件」按它换着显示。 */ -const TESTS_HIDDEN_CONTEXT = 'superpowers.codeReader.testsHidden' - -/** 侧栏树的筛选:review 审查工单(只摆 PR 改过的文件),uncommitted 未提交的更改(只摆各仓库里没提交的文件)。 */ -export type TreeFilterKind = 'review' | 'uncommitted' - -/** 一种筛选。 */ -export interface TreeFilter { - /** 标题栏的「刷新」:重读这种筛选摆的文件 */ - reload: () => Promise - /** 「显示全部」或换成别的筛选:不再筛、不再记着,正在进的作废 */ - exit: () => Promise - /** 开着这种筛选时 uri 这个文件的预览对比什么(见 DiffBase);不在这种筛选管的范围里(仓库外、工作副本外)时没有 */ - diffBase: (uri: Uri) => DiffBase | undefined - /** 开着这种筛选时隐藏测试文件的开关换了:按 TreeFilters.testsHidden 马上重摆树 */ - testsToggled: () => void -} - -/** - * 侧栏树上的几种筛选,同时只开一种:开一种时别的都退出;上下文键跟着开着的那种。 - * 筛选开起来时自己换上树、标题后面的字(description)、标题下面的一行(message);退出时由这里回到工作区文件树。 - * 开着的筛选也定打开着的预览对比什么(diffBase),换了筛选、基准变了都通知预览重新比。 - * 隐藏测试文件的开关也在这里:几种筛选共用一个、按工作区记住,换了只叫开着的那种重摆。 - */ -export class TreeFilters { - private readonly filters: TreeFilter[] = [] - private current: TreeFilter | undefined - private readonly baseChange = new EventEmitter() - /** 开着的筛选换了(开了、换了、关了),或它的对比基准变了(git 状态变了、审查重读了 PR):打开着的预览照 diffBase 重新比 */ - readonly onDidChangeDiffBase: Event = this.baseChange.event - private hideTests: boolean - - /** state 是 workspaceState:隐藏测试文件的开关记在这里。 */ - constructor(private readonly files: FileTreeProvider, private readonly view: TreeView, private readonly state: Memento) { - this.hideTests = state.get(HIDE_TESTS_KEY) ?? false - void commands.executeCommand('setContext', TESTS_HIDDEN_CONTEXT, this.hideTests) - } - - /** 隐藏着测试文件:筛选摆树时把测试文件剔掉 */ - get testsHidden(): boolean { - return this.hideTests - } - - /** 标题栏的「隐藏 / 显示测试文件」:换开关、按工作区记住,开着的筛选马上按新开关重摆。 */ - async setTestsHidden(hidden: boolean): Promise { - this.hideTests = hidden - this.current?.testsToggled() - await Promise.all([ - commands.executeCommand('setContext', TESTS_HIDDEN_CONTEXT, hidden), - this.state.update(HIDE_TESTS_KEY, hidden || undefined), - ]) - } - - /** 开着的筛选下 uri 这个文件的预览对比什么;没开筛选(全部文件)时没有,预览不标差异。 */ - diffBase(uri: Uri): DiffBase | undefined { - return this.current?.diffBase(uri) - } - - /** filter 的对比基准变了时调;它没开着就不理。 */ - baseChanged(filter: TreeFilter): void { - if (this.current === filter) - this.baseChange.fire() - } - - dispose(): void { - this.baseChange.dispose() - } - - /** 开着的那种筛选;没开是全部文件 */ - get active(): TreeFilter | undefined { - return this.current - } - - /** 登记一种筛选:别的筛选开起来时让它退出。 */ - add(filter: TreeFilter): void { - this.filters.push(filter) - } - - /** - * 视图第一次露出来时调 run(已经露着就马上调):筛选接着上次的,没打开侧栏就不去拉 PR、读 git。 - * @returns 还没露出来时盯着的监听 - */ - whenShown(run: () => void): Disposable { - if (this.view.visible) { - run() - return Disposable.from() - } - const shown = this.view.onDidChangeVisibility(({ visible }) => { - if (!visible) - return - shown.dispose() - run() - }) - return shown - } - - /** filter 换上侧栏树之前调:别的筛选都退出(同步的部分在这里就做完了,调用方紧接着换上自己的),上下文键设成 kind。 */ - async enter(filter: TreeFilter, kind: TreeFilterKind): Promise { - this.current = filter - this.baseChange.fire() - await Promise.all([ - ...this.filters.filter(other => other !== filter).map(other => other.exit()), - commands.executeCommand('setContext', FILTER_CONTEXT, kind), - ]) - } - - /** filter 退出时调:它开着就回到工作区文件树,清掉标题后面的字、标题下面的一行和上下文键;没开着(还在进、或已经换成别的)什么都不动。 */ - async leave(filter: TreeFilter): Promise { - if (this.current !== filter) - return - this.current = undefined - this.baseChange.fire() - this.files.showChanges(undefined) - this.view.description = undefined - this.view.message = undefined - await commands.executeCommand('setContext', FILTER_CONTEXT, undefined) - } -} diff --git a/vscode/src/codeReader/explorer/treeLocator.ts b/vscode/src/codeReader/explorer/treeLocator.ts index b1a2b2a..81ec2a7 100644 --- a/vscode/src/codeReader/explorer/treeLocator.ts +++ b/vscode/src/codeReader/explorer/treeLocator.ts @@ -21,11 +21,11 @@ function activeTabUri(): Uri | undefined { } /** - * 侧栏的树定位到某个文件 / 目录:展开路上的目录、选中那一行(目录顺带展开),焦点留在编辑器。 + * 侧栏的一棵树(主栏、各收藏栏各一个)定位到某个文件 / 目录:展开路上的目录、选中那一行(目录顺带展开),焦点留在编辑器。 * 跟着当前文件走:激活的标签页换成一个文件就定位过去,点链接、跳到定义、在标签栏切换、侧栏点开都走这一条; * 规则同 VS Code 资源管理器:`explorer.autoReveal` 关掉不跟,`explorer.autoRevealExclude` 里的不跟; - * 树上没有的(工作区外、被 files.exclude 藏了、筛选时没摆它)不动,也不退出筛选。 - * 侧栏这棵树没露出来时不跟(reveal 会把它亮出来,抢了用户的侧栏),露出来时补上。 + * 树上没有的(工作区外、收藏的目录外、被 files.exclude 藏了、审查时没摆它)不动,也不退出审查。 + * 这棵树没露出来时不跟(reveal 会把它亮出来,抢了用户的侧栏),露出来时补上。 */ export class TreeLocator { private readonly disposables: Disposable[] @@ -50,7 +50,7 @@ export class TreeLocator { /** * 在树上定位 uri(仓库链接指到的目录);侧栏这棵树没露出来时 VS Code 把它亮出来,点了链接就是要看。 - * @returns 树上没有它(工作区外、被藏了、筛选时没摆它)时 false + * @returns 树上没有它(工作区外、被藏了、审查时没摆它)时 false */ async reveal(uri: Uri): Promise { // 要看的是这一行:正在跟的作废,树因此亮出来时也不再跟回当前文件 @@ -89,7 +89,7 @@ export class TreeLocator { await this.show(node) } catch (err) { - // 找到行之后树刚好刷新了(文件增删、换了筛选),这一行已经不是树上的那个对象 + // 找到行之后树刚好刷新了(文件增删、进出审查),这一行已经不是树上的那个对象 const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'warn', source: 'codeReader', message: `侧栏没能定位到 ${uri.fsPath}:${message}` }) } diff --git a/vscode/src/codeReader/explorer/viewShown.ts b/vscode/src/codeReader/explorer/viewShown.ts new file mode 100644 index 0000000..8c6de23 --- /dev/null +++ b/vscode/src/codeReader/explorer/viewShown.ts @@ -0,0 +1,20 @@ +import type { TreeView } from 'vscode' +import { Disposable } from 'vscode' + +/** + * 侧栏一栏第一次露出来时调 run(已经露着就马上调):没展开这一栏就不去拉 PR、读 git。 + * @returns 还没露出来时盯着的监听 + */ +export function whenShown(view: TreeView, run: () => void): Disposable { + if (view.visible) { + run() + return Disposable.from() + } + const shown = view.onDidChangeVisibility(({ visible }) => { + if (!visible) + return + shown.dispose() + run() + }) + return shown +} diff --git a/vscode/src/codeReader/favorites/favoriteSlots.test.ts b/vscode/src/codeReader/favorites/favoriteSlots.test.ts new file mode 100644 index 0000000..0aebcca --- /dev/null +++ b/vscode/src/codeReader/favorites/favoriteSlots.test.ts @@ -0,0 +1,59 @@ +import type { Favorite } from './favoriteSlots' +import { describe, expect, it } from 'vitest' +import { FAVORITE_SLOTS, favoriteCommand, favoriteContext, favoriteViewId, 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)) + +describe('新收藏占哪个栏位', () => { + it('没有收藏时占第 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 }) + }) + + 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 }) + }) + + 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 = [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) }) + }) +}) + +describe('重命名、取消收藏', () => { + it('重命名只改那一栏的名字,栏位不动', () => { + expect(renameFavorite([favorite(1), favorite(4)], 4, '后端接口')).toEqual([favorite(1), favorite(4, '后端接口')]) + }) + + it('取消收藏只去掉那一栏;没占着的栏位什么都不变', () => { + expect(removeFavorite([favorite(1), favorite(4)], 1)).toEqual([favorite(4)]) + expect(removeFavorite([favorite(1), favorite(4)], 2)).toEqual([favorite(1), favorite(4)]) + }) +}) + +describe('栏位对应的视图、上下文键、命令', () => { + it('第 n 个栏位各有自己的视图 id、显示不显示的上下文键、标题栏上重命名 / 取消收藏的命令', () => { + expect(favoriteViewId(3)).toBe('superpowers.codeReader.favorite3') + expect(favoriteContext(3)).toBe('superpowers.codeReader.hasFavorite3') + 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 new file mode 100644 index 0000000..e05d422 --- /dev/null +++ b/vscode/src/codeReader/favorites/favoriteSlots.ts @@ -0,0 +1,49 @@ +/** + * 收藏栏的栏位:VS Code 的视图要在 package.json 里预先声明,所以声明了固定这么多个收藏栏(superpowers.codeReader.favorite1 … favorite30), + * 有收藏占着的才显示。这里是栏位号 1、2、3… + */ +export const FAVORITE_SLOTS: readonly number[] = Array.from({ length: 30 }, (_, index) => index + 1) + +/** 一个收藏:目录(Uri 字符串)、栏标题上的名字、占着第几个栏位。 */ +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 按栏位记着,所以不会错乱。 + */ +export function placeFavorite(favorites: readonly Favorite[], uri: string): { 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)) + return slot === undefined ? 'full' : { slot } +} + +/** 第 slot 栏的收藏改叫 name,栏位不动。 */ +export function renameFavorite(favorites: readonly Favorite[], slot: number, name: string): Favorite[] { + return favorites.map(favorite => favorite.slot === slot ? { ...favorite, name } : favorite) +} + +/** 取消第 slot 栏的收藏,空出这个栏位。 */ +export function removeFavorite(favorites: readonly Favorite[], slot: number): Favorite[] { + return favorites.filter(favorite => favorite.slot !== slot) +} diff --git a/vscode/src/codeReader/favorites/favoriteViews.ts b/vscode/src/codeReader/favorites/favoriteViews.ts new file mode 100644 index 0000000..f41a20d --- /dev/null +++ b/vscode/src/codeReader/favorites/favoriteViews.ts @@ -0,0 +1,160 @@ +import type { Memento, TreeView } from 'vscode' +import type { FileNode, FileTreeDecorator, TreeNode } from '../explorer/fileTree' +import type { Favorite } from './favoriteSlots' +import * as path from 'node:path' +import { commands, 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' + +/** 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`) +} + +/** + * 一个收藏栏:这个目录下的完整文件树(自己的文件树和视图),标题是起的名字、后面淡色写目录相对工作区的路径; + * 当前文件在这个目录下、这一栏露着时跟过去定位。目录被删了时栏里说一句目录不在了。收藏时建、取消收藏时销毁。 + */ +class FavoritePane { + private readonly uri: Uri + private readonly view: TreeView + private readonly disposables: { dispose: () => unknown }[] + /** 第几次查目录在不在:连着查时只认最后一次 */ + private round = 0 + + 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.description = workspace.asRelativePath(this.uri) + this.disposables = [ + files, + this.view, + new TreeLocator(files, this.view), + // 树整棵重摆时(目录下有增删、工作区文件夹变了)看看目录还在不在 + files.onDidChangeTreeData((nodes) => { + if (!nodes) + void this.check() + }), + ] + void this.check() + } + + rename(name: string): void { + this.view.title = name + } + + dispose(): void { + for (const disposable of this.disposables) + disposable.dispose() + } + + private async check(): Promise { + const round = ++this.round + const exists = await workspace.fs.stat(this.uri).then(stat => (stat.type & FileType.Directory) !== 0, () => false) + if (round === this.round) + this.view.message = exists ? undefined : `目录不在了:${workspace.asRelativePath(this.uri)}` + } +} + +/** + * 侧栏的收藏栏:收藏一个目录占一个栏位,栏里是这个目录下的完整文件树,规则同主栏的工作区文件树、不受审查影响(栏位怎么分见 placeFavorite)。 + * 只给有收藏占着的栏位建视图,上下文键让这一栏显示出来;收藏按工作区记住,打开窗口时照记着的建回来。 + */ +export class FavoriteViews { + private favorites: Favorite[] + /** 栏位号 → 那一栏 */ + private readonly panes = new Map() + + /** + * @param state workspaceState + * @param openCommand 点文件时执行的命令,参数是文件 Uri + * @param decorator 给行添信息(文件注释),和别的栏共用 + */ + constructor(private readonly state: Memento, private readonly openCommand: string, private readonly decorator: FileTreeDecorator) { + this.favorites = state.get(FAVORITES_KEY) ?? [] + for (const favorite of this.favorites) + this.open(favorite) + void Promise.all(this.favorites.map(favorite => commands.executeCommand('setContext', favoriteContext(favorite.slot), true))) + } + + /** 目录行上的「收藏目录」:起个名字(默认是目录名)占一个空栏位并亮出那一栏;收藏过的说一声、亮出原来那一栏,满了说一声。 */ + async add(node: FileNode | undefined): Promise { + if (!node) + return // 只挂在侧栏的目录行上,别处调用没有目标 + const uri = node.uri.toString() + const placement = placeFavorite(this.favorites, uri) + if (placement === 'full') { + void window.showWarningMessage(`收藏栏满了(最多 ${FAVORITE_SLOTS.length} 个),先取消一个再收藏`) + return + } + if ('existing' in placement) { + void window.showInformationMessage(`这个目录已经收藏过了:「${placement.existing.name}」`) + await focus(placement.existing.slot) + return + } + const name = await askName('收藏目录', 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) + } + + /** 收藏栏标题栏的「重命名」:改第 slot 栏的标题。 */ + async rename(slot: number): Promise { + const favorite = this.favorites.find(item => item.slot === slot) + if (!favorite) + return + const name = await askName('重命名收藏', favorite.name) + if (name === undefined) + return + this.panes.get(slot)?.rename(name) + await this.save(renameFavorite(this.favorites, slot, name)) + } + + /** 收藏栏标题栏的「取消收藏」:藏起第 slot 栏、空出栏位,再销毁它的视图(免得露着的栏里闪一下没有数据)。 */ + async remove(slot: number): Promise { + 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), + ]) + pane?.dispose() + } + + dispose(): void { + for (const pane of this.panes.values()) + pane.dispose() + } + + private open(favorite: Favorite): void { + this.panes.set(favorite.slot, new FavoritePane(favorite, this.openCommand, this.decorator)) + } + + private async save(favorites: Favorite[]): Promise { + this.favorites = favorites + await this.state.update(FAVORITES_KEY, favorites.length > 0 ? favorites : undefined) + } +} diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index 54463a3..6151a79 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -7,6 +7,7 @@ import { logger } from '../logging/logger' import { packageCommandRules } from './commands/packageCommands' import { shellCommandRules } from './commands/shellCommands' import { systemCommandRules } from './commands/systemCommands' +import { locatedDiffBases } from './diff/diffBase' import { BlockFilter } from './editor/blockFilter' import { NEXT_OCCURRENCE, PREVIOUS_OCCURRENCE } from './editor/occurrenceNavigation' import { languageOf, openingFor } from './editor/openTarget' @@ -19,8 +20,9 @@ import { FileTreeProvider } from './explorer/fileTree' import { registerPathAsk } from './explorer/pathAskPanel' import { registerRevealCommands } from './explorer/revealCommands' import { RowDecorator } from './explorer/rowDecorator' -import { TreeFilters } from './explorer/treeFilters' import { TreeLocator } from './explorer/treeLocator' +import { FAVORITE_SLOTS, favoriteCommand } from './favorites/favoriteSlots' +import { FavoriteViews } from './favorites/favoriteViews' import { createBashAdapter } from './languages/bash' import { createDockerfileAdapter } from './languages/dockerfile' import { createPythonAdapter } from './languages/python' @@ -52,7 +54,8 @@ import { dockerfileZhTranslator } from './translators/dockerfileZh' import { pythonZhTranslator } from './translators/pythonZh' import { rustZhTranslator } from './translators/rustZh' import { typescriptZhTranslator } from './translators/typescriptZh' -import { UncommittedMode } from './uncommitted/uncommittedMode' +import { HeadBases } from './uncommitted/headBases' +import { UncommittedView } from './uncommitted/uncommittedView' import { workspaceIndexHost } from './workspaceFiles' import { WorkspaceIndexes } from './workspaceIndex' @@ -72,7 +75,8 @@ async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise { } /** - * 接入代码阅读:注册表、侧栏文件树(问路径、复制、在系统文件管理器里显示、文件注释、筛选:按审查工单只看 PR 改过的文件、只看未提交的更改)、预览编辑器(含问 AI)、编辑器右上角的「预览 / 编辑」切换。 + * 接入代码阅读:注册表、侧栏的几栏(主栏的文件树可以按审查工单只看 PR 改过的文件,常驻的「未提交的更改」,每个收藏目录一栏; + * 行上有问路径、复制、在系统文件管理器里显示、文件注释、收藏目录)、预览编辑器(含问 AI,按文件所在位置标差异)、编辑器右上角的「预览 / 编辑」切换。 * 内置的语言插件、翻译插件、库规则和命令规则与其他扩展一样通过返回的 API 注册。 */ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { @@ -94,14 +98,17 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { } const notes = new FileNotes() const entries = new WorkspaceEntries() - const files = new FileTreeProvider(OPEN_COMMAND, new RowDecorator(notes, entries)) + const decorator = new RowDecorator(notes, entries) + const files = new FileTreeProvider(OPEN_COMMAND, decorator) const view = window.createTreeView(FILES_VIEW, { treeDataProvider: files, showCollapseAll: true }) const locator = new TreeLocator(files, view) - const filters = new TreeFilters(files, view, context.workspaceState) - const review = new ReviewMode(context, FILES_VIEW, view, files, filters) - const uncommitted = new UncommittedMode(context, FILES_VIEW, view, files, filters) - // 侧栏开着的筛选定预览对比什么:未提交的更改对比上一次提交,审查工单对比 PR 的目标分支 - const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, namesIn, (index, key) => indexes.find(index, key), filters, entries, uri => locator.reveal(uri)) + 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 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)) context.subscriptions.push( qa, @@ -129,18 +136,27 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { files, view, locator, - filters, review, uncommitted, - // 开着筛选时刷新是重读它摆的文件(PR 改过的、git 状态),平时是重读工作区文件树 - commands.registerCommand('superpowers.codeReader.refresh', () => filters.active ? filters.active.reload() : files.refresh()), + favorites, + heads, + // 主栏审查时刷新是重读 PR 改过的文件,平时是重读工作区文件树 + commands.registerCommand('superpowers.codeReader.refresh', () => review.reviewing ? review.reload() : files.refresh()), commands.registerCommand('superpowers.codeReader.pickReviewIssue', () => review.pick()), - commands.registerCommand('superpowers.codeReader.showUncommitted', () => uncommitted.enter()), - commands.registerCommand('superpowers.codeReader.hideTests', () => filters.setTestsHidden(true)), - commands.registerCommand('superpowers.codeReader.showTests', () => filters.setTestsHidden(false)), + commands.registerCommand('superpowers.codeReader.hideReviewTests', () => review.setTestsHidden(true)), + commands.registerCommand('superpowers.codeReader.showReviewTests', () => review.setTestsHidden(false)), + commands.registerCommand('superpowers.codeReader.showAllFiles', () => review.exit()), + commands.registerCommand('superpowers.codeReader.refreshUncommitted', () => uncommitted.reload()), + commands.registerCommand('superpowers.codeReader.hideUncommittedTests', () => uncommitted.setTestsHidden(true)), + commands.registerCommand('superpowers.codeReader.showUncommittedTests', () => uncommitted.setTestsHidden(false)), // 「更改」组文件行上的按钮传来的是这一行的节点 commands.registerCommand('superpowers.codeReader.stage', (node?: FileNode) => uncommitted.stage(node)), - commands.registerCommand('superpowers.codeReader.showAllFiles', () => filters.active?.exit()), + // 目录行上的按钮传来的是这一行的节点;收藏栏标题栏上的按钮不带是哪一栏,所以每个栏位各一条命令 + commands.registerCommand('superpowers.codeReader.addFavorite', (node?: FileNode) => favorites.add(node)), + ...FAVORITE_SLOTS.flatMap(slot => [ + commands.registerCommand(favoriteCommand('rename', slot), () => favorites.rename(slot)), + commands.registerCommand(favoriteCommand('remove', slot), () => favorites.remove(slot)), + ]), commands.registerCommand(OPEN_COMMAND, (uri: Uri) => openFile(uri, registry)), // 标题栏按钮传来的是 (文件 Uri, { groupId }),原样转给 VS Code 定位是哪一组的哪个标签 commands.registerCommand('superpowers.codeReader.showPreview', (...args: unknown[]) => showPreview(previews, args)), diff --git a/vscode/src/codeReader/review/reviewMode.ts b/vscode/src/codeReader/review/reviewMode.ts index 13a7d2d..a64df3f 100644 --- a/vscode/src/codeReader/review/reviewMode.ts +++ b/vscode/src/codeReader/review/reviewMode.ts @@ -1,15 +1,17 @@ -import type { Disposable, ExtensionContext, QuickPickItem, TreeView, WorkspaceFolder } from 'vscode' +import type { Disposable, Event, ExtensionContext, QuickPickItem, TreeView, WorkspaceFolder } from 'vscode' import type { PrFileDto } from '../../gitea/api' import type { RepoContext } from '../../gitea/repoContext' import type { Issue } from '../../gitea/types' -import type { DiffBase } from '../diff/diffBase' +import type { DiffBase, DiffBaseSource } from '../diff/diffBase' import type { FileTreeProvider, TreeNode } from '../explorer/fileTree' -import type { TreeFilter, TreeFilters } from '../explorer/treeFilters' import type { FileCount } from './pickList' import * as path from 'node:path' -import { QuickPickItemKind, Uri, window, workspace } from 'vscode' +import { commands, EventEmitter, QuickPickItemKind, Uri, window, workspace } from 'vscode' import { fileAtRef } from '../../git/fileAt' import { logger } from '../../logging/logger' +import { WORKSPACE_TREE } from '../explorer/fileTree' +import { TestsToggle } from '../explorer/testsToggle' +import { whenShown } from '../explorer/viewShown' import { toPathKey } from '../pathMap' import { ChangeDecorations } from './changeDecorations' import { changeOf, reviewBaseFile, reviewTree } from './changedFiles' @@ -20,6 +22,10 @@ import { localWorktree, prepareWorktree } from './reviewWorktree' /** workspaceState:正在审查的工单号,重开窗口接着审查。 */ const REVIEW_ISSUE_KEY = 'superpowers.codeReader.reviewIssue' +/** 上下文键:主栏正审查着工单。标题栏的「显示全部」、隐藏 / 显示测试文件按它显示,主栏行上的注释、收藏按钮按它藏起来(那些行是工作副本里的)。 */ +const REVIEWING_CONTEXT = 'superpowers.codeReader.reviewing' +/** 审查时隐藏测试文件的开关:记在 workspaceState 的键、标题栏按它换按钮的上下文键 */ +const REVIEW_TESTS = { state: 'superpowers.codeReader.reviewHideTests', context: 'superpowers.codeReader.reviewTestsHidden' } /** * 怎么进的审查,决定不能审查、出错时怎么收场: @@ -49,34 +55,43 @@ function errorText(err: unknown): string { } /** - * 侧栏按审查工单筛文件:「选工单」列出看板「审查」「进行中」列里能审查的工单,选中后树里只剩这张工单的 PR 改过的文件(按改动着色), - * 点开的是本机这张工单的工作副本里 PR 最新的代码,预览对比 PR 的目标分支(diffBase);「显示全部」回到工作区文件树。选的工单按工作区记住。 - * 和别的筛选(未提交的更改)互斥,见 TreeFilters。 + * 主栏按审查工单筛文件:「选工单」列出看板「审查」「进行中」列里能审查的工单,选中后主栏只剩这张工单的 PR 改过的文件(按改动着色), + * 点开的是本机这张工单的工作副本里 PR 最新的代码,工作副本里的文件预览对比 PR 的目标分支(diffBase);「显示全部」回到工作区文件树。 + * 选的工单按工作区记住,主栏第一次露出来时接着审查;隐藏测试文件的开关是审查自己的,也按工作区记住。 */ -export class ReviewMode implements TreeFilter, Disposable { +export class ReviewMode implements DiffBaseSource, Disposable { private readonly decorations = new ChangeDecorations() + private readonly baseChange = new EventEmitter() + /** 审查的对比基准变了(进了审查、重读了 PR、换了工单、退出了):打开着的预览重新问 */ + readonly onDidChange: Event = this.baseChange.event + private readonly tests: TestsToggle private readonly disposables: Disposable[] - /** 正在审查的:工单、PR、改过的文件、工作副本、对比基准 */ + /** 正在审查的(主栏摆着它):工单、PR、改过的文件、工作副本、对比基准 */ private loaded: Loaded | undefined /** 每开始一次进审查、退出审查都加一:慢的那次回来时已经不是最新的,结果丢掉。 */ private generation = 0 - /** viewId 是侧栏视图的 id:进审查时在这个视图上转进度。 */ + /** viewId、view、files 是主栏的视图 id、视图、文件树:进审查时在主栏上转进度、换上 PR 改过的文件。 */ constructor( private readonly context: ExtensionContext, private readonly viewId: string, private readonly view: TreeView, private readonly files: FileTreeProvider, - private readonly filters: TreeFilters, ) { - filters.add(this) + this.tests = new TestsToggle(context.workspaceState, REVIEW_TESTS, () => this.place()) this.disposables = [ this.decorations, + this.baseChange, window.registerFileDecorationProvider(this.decorations), - filters.whenShown(() => void this.restore()), + whenShown(view, () => void this.restore()), ] } + /** 正审查着工单:主栏只摆着 PR 改过的文件 */ + get reviewing(): boolean { + return this.loaded !== undefined + } + /** * 「选工单」:顶部弹出快速选择,列出看板上现在摆着的「审查」「进行中」列里能审查的工单(按列分组),马上就有; * 各 PR 改了几个文件一起去读,读到一个换一项,都读完才停止转圈。 @@ -152,13 +167,20 @@ export class ReviewMode implements TreeFilter, Disposable { : [])) } - /** 「显示全部」或换成别的筛选:不再审查、不再记着这张工单,正在进的审查作废;正审查着的回到工作区文件树。 */ + /** 「显示全部」:不再审查、不再记着这张工单,正在进的审查作废;正审查着的主栏回到工作区文件树,清掉标题后面的字和标题下面的一行。 */ async exit(): Promise { this.generation++ + const shown = this.loaded !== undefined this.loaded = undefined this.decorations.show([]) + if (shown) { + this.baseChange.fire() + this.files.show(WORKSPACE_TREE) + this.view.description = undefined + this.view.message = undefined + } await Promise.all([ - this.filters.leave(this), + commands.executeCommand('setContext', REVIEWING_CONTEXT, false), this.context.workspaceState.update(REVIEW_ISSUE_KEY, undefined), ]) } @@ -188,9 +210,9 @@ export class ReviewMode implements TreeFilter, Disposable { } } - /** 隐藏测试文件的开关换了:正审查着的文件按新开关重摆。 */ - testsToggled(): void { - this.place() + /** 审查时标题栏的「隐藏 / 显示测试文件」:正审查着的文件按新开关重摆。 */ + setTestsHidden(hidden: boolean): Promise { + return this.tests.set(hidden) } dispose(): void { @@ -277,13 +299,13 @@ export class ReviewMode implements TreeFilter, Disposable { private async show(loaded: Loaded): Promise { const root = Uri.file(loaded.worktree) - // 先记下再进:进的时候打开着的预览就来问对比基准 + // 先记下再通知:打开着的预览马上来问对比基准 this.loaded = loaded - const entering = this.filters.enter(this, 'review') + this.baseChange.fire() this.place() this.decorations.show(loaded.files.map(file => ({ uri: Uri.joinPath(root, file.path), change: changeOf(file.status) }))) await Promise.all([ - entering, + commands.executeCommand('setContext', REVIEWING_CONTEXT, true), this.context.workspaceState.update(REVIEW_ISSUE_KEY, loaded.issue.number), ]) } @@ -296,8 +318,8 @@ export class ReviewMode implements TreeFilter, Disposable { const { issue, pr, files, worktree } = loaded // 工作副本当根,名字照写工作区文件夹的:看的还是这个仓库,只是 PR 最新的代码 const folder: WorkspaceFolder = { uri: Uri.file(worktree), name: workspace.workspaceFolders?.[0]?.name ?? path.basename(worktree), index: 0 } - const { tree, hidden } = reviewTree(files, this.filters.testsHidden) - this.files.showChanges([{ roots: [{ folder, tree }] }]) + const { tree, hidden } = reviewTree(files, this.tests.hidden) + this.files.show({ kind: 'changes', view: [{ roots: [{ folder, tree }] }] }) this.view.description = reviewDescription(issue.number, hidden) this.view.message = reviewMessage(issue, pr, files.length) } diff --git a/vscode/src/codeReader/uncommitted/gitWatch.ts b/vscode/src/codeReader/uncommitted/gitWatch.ts new file mode 100644 index 0000000..f2f1668 --- /dev/null +++ b/vscode/src/codeReader/uncommitted/gitWatch.ts @@ -0,0 +1,42 @@ +import type { GitApi, GitRepository } from '../../git/gitApi' +import { Disposable, workspace } from 'vscode' + +/** git 状态连着变(保存、暂存、切分支)时合成一次。 */ +const SETTLE_MS = 300 + +/** + * 盯着 git:各仓库的状态变了(改动、HEAD 动了)、开了 / 关了仓库、git 扩展找完仓库、工作区文件夹变了,连着变的合成一次调 changed。 + * @returns 不再盯了时 dispose + */ +export function watchGit(api: GitApi, changed: () => void): Disposable { + let timer: NodeJS.Timeout | undefined + const schedule = (): void => { + clearTimeout(timer) + timer = setTimeout(changed, SETTLE_MS) + } + const repositories = new Map() + const add = (repository: GitRepository): void => { + if (!repositories.has(repository)) + repositories.set(repository, repository.state.onDidChange(schedule)) + } + api.repositories.forEach(add) + return Disposable.from( + api.onDidOpenRepository((repository) => { + add(repository) + schedule() + }), + api.onDidCloseRepository((repository) => { + repositories.get(repository)?.dispose() + repositories.delete(repository) + schedule() + }), + api.onDidChangeState(schedule), + workspace.onDidChangeWorkspaceFolders(schedule), + { + dispose: () => { + clearTimeout(timer) + repositories.forEach(listener => listener.dispose()) + }, + }, + ) +} diff --git a/vscode/src/codeReader/uncommitted/headBases.ts b/vscode/src/codeReader/uncommitted/headBases.ts new file mode 100644 index 0000000..4bef6b8 --- /dev/null +++ b/vscode/src/codeReader/uncommitted/headBases.ts @@ -0,0 +1,64 @@ +import type { Disposable, Event, Uri } from 'vscode' +import type { GitApi } from '../../git/gitApi' +import type { DiffBase, DiffBaseSource } from '../diff/diffBase' +import { EventEmitter, workspace } from 'vscode' +import { fileAtRef } from '../../git/fileAt' +import { loadGitApi } from '../../git/gitExtension' +import { logger } from '../../logging/logger' +import { watchGit } from './gitWatch' +import { headDiffBase } from './uncommittedFiles' + +function errorText(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + +/** + * 工作区仓库里有没提交改动的文件,预览对比上一次提交(见 headDiffBase),不管是从哪一栏、点链接还是切标签打开的。 + * 第一次有预览来问时才去读 git,读到了通知预览重新问;之后盯着 git 状态,改动、HEAD 变了都通知打开着的预览重新比。 + */ +export class HeadBases implements DiffBaseSource, Disposable { + private readonly changed = new EventEmitter() + readonly onDidChange: Event = this.changed.event + private api: GitApi | undefined + /** 已经去读 git 了:读没读到都不再读 */ + private loading = false + private watching: Disposable | undefined + + diffBase(uri: Uri): DiffBase | undefined { + const api = this.api + if (!api) { + void this.load() + return undefined + } + const repository = api.getRepository(uri) + if (!repository) + return undefined + return headDiffBase(repository, uri.path, workspace.workspaceFolders ?? [], (head, relative) => fileAtRef(repository.rootUri.fsPath, head, relative)) + } + + dispose(): void { + this.watching?.dispose() + this.changed.dispose() + } + + private async load(): Promise { + if (this.loading) + return + this.loading = true + let api: GitApi | undefined + try { + api = await loadGitApi() + } + catch (err) { + logger.add({ level: 'warn', source: 'codeReader', message: '对比上一次提交:激活 vscode.git 扩展失败,预览不标差异', details: errorText(err) }) + return + } + if (!api) { + logger.add({ level: 'info', source: 'codeReader', message: '对比上一次提交:vscode.git 扩展没装或被禁用了,预览不标差异' }) + return + } + this.api = api + this.watching = watchGit(api, () => this.changed.fire()) + this.changed.fire() + } +} diff --git a/vscode/src/codeReader/uncommitted/testFiles.ts b/vscode/src/codeReader/uncommitted/testFiles.ts index 69e7d81..9ca903f 100644 --- a/vscode/src/codeReader/uncommitted/testFiles.ts +++ b/vscode/src/codeReader/uncommitted/testFiles.ts @@ -26,7 +26,7 @@ export function isTestFile(path: string): boolean { return segments.some(dir => TEST_DIRS.has(dir.toLowerCase())) || TEST_FILE_NAMES.some(pattern => pattern.test(name)) } -/** 视图标题后面淡色的字(TreeView.description):藏了测试文件时在后面注明藏了几个。「未提交的更改」和审查用同一个说法。 */ +/** 视图标题后面淡色的字(TreeView.description):藏了测试文件时在后面注明藏了几个(审查时用)。 */ export function withHiddenTests(description: string, hiddenTests: number): string { return hiddenTests > 0 ? `${description} · 已隐藏 ${hiddenTests} 个测试文件` : description } diff --git a/vscode/src/codeReader/uncommitted/uncommittedFiles.test.ts b/vscode/src/codeReader/uncommitted/uncommittedFiles.test.ts index 90307cb..7621356 100644 --- a/vscode/src/codeReader/uncommitted/uncommittedFiles.test.ts +++ b/vscode/src/codeReader/uncommitted/uncommittedFiles.test.ts @@ -1,8 +1,8 @@ import type { GitStatus } from '../../git/gitApi' import type { ChangeGroups } from './uncommittedFiles' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { GIT_STATUS } from '../../git/gitApi' -import { changeOfGitStatus, headMissingText, isUntracked, uncommittedDescription, uncommittedFiles, uncommittedMessage, uncommittedRoots, withoutTestFiles, workspaceOrder } from './uncommittedFiles' +import { changeOfGitStatus, headDiffBase, headMissingText, isUntracked, uncommittedDescription, uncommittedFiles, uncommittedMessage, uncommittedRoots, withoutTestFiles, workspaceOrder } from './uncommittedFiles' const at = (path: string, status: GitStatus): { uri: { path: string }, status: GitStatus } => ({ uri: { path }, status }) @@ -176,9 +176,9 @@ describe('隐藏测试文件', () => { }) describe('标题后面的字、标题下面的一行', () => { - it('藏了测试文件时标题后面注明藏了几个', () => { - expect(uncommittedDescription(0)).toBe('未提交的更改') - expect(uncommittedDescription(3)).toBe('未提交的更改 · 已隐藏 3 个测试文件') + it('藏了测试文件时标题后面注明藏了几个,没藏的不写(标题本身就是「未提交的更改」)', () => { + expect(uncommittedDescription(0)).toBeUndefined() + expect(uncommittedDescription(3)).toBe('已隐藏 3 个测试文件') }) it('树上有文件时没有提示;只剩藏起来的测试文件、什么都没有、git 还在找仓库时各说一句', () => { @@ -203,3 +203,53 @@ describe('对比上一次提交', () => { expect(headMissingText({ hasCommit: false, untracked: true })).toBe('仓库里还没有提交') }) }) + +describe('工作区仓库里的文件按位置对比上一次提交', () => { + const folders = [{ uri: { path: '/ws' }, name: 'ws' }] + const repository = (root: string, parts: Partial, HEAD: { commit?: string } = { commit: 'abc123' }) => + ({ rootUri: { path: root }, state: { ...groups(parts), HEAD } }) + + it('有没提交的改动(暂存的、没暂存的、未跟踪的、冲突的)才比:基准是上一次提交里的同一个文件', async () => { + for (const parts of [ + { indexChanges: [at('/ws/src/a.ts', GIT_STATUS.INDEX_MODIFIED)] }, + { workingTreeChanges: [at('/ws/src/a.ts', GIT_STATUS.MODIFIED)] }, + { untrackedChanges: [at('/ws/src/a.ts', GIT_STATUS.UNTRACKED)] }, + { mergeChanges: [at('/ws/src/a.ts', GIT_STATUS.BOTH_MODIFIED)] }, + ]) { + const read = vi.fn(async () => 'old') + const base = headDiffBase(repository('/ws', parts), '/ws/src/a.ts', folders, read) + + expect(base).toMatchObject({ label: '上一次提交', key: 'abc123:src/a.ts' }) + expect(await base?.read()).toBe('old') + expect(read).toHaveBeenCalledWith('abc123', 'src/a.ts') + } + }) + + it('没改动的文件不比(预览不标差异)', () => { + const repo = repository('/ws', { workingTreeChanges: [at('/ws/src/b.ts', GIT_STATUS.MODIFIED)] }) + expect(headDiffBase(repo, '/ws/src/a.ts', folders, vi.fn())).toBeUndefined() + }) + + it('不算工作区里的仓库(审查的工作副本、别处的仓库)里的不比,改了也不比', () => { + const worktree = repository('/worktrees/issue-446', { workingTreeChanges: [at('/worktrees/issue-446/a.ts', GIT_STATUS.MODIFIED)] }) + expect(headDiffBase(worktree, '/worktrees/issue-446/a.ts', folders, vi.fn())).toBeUndefined() + }) + + it('嵌套在工作区里的仓库、包着工作区的仓库都算,路径相对仓库根', () => { + const nested = repository('/ws/packages/lib', { workingTreeChanges: [at('/ws/packages/lib/x.ts', GIT_STATUS.MODIFIED)] }) + expect(headDiffBase(nested, '/ws/packages/lib/x.ts', folders, vi.fn())?.key).toBe('abc123:x.ts') + const outer = repository('/repo', { workingTreeChanges: [at('/repo/app/x.ts', GIT_STATUS.MODIFIED)] }) + expect(headDiffBase(outer, '/repo/app/x.ts', [{ uri: { path: '/repo/app' }, name: 'app' }], vi.fn())?.key).toBe('abc123:app/x.ts') + }) + + it('新文件:上一次提交里没有,横幅说还没被跟踪;仓库还没有提交时不去读', async () => { + const untracked = repository('/ws', { untrackedChanges: [at('/ws/new.ts', GIT_STATUS.UNTRACKED)] }) + expect(headDiffBase(untracked, '/ws/new.ts', folders, vi.fn())?.missing).toBe('还没被 git 跟踪,上一次提交里没有这个文件') + + const read = vi.fn() + const empty = headDiffBase(repository('/ws', { indexChanges: [at('/ws/new.ts', GIT_STATUS.INDEX_ADDED)] }, {}), '/ws/new.ts', folders, read) + expect(empty?.missing).toBe('仓库里还没有提交') + expect(await empty?.read()).toBeNull() + expect(read).not.toHaveBeenCalled() + }) +}) diff --git a/vscode/src/codeReader/uncommitted/uncommittedFiles.ts b/vscode/src/codeReader/uncommitted/uncommittedFiles.ts index 67487ca..045fa5e 100644 --- a/vscode/src/codeReader/uncommitted/uncommittedFiles.ts +++ b/vscode/src/codeReader/uncommitted/uncommittedFiles.ts @@ -1,9 +1,10 @@ import type { GitStatus } from '../../git/gitApi' +import type { DiffBase } from '../diff/diffBase' import type { ChangedFile, FileChange } from '../explorer/changedTree' import { posix } from 'node:path' import { GIT_STATUS } from '../../git/gitApi' import { toPathKey } from '../pathMap' -import { isTestFile, withHiddenTests } from './testFiles' +import { isTestFile } from './testFiles' /** 一组改动里的一项:文件在哪(Uri.path)、git 状态。 */ interface StatusEntry { @@ -147,9 +148,9 @@ export function withoutTestFiles(roots: readonly T[] /** 「更改」区里文件行的 contextValue 带的标记(见 entryContext):这些行上挂「暂存」按钮。 */ export const UNSTAGED_MARKER = 'unstaged' -/** 开着时视图标题后面淡色的字(TreeView.description):藏了测试文件时注明藏了几个。 */ -export function uncommittedDescription(hiddenTests: number): string { - return withHiddenTests('未提交的更改', hiddenTests) +/** 这一栏标题后面淡色的字(TreeView.description):藏了测试文件时注明藏了几个,没藏的不写(标题本身就是「未提交的更改」)。 */ +export function uncommittedDescription(hiddenTests: number): string | undefined { + return hiddenTests > 0 ? `已隐藏 ${hiddenTests} 个测试文件` : undefined } /** @@ -163,7 +164,7 @@ export function uncommittedMessage(state: { hasFiles: boolean, hiddenTests: numb return state.loading ? '正在读取 git 状态…' : '没有未提交的更改' } -/** 「未提交的更改」开着时预览对比的基准:上一次提交(HEAD)。 */ +/** 工作区仓库里有改动的文件,预览对比的基准:上一次提交(HEAD)。 */ export const HEAD_BASE_LABEL = '上一次提交' /** 文件是不是未跟踪的:设置 git.untrackedChanges 是 mixed(默认)时未跟踪的混在没暂存的那组里,按状态认。 */ @@ -177,3 +178,37 @@ export function headMissingText(state: { hasCommit: boolean, untracked: boolean return '仓库里还没有提交' return state.untracked ? '还没被 git 跟踪,上一次提交里没有这个文件' : '上一次提交里没有这个文件' } + +/** 仓库里用到的,再加上当前检出的提交。 */ +interface HeadRepositoryLike extends RepositoryLike { + readonly state: ChangeGroups & { readonly HEAD: { readonly commit?: string } | undefined } +} + +/** + * 工作区仓库里的文件对比上一次提交(HEAD)时的基准。仓库算工作区里的(见 workspaceOrder)、 + * 这个文件有没提交的改动(暂存的、没暂存的、未跟踪的、冲突的)才比;没改动的、不算工作区里的仓库(审查的工作副本、别处的仓库)里的没有。 + * @param repository 文件所在的仓库 + * @param filePath 文件在哪(Uri.path) + * @param folders 工作区文件夹 + * @param read 取 head 这个提交上 relative(相对仓库根、`/` 分隔)这个文件的内容,那个提交里没有时是 null + */ +export function headDiffBase( + repository: HeadRepositoryLike, + filePath: string, + folders: readonly FolderLike[], + read: (head: string, relative: string) => Promise, +): DiffBase | undefined { + const { rootUri, state } = repository + const relative = toPathKey(rootUri.path, filePath) + const changed = [state.mergeChanges, state.indexChanges, state.workingTreeChanges, state.untrackedChanges] + .some(group => group.some(entry => entry.uri.path === filePath)) + if (relative === undefined || !changed || workspaceOrder(rootUri.path, folders) < 0) + return undefined + const head = state.HEAD?.commit + return { + label: HEAD_BASE_LABEL, + key: `${head ?? ''}:${relative}`, + read: () => head ? read(head, relative) : Promise.resolve(null), + missing: headMissingText({ hasCommit: head !== undefined, untracked: isUntracked(state, filePath) }), + } +} diff --git a/vscode/src/codeReader/uncommitted/uncommittedMode.ts b/vscode/src/codeReader/uncommitted/uncommittedMode.ts deleted file mode 100644 index 29041a1..0000000 --- a/vscode/src/codeReader/uncommitted/uncommittedMode.ts +++ /dev/null @@ -1,266 +0,0 @@ -import type { ExtensionContext, TreeView, Uri, WorkspaceFolder } from 'vscode' -import type { GitApi, GitRepository } from '../../git/gitApi' -import type { DiffBase } from '../diff/diffBase' -import type { ChangedFile } from '../explorer/changedTree' -import type { ChangedSection, FileNode, FileTreeProvider, TreeNode } from '../explorer/fileTree' -import type { TreeFilter, TreeFilters } from '../explorer/treeFilters' -import type { UncommittedRoot } from './uncommittedFiles' -import * as path from 'node:path' -import { Disposable, window, workspace } from 'vscode' -import { fileAtRef } from '../../git/fileAt' -import { loadGitApi } from '../../git/gitExtension' -import { logger } from '../../logging/logger' -import { buildChangedTree } from '../explorer/changedTree' -import { toPathKey } from '../pathMap' -import { HEAD_BASE_LABEL, headMissingText, isUntracked, uncommittedDescription, uncommittedMessage, uncommittedRoots, UNSTAGED_MARKER, withoutTestFiles, workspaceOrder } from './uncommittedFiles' - -/** workspaceState:开着「未提交的更改」,重开窗口接着显示。 */ -const UNCOMMITTED_KEY = 'superpowers.codeReader.uncommitted' -/** 两个分组的名字,照 VS Code 源代码管理视图的中文界面。 */ -const STAGED_LABEL = '暂存的更改' -const CHANGES_LABEL = '更改' -/** git 状态连着变(保存、暂存、切分支)时合成一次重摆。 */ -const REBUILD_DELAY_MS = 300 - -/** 怎么进的:enter 点按钮(出错时说一声)、restore 重开窗口接着显示(出错只记日志)。 */ -type How = 'enter' | 'restore' - -function errorText(err: unknown): string { - return err instanceof Error ? err.message : String(err) -} - -/** - * 侧栏只看未提交的更改:照源代码管理视图分「暂存的更改」「更改」两组(空的不摆),每组下面每个有改动的仓库一个根。 - * 文件就是工作区里的文件,颜色和 M / A / D 徽标由 VS Code 自带的 git 装饰加上;「更改」组里的文件行上有「暂存」按钮。 - * 可以隐藏测试文件(和审查共用一个开关,见 TreeFilters.testsHidden)。开着时盯着 git 状态自动重摆,打开的预览对比上一次提交(diffBase); - * 「显示全部」回到工作区文件树。开着没开按工作区记住;和审查工单互斥,见 TreeFilters。 - */ -export class UncommittedMode implements TreeFilter, Disposable { - private readonly disposables: Disposable[] - /** 开着时的 git API;没开时 undefined */ - private api: GitApi | undefined - /** 开着时盯 git 状态的监听 */ - private watching: Disposable | undefined - private timer: NodeJS.Timeout | undefined - /** 树上现在摆的改动(序列化),没变就不重画 */ - private shown: string | undefined - /** 每进一次、退一次都加一:等 git 扩展回来时已经退出或又进了一次的,结果丢掉。 */ - private generation = 0 - - /** viewId 是侧栏视图的 id:刷新时在这个视图上转进度。 */ - constructor( - private readonly context: ExtensionContext, - private readonly viewId: string, - private readonly view: TreeView, - private readonly files: FileTreeProvider, - private readonly filters: TreeFilters, - ) { - filters.add(this) - this.disposables = [filters.whenShown(() => void this.restore())] - } - - /** 「未提交的更改」:别的筛选退出,树里换成未提交的文件,之后跟着 git 状态变。 */ - async enter(): Promise { - await this.open('enter') - } - - /** 「显示全部」或换成别的筛选:不再盯 git、不再记着,正在进的作废;开着的回到工作区文件树。 */ - async exit(): Promise { - this.generation++ - this.stopWatching() - await Promise.all([ - this.filters.leave(this), - this.context.workspaceState.update(UNCOMMITTED_KEY, undefined), - ]) - } - - /** 开着时的「刷新」:让 git 扩展重读工作区各仓库的状态,再整棵重摆。 */ - async reload(): Promise { - const api = this.api - if (!api) - return - const folders = workspace.workspaceFolders ?? [] - const repositories = api.repositories.filter(repository => workspaceOrder(repository.rootUri.path, folders) >= 0) - await window.withProgress({ location: { viewId: this.viewId } }, () => Promise.all(repositories.map(repository => this.status(repository)))) - if (this.api !== api) - return - this.shown = undefined - this.rebuild() - } - - /** 隐藏测试文件的开关换了:两组都剔除 / 放回测试文件。 */ - testsToggled(): void { - this.rebuild() - } - - /** 「更改」组里文件行上的「暂存」:把这个文件放进暂存区(新文件、删掉的、冲突的都行),树跟着 git 状态变。 */ - async stage(node: FileNode | undefined): Promise { - const api = this.api - if (!node || !api) - return // 只挂在开着时「更改」组的文件行上 - const name = path.basename(node.uri.fsPath) - const repository = api.getRepository(node.uri) - if (!repository) { - logger.add({ level: 'warn', source: 'codeReader', message: `未提交的更改:${name} 不在打开的 git 仓库里,没法暂存`, details: node.uri.fsPath }) - void window.showErrorMessage(`没法暂存 ${name}:找不到它所在的 git 仓库`) - return - } - try { - await repository.add([node.uri.fsPath]) - } - catch (err) { - logger.add({ level: 'error', source: 'codeReader', message: `未提交的更改:暂存 ${name} 失败`, details: `${node.uri.fsPath}\n${errorText(err)}` }) - void window.showErrorMessage(`没能暂存 ${name}:${errorText(err)}`) - } - } - - /** 开着时,工作区仓库里的文件对比上一次提交(HEAD);不在工作区的仓库里(审查的工作副本、别处的文件)的不比。 */ - diffBase(uri: Uri): DiffBase | undefined { - const repository = this.api?.getRepository(uri) - if (!repository || workspaceOrder(repository.rootUri.path, workspace.workspaceFolders ?? []) < 0) - return undefined - const relative = toPathKey(repository.rootUri.path, uri.path) - if (relative === undefined) - return undefined - const head = repository.state.HEAD?.commit - const root = repository.rootUri.fsPath - return { - label: HEAD_BASE_LABEL, - key: `${head ?? ''}:${relative}`, - read: () => head ? fileAtRef(root, head, relative) : Promise.resolve(null), - missing: headMissingText({ hasCommit: head !== undefined, untracked: isUntracked(repository.state, uri.path) }), - } - } - - dispose(): void { - this.stopWatching() - for (const disposable of this.disposables) - disposable.dispose() - } - - private async restore(): Promise { - if (this.context.workspaceState.get(UNCOMMITTED_KEY)) - await this.open('restore') - } - - private async open(how: How): Promise { - const generation = ++this.generation - let api: GitApi | undefined - try { - api = await loadGitApi() - } - catch (err) { - this.fail(how, '激活 vscode.git 扩展失败', errorText(err)) - return - } - if (generation !== this.generation) - return - if (!api) { - this.fail(how, 'vscode.git 扩展没装或被禁用了') - return - } - this.stopWatching() - this.api = api - this.watching = this.watch(api) - const entering = this.filters.enter(this, 'uncommitted') - this.rebuild() - await Promise.all([entering, this.context.workspaceState.update(UNCOMMITTED_KEY, true)]) - } - - private fail(how: How, message: string, details?: string): void { - logger.add({ level: 'warn', source: 'codeReader', message: `未提交的更改:${message}`, ...(details ? { details } : {}) }) - if (how === 'enter') - void window.showErrorMessage(`没法显示未提交的更改:${message}`) - } - - private async status(repository: GitRepository): Promise { - try { - await repository.status() - } - catch (err) { - logger.add({ level: 'warn', source: 'codeReader', message: '未提交的更改:重读 git 状态失败', details: `${repository.rootUri.fsPath}\n${errorText(err)}` }) - } - } - - /** - * 盯着 git:各仓库的状态变了、开了 / 关了仓库、git 扩展找完仓库、工作区文件夹变了,都合成一次重摆; - * 打开着的预览也跟着重新比(提交后 HEAD 动了,对比的上一次提交就换了)。 - */ - private watch(api: GitApi): Disposable { - const repositories = new Map() - const schedule = (): void => { - clearTimeout(this.timer) - this.timer = setTimeout(() => { - this.rebuild() - this.filters.baseChanged(this) - }, REBUILD_DELAY_MS) - } - const add = (repository: GitRepository): void => { - if (!repositories.has(repository)) - repositories.set(repository, repository.state.onDidChange(schedule)) - } - api.repositories.forEach(add) - return Disposable.from( - api.onDidOpenRepository((repository) => { - add(repository) - schedule() - }), - api.onDidCloseRepository((repository) => { - repositories.get(repository)?.dispose() - repositories.delete(repository) - schedule() - }), - api.onDidChangeState(schedule), - workspace.onDidChangeWorkspaceFolders(schedule), - { dispose: () => repositories.forEach(listener => listener.dispose()) }, - ) - } - - private stopWatching(): void { - clearTimeout(this.timer) - this.timer = undefined - this.watching?.dispose() - this.watching = undefined - this.api = undefined - this.shown = undefined - } - - /** 按 git 现在的状态(和隐不隐藏测试文件)重摆树、标题后面的字、标题下面的一行;树上摆的没变就不重画。 */ - private rebuild(): void { - const api = this.api - if (!api) - return - const folders = workspace.workspaceFolders ?? [] - const all = uncommittedRoots(api.repositories, folders) - const { roots, hidden } = this.filters.testsHidden ? withoutTestFiles(all) : { roots: all, hidden: 0 } - this.view.description = uncommittedDescription(hidden) - this.view.message = uncommittedMessage({ hasFiles: roots.length > 0, hiddenTests: hidden, loading: api.state !== 'initialized' }) - const shown = JSON.stringify(roots.map(root => [root.repository.rootUri.toString(), root.name, root.staged, root.changes])) - if (shown === this.shown) - return - this.shown = shown - // 仓库根不是工作区文件夹时(嵌套的仓库、包着工作区文件夹的仓库)拿仓库根当根,名字用仓库根目录的名字 - const placed = roots.map(root => ({ root, folder: root.folder ?? { uri: root.repository.rootUri, name: root.name, index: folders.length } })) - this.files.showChanges([ - section(STAGED_LABEL, placed, root => root.staged), - section(CHANGES_LABEL, placed, root => root.changes, UNSTAGED_MARKER), - ].filter(group => group.roots.length > 0)) - } -} - -/** 一个分组:有这个区的文件的仓库各一个根,分组行后面写这个区一共几个文件。 */ -function section( - label: string, - placed: readonly { root: UncommittedRoot, folder: WorkspaceFolder }[], - filesOf: (root: UncommittedRoot) => ChangedFile[], - fileMarker?: string, -): ChangedSection { - const withFiles = placed.filter(({ root }) => filesOf(root).length > 0) - const count = withFiles.reduce((sum, { root }) => sum + filesOf(root).length, 0) - return { - label, - description: String(count), - ...(fileMarker ? { fileMarker } : {}), - roots: withFiles.map(({ root, folder }) => ({ folder, tree: buildChangedTree(filesOf(root)) })), - } -} diff --git a/vscode/src/codeReader/uncommitted/uncommittedView.ts b/vscode/src/codeReader/uncommitted/uncommittedView.ts new file mode 100644 index 0000000..c9c6433 --- /dev/null +++ b/vscode/src/codeReader/uncommitted/uncommittedView.ts @@ -0,0 +1,178 @@ +import type { Disposable, ExtensionContext, TreeView, WorkspaceFolder } from 'vscode' +import type { GitApi, GitRepository } from '../../git/gitApi' +import type { ChangedFile } from '../explorer/changedTree' +import type { ChangedSection, FileNode, FileTreeDecorator, TreeNode } from '../explorer/fileTree' +import type { UncommittedRoot } from './uncommittedFiles' +import * as path from 'node:path' +import { window, workspace } from 'vscode' +import { loadGitApi } from '../../git/gitExtension' +import { logger } from '../../logging/logger' +import { buildChangedTree } from '../explorer/changedTree' +import { FileTreeProvider } from '../explorer/fileTree' +import { TestsToggle } from '../explorer/testsToggle' +import { whenShown } from '../explorer/viewShown' +import { watchGit } from './gitWatch' +import { uncommittedDescription, uncommittedMessage, uncommittedRoots, UNSTAGED_MARKER, withoutTestFiles, workspaceOrder } from './uncommittedFiles' + +/** 「未提交的更改」这一栏的视图 id */ +const UNCOMMITTED_VIEW = 'superpowers.codeReader.uncommitted' +/** 这一栏隐藏测试文件的开关:记在 workspaceState 的键、标题栏按它换按钮的上下文键 */ +const UNCOMMITTED_TESTS = { state: 'superpowers.codeReader.uncommittedHideTests', context: 'superpowers.codeReader.uncommittedTestsHidden' } +/** 两个分组的名字,照 VS Code 源代码管理视图的中文界面。 */ +const STAGED_LABEL = '暂存的更改' +const CHANGES_LABEL = '更改' + +function errorText(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + +/** + * 侧栏「未提交的更改」一栏:照源代码管理视图分「暂存的更改」「更改」两组(空的不摆),每组下面每个有改动的仓库一个根。 + * 文件就是工作区里的文件,颜色和 M / A / D 徽标由 VS Code 自带的 git 装饰加上;「更改」组里的文件行上有「暂存」按钮。 + * 这一栏第一次露出来时才去读 git,之后盯着 git 状态自动重摆;隐藏测试文件的开关是这一栏自己的,按工作区记住。 + */ +export class UncommittedView implements Disposable { + private readonly files: FileTreeProvider + private readonly view: TreeView + private readonly tests: TestsToggle + private readonly disposables: Disposable[] + /** 读到的 git API;还没读到时 undefined */ + private api: GitApi | undefined + /** 树上现在摆的改动(序列化),没变就不重画 */ + private shown: string | undefined + + /** + * @param context 隐藏测试文件的开关记在它的 workspaceState 里 + * @param openCommand 点文件时执行的命令,参数是文件 Uri + * @param decorator 给行添信息(文件注释),和别的栏共用 + */ + constructor(context: ExtensionContext, openCommand: string, decorator: FileTreeDecorator) { + this.files = new FileTreeProvider(openCommand, decorator, { kind: 'changes', view: [] }) + this.view = window.createTreeView(UNCOMMITTED_VIEW, { treeDataProvider: this.files, showCollapseAll: true }) + this.tests = new TestsToggle(context.workspaceState, UNCOMMITTED_TESTS, () => this.rebuild()) + this.disposables = [this.files, this.view, whenShown(this.view, () => void this.open())] + } + + /** 标题栏的「隐藏 / 显示测试文件」 */ + setTestsHidden(hidden: boolean): Promise { + return this.tests.set(hidden) + } + + /** 标题栏的「刷新」:让 git 扩展重读工作区各仓库的状态,再整棵重摆;之前没读到 git 的再去读一次。 */ + async reload(): Promise { + const api = this.api + if (!api) { + await this.open() + return + } + const folders = workspace.workspaceFolders ?? [] + const repositories = api.repositories.filter(repository => workspaceOrder(repository.rootUri.path, folders) >= 0) + await window.withProgress({ location: { viewId: UNCOMMITTED_VIEW } }, () => Promise.all(repositories.map(repository => this.status(repository)))) + this.shown = undefined + this.rebuild() + } + + /** 「更改」组里文件行上的「暂存」:把这个文件放进暂存区(新文件、删掉的、冲突的都行),树跟着 git 状态变。 */ + async stage(node: FileNode | undefined): Promise { + const api = this.api + if (!node || !api) + return // 只挂在「更改」组的文件行上,摆得出这些行就已经读到了 git + const name = path.basename(node.uri.fsPath) + const repository = api.getRepository(node.uri) + if (!repository) { + logger.add({ level: 'warn', source: 'codeReader', message: `未提交的更改:${name} 不在打开的 git 仓库里,没法暂存`, details: node.uri.fsPath }) + void window.showErrorMessage(`没法暂存 ${name}:找不到它所在的 git 仓库`) + return + } + try { + await repository.add([node.uri.fsPath]) + } + catch (err) { + logger.add({ level: 'error', source: 'codeReader', message: `未提交的更改:暂存 ${name} 失败`, details: `${node.uri.fsPath}\n${errorText(err)}` }) + void window.showErrorMessage(`没能暂存 ${name}:${errorText(err)}`) + } + } + + dispose(): void { + for (const disposable of this.disposables) + disposable.dispose() + } + + /** 读 git API、盯着 git 状态、摆上树;读不到时这一栏写原因(点刷新再读)。 */ + private async open(): Promise { + this.view.message = uncommittedMessage({ hasFiles: false, hiddenTests: 0, loading: true }) + let api: GitApi | undefined + try { + api = await loadGitApi() + } + catch (err) { + this.fail('激活 vscode.git 扩展失败', errorText(err)) + return + } + if (!api) { + this.fail('vscode.git 扩展没装或被禁用了') + return + } + if (this.api) + return // 第一次露出来和刷新同时在读,先回来的已经接上了 + this.api = api + this.disposables.push(watchGit(api, () => this.rebuild())) + this.rebuild() + } + + private fail(message: string, details?: string): void { + logger.add({ level: 'warn', source: 'codeReader', message: `未提交的更改:${message}`, ...(details ? { details } : {}) }) + this.view.message = `没法读取未提交的更改:${message}` + } + + private async status(repository: GitRepository): Promise { + try { + await repository.status() + } + catch (err) { + logger.add({ level: 'warn', source: 'codeReader', message: '未提交的更改:重读 git 状态失败', details: `${repository.rootUri.fsPath}\n${errorText(err)}` }) + } + } + + /** 按 git 现在的状态(和隐不隐藏测试文件)重摆树、标题后面的字、标题下面的一行;树上摆的没变就不重画。 */ + private rebuild(): void { + const api = this.api + if (!api) + return + const folders = workspace.workspaceFolders ?? [] + const all = uncommittedRoots(api.repositories, folders) + const { roots, hidden } = this.tests.hidden ? withoutTestFiles(all) : { roots: all, hidden: 0 } + this.view.description = uncommittedDescription(hidden) + this.view.message = uncommittedMessage({ hasFiles: roots.length > 0, hiddenTests: hidden, loading: api.state !== 'initialized' }) + const shown = JSON.stringify(roots.map(root => [root.repository.rootUri.toString(), root.name, root.staged, root.changes])) + if (shown === this.shown) + return + this.shown = shown + // 仓库根不是工作区文件夹时(嵌套的仓库、包着工作区文件夹的仓库)拿仓库根当根,名字用仓库根目录的名字 + const placed = roots.map(root => ({ root, folder: root.folder ?? { uri: root.repository.rootUri, name: root.name, index: folders.length } })) + this.files.show({ + kind: 'changes', + view: [ + section(STAGED_LABEL, placed, root => root.staged), + section(CHANGES_LABEL, placed, root => root.changes, UNSTAGED_MARKER), + ].filter(group => group.roots.length > 0), + }) + } +} + +/** 一个分组:有这一组文件的各仓库各一个根,分组行后面写这一组一共几个文件。 */ +function section( + label: string, + placed: readonly { root: UncommittedRoot, folder: WorkspaceFolder }[], + filesOf: (root: UncommittedRoot) => ChangedFile[], + fileMarker?: string, +): ChangedSection { + const withFiles = placed.filter(({ root }) => filesOf(root).length > 0) + const count = withFiles.reduce((sum, { root }) => sum + filesOf(root).length, 0) + return { + label, + description: String(count), + ...(fileMarker ? { fileMarker } : {}), + roots: withFiles.map(({ root, folder }) => ({ folder, tree: buildChangedTree(filesOf(root)) })), + } +} diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index f2c6756..75cbb11 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -72,7 +72,7 @@ export function App(): ReactElement | null { const [keep, setKeep] = useState>(NONE) /** 页面上这个文档版本的中文名:文档带来的,扩展端查到了再换 */ const [names, setNames] = useState() - /** 这份文件相比对比基准改了什么:侧栏开着「未提交的更改」或审查工单时扩展端算好了补来,不比时是 null */ + /** 这份文件相比对比基准改了什么:审查工作副本里的、工作区仓库里有改动的文件扩展端算好了补来,不比时是 null */ const [diff, setDiff] = useState(null) /** 收到消息时读眼下哪些块在编辑(消息回调只在挂载时注册一次) */ const editing = useRef(view.editing) diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index a0cd1de..d9a647a 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -464,7 +464,7 @@ export interface StickyScrollSettings { * rewriteEnd:askId 那一轮重写结束了,见 RewriteEnd:done 是模型答完、记下了(没写进文件时带原因);failed、stopped 没记,文件没改 * moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现 * displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理 - * diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有);每来一份文档之后补来,基准变了再来;null 是不比了 + * diff:这份文件相比对比基准改了什么(审查工作副本里的、工作区仓库里有改动的文件才有);每来一份文档之后补来,基准变了再来;null 是不比了 * repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径,unresolved 是找不到的不带路径的引用 * repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到 * repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了