✨ 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
@@ -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 { ChangedTree, FileChange } from '../review/changedFiles'
|
||||
import type { ChangedTree, FileChange } from './changedTree'
|
||||
import type { ExcludeSetting } from './fileEntries'
|
||||
import * as path from 'node:path'
|
||||
import { EventEmitter, FileType, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri, workspace } from 'vscode'
|
||||
import { CHANGE_STYLES } from '../review/changedFiles'
|
||||
import { createExcludeMatcher, entryContext, sortEntries } from './fileEntries'
|
||||
|
||||
export interface FileNode {
|
||||
uri: Uri
|
||||
/** 这一行所在的根:工作区文件夹;只摆改动的文件时是那个根(仓库根、审查的工作副本) */
|
||||
folder: WorkspaceFolder
|
||||
isDirectory: boolean
|
||||
isRoot: boolean
|
||||
/** 审查工单时这个文件在 PR 里怎么改的;普通的树、目录没有 */
|
||||
/** 只摆改动的文件时这个文件怎么改的;普通的树、目录没有 */
|
||||
change?: FileChange
|
||||
}
|
||||
|
||||
/** 审查工单时树上摆什么:工作副本当唯一的根(folder),下面只有 PR 改过的文件(tree)。 */
|
||||
export interface ChangedFilesView {
|
||||
/** 只摆改动的文件时的一个根:folder 是根目录和根节点上的名字,tree 是它下面改动的文件。 */
|
||||
export interface ChangedRoot {
|
||||
folder: WorkspaceFolder
|
||||
tree: ChangedTree
|
||||
}
|
||||
|
||||
/** 只摆改动的文件时树上摆什么:每个根一个根节点,下面只有改动的文件和通往它们的目录。 */
|
||||
export type ChangedFilesView = readonly ChangedRoot[]
|
||||
|
||||
/** 删掉的文件那一行的悬停提示:它已经不在了,点了打不开。 */
|
||||
const DELETED_TOOLTIP = '已删除,没有内容可打开'
|
||||
|
||||
/** 给树上的行添信息(比如文件注释):每次画行时调 decorate,onDidChange 报出哪些 Uri 的行要重画。 */
|
||||
export interface FileTreeDecorator {
|
||||
readonly onDidChange: Event<readonly Uri[]>
|
||||
@@ -33,7 +39,7 @@ const REFRESH_DELAY_MS = 300
|
||||
/**
|
||||
* 侧栏的工作区文件树:每个工作区文件夹一个根节点,只看和打开。
|
||||
* 节点用 resourceUri,图标、git 改动颜色都由 VS Code 按文件图标主题和 SCM 装饰自动加上。
|
||||
* 审查工单时换成只摆 PR 改过的文件(showChanges):目录全部展开,删掉的文件列出来但打不开。
|
||||
* 筛选时换成只摆改动的文件(showChanges:审查工单时 PR 改过的,未提交的更改时各仓库里没提交的):目录全部展开,删掉的文件列出来但打不开。
|
||||
*/
|
||||
export class FileTreeProvider implements TreeDataProvider<FileNode> {
|
||||
/** undefined 是整棵树重画,数组是只重画这几行 */
|
||||
@@ -43,7 +49,7 @@ export class FileTreeProvider implements TreeDataProvider<FileNode> {
|
||||
/** 按 Uri 记下交给 VS Code 的节点:只重画几行时要交回同一个对象,VS Code 按对象认行。 */
|
||||
private readonly nodes = new Map<string, FileNode>()
|
||||
private timer: NodeJS.Timeout | undefined
|
||||
/** 审查工单时只摆的那些文件;undefined 是普通的工作区文件树 */
|
||||
/** 筛选时只摆的那些文件;undefined 是普通的工作区文件树 */
|
||||
private changes: ChangedFilesView | undefined
|
||||
|
||||
/**
|
||||
@@ -81,7 +87,7 @@ export class FileTreeProvider implements TreeDataProvider<FileNode> {
|
||||
return item
|
||||
}
|
||||
|
||||
/** 换成只摆 PR 改过的文件(undefined 回到工作区文件树),整棵树重画。 */
|
||||
/** 换成只摆改动的文件(undefined 回到工作区文件树),整棵树重画。 */
|
||||
showChanges(view: ChangedFilesView | undefined): void {
|
||||
this.changes = view
|
||||
this.refresh()
|
||||
@@ -119,7 +125,7 @@ export class FileTreeProvider implements TreeDataProvider<FileNode> {
|
||||
const item = new TreeItem(node.uri, TreeItemCollapsibleState.None)
|
||||
item.iconPath = ThemeIcon.File
|
||||
if (node.change === 'D')
|
||||
item.tooltip = CHANGE_STYLES.D.tooltip // 删掉的文件没有最新的版本可看,不挂打开的命令
|
||||
item.tooltip = DELETED_TOOLTIP // 删掉的文件没有内容可看,不挂打开的命令
|
||||
else
|
||||
item.command = { command: this.openCommand, title: '打开', arguments: [node.uri] }
|
||||
return item
|
||||
@@ -142,12 +148,14 @@ export class FileTreeProvider implements TreeDataProvider<FileNode> {
|
||||
return node
|
||||
}
|
||||
|
||||
/** 审查工单时的一层:根下面是工作副本,再往下只有 PR 改过的文件和通往它们的目录。 */
|
||||
private changedChildren({ folder, tree }: ChangedFilesView, node?: FileNode): FileNode[] {
|
||||
/** 只摆改动的文件时的一层:最上面是各个根,再往下只有改动的文件和通往它们的目录。 */
|
||||
private changedChildren(roots: ChangedFilesView, node?: FileNode): FileNode[] {
|
||||
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)
|
||||
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('审查工单', () => {
|
||||
const REVIEWING = 'superpowers.codeReader.reviewing'
|
||||
describe('筛选(审查工单、未提交的更改)', () => {
|
||||
const FILTER = 'superpowers.codeReader.filter'
|
||||
const PICK = 'superpowers.codeReader.pickReviewIssue'
|
||||
const UNCOMMITTED = 'superpowers.codeReader.showUncommitted'
|
||||
const SHOW_ALL = 'superpowers.codeReader.showAllFiles'
|
||||
|
||||
it('标题栏:第一个「选工单」,审查时才有「显示全部」,然后「刷新」', () => {
|
||||
it('标题栏:选工单、未提交的更改、开着筛选时才有的「显示全部」、刷新', () => {
|
||||
const title = menus['view/title']
|
||||
.filter(item => item.when.startsWith('view == superpowers.codeReader.files'))
|
||||
.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.find(item => item.command === SHOW_ALL)?.when).toContain(`&& ${REVIEWING}`)
|
||||
expect(title.find(item => item.command === PICK)?.when).not.toContain(REVIEWING)
|
||||
expect(title.find(item => item.command === SHOW_ALL)?.when).toBe(`view == superpowers.codeReader.files && ${FILTER}`)
|
||||
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 === UNCOMMITTED)).toMatchObject({ title: '未提交的更改', icon: '$(git-commit)' })
|
||||
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)
|
||||
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'))
|
||||
expect(copyItems.some(item => item.when.includes(REVIEWING))).toBe(false)
|
||||
expect(copyItems.some(item => item.when.includes(FILTER))).toBe(false)
|
||||
})
|
||||
|
||||
it('命令面板里「显示全部」只在审查时有', () => {
|
||||
expect(menus.commandPalette.find(item => item.command === SHOW_ALL)?.when).toBe(REVIEWING)
|
||||
it('命令面板里「显示全部」只在开着筛选时有', () => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user