✨ feat(vscode): 代码阅读侧栏加「未提交的更改」筛选
- 标题栏新按钮「未提交的更改」:树里只剩工作区各 git 仓库里没提交的文件(暂存、没暂存、未跟踪、合并冲突),每个有改动的仓库一个根,跟着 git 状态自动重摆;上色和徽标交给 VS Code 自带的 git 装饰
- 两种筛选互斥,由 TreeFilters 统一管,上下文键合并成 superpowers.codeReader.filter(review / uncommitted)
- 改动文件树改成多根,buildChangedTree 挪到 explorer/changedTree 并改吃 {path, change}
- 内置 git 扩展 API 的类型和加载抽到 src/git/gitApi、gitExtension,提交按钮的监听也改用它
- 文件注释按文件所在的工作区文件夹记,不看树从哪个根摆下来
This commit is contained in:
1 parent
61c4728dce
commit
7f460d1257
18 files changed
+847
-240
No files matched your search
+19
-8
@@ -109,6 +109,12 @@
|
|||||||
"category": "代码阅读",
|
"category": "代码阅读",
|
||||||
"icon": "$(git-pull-request)"
|
"icon": "$(git-pull-request)"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "superpowers.codeReader.showUncommitted",
|
||||||
|
"title": "未提交的更改",
|
||||||
|
"category": "代码阅读",
|
||||||
|
"icon": "$(git-commit)"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "superpowers.codeReader.showAllFiles",
|
"command": "superpowers.codeReader.showAllFiles",
|
||||||
"title": "显示全部",
|
"title": "显示全部",
|
||||||
@@ -255,14 +261,19 @@
|
|||||||
"group": "navigation@1"
|
"group": "navigation@1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "superpowers.codeReader.showAllFiles",
|
"command": "superpowers.codeReader.showUncommitted",
|
||||||
"when": "view == superpowers.codeReader.files && superpowers.codeReader.reviewing",
|
"when": "view == superpowers.codeReader.files",
|
||||||
"group": "navigation@2"
|
"group": "navigation@2"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "superpowers.codeReader.showAllFiles",
|
||||||
|
"when": "view == superpowers.codeReader.files && superpowers.codeReader.filter",
|
||||||
|
"group": "navigation@3"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "superpowers.codeReader.refresh",
|
"command": "superpowers.codeReader.refresh",
|
||||||
"when": "view == superpowers.codeReader.files",
|
"when": "view == superpowers.codeReader.files",
|
||||||
"group": "navigation@3"
|
"group": "navigation@4"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"view/item/context": [
|
"view/item/context": [
|
||||||
@@ -273,12 +284,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "superpowers.codeReader.addNote",
|
"command": "superpowers.codeReader.addNote",
|
||||||
"when": "view == superpowers.codeReader.files && !superpowers.codeReader.reviewing && viewItem =~ /^(file|folder)$/",
|
"when": "view == superpowers.codeReader.files && superpowers.codeReader.filter != review && viewItem =~ /^(file|folder)$/",
|
||||||
"group": "inline@2"
|
"group": "inline@2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "superpowers.codeReader.editNote",
|
"command": "superpowers.codeReader.editNote",
|
||||||
"when": "view == superpowers.codeReader.files && !superpowers.codeReader.reviewing && viewItem =~ /^(file|folder) noted$/",
|
"when": "view == superpowers.codeReader.files && superpowers.codeReader.filter != review && viewItem =~ /^(file|folder) noted$/",
|
||||||
"group": "inline@2"
|
"group": "inline@2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -308,12 +319,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "superpowers.codeReader.addNote",
|
"command": "superpowers.codeReader.addNote",
|
||||||
"when": "view == superpowers.codeReader.files && !superpowers.codeReader.reviewing && viewItem =~ /^(file|folder)$/",
|
"when": "view == superpowers.codeReader.files && superpowers.codeReader.filter != review && viewItem =~ /^(file|folder)$/",
|
||||||
"group": "notes"
|
"group": "notes"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "superpowers.codeReader.editNote",
|
"command": "superpowers.codeReader.editNote",
|
||||||
"when": "view == superpowers.codeReader.files && !superpowers.codeReader.reviewing && viewItem =~ /^(file|folder) noted$/",
|
"when": "view == superpowers.codeReader.files && superpowers.codeReader.filter != review && viewItem =~ /^(file|folder) noted$/",
|
||||||
"group": "notes"
|
"group": "notes"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -336,7 +347,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "superpowers.codeReader.showAllFiles",
|
"command": "superpowers.codeReader.showAllFiles",
|
||||||
"when": "superpowers.codeReader.reviewing"
|
"when": "superpowers.codeReader.filter"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "superpowers.codeReader.showPreview",
|
"command": "superpowers.codeReader.showPreview",
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { buildChangedTree } from './changedTree'
|
||||||
|
|
||||||
|
describe('改动的文件树', () => {
|
||||||
|
// 原型「审查 · 改动的文件」里的 6 个文件(schemas.py 是改名的,算修改)
|
||||||
|
const files = [
|
||||||
|
{ path: 'backend/src/luffy_agent/user/routes.py', change: 'M' },
|
||||||
|
{ path: 'backend/tests/user/test_avatar.py', change: 'A' },
|
||||||
|
{ path: 'backend/src/luffy_agent/core/storage.py', change: 'M' },
|
||||||
|
{ path: 'backend/src/luffy_agent/user/legacy_profile.py', change: 'D' },
|
||||||
|
{ path: 'backend/src/luffy_agent/user/services.py', change: 'M' },
|
||||||
|
{ path: 'backend/src/luffy_agent/user/schemas.py', change: 'M' },
|
||||||
|
] as const
|
||||||
|
const tree = buildChangedTree(files)
|
||||||
|
const names = (dir: string): string[] => (tree.get(dir) ?? []).map(entry => entry.name)
|
||||||
|
|
||||||
|
it('目录层级照留,每层只有通往改动的文件的目录', () => {
|
||||||
|
expect(names('')).toEqual(['backend'])
|
||||||
|
expect(names('backend')).toEqual(['src', 'tests'])
|
||||||
|
expect(names('backend/src')).toEqual(['luffy_agent'])
|
||||||
|
expect(names('backend/src/luffy_agent')).toEqual(['core', 'user'])
|
||||||
|
expect(names('backend/src/luffy_agent/core')).toEqual(['storage.py'])
|
||||||
|
expect(names('backend/tests/user')).toEqual(['test_avatar.py'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('同一层里的文件按名字排,带着各自的改动和相对根目录的路径', () => {
|
||||||
|
expect(tree.get('backend/src/luffy_agent/user')).toEqual([
|
||||||
|
{ name: 'legacy_profile.py', path: 'backend/src/luffy_agent/user/legacy_profile.py', isDirectory: false, change: 'D' },
|
||||||
|
{ name: 'routes.py', path: 'backend/src/luffy_agent/user/routes.py', isDirectory: false, change: 'M' },
|
||||||
|
{ name: 'schemas.py', path: 'backend/src/luffy_agent/user/schemas.py', isDirectory: false, change: 'M' },
|
||||||
|
{ name: 'services.py', path: 'backend/src/luffy_agent/user/services.py', isDirectory: false, change: 'M' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('目录没有改动,路径相对根目录', () => {
|
||||||
|
expect(tree.get('backend')).toEqual([
|
||||||
|
{ name: 'src', path: 'backend/src', isDirectory: true },
|
||||||
|
{ name: 'tests', path: 'backend/tests', isDirectory: true },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('目录在前、文件在后,名字不区分大小写,和普通的树一样', () => {
|
||||||
|
const mixed = buildChangedTree([
|
||||||
|
{ path: 'README.md', change: 'M' },
|
||||||
|
{ path: 'src/a.ts', change: 'A' },
|
||||||
|
{ path: 'Docs/b.md', change: 'A' },
|
||||||
|
{ path: 'package.json', change: 'M' },
|
||||||
|
])
|
||||||
|
expect(mixed.get('')?.map(entry => entry.name)).toEqual(['Docs', 'src', 'package.json', 'README.md'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('文件换成同名目录(删掉 a、新加 a/b)两项都留着', () => {
|
||||||
|
const replaced = buildChangedTree([{ path: 'a', change: 'D' }, { path: 'a/b', change: 'A' }])
|
||||||
|
expect(replaced.get('')).toEqual([
|
||||||
|
{ name: 'a', path: 'a', isDirectory: true },
|
||||||
|
{ name: 'a', path: 'a', isDirectory: false, change: 'D' },
|
||||||
|
])
|
||||||
|
expect(replaced.get('a')?.map(entry => entry.change)).toEqual(['A'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('没有改动的文件时根下面是空的', () => {
|
||||||
|
expect(buildChangedTree([]).get('')).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { DirEntry } from './fileEntries'
|
||||||
|
import { sortEntries } from './fileEntries'
|
||||||
|
|
||||||
|
/** 一个文件怎么改的,照 VS Code 源代码管理的写法:修改 M、新增 A、删除 D。 */
|
||||||
|
export type FileChange = 'M' | 'A' | 'D'
|
||||||
|
|
||||||
|
/** 改动的一个文件。 */
|
||||||
|
export interface ChangedFile {
|
||||||
|
/** 相对根目录(仓库根、审查的工作副本),`/` 分隔 */
|
||||||
|
path: string
|
||||||
|
change: FileChange
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 改动的文件树上的一项。 */
|
||||||
|
export interface ChangedEntry extends DirEntry {
|
||||||
|
/** 相对根目录,`/` 分隔 */
|
||||||
|
path: string
|
||||||
|
/** 文件怎么改的;目录没有 */
|
||||||
|
change?: FileChange
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按目录分好的改动:键是目录相对根目录的路径(根目录是 `''`),值是它下面一层的目录和文件。 */
|
||||||
|
export type ChangedTree = ReadonlyMap<string, readonly ChangedEntry[]>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 改动的文件 → 按目录分好的树:只有改动的文件和通往它们的目录,目录层级照留,
|
||||||
|
* 每层的排法和侧栏普通的树一样(目录在前、名字不区分大小写)。
|
||||||
|
* 同一路径上一个文件换成了同名目录(删掉文件 `a`、新加 `a/b`)时两项都留着。
|
||||||
|
*/
|
||||||
|
export function buildChangedTree(files: readonly ChangedFile[]): ChangedTree {
|
||||||
|
const levels = new Map<string, Map<string, ChangedEntry>>([['', new Map()]])
|
||||||
|
const add = (dir: string, entry: ChangedEntry): void => {
|
||||||
|
let level = levels.get(dir)
|
||||||
|
if (!level)
|
||||||
|
levels.set(dir, level = new Map())
|
||||||
|
const key = `${entry.isDirectory ? 'dir' : 'file'}:${entry.name}`
|
||||||
|
if (!level.has(key))
|
||||||
|
level.set(key, entry)
|
||||||
|
}
|
||||||
|
for (const file of files) {
|
||||||
|
const segments = file.path.split('/')
|
||||||
|
segments.forEach((name, index) => {
|
||||||
|
const dir = segments.slice(0, index).join('/')
|
||||||
|
const path = segments.slice(0, index + 1).join('/')
|
||||||
|
add(dir, index === segments.length - 1
|
||||||
|
? { name, path, isDirectory: false, change: file.change }
|
||||||
|
: { name, path, isDirectory: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return new Map([...levels].map(([dir, level]) => [dir, sortEntries([...level.values()])]))
|
||||||
|
}
|
||||||
@@ -1,26 +1,32 @@
|
|||||||
import type { Event, TreeDataProvider, WorkspaceFolder } from 'vscode'
|
import type { Event, TreeDataProvider, WorkspaceFolder } from 'vscode'
|
||||||
import type { ChangedTree, FileChange } from '../review/changedFiles'
|
import type { ChangedTree, FileChange } from './changedTree'
|
||||||
import type { ExcludeSetting } from './fileEntries'
|
import type { ExcludeSetting } from './fileEntries'
|
||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
import { EventEmitter, FileType, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri, workspace } from 'vscode'
|
import { EventEmitter, FileType, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri, workspace } from 'vscode'
|
||||||
import { CHANGE_STYLES } from '../review/changedFiles'
|
|
||||||
import { createExcludeMatcher, entryContext, sortEntries } from './fileEntries'
|
import { createExcludeMatcher, entryContext, sortEntries } from './fileEntries'
|
||||||
|
|
||||||
export interface FileNode {
|
export interface FileNode {
|
||||||
uri: Uri
|
uri: Uri
|
||||||
|
/** 这一行所在的根:工作区文件夹;只摆改动的文件时是那个根(仓库根、审查的工作副本) */
|
||||||
folder: WorkspaceFolder
|
folder: WorkspaceFolder
|
||||||
isDirectory: boolean
|
isDirectory: boolean
|
||||||
isRoot: boolean
|
isRoot: boolean
|
||||||
/** 审查工单时这个文件在 PR 里怎么改的;普通的树、目录没有 */
|
/** 只摆改动的文件时这个文件怎么改的;普通的树、目录没有 */
|
||||||
change?: FileChange
|
change?: FileChange
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 审查工单时树上摆什么:工作副本当唯一的根(folder),下面只有 PR 改过的文件(tree)。 */
|
/** 只摆改动的文件时的一个根:folder 是根目录和根节点上的名字,tree 是它下面改动的文件。 */
|
||||||
export interface ChangedFilesView {
|
export interface ChangedRoot {
|
||||||
folder: WorkspaceFolder
|
folder: WorkspaceFolder
|
||||||
tree: ChangedTree
|
tree: ChangedTree
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 只摆改动的文件时树上摆什么:每个根一个根节点,下面只有改动的文件和通往它们的目录。 */
|
||||||
|
export type ChangedFilesView = readonly ChangedRoot[]
|
||||||
|
|
||||||
|
/** 删掉的文件那一行的悬停提示:它已经不在了,点了打不开。 */
|
||||||
|
const DELETED_TOOLTIP = '已删除,没有内容可打开'
|
||||||
|
|
||||||
/** 给树上的行添信息(比如文件注释):每次画行时调 decorate,onDidChange 报出哪些 Uri 的行要重画。 */
|
/** 给树上的行添信息(比如文件注释):每次画行时调 decorate,onDidChange 报出哪些 Uri 的行要重画。 */
|
||||||
export interface FileTreeDecorator {
|
export interface FileTreeDecorator {
|
||||||
readonly onDidChange: Event<readonly Uri[]>
|
readonly onDidChange: Event<readonly Uri[]>
|
||||||
@@ -33,7 +39,7 @@ const REFRESH_DELAY_MS = 300
|
|||||||
/**
|
/**
|
||||||
* 侧栏的工作区文件树:每个工作区文件夹一个根节点,只看和打开。
|
* 侧栏的工作区文件树:每个工作区文件夹一个根节点,只看和打开。
|
||||||
* 节点用 resourceUri,图标、git 改动颜色都由 VS Code 按文件图标主题和 SCM 装饰自动加上。
|
* 节点用 resourceUri,图标、git 改动颜色都由 VS Code 按文件图标主题和 SCM 装饰自动加上。
|
||||||
* 审查工单时换成只摆 PR 改过的文件(showChanges):目录全部展开,删掉的文件列出来但打不开。
|
* 筛选时换成只摆改动的文件(showChanges:审查工单时 PR 改过的,未提交的更改时各仓库里没提交的):目录全部展开,删掉的文件列出来但打不开。
|
||||||
*/
|
*/
|
||||||
export class FileTreeProvider implements TreeDataProvider<FileNode> {
|
export class FileTreeProvider implements TreeDataProvider<FileNode> {
|
||||||
/** undefined 是整棵树重画,数组是只重画这几行 */
|
/** undefined 是整棵树重画,数组是只重画这几行 */
|
||||||
@@ -43,7 +49,7 @@ export class FileTreeProvider implements TreeDataProvider<FileNode> {
|
|||||||
/** 按 Uri 记下交给 VS Code 的节点:只重画几行时要交回同一个对象,VS Code 按对象认行。 */
|
/** 按 Uri 记下交给 VS Code 的节点:只重画几行时要交回同一个对象,VS Code 按对象认行。 */
|
||||||
private readonly nodes = new Map<string, FileNode>()
|
private readonly nodes = new Map<string, FileNode>()
|
||||||
private timer: NodeJS.Timeout | undefined
|
private timer: NodeJS.Timeout | undefined
|
||||||
/** 审查工单时只摆的那些文件;undefined 是普通的工作区文件树 */
|
/** 筛选时只摆的那些文件;undefined 是普通的工作区文件树 */
|
||||||
private changes: ChangedFilesView | undefined
|
private changes: ChangedFilesView | undefined
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,7 +87,7 @@ export class FileTreeProvider implements TreeDataProvider<FileNode> {
|
|||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 换成只摆 PR 改过的文件(undefined 回到工作区文件树),整棵树重画。 */
|
/** 换成只摆改动的文件(undefined 回到工作区文件树),整棵树重画。 */
|
||||||
showChanges(view: ChangedFilesView | undefined): void {
|
showChanges(view: ChangedFilesView | undefined): void {
|
||||||
this.changes = view
|
this.changes = view
|
||||||
this.refresh()
|
this.refresh()
|
||||||
@@ -119,7 +125,7 @@ export class FileTreeProvider implements TreeDataProvider<FileNode> {
|
|||||||
const item = new TreeItem(node.uri, TreeItemCollapsibleState.None)
|
const item = new TreeItem(node.uri, TreeItemCollapsibleState.None)
|
||||||
item.iconPath = ThemeIcon.File
|
item.iconPath = ThemeIcon.File
|
||||||
if (node.change === 'D')
|
if (node.change === 'D')
|
||||||
item.tooltip = CHANGE_STYLES.D.tooltip // 删掉的文件没有最新的版本可看,不挂打开的命令
|
item.tooltip = DELETED_TOOLTIP // 删掉的文件没有内容可看,不挂打开的命令
|
||||||
else
|
else
|
||||||
item.command = { command: this.openCommand, title: '打开', arguments: [node.uri] }
|
item.command = { command: this.openCommand, title: '打开', arguments: [node.uri] }
|
||||||
return item
|
return item
|
||||||
@@ -142,12 +148,14 @@ export class FileTreeProvider implements TreeDataProvider<FileNode> {
|
|||||||
return node
|
return node
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 审查工单时的一层:根下面是工作副本,再往下只有 PR 改过的文件和通往它们的目录。 */
|
/** 只摆改动的文件时的一层:最上面是各个根,再往下只有改动的文件和通往它们的目录。 */
|
||||||
private changedChildren({ folder, tree }: ChangedFilesView, node?: FileNode): FileNode[] {
|
private changedChildren(roots: ChangedFilesView, node?: FileNode): FileNode[] {
|
||||||
if (!node)
|
if (!node)
|
||||||
return [this.nodeAt(folder.uri, folder, true, true)]
|
return roots.map(({ folder }) => this.nodeAt(folder.uri, folder, true, true))
|
||||||
|
const { folder } = node
|
||||||
|
const tree = roots.find(root => root.folder === folder)?.tree
|
||||||
const dir = path.posix.relative(folder.uri.path, node.uri.path)
|
const dir = path.posix.relative(folder.uri.path, node.uri.path)
|
||||||
return (tree.get(dir) ?? []).map(entry => this.nodeAt(Uri.joinPath(folder.uri, entry.path), folder, entry.isDirectory, false, entry.change))
|
return (tree?.get(dir) ?? []).map(entry => this.nodeAt(Uri.joinPath(folder.uri, entry.path), folder, entry.isDirectory, false, entry.change))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 还没展开过的行不用管,展开时自然按最新的画。 */
|
/** 还没展开过的行不用管,展开时自然按最新的画。 */
|
||||||
|
|||||||
@@ -103,35 +103,43 @@ describe('右键菜单的注释', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('审查工单', () => {
|
describe('筛选(审查工单、未提交的更改)', () => {
|
||||||
const REVIEWING = 'superpowers.codeReader.reviewing'
|
const FILTER = 'superpowers.codeReader.filter'
|
||||||
const PICK = 'superpowers.codeReader.pickReviewIssue'
|
const PICK = 'superpowers.codeReader.pickReviewIssue'
|
||||||
|
const UNCOMMITTED = 'superpowers.codeReader.showUncommitted'
|
||||||
const SHOW_ALL = 'superpowers.codeReader.showAllFiles'
|
const SHOW_ALL = 'superpowers.codeReader.showAllFiles'
|
||||||
|
|
||||||
it('标题栏:第一个「选工单」,审查时才有「显示全部」,然后「刷新」', () => {
|
it('标题栏:选工单、未提交的更改、开着筛选时才有的「显示全部」、刷新', () => {
|
||||||
const title = menus['view/title']
|
const title = menus['view/title']
|
||||||
.filter(item => item.when.startsWith('view == superpowers.codeReader.files'))
|
.filter(item => item.when.startsWith('view == superpowers.codeReader.files'))
|
||||||
.sort((a, b) => orderOf(a) - orderOf(b))
|
.sort((a, b) => orderOf(a) - orderOf(b))
|
||||||
expect(title.map(item => item.command)).toEqual([PICK, SHOW_ALL, 'superpowers.codeReader.refresh'])
|
expect(title.map(item => item.command)).toEqual([PICK, UNCOMMITTED, SHOW_ALL, 'superpowers.codeReader.refresh'])
|
||||||
expect(title.every(item => item.group?.startsWith('navigation'))).toBe(true)
|
expect(title.every(item => item.group?.startsWith('navigation'))).toBe(true)
|
||||||
expect(title.find(item => item.command === SHOW_ALL)?.when).toContain(`&& ${REVIEWING}`)
|
expect(title.find(item => item.command === SHOW_ALL)?.when).toBe(`view == superpowers.codeReader.files && ${FILTER}`)
|
||||||
expect(title.find(item => item.command === PICK)?.when).not.toContain(REVIEWING)
|
for (const command of [PICK, UNCOMMITTED])
|
||||||
|
expect(title.find(item => item.command === command)?.when).not.toContain(FILTER)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('按钮的字和图标:选工单 git-pull-request,显示全部 close', () => {
|
it('按钮的字和图标:选工单 git-pull-request,未提交的更改 git-commit,显示全部 close', () => {
|
||||||
expect(commands.find(item => item.command === PICK)).toMatchObject({ title: '选工单', icon: '$(git-pull-request)' })
|
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 === SHOW_ALL)).toMatchObject({ title: '显示全部', icon: '$(close)' })
|
expect(commands.find(item => item.command === SHOW_ALL)).toMatchObject({ title: '显示全部', icon: '$(close)' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('审查时行上没有注释按钮(工作副本里不记注释),复制照旧', () => {
|
it('审查时行上没有注释按钮(工作副本里不记注释),未提交的更改时照常有;复制照旧', () => {
|
||||||
const noteItems = menus['view/item/context'].filter(item => item.command === ADD_NOTE || item.command === EDIT_NOTE)
|
const noteItems = menus['view/item/context'].filter(item => item.command === ADD_NOTE || item.command === EDIT_NOTE)
|
||||||
expect(noteItems.length).toBeGreaterThan(0)
|
expect(noteItems.length).toBeGreaterThan(0)
|
||||||
expect(noteItems.every(item => item.when.includes(`!${REVIEWING}`))).toBe(true)
|
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'))
|
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(REVIEWING))).toBe(false)
|
expect(copyItems.some(item => item.when.includes(FILTER))).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('命令面板里「显示全部」只在审查时有', () => {
|
it('命令面板里「显示全部」只在开着筛选时有', () => {
|
||||||
expect(menus.commandPalette.find(item => item.command === SHOW_ALL)?.when).toBe(REVIEWING)
|
expect(menus.commandPalette.find(item => item.command === SHOW_ALL)?.when).toBe(FILTER)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('只用一个上下文键,旧的 superpowers.codeReader.reviewing 不再出现', () => {
|
||||||
|
const whens = Object.values(menus).flat().map(item => item.when)
|
||||||
|
expect(whens.some(when => when.includes('superpowers.codeReader.reviewing'))).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { TreeView } from 'vscode'
|
||||||
|
import type { FileNode, FileTreeProvider } from './fileTree'
|
||||||
|
import { commands, Disposable } from 'vscode'
|
||||||
|
|
||||||
|
/** 上下文键:侧栏树开着哪种筛选,没设是全部文件。标题栏的「显示全部」、行上的注释按钮按它显示 / 藏起来。 */
|
||||||
|
const FILTER_CONTEXT = 'superpowers.codeReader.filter'
|
||||||
|
|
||||||
|
/** 侧栏树的筛选:review 审查工单(只摆 PR 改过的文件),uncommitted 未提交的更改(只摆各仓库里没提交的文件)。 */
|
||||||
|
export type TreeFilterKind = 'review' | 'uncommitted'
|
||||||
|
|
||||||
|
/** 一种筛选。 */
|
||||||
|
export interface TreeFilter {
|
||||||
|
/** 标题栏的「刷新」:重读这种筛选摆的文件 */
|
||||||
|
reload: () => Promise<void>
|
||||||
|
/** 「显示全部」或换成别的筛选:不再筛、不再记着,正在进的作废 */
|
||||||
|
exit: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 侧栏树上的几种筛选,同时只开一种:开一种时别的都退出;上下文键跟着开着的那种。
|
||||||
|
* 筛选开起来时自己换上树、标题后面的字(description)、标题下面的一行(message);退出时由这里回到工作区文件树。
|
||||||
|
*/
|
||||||
|
export class TreeFilters {
|
||||||
|
private readonly filters: TreeFilter[] = []
|
||||||
|
private current: TreeFilter | undefined
|
||||||
|
|
||||||
|
constructor(private readonly files: FileTreeProvider, private readonly view: TreeView<FileNode>) {}
|
||||||
|
|
||||||
|
/** 开着的那种筛选;没开是全部文件 */
|
||||||
|
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<void> {
|
||||||
|
this.current = filter
|
||||||
|
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<void> {
|
||||||
|
if (this.current !== filter)
|
||||||
|
return
|
||||||
|
this.current = undefined
|
||||||
|
this.files.showChanges(undefined)
|
||||||
|
this.view.description = undefined
|
||||||
|
this.view.message = undefined
|
||||||
|
await commands.executeCommand('setContext', FILTER_CONTEXT, undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './editor/previewEditor
|
|||||||
import { showPreview, showSource } from './editor/switchEditor'
|
import { showPreview, showSource } from './editor/switchEditor'
|
||||||
import { registerCopyCommands } from './explorer/copyCommands'
|
import { registerCopyCommands } from './explorer/copyCommands'
|
||||||
import { FileTreeProvider } from './explorer/fileTree'
|
import { FileTreeProvider } from './explorer/fileTree'
|
||||||
|
import { TreeFilters } from './explorer/treeFilters'
|
||||||
import { createBashAdapter } from './languages/bash'
|
import { createBashAdapter } from './languages/bash'
|
||||||
import { createDockerfileAdapter } from './languages/dockerfile'
|
import { createDockerfileAdapter } from './languages/dockerfile'
|
||||||
import { createPythonAdapter } from './languages/python'
|
import { createPythonAdapter } from './languages/python'
|
||||||
@@ -33,6 +34,7 @@ import { bashZhTranslator } from './translators/bashZh'
|
|||||||
import { dockerfileZhTranslator } from './translators/dockerfileZh'
|
import { dockerfileZhTranslator } from './translators/dockerfileZh'
|
||||||
import { pythonZhTranslator } from './translators/pythonZh'
|
import { pythonZhTranslator } from './translators/pythonZh'
|
||||||
import { typescriptZhTranslator } from './translators/typescriptZh'
|
import { typescriptZhTranslator } from './translators/typescriptZh'
|
||||||
|
import { UncommittedMode } from './uncommitted/uncommittedMode'
|
||||||
import { workspaceIndexHost } from './workspaceFiles'
|
import { workspaceIndexHost } from './workspaceFiles'
|
||||||
import { WorkspaceIndexes } from './workspaceIndex'
|
import { WorkspaceIndexes } from './workspaceIndex'
|
||||||
|
|
||||||
@@ -62,7 +64,7 @@ async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 接入代码阅读:注册表、侧栏文件树(复制、文件注释、按审查工单只看 PR 改过的文件)、预览编辑器(含问 AI)、编辑器右上角的「预览 / 编辑」切换。
|
* 接入代码阅读:注册表、侧栏文件树(复制、文件注释、筛选:按审查工单只看 PR 改过的文件、只看未提交的更改)、预览编辑器(含问 AI)、编辑器右上角的「预览 / 编辑」切换。
|
||||||
* 内置的语言插件、翻译插件、库规则和命令规则与其他扩展一样通过返回的 API 注册。
|
* 内置的语言插件、翻译插件、库规则和命令规则与其他扩展一样通过返回的 API 注册。
|
||||||
*/
|
*/
|
||||||
export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||||
@@ -85,7 +87,9 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
|||||||
const notes = new FileNotes()
|
const notes = new FileNotes()
|
||||||
const files = new FileTreeProvider(OPEN_COMMAND, notes)
|
const files = new FileTreeProvider(OPEN_COMMAND, notes)
|
||||||
const view = window.createTreeView(FILES_VIEW, { treeDataProvider: files, showCollapseAll: true })
|
const view = window.createTreeView(FILES_VIEW, { treeDataProvider: files, showCollapseAll: true })
|
||||||
const review = new ReviewMode(context, FILES_VIEW, view, files)
|
const filters = new TreeFilters(files, view)
|
||||||
|
const review = new ReviewMode(context, FILES_VIEW, view, files, filters)
|
||||||
|
const uncommitted = new UncommittedMode(context, FILES_VIEW, view, files, filters)
|
||||||
|
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
qa,
|
qa,
|
||||||
@@ -101,10 +105,12 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
|||||||
files,
|
files,
|
||||||
view,
|
view,
|
||||||
review,
|
review,
|
||||||
// 审查工单时刷新是重读 PR 改过的文件,平时是重读工作区文件树
|
uncommitted,
|
||||||
commands.registerCommand('superpowers.codeReader.refresh', () => review.active ? review.reload() : files.refresh()),
|
// 开着筛选时刷新是重读它摆的文件(PR 改过的、git 状态),平时是重读工作区文件树
|
||||||
|
commands.registerCommand('superpowers.codeReader.refresh', () => filters.active ? filters.active.reload() : files.refresh()),
|
||||||
commands.registerCommand('superpowers.codeReader.pickReviewIssue', () => review.pick()),
|
commands.registerCommand('superpowers.codeReader.pickReviewIssue', () => review.pick()),
|
||||||
commands.registerCommand('superpowers.codeReader.showAllFiles', () => review.exit()),
|
commands.registerCommand('superpowers.codeReader.showUncommitted', () => uncommitted.enter()),
|
||||||
|
commands.registerCommand('superpowers.codeReader.showAllFiles', () => filters.active?.exit()),
|
||||||
commands.registerCommand(OPEN_COMMAND, (uri: Uri) => openFile(uri, registry)),
|
commands.registerCommand(OPEN_COMMAND, (uri: Uri) => openFile(uri, registry)),
|
||||||
// 标题栏按钮传来的是 (文件 Uri, { groupId }),原样转给 VS Code 定位是哪一组的哪个标签
|
// 标题栏按钮传来的是 (文件 Uri, { groupId }),原样转给 VS Code 定位是哪一组的哪个标签
|
||||||
commands.registerCommand('superpowers.codeReader.showPreview', (...args: unknown[]) => showPreview(previews, args)),
|
commands.registerCommand('superpowers.codeReader.showPreview', (...args: unknown[]) => showPreview(previews, args)),
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { Disposable, TreeItem } from 'vscode'
|
import type { Disposable, TreeItem } from 'vscode'
|
||||||
import type { FileNode, FileTreeDecorator } from '../explorer/fileTree'
|
import type { FileNode, FileTreeDecorator } from '../explorer/fileTree'
|
||||||
import { MarkdownString } from 'vscode'
|
import { MarkdownString } from 'vscode'
|
||||||
import { FolderStore } from '../folderStore'
|
import { FolderStore, locate } from '../folderStore'
|
||||||
import { toPathKey } from '../pathMap'
|
|
||||||
import { noteDecoration } from './noteItem'
|
import { noteDecoration } from './noteItem'
|
||||||
import { withNote } from './noteMap'
|
import { withNote } from './noteMap'
|
||||||
import { NOTES_FILE } from './store'
|
import { NOTES_FILE } from './store'
|
||||||
@@ -10,6 +9,7 @@ import { NOTES_FILE } from './store'
|
|||||||
/**
|
/**
|
||||||
* 读代码时给目录 / 文件记的注释。
|
* 读代码时给目录 / 文件记的注释。
|
||||||
* 盘上每个工作区文件夹一份 `.spx/code-notes.json`;内存里留一份给侧栏树画行时同步取用。
|
* 盘上每个工作区文件夹一份 `.spx/code-notes.json`;内存里留一份给侧栏树画行时同步取用。
|
||||||
|
* 注释记在文件所在的工作区文件夹里,和树从哪个根摆下来(工作区文件夹、仓库根)无关;工作区外面的文件(审查的工作副本)不记。
|
||||||
* 在 VS Code 里改名、挪位置,注释跟着走;删掉就一起删。外部改了文件(git pull、手改)按文件重读(见 FolderStore)。
|
* 在 VS Code 里改名、挪位置,注释跟着走;删掉就一起删。外部改了文件(git pull、手改)按文件重读(见 FolderStore)。
|
||||||
*/
|
*/
|
||||||
export class FileNotes implements FileTreeDecorator, Disposable {
|
export class FileNotes implements FileTreeDecorator, Disposable {
|
||||||
@@ -17,15 +17,15 @@ export class FileNotes implements FileTreeDecorator, Disposable {
|
|||||||
/** 注释变了的那些行 */
|
/** 注释变了的那些行 */
|
||||||
readonly onDidChange = this.store.onDidChange
|
readonly onDidChange = this.store.onDidChange
|
||||||
|
|
||||||
/** 这一行的注释;工作区根节点不记注释。 */
|
/** 这一行的注释;工作区文件夹本身不记注释。 */
|
||||||
noteOf(node: FileNode): string | undefined {
|
noteOf(node: FileNode): string | undefined {
|
||||||
const key = toPathKey(node.folder.uri.path, node.uri.path)
|
const at = locate(node.uri)
|
||||||
return key === undefined ? undefined : this.store.get(node.folder, key)
|
return at && this.store.get(at.folder, at.key)
|
||||||
}
|
}
|
||||||
|
|
||||||
decorate(item: TreeItem, node: FileNode): void {
|
decorate(item: TreeItem, node: FileNode): void {
|
||||||
const key = toPathKey(node.folder.uri.path, node.uri.path)
|
const at = locate(node.uri)
|
||||||
const decoration = key === undefined ? undefined : noteDecoration(item.contextValue ?? '', key, this.noteOf(node))
|
const decoration = at && noteDecoration(item.contextValue ?? '', at.key, this.store.get(at.folder, at.key))
|
||||||
if (!decoration)
|
if (!decoration)
|
||||||
return
|
return
|
||||||
item.description = decoration.description
|
item.description = decoration.description
|
||||||
@@ -35,9 +35,9 @@ export class FileNotes implements FileTreeDecorator, Disposable {
|
|||||||
|
|
||||||
/** 保存注释:去掉首尾空白,空的就删掉这条。 */
|
/** 保存注释:去掉首尾空白,空的就删掉这条。 */
|
||||||
async save(node: FileNode, text: string): Promise<void> {
|
async save(node: FileNode, text: string): Promise<void> {
|
||||||
const key = toPathKey(node.folder.uri.path, node.uri.path)
|
const at = locate(node.uri)
|
||||||
if (key !== undefined)
|
if (at)
|
||||||
await this.store.update(node.folder, notes => withNote(notes, key, text))
|
await this.store.update(at.folder, notes => withNote(notes, at.key, text))
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Disposable, Event, FileDecorationProvider, Uri } from 'vscode'
|
import type { Disposable, Event, FileDecorationProvider, Uri } from 'vscode'
|
||||||
import type { FileChange } from './changedFiles'
|
import type { FileChange } from '../explorer/changedTree'
|
||||||
import { EventEmitter, FileDecoration, ThemeColor } from 'vscode'
|
import { EventEmitter, FileDecoration, ThemeColor } from 'vscode'
|
||||||
import { CHANGE_STYLES } from './changedFiles'
|
import { CHANGE_STYLES } from './changedFiles'
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { buildChangedTree, CHANGE_STYLES, changeOf } from './changedFiles'
|
import { CHANGE_STYLES, changeOf } from './changedFiles'
|
||||||
|
|
||||||
describe('改动状态', () => {
|
describe('改动状态', () => {
|
||||||
it('新增 A、删除 D,别的(改名、复制、改了、没改)都算修改 M', () => {
|
it('新增 A、删除 D,别的(改名、复制、改了、没改)都算修改 M', () => {
|
||||||
@@ -16,65 +16,3 @@ describe('改动状态', () => {
|
|||||||
expect(CHANGE_STYLES.D.tooltip).toBe('PR 里删掉了')
|
expect(CHANGE_STYLES.D.tooltip).toBe('PR 里删掉了')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('改动的文件树', () => {
|
|
||||||
// 原型「审查 · 改动的文件」里的 6 个文件,外加一个改名的
|
|
||||||
const files = [
|
|
||||||
{ path: 'backend/src/luffy_agent/user/routes.py', status: 'changed' },
|
|
||||||
{ path: 'backend/tests/user/test_avatar.py', status: 'added' },
|
|
||||||
{ path: 'backend/src/luffy_agent/core/storage.py', status: 'changed' },
|
|
||||||
{ path: 'backend/src/luffy_agent/user/legacy_profile.py', status: 'deleted' },
|
|
||||||
{ path: 'backend/src/luffy_agent/user/services.py', status: 'changed' },
|
|
||||||
{ path: 'backend/src/luffy_agent/user/schemas.py', status: 'renamed' },
|
|
||||||
]
|
|
||||||
const tree = buildChangedTree(files)
|
|
||||||
const names = (dir: string): string[] => (tree.get(dir) ?? []).map(entry => entry.name)
|
|
||||||
|
|
||||||
it('目录层级照留,每层只有通往改过的文件的目录', () => {
|
|
||||||
expect(names('')).toEqual(['backend'])
|
|
||||||
expect(names('backend')).toEqual(['src', 'tests'])
|
|
||||||
expect(names('backend/src')).toEqual(['luffy_agent'])
|
|
||||||
expect(names('backend/src/luffy_agent')).toEqual(['core', 'user'])
|
|
||||||
expect(names('backend/src/luffy_agent/core')).toEqual(['storage.py'])
|
|
||||||
expect(names('backend/tests/user')).toEqual(['test_avatar.py'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('同一层里的文件按名字排,带着各自的改动和相对仓库根的路径', () => {
|
|
||||||
expect(tree.get('backend/src/luffy_agent/user')).toEqual([
|
|
||||||
{ name: 'legacy_profile.py', path: 'backend/src/luffy_agent/user/legacy_profile.py', isDirectory: false, change: 'D' },
|
|
||||||
{ name: 'routes.py', path: 'backend/src/luffy_agent/user/routes.py', isDirectory: false, change: 'M' },
|
|
||||||
{ name: 'schemas.py', path: 'backend/src/luffy_agent/user/schemas.py', isDirectory: false, change: 'M' },
|
|
||||||
{ name: 'services.py', path: 'backend/src/luffy_agent/user/services.py', isDirectory: false, change: 'M' },
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('目录没有改动,路径相对仓库根', () => {
|
|
||||||
expect(tree.get('backend')).toEqual([
|
|
||||||
{ name: 'src', path: 'backend/src', isDirectory: true },
|
|
||||||
{ name: 'tests', path: 'backend/tests', isDirectory: true },
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('目录在前、文件在后,名字不区分大小写,和普通的树一样', () => {
|
|
||||||
const mixed = buildChangedTree([
|
|
||||||
{ path: 'README.md', status: 'changed' },
|
|
||||||
{ path: 'src/a.ts', status: 'added' },
|
|
||||||
{ path: 'Docs/b.md', status: 'added' },
|
|
||||||
{ path: 'package.json', status: 'changed' },
|
|
||||||
])
|
|
||||||
expect(mixed.get('')?.map(entry => entry.name)).toEqual(['Docs', 'src', 'package.json', 'README.md'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('文件换成同名目录(删掉 a、新加 a/b)两项都留着', () => {
|
|
||||||
const replaced = buildChangedTree([{ path: 'a', status: 'deleted' }, { path: 'a/b', status: 'added' }])
|
|
||||||
expect(replaced.get('')).toEqual([
|
|
||||||
{ name: 'a', path: 'a', isDirectory: true },
|
|
||||||
{ name: 'a', path: 'a', isDirectory: false, change: 'D' },
|
|
||||||
])
|
|
||||||
expect(replaced.get('a')?.map(entry => entry.change)).toEqual(['A'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('没有改动的文件时根下面是空的', () => {
|
|
||||||
expect(buildChangedTree([]).get('')).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
import type { DirEntry } from '../explorer/fileEntries'
|
import type { FileChange } from '../explorer/changedTree'
|
||||||
import { sortEntries } from '../explorer/fileEntries'
|
|
||||||
|
|
||||||
/** PR 里一个文件怎么改的,照 VS Code 源代码管理的写法:修改 M、新增 A、删除 D。 */
|
|
||||||
export type FileChange = 'M' | 'A' | 'D'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gitea 给的改动状态 → M / A / D。Gitea 的状态有 added、deleted、changed、renamed、copied、unchanged:
|
* Gitea 给的改动状态 → M / A / D。Gitea 的状态有 added、deleted、changed、renamed、copied、unchanged:
|
||||||
@@ -12,48 +8,9 @@ export function changeOf(status: string): FileChange {
|
|||||||
return status === 'added' ? 'A' : status === 'deleted' ? 'D' : 'M'
|
return status === 'added' ? 'A' : status === 'deleted' ? 'D' : 'M'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 各种改动在侧栏上的样子:颜色用 VS Code 源代码管理的 git 装饰色(主题色 id),徽标就是字母本身。 */
|
/** 审查时各种改动在侧栏上的样子:颜色用 VS Code 源代码管理的 git 装饰色(主题色 id),徽标就是字母本身。 */
|
||||||
export const CHANGE_STYLES: Record<FileChange, { color: string, tooltip: string }> = {
|
export const CHANGE_STYLES: Record<FileChange, { color: string, tooltip: string }> = {
|
||||||
M: { color: 'gitDecoration.modifiedResourceForeground', tooltip: 'PR 里改了' },
|
M: { color: 'gitDecoration.modifiedResourceForeground', tooltip: 'PR 里改了' },
|
||||||
A: { color: 'gitDecoration.addedResourceForeground', tooltip: 'PR 里新加的' },
|
A: { color: 'gitDecoration.addedResourceForeground', tooltip: 'PR 里新加的' },
|
||||||
D: { color: 'gitDecoration.deletedResourceForeground', tooltip: 'PR 里删掉了' },
|
D: { color: 'gitDecoration.deletedResourceForeground', tooltip: 'PR 里删掉了' },
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 改动的文件树上的一项。 */
|
|
||||||
export interface ChangedEntry extends DirEntry {
|
|
||||||
/** 相对仓库根,`/` 分隔 */
|
|
||||||
path: string
|
|
||||||
/** 文件怎么改的;目录没有 */
|
|
||||||
change?: FileChange
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按目录分好的改动:键是目录相对仓库根的路径(仓库根是 `''`),值是它下面一层的目录和文件。 */
|
|
||||||
export type ChangedTree = ReadonlyMap<string, readonly ChangedEntry[]>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PR 改过的文件 → 按目录分好的树:只有改过的文件和通往它们的目录,目录层级照留,
|
|
||||||
* 每层的排法和侧栏普通的树一样(目录在前、名字不区分大小写)。
|
|
||||||
* 同一路径上一个文件换成了同名目录(删掉文件 `a`、新加 `a/b`)时两项都留着。
|
|
||||||
*/
|
|
||||||
export function buildChangedTree(files: readonly { path: string, status: string }[]): ChangedTree {
|
|
||||||
const levels = new Map<string, Map<string, ChangedEntry>>([['', new Map()]])
|
|
||||||
const add = (dir: string, entry: ChangedEntry): void => {
|
|
||||||
let level = levels.get(dir)
|
|
||||||
if (!level)
|
|
||||||
levels.set(dir, level = new Map())
|
|
||||||
const key = `${entry.isDirectory ? 'dir' : 'file'}:${entry.name}`
|
|
||||||
if (!level.has(key))
|
|
||||||
level.set(key, entry)
|
|
||||||
}
|
|
||||||
for (const file of files) {
|
|
||||||
const segments = file.path.split('/')
|
|
||||||
segments.forEach((name, index) => {
|
|
||||||
const dir = segments.slice(0, index).join('/')
|
|
||||||
const path = segments.slice(0, index + 1).join('/')
|
|
||||||
add(dir, index === segments.length - 1
|
|
||||||
? { name, path, isDirectory: false, change: changeOf(file.status) }
|
|
||||||
: { name, path, isDirectory: true })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return new Map([...levels].map(([dir, level]) => [dir, sortEntries([...level.values()])]))
|
|
||||||
}
|
|
||||||
@@ -3,12 +3,14 @@ import type { PrFileDto } from '../../gitea/api'
|
|||||||
import type { RepoContext } from '../../gitea/repoContext'
|
import type { RepoContext } from '../../gitea/repoContext'
|
||||||
import type { Issue } from '../../gitea/types'
|
import type { Issue } from '../../gitea/types'
|
||||||
import type { FileNode, FileTreeProvider } from '../explorer/fileTree'
|
import type { FileNode, FileTreeProvider } from '../explorer/fileTree'
|
||||||
|
import type { TreeFilter, TreeFilters } from '../explorer/treeFilters'
|
||||||
import type { FileCount } from './pickList'
|
import type { FileCount } from './pickList'
|
||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
import { commands, QuickPickItemKind, Uri, window, workspace } from 'vscode'
|
import { QuickPickItemKind, Uri, window, workspace } from 'vscode'
|
||||||
import { logger } from '../../logging/logger'
|
import { logger } from '../../logging/logger'
|
||||||
|
import { buildChangedTree } from '../explorer/changedTree'
|
||||||
import { ChangeDecorations } from './changeDecorations'
|
import { ChangeDecorations } from './changeDecorations'
|
||||||
import { buildChangedTree, changeOf } from './changedFiles'
|
import { changeOf } from './changedFiles'
|
||||||
import { countingFiles, pickEntries, pickItemText, pickRows, withFileCount } from './pickList'
|
import { countingFiles, pickEntries, pickItemText, pickRows, withFileCount } from './pickList'
|
||||||
import { columnLabel, REVIEW_COLUMNS_TEXT, reviewableIssue, reviewablePullRequest, reviewDescription, reviewMessage } from './reviewRules'
|
import { columnLabel, REVIEW_COLUMNS_TEXT, reviewableIssue, reviewablePullRequest, reviewDescription, reviewMessage } from './reviewRules'
|
||||||
import { loadIssue, loadPullRequest, loadPullRequestFiles, requireRepo, reviewCandidateIssues } from './reviewSource'
|
import { loadIssue, loadPullRequest, loadPullRequestFiles, requireRepo, reviewCandidateIssues } from './reviewSource'
|
||||||
@@ -16,8 +18,6 @@ import { localWorktree, prepareWorktree } from './reviewWorktree'
|
|||||||
|
|
||||||
/** workspaceState:正在审查的工单号,重开窗口接着审查。 */
|
/** workspaceState:正在审查的工单号,重开窗口接着审查。 */
|
||||||
const REVIEW_ISSUE_KEY = 'superpowers.codeReader.reviewIssue'
|
const REVIEW_ISSUE_KEY = 'superpowers.codeReader.reviewIssue'
|
||||||
/** 上下文键:正在审查工单。标题栏的「显示全部」、行上的注释按钮按它显示 / 藏起来。 */
|
|
||||||
const REVIEWING_CONTEXT = 'superpowers.codeReader.reviewing'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 怎么进的审查,决定不能审查、出错时怎么收场:
|
* 怎么进的审查,决定不能审查、出错时怎么收场:
|
||||||
@@ -45,8 +45,9 @@ function errorText(err: unknown): string {
|
|||||||
/**
|
/**
|
||||||
* 侧栏按审查工单筛文件:「选工单」列出看板「审查」「进行中」列里能审查的工单,选中后树里只剩这张工单的 PR 改过的文件(按改动着色),
|
* 侧栏按审查工单筛文件:「选工单」列出看板「审查」「进行中」列里能审查的工单,选中后树里只剩这张工单的 PR 改过的文件(按改动着色),
|
||||||
* 点开的是本机这张工单的工作副本里 PR 最新的代码;「显示全部」回到工作区文件树。选的工单按工作区记住。
|
* 点开的是本机这张工单的工作副本里 PR 最新的代码;「显示全部」回到工作区文件树。选的工单按工作区记住。
|
||||||
|
* 和别的筛选(未提交的更改)互斥,见 TreeFilters。
|
||||||
*/
|
*/
|
||||||
export class ReviewMode implements Disposable {
|
export class ReviewMode implements TreeFilter, Disposable {
|
||||||
private readonly decorations = new ChangeDecorations()
|
private readonly decorations = new ChangeDecorations()
|
||||||
private readonly disposables: Disposable[]
|
private readonly disposables: Disposable[]
|
||||||
/** 正在审查的工单号 */
|
/** 正在审查的工单号 */
|
||||||
@@ -60,25 +61,14 @@ export class ReviewMode implements Disposable {
|
|||||||
private readonly viewId: string,
|
private readonly viewId: string,
|
||||||
private readonly view: TreeView<FileNode>,
|
private readonly view: TreeView<FileNode>,
|
||||||
private readonly files: FileTreeProvider,
|
private readonly files: FileTreeProvider,
|
||||||
|
private readonly filters: TreeFilters,
|
||||||
) {
|
) {
|
||||||
this.disposables = [this.decorations, window.registerFileDecorationProvider(this.decorations)]
|
filters.add(this)
|
||||||
// 视图第一次露出来时才接着上次的审查:没打开侧栏就不去拉 PR、动工作副本
|
this.disposables = [
|
||||||
if (view.visible) {
|
this.decorations,
|
||||||
void this.restore()
|
window.registerFileDecorationProvider(this.decorations),
|
||||||
}
|
filters.whenShown(() => void this.restore()),
|
||||||
else {
|
]
|
||||||
const shown = view.onDidChangeVisibility(({ visible }) => {
|
|
||||||
if (!visible)
|
|
||||||
return
|
|
||||||
shown.dispose()
|
|
||||||
void this.restore()
|
|
||||||
})
|
|
||||||
this.disposables.push(shown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get active(): boolean {
|
|
||||||
return this.reviewing !== undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -156,16 +146,13 @@ export class ReviewMode implements Disposable {
|
|||||||
: []))
|
: []))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 「显示全部」:回到工作区文件树,不再记着这张工单。 */
|
/** 「显示全部」或换成别的筛选:不再审查、不再记着这张工单,正在进的审查作废;正审查着的回到工作区文件树。 */
|
||||||
async exit(): Promise<void> {
|
async exit(): Promise<void> {
|
||||||
this.generation++
|
this.generation++
|
||||||
this.reviewing = undefined
|
this.reviewing = undefined
|
||||||
this.files.showChanges(undefined)
|
|
||||||
this.decorations.show([])
|
this.decorations.show([])
|
||||||
this.view.description = undefined
|
|
||||||
this.view.message = undefined
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
commands.executeCommand('setContext', REVIEWING_CONTEXT, false),
|
this.filters.leave(this),
|
||||||
this.context.workspaceState.update(REVIEW_ISSUE_KEY, undefined),
|
this.context.workspaceState.update(REVIEW_ISSUE_KEY, undefined),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
@@ -261,13 +248,15 @@ export class ReviewMode implements Disposable {
|
|||||||
const root = Uri.file(worktree)
|
const root = Uri.file(worktree)
|
||||||
// 工作副本当根,名字照写工作区文件夹的:看的还是这个仓库,只是 PR 最新的代码
|
// 工作副本当根,名字照写工作区文件夹的:看的还是这个仓库,只是 PR 最新的代码
|
||||||
const folder: WorkspaceFolder = { uri: root, name: workspace.workspaceFolders?.[0]?.name ?? path.basename(worktree), index: 0 }
|
const folder: WorkspaceFolder = { uri: root, name: workspace.workspaceFolders?.[0]?.name ?? path.basename(worktree), index: 0 }
|
||||||
|
const changes = files.map(file => ({ path: file.path, change: changeOf(file.status) }))
|
||||||
|
const entering = this.filters.enter(this, 'review')
|
||||||
this.reviewing = issue.number
|
this.reviewing = issue.number
|
||||||
this.files.showChanges({ folder, tree: buildChangedTree(files) })
|
this.files.showChanges([{ folder, tree: buildChangedTree(changes) }])
|
||||||
this.decorations.show(files.map(file => ({ uri: Uri.joinPath(root, file.path), change: changeOf(file.status) })))
|
this.decorations.show(changes.map(file => ({ uri: Uri.joinPath(root, file.path), change: file.change })))
|
||||||
this.view.description = reviewDescription(issue.number)
|
this.view.description = reviewDescription(issue.number)
|
||||||
this.view.message = reviewMessage(issue, pr, files.length)
|
this.view.message = reviewMessage(issue, pr, files.length)
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
commands.executeCommand('setContext', REVIEWING_CONTEXT, true),
|
entering,
|
||||||
this.context.workspaceState.update(REVIEW_ISSUE_KEY, issue.number),
|
this.context.workspaceState.update(REVIEW_ISSUE_KEY, issue.number),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import type { GitStatus } from '../../git/gitApi'
|
||||||
|
import type { ChangeGroups } from './uncommittedFiles'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { GIT_STATUS } from '../../git/gitApi'
|
||||||
|
import { changeOfGitStatus, uncommittedFiles, uncommittedRoots, workspaceOrder } from './uncommittedFiles'
|
||||||
|
|
||||||
|
const at = (path: string, status: GitStatus): { uri: { path: string }, status: GitStatus } => ({ uri: { path }, status })
|
||||||
|
|
||||||
|
function groups(parts: Partial<ChangeGroups>): ChangeGroups {
|
||||||
|
return { mergeChanges: [], indexChanges: [], workingTreeChanges: [], untrackedChanges: [], ...parts }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('git 状态 → 改动', () => {
|
||||||
|
it('未跟踪、新增(含 add -N、合并时新加的)算 A', () => {
|
||||||
|
for (const status of [GIT_STATUS.UNTRACKED, GIT_STATUS.INDEX_ADDED, GIT_STATUS.INTENT_TO_ADD, GIT_STATUS.ADDED_BY_US, GIT_STATUS.ADDED_BY_THEM, GIT_STATUS.BOTH_ADDED])
|
||||||
|
expect(changeOfGitStatus(status)).toBe('A')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('删除(暂存的、没暂存的、合并时双方都删的)算 D', () => {
|
||||||
|
for (const status of [GIT_STATUS.INDEX_DELETED, GIT_STATUS.DELETED, GIT_STATUS.BOTH_DELETED])
|
||||||
|
expect(changeOfGitStatus(status)).toBe('D')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('别的都算 M:改了、改名、复制、改类型、合并冲突(一方删一方改的文件还在工作区里)', () => {
|
||||||
|
for (const status of [
|
||||||
|
GIT_STATUS.INDEX_MODIFIED,
|
||||||
|
GIT_STATUS.MODIFIED,
|
||||||
|
GIT_STATUS.INDEX_RENAMED,
|
||||||
|
GIT_STATUS.INTENT_TO_RENAME,
|
||||||
|
GIT_STATUS.INDEX_COPIED,
|
||||||
|
GIT_STATUS.TYPE_CHANGED,
|
||||||
|
GIT_STATUS.BOTH_MODIFIED,
|
||||||
|
GIT_STATUS.DELETED_BY_US,
|
||||||
|
GIT_STATUS.DELETED_BY_THEM,
|
||||||
|
])
|
||||||
|
expect(changeOfGitStatus(status)).toBe('M')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('一个仓库里没提交的文件', () => {
|
||||||
|
it('合并冲突、暂存、没暂存、未跟踪的合在一起,路径相对仓库根', () => {
|
||||||
|
expect(uncommittedFiles('/repo', groups({
|
||||||
|
mergeChanges: [at('/repo/conflict.ts', GIT_STATUS.BOTH_MODIFIED)],
|
||||||
|
indexChanges: [at('/repo/src/staged.ts', GIT_STATUS.INDEX_MODIFIED)],
|
||||||
|
workingTreeChanges: [at('/repo/src/gone.ts', GIT_STATUS.DELETED)],
|
||||||
|
untrackedChanges: [at('/repo/docs/new.md', GIT_STATUS.UNTRACKED)],
|
||||||
|
}))).toEqual([
|
||||||
|
{ path: 'conflict.ts', change: 'M' },
|
||||||
|
{ path: 'src/staged.ts', change: 'M' },
|
||||||
|
{ path: 'src/gone.ts', change: 'D' },
|
||||||
|
{ path: 'docs/new.md', change: 'A' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('暂存了又改了的只留一项', () => {
|
||||||
|
expect(uncommittedFiles('/repo', groups({
|
||||||
|
indexChanges: [at('/repo/a.ts', GIT_STATUS.INDEX_MODIFIED)],
|
||||||
|
workingTreeChanges: [at('/repo/a.ts', GIT_STATUS.MODIFIED)],
|
||||||
|
}))).toEqual([{ path: 'a.ts', change: 'M' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('新加的暂存后又改了,还是 A', () => {
|
||||||
|
expect(uncommittedFiles('/repo', groups({
|
||||||
|
indexChanges: [at('/repo/a.ts', GIT_STATUS.INDEX_ADDED)],
|
||||||
|
workingTreeChanges: [at('/repo/a.ts', GIT_STATUS.MODIFIED)],
|
||||||
|
}))).toEqual([{ path: 'a.ts', change: 'A' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('新加的暂存后又在盘上删了,是 D(打不开)', () => {
|
||||||
|
expect(uncommittedFiles('/repo', groups({
|
||||||
|
indexChanges: [at('/repo/a.ts', GIT_STATUS.INDEX_ADDED)],
|
||||||
|
workingTreeChanges: [at('/repo/a.ts', GIT_STATUS.DELETED)],
|
||||||
|
}))).toEqual([{ path: 'a.ts', change: 'D' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('从暂存区删掉、文件还在盘上(git rm --cached)的,照盘上算 A', () => {
|
||||||
|
expect(uncommittedFiles('/repo', groups({
|
||||||
|
indexChanges: [at('/repo/a.ts', GIT_STATUS.INDEX_DELETED)],
|
||||||
|
untrackedChanges: [at('/repo/a.ts', GIT_STATUS.UNTRACKED)],
|
||||||
|
}))).toEqual([{ path: 'a.ts', change: 'A' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('仓库根本身、仓库外面的路径不要', () => {
|
||||||
|
expect(uncommittedFiles('/repo', groups({
|
||||||
|
workingTreeChanges: [at('/repo', GIT_STATUS.MODIFIED), at('/other/a.ts', GIT_STATUS.MODIFIED), at('/repository/b.ts', GIT_STATUS.MODIFIED)],
|
||||||
|
}))).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('没有改动时是空的', () => {
|
||||||
|
expect(uncommittedFiles('/repo', groups({}))).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('工作区里有改动的仓库', () => {
|
||||||
|
const repo = (root: string, parts: Partial<ChangeGroups>): { rootUri: { path: string }, state: ChangeGroups } => ({ rootUri: { path: root }, state: groups(parts) })
|
||||||
|
const folder = (path: string, name: string): { uri: { path: string }, name: string } => ({ uri: { path }, name })
|
||||||
|
const changed = (root: string): ReturnType<typeof repo> => repo(root, { workingTreeChanges: [at(`${root}/a.ts`, GIT_STATUS.MODIFIED)] })
|
||||||
|
|
||||||
|
it('仓库根就是工作区文件夹时带着文件夹、用文件夹的名字', () => {
|
||||||
|
const app = folder('/work/app', '应用')
|
||||||
|
const [root] = uncommittedRoots([changed('/work/app')], [app])
|
||||||
|
expect(root.folder).toBe(app)
|
||||||
|
expect(root.name).toBe('应用')
|
||||||
|
expect(root.files).toEqual([{ path: 'a.ts', change: 'M' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('嵌套在工作区文件夹里的、包着工作区文件夹的仓库也算,名字用仓库根目录的名字', () => {
|
||||||
|
const roots = uncommittedRoots([changed('/work/app/libs/sdk'), changed('/work')], [folder('/work/app', 'app')])
|
||||||
|
expect(roots.map(root => [root.name, root.folder])).toEqual([['work', undefined], ['sdk', undefined]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('跟工作区文件夹不沾边的仓库(审查用的工作副本)不算', () => {
|
||||||
|
expect(uncommittedRoots([changed('/worktrees/review-1'), changed('/work/application')], [folder('/work/app', 'app')])).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('没有改动的仓库不摆', () => {
|
||||||
|
expect(uncommittedRoots([repo('/work/app', {})], [folder('/work/app', 'app')])).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('按工作区文件夹的次序排,同一个文件夹里按仓库根的路径排', () => {
|
||||||
|
const roots = uncommittedRoots(
|
||||||
|
[changed('/b/zeta'), changed('/a'), changed('/b'), changed('/b/alpha')],
|
||||||
|
[folder('/b', 'B'), folder('/a', 'A')],
|
||||||
|
)
|
||||||
|
expect(roots.map(root => root.repository.rootUri.path)).toEqual(['/b', '/b/alpha', '/b/zeta', '/a'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('算不算工作区里的仓库', () => {
|
||||||
|
const folders = [{ uri: { path: '/work/app' }, name: 'app' }, { uri: { path: '/lib' }, name: 'lib' }]
|
||||||
|
|
||||||
|
it('仓库根就是、在里面、包着工作区文件夹的算,给出沾边的第一个工作区文件夹的次序', () => {
|
||||||
|
expect(workspaceOrder('/work/app', folders)).toBe(0)
|
||||||
|
expect(workspaceOrder('/work/app/vendor/sdk', folders)).toBe(0)
|
||||||
|
expect(workspaceOrder('/work', folders)).toBe(0)
|
||||||
|
expect(workspaceOrder('/lib', folders)).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('不沾边的(名字只是前缀相同的、别处的工作副本)是 -1', () => {
|
||||||
|
expect(workspaceOrder('/work/application', folders)).toBe(-1)
|
||||||
|
expect(workspaceOrder('/worktrees/review-1', folders)).toBe(-1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type { GitStatus } from '../../git/gitApi'
|
||||||
|
import type { ChangedFile, FileChange } from '../explorer/changedTree'
|
||||||
|
import { posix } from 'node:path'
|
||||||
|
import { GIT_STATUS } from '../../git/gitApi'
|
||||||
|
import { toPathKey } from '../pathMap'
|
||||||
|
|
||||||
|
/** 一组改动里的一项:文件在哪(Uri.path)、git 状态。 */
|
||||||
|
interface StatusEntry {
|
||||||
|
readonly uri: { readonly path: string }
|
||||||
|
readonly status: GitStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仓库状态里没提交的几组,照 git.d.ts 的 RepositoryState。 */
|
||||||
|
export interface ChangeGroups {
|
||||||
|
readonly mergeChanges: readonly StatusEntry[]
|
||||||
|
readonly indexChanges: readonly StatusEntry[]
|
||||||
|
readonly workingTreeChanges: readonly StatusEntry[]
|
||||||
|
readonly untrackedChanges: readonly StatusEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADDED = new Set<GitStatus>([
|
||||||
|
GIT_STATUS.INDEX_ADDED,
|
||||||
|
GIT_STATUS.UNTRACKED,
|
||||||
|
GIT_STATUS.INTENT_TO_ADD,
|
||||||
|
GIT_STATUS.ADDED_BY_US,
|
||||||
|
GIT_STATUS.ADDED_BY_THEM,
|
||||||
|
GIT_STATUS.BOTH_ADDED,
|
||||||
|
])
|
||||||
|
|
||||||
|
const DELETED = new Set<GitStatus>([
|
||||||
|
GIT_STATUS.INDEX_DELETED,
|
||||||
|
GIT_STATUS.DELETED,
|
||||||
|
GIT_STATUS.BOTH_DELETED,
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* git 的文件状态 → M / A / D:未跟踪、新增(含 `git add -N`、合并时一方或双方新加的)算 A,
|
||||||
|
* 删除(含合并时双方都删了的)算 D,别的(改了、改名、复制、改类型、合并冲突)都算 M。
|
||||||
|
* 合并时一方删了、另一方改了的,冲突的文件还留在工作区里,也算 M。
|
||||||
|
*/
|
||||||
|
export function changeOfGitStatus(status: GitStatus): FileChange {
|
||||||
|
return ADDED.has(status) ? 'A' : DELETED.has(status) ? 'D' : 'M'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一个仓库里没提交的文件:合并冲突的、暂存的、没暂存的、未跟踪的合在一起,路径相对仓库根、`/` 分隔。
|
||||||
|
* 同一个文件在几组里都有(暂存了又改了)只留一项:照上面的次序越往后越接近盘上现在的样子,后面的盖掉前面的;
|
||||||
|
* 只是「改了」不盖掉前面的新增(新加的文件暂存后又改了,还是新加的)。
|
||||||
|
*/
|
||||||
|
export function uncommittedFiles(rootPath: string, groups: ChangeGroups): ChangedFile[] {
|
||||||
|
const files = new Map<string, FileChange>()
|
||||||
|
for (const group of [groups.mergeChanges, groups.indexChanges, groups.workingTreeChanges, groups.untrackedChanges]) {
|
||||||
|
for (const { uri, status } of group) {
|
||||||
|
const key = toPathKey(rootPath, uri.path)
|
||||||
|
if (key === undefined)
|
||||||
|
continue
|
||||||
|
const change = changeOfGitStatus(status)
|
||||||
|
const before = files.get(key)
|
||||||
|
files.set(key, change === 'M' && before !== undefined ? before : change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...files].map(([path, change]) => ({ path, change }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameOrUnder(child: string, parent: string): boolean {
|
||||||
|
return child === parent || toPathKey(parent, child) !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工作区文件夹里用到的:在哪(Uri.path)、叫什么。 */
|
||||||
|
interface FolderLike {
|
||||||
|
readonly uri: { readonly path: string }
|
||||||
|
readonly name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仓库里用到的:仓库根(Uri.path)和没提交的几组改动。 */
|
||||||
|
interface RepositoryLike {
|
||||||
|
readonly rootUri: { readonly path: string }
|
||||||
|
readonly state: ChangeGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 有没提交的文件的一个仓库,侧栏上一个根节点。 */
|
||||||
|
export interface UncommittedRoot<R, F> {
|
||||||
|
repository: R
|
||||||
|
/** 仓库根就是这个工作区文件夹时是它 */
|
||||||
|
folder: F | undefined
|
||||||
|
/** 根节点上的名字:仓库根就是工作区文件夹时用文件夹的名字,别的用仓库根目录的名字 */
|
||||||
|
name: string
|
||||||
|
files: ChangedFile[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仓库算不算工作区里的:仓库根就是某个工作区文件夹、在某个工作区文件夹里面(嵌套的仓库),
|
||||||
|
* 或者包着某个工作区文件夹(打开的是仓库里的子目录);编辑器打开别处的文件时 git 扩展顺带打开的仓库(比如审查用的工作副本)不算。
|
||||||
|
* @returns 沾边的第一个工作区文件夹的次序;不算工作区里的是 -1
|
||||||
|
*/
|
||||||
|
export function workspaceOrder(rootPath: string, folders: readonly FolderLike[]): number {
|
||||||
|
return folders.findIndex(folder => sameOrUnder(rootPath, folder.uri.path) || sameOrUnder(folder.uri.path, rootPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作区里(见 workspaceOrder)有没提交的文件的仓库,各一个根。
|
||||||
|
* 按沾边的工作区文件夹的次序排,同一个文件夹沾边的按仓库根的路径排。
|
||||||
|
*/
|
||||||
|
export function uncommittedRoots<R extends RepositoryLike, F extends FolderLike>(repositories: readonly R[], folders: readonly F[]): UncommittedRoot<R, F>[] {
|
||||||
|
return repositories
|
||||||
|
.map(repository => ({ repository, rootPath: repository.rootUri.path, order: workspaceOrder(repository.rootUri.path, folders) }))
|
||||||
|
.filter(({ order }) => order >= 0)
|
||||||
|
.sort((a, b) => a.order - b.order || (a.rootPath < b.rootPath ? -1 : a.rootPath > b.rootPath ? 1 : 0))
|
||||||
|
.map(({ repository, rootPath }) => {
|
||||||
|
const folder = folders.find(item => item.uri.path === rootPath)
|
||||||
|
return { repository, folder, name: folder?.name ?? posix.basename(rootPath), files: uncommittedFiles(rootPath, repository.state) }
|
||||||
|
})
|
||||||
|
.filter(root => root.files.length > 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import type { ExtensionContext, TreeView } from 'vscode'
|
||||||
|
import type { GitApi, GitRepository } from '../../git/gitApi'
|
||||||
|
import type { FileNode, FileTreeProvider } from '../explorer/fileTree'
|
||||||
|
import type { TreeFilter, TreeFilters } from '../explorer/treeFilters'
|
||||||
|
import { Disposable, window, workspace } from 'vscode'
|
||||||
|
import { loadGitApi } from '../../git/gitExtension'
|
||||||
|
import { logger } from '../../logging/logger'
|
||||||
|
import { buildChangedTree } from '../explorer/changedTree'
|
||||||
|
import { uncommittedRoots, workspaceOrder } from './uncommittedFiles'
|
||||||
|
|
||||||
|
/** workspaceState:开着「未提交的更改」,重开窗口接着显示。 */
|
||||||
|
const UNCOMMITTED_KEY = 'superpowers.codeReader.uncommitted'
|
||||||
|
/** 开着时视图标题后面淡色的字(TreeView.description)。 */
|
||||||
|
const UNCOMMITTED_DESCRIPTION = '未提交的更改'
|
||||||
|
/** 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 侧栏只看未提交的更改:树里只剩工作区各 git 仓库里没提交的文件(暂存的、没暂存的、未跟踪的、合并冲突的),每个有改动的仓库一个根。
|
||||||
|
* 文件就是工作区里的文件,颜色和 M / A / D 徽标由 VS Code 自带的 git 装饰加上。开着时盯着 git 状态自动重摆;「显示全部」回到工作区文件树。
|
||||||
|
* 开着没开按工作区记住;和审查工单互斥,见 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<FileNode>,
|
||||||
|
private readonly files: FileTreeProvider,
|
||||||
|
private readonly filters: TreeFilters,
|
||||||
|
) {
|
||||||
|
filters.add(this)
|
||||||
|
this.disposables = [filters.whenShown(() => void this.restore())]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「未提交的更改」:别的筛选退出,树里换成未提交的文件,之后跟着 git 状态变。 */
|
||||||
|
async enter(): Promise<void> {
|
||||||
|
await this.open('enter')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「显示全部」或换成别的筛选:不再盯 git、不再记着,正在进的作废;开着的回到工作区文件树。 */
|
||||||
|
async exit(): Promise<void> {
|
||||||
|
this.generation++
|
||||||
|
this.stopWatching()
|
||||||
|
await Promise.all([
|
||||||
|
this.filters.leave(this),
|
||||||
|
this.context.workspaceState.update(UNCOMMITTED_KEY, undefined),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开着时的「刷新」:让 git 扩展重读工作区各仓库的状态,再整棵重摆。 */
|
||||||
|
async reload(): Promise<void> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.stopWatching()
|
||||||
|
for (const disposable of this.disposables)
|
||||||
|
disposable.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async restore(): Promise<void> {
|
||||||
|
if (this.context.workspaceState.get<boolean>(UNCOMMITTED_KEY))
|
||||||
|
await this.open('restore')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async open(how: How): Promise<void> {
|
||||||
|
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.view.description = UNCOMMITTED_DESCRIPTION
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
await repository.status()
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
logger.add({ level: 'warn', source: 'codeReader', message: '未提交的更改:重读 git 状态失败', details: `${repository.rootUri.fsPath}\n${errorText(err)}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 盯着 git:各仓库的状态变了、开了 / 关了仓库、git 扩展找完仓库、工作区文件夹变了,都合成一次重摆。 */
|
||||||
|
private watch(api: GitApi): Disposable {
|
||||||
|
const repositories = new Map<GitRepository, Disposable>()
|
||||||
|
const schedule = (): void => {
|
||||||
|
clearTimeout(this.timer)
|
||||||
|
this.timer = setTimeout(() => this.rebuild(), 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 roots = uncommittedRoots(api.repositories, folders)
|
||||||
|
this.view.message = roots.length > 0
|
||||||
|
? undefined
|
||||||
|
: api.state === 'initialized' ? '没有未提交的更改' : '正在读取 git 状态…'
|
||||||
|
const shown = JSON.stringify(roots.map(root => [root.repository.rootUri.toString(), root.name, root.files]))
|
||||||
|
if (shown === this.shown)
|
||||||
|
return
|
||||||
|
this.shown = shown
|
||||||
|
this.files.showChanges(roots.map(root => ({
|
||||||
|
// 仓库根不是工作区文件夹时(嵌套的仓库、包着工作区文件夹的仓库)拿仓库根当根,名字用仓库根目录的名字
|
||||||
|
folder: root.folder ?? { uri: root.repository.rootUri, name: root.name, index: folders.length },
|
||||||
|
tree: buildChangedTree(root.files),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { Event, Uri } from 'vscode'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内置 git 扩展(vscode.git)API 里用到的那一小部分,照 microsoft/vscode 的 extensions/git/src/api/git.d.ts 写的结构类型。
|
||||||
|
* 这里只有类型和状态值,运行时不依赖 vscode,纯函数和单测可以直接用;取 API 见 gitExtension.ts。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 文件在 git 里的状态,数值照 git.d.ts 的 Status 枚举。 */
|
||||||
|
export const GIT_STATUS = {
|
||||||
|
INDEX_MODIFIED: 0,
|
||||||
|
INDEX_ADDED: 1,
|
||||||
|
INDEX_DELETED: 2,
|
||||||
|
INDEX_RENAMED: 3,
|
||||||
|
INDEX_COPIED: 4,
|
||||||
|
MODIFIED: 5,
|
||||||
|
DELETED: 6,
|
||||||
|
UNTRACKED: 7,
|
||||||
|
IGNORED: 8,
|
||||||
|
INTENT_TO_ADD: 9,
|
||||||
|
INTENT_TO_RENAME: 10,
|
||||||
|
TYPE_CHANGED: 11,
|
||||||
|
ADDED_BY_US: 12,
|
||||||
|
ADDED_BY_THEM: 13,
|
||||||
|
DELETED_BY_US: 14,
|
||||||
|
DELETED_BY_THEM: 15,
|
||||||
|
BOTH_ADDED: 16,
|
||||||
|
BOTH_DELETED: 17,
|
||||||
|
BOTH_MODIFIED: 18,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type GitStatus = (typeof GIT_STATUS)[keyof typeof GIT_STATUS]
|
||||||
|
|
||||||
|
/** 一个文件的改动;改名的 uri 是新路径。 */
|
||||||
|
export interface GitChange {
|
||||||
|
readonly uri: Uri
|
||||||
|
readonly status: GitStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitRepositoryState {
|
||||||
|
/** 合并冲突的 */
|
||||||
|
readonly mergeChanges: readonly GitChange[]
|
||||||
|
/** 暂存的 */
|
||||||
|
readonly indexChanges: readonly GitChange[]
|
||||||
|
/** 没暂存的;设置 git.untrackedChanges 是 mixed(默认)时也含未跟踪的 */
|
||||||
|
readonly workingTreeChanges: readonly GitChange[]
|
||||||
|
/** 未跟踪的;设置 git.untrackedChanges 是 separate 时才有 */
|
||||||
|
readonly untrackedChanges: readonly GitChange[]
|
||||||
|
readonly onDidChange: Event<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitRepository {
|
||||||
|
readonly rootUri: Uri
|
||||||
|
readonly state: GitRepositoryState
|
||||||
|
/** 重读这个仓库的 git 状态 */
|
||||||
|
status: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** initialized 之前 git 扩展还在找工作区里的仓库,repositories 可能不全。 */
|
||||||
|
export type GitApiState = 'uninitialized' | 'initialized'
|
||||||
|
|
||||||
|
export interface GitApi {
|
||||||
|
readonly state: GitApiState
|
||||||
|
readonly onDidChangeState: Event<GitApiState>
|
||||||
|
readonly repositories: readonly GitRepository[]
|
||||||
|
readonly onDidOpenRepository: Event<GitRepository>
|
||||||
|
readonly onDidCloseRepository: Event<GitRepository>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** vscode.git 扩展激活后导出的东西。 */
|
||||||
|
export interface GitExtension {
|
||||||
|
/** 设置 git.enabled 关掉时是 false,这时 getAPI 会抛错 */
|
||||||
|
readonly enabled: boolean
|
||||||
|
getAPI: (version: 1) => GitApi
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { GitApi, GitExtension } from './gitApi'
|
||||||
|
import { extensions } from 'vscode'
|
||||||
|
|
||||||
|
/** 取内置 git 扩展的 API(还没激活就先激活);没装、被禁用(设置 git.enabled 关了)时是 undefined,激活失败时抛错。 */
|
||||||
|
export async function loadGitApi(): Promise<GitApi | undefined> {
|
||||||
|
const extension = extensions.getExtension<GitExtension>('vscode.git')
|
||||||
|
if (!extension)
|
||||||
|
return undefined
|
||||||
|
const git = extension.isActive ? extension.exports : await extension.activate()
|
||||||
|
return git.enabled ? git.getAPI(1) : undefined
|
||||||
|
}
|
||||||
@@ -1,47 +1,19 @@
|
|||||||
import type { Uri } from 'vscode'
|
import type { GitRepository } from '../../git/gitApi'
|
||||||
import type { KanbanWebviewPanel } from '../KanbanPanel'
|
import type { KanbanWebviewPanel } from '../KanbanPanel'
|
||||||
import { accessSync, constants } from 'node:fs'
|
import { accessSync, constants } from 'node:fs'
|
||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
import { extensions, workspace } from 'vscode'
|
import { workspace } from 'vscode'
|
||||||
import { resolveCommitProfileForRun } from '../../cc/commitProfile'
|
import { resolveCommitProfileForRun } from '../../cc/commitProfile'
|
||||||
import { resolveProfilePath } from '../../cc/profiles'
|
import { resolveProfilePath } from '../../cc/profiles'
|
||||||
import { spawnClaude } from '../../cc/spawnClaude'
|
import { spawnClaude } from '../../cc/spawnClaude'
|
||||||
import { findEnvFiles, lockEnvFiles, unlockEnvFiles } from '../../files/envLock'
|
import { findEnvFiles, lockEnvFiles, unlockEnvFiles } from '../../files/envLock'
|
||||||
|
import { loadGitApi } from '../../git/gitExtension'
|
||||||
import { missingGitIdentityKeys, readGitIdentity } from '../../git/identity'
|
import { missingGitIdentityKeys, readGitIdentity } from '../../git/identity'
|
||||||
import { logger } from '../../logging/logger'
|
import { logger } from '../../logging/logger'
|
||||||
import { getEffectiveCommitProfilePath } from '../../settings/store'
|
import { getEffectiveCommitProfilePath } from '../../settings/store'
|
||||||
import { makeNonce } from '../../webview/html'
|
import { makeNonce } from '../../webview/html'
|
||||||
import { dismiss, toast } from './handoffFlow'
|
import { dismiss, toast } from './handoffFlow'
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal structural typing for the bits of the built-in `vscode.git`
|
|
||||||
* extension API we consume to observe working-tree state.
|
|
||||||
* Reference: microsoft/vscode → extensions/git/src/api/git.d.ts
|
|
||||||
*/
|
|
||||||
interface GitExtensionApiRepositoryStateChange {
|
|
||||||
(listener: () => unknown): { dispose: () => void }
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GitExtensionApiRepositoryState {
|
|
||||||
readonly workingTreeChanges: ReadonlyArray<{ uri: Uri }>
|
|
||||||
readonly indexChanges: ReadonlyArray<{ uri: Uri }>
|
|
||||||
readonly untrackedChanges?: ReadonlyArray<{ uri: Uri }>
|
|
||||||
readonly onDidChange: GitExtensionApiRepositoryStateChange
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GitExtensionApiRepository {
|
|
||||||
readonly rootUri: Uri
|
|
||||||
readonly state: GitExtensionApiRepositoryState
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GitExtensionApi {
|
|
||||||
readonly repositories: ReadonlyArray<GitExtensionApiRepository>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GitExtensionExports {
|
|
||||||
getAPI: (version: 1) => GitExtensionApi
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleCommitRun(panel: KanbanWebviewPanel): Promise<void> {
|
export async function handleCommitRun(panel: KanbanWebviewPanel): Promise<void> {
|
||||||
if (panel.commitRunning)
|
if (panel.commitRunning)
|
||||||
return
|
return
|
||||||
@@ -266,26 +238,20 @@ export function setupGitWatcher(panel: KanbanWebviewPanel): void {
|
|||||||
if (!wsRoot)
|
if (!wsRoot)
|
||||||
return
|
return
|
||||||
|
|
||||||
const gitExt = extensions.getExtension<GitExtensionExports>('vscode.git')
|
void loadGitApi().then((api) => {
|
||||||
if (!gitExt) {
|
if (!api) {
|
||||||
logger.add({
|
logger.add({
|
||||||
level: 'warn',
|
level: 'warn',
|
||||||
source: 'panel',
|
source: 'panel',
|
||||||
message: 'vscode.git 扩展未找到,提交按钮将持续显示',
|
message: 'vscode.git 扩展没装或被禁用,提交按钮将持续显示',
|
||||||
})
|
})
|
||||||
// Fallback: keep the button visible so users can at least trigger
|
// Fallback: keep the button visible so users can at least trigger
|
||||||
// commits manually even though we can't observe state.
|
// commits manually even though we can't observe state.
|
||||||
updateHasChanges(panel, true)
|
updateHasChanges(panel, true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const activation = gitExt.isActive
|
const findRepo = (): GitRepository | undefined =>
|
||||||
? Promise.resolve(gitExt.exports)
|
|
||||||
: Promise.resolve(gitExt.activate())
|
|
||||||
|
|
||||||
void activation.then((exports) => {
|
|
||||||
const api = exports.getAPI(1)
|
|
||||||
const findRepo = (): GitExtensionApiRepository | undefined =>
|
|
||||||
api.repositories.find(r => r.rootUri.fsPath === wsRoot)
|
api.repositories.find(r => r.rootUri.fsPath === wsRoot)
|
||||||
|
|
||||||
const updateState = (): void => {
|
const updateState = (): void => {
|
||||||
@@ -297,11 +263,11 @@ export function setupGitWatcher(panel: KanbanWebviewPanel): void {
|
|||||||
const total
|
const total
|
||||||
= repo.state.workingTreeChanges.length
|
= repo.state.workingTreeChanges.length
|
||||||
+ repo.state.indexChanges.length
|
+ repo.state.indexChanges.length
|
||||||
+ (repo.state.untrackedChanges?.length ?? 0)
|
+ repo.state.untrackedChanges.length
|
||||||
updateHasChanges(panel, total > 0)
|
updateHasChanges(panel, total > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscribe = (repo: GitExtensionApiRepository): void => {
|
const subscribe = (repo: GitRepository): void => {
|
||||||
panel.gitStateDisposable = repo.state.onDidChange(updateState)
|
panel.gitStateDisposable = repo.state.onDidChange(updateState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user