✨ 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
@@ -1,5 +1,5 @@
|
||||
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 { CHANGE_STYLES } from './changedFiles'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildChangedTree, CHANGE_STYLES, changeOf } from './changedFiles'
|
||||
import { CHANGE_STYLES, changeOf } from './changedFiles'
|
||||
|
||||
describe('改动状态', () => {
|
||||
it('新增 A、删除 D,别的(改名、复制、改了、没改)都算修改 M', () => {
|
||||
@@ -16,65 +16,3 @@ describe('改动状态', () => {
|
||||
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 { sortEntries } from '../explorer/fileEntries'
|
||||
|
||||
/** PR 里一个文件怎么改的,照 VS Code 源代码管理的写法:修改 M、新增 A、删除 D。 */
|
||||
export type FileChange = 'M' | 'A' | 'D'
|
||||
import type { FileChange } from '../explorer/changedTree'
|
||||
|
||||
/**
|
||||
* 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'
|
||||
}
|
||||
|
||||
/** 各种改动在侧栏上的样子:颜色用 VS Code 源代码管理的 git 装饰色(主题色 id),徽标就是字母本身。 */
|
||||
/** 审查时各种改动在侧栏上的样子:颜色用 VS Code 源代码管理的 git 装饰色(主题色 id),徽标就是字母本身。 */
|
||||
export const CHANGE_STYLES: Record<FileChange, { color: string, tooltip: string }> = {
|
||||
M: { color: 'gitDecoration.modifiedResourceForeground', tooltip: 'PR 里改了' },
|
||||
A: { color: 'gitDecoration.addedResourceForeground', 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 { Issue } from '../../gitea/types'
|
||||
import type { FileNode, FileTreeProvider } from '../explorer/fileTree'
|
||||
import type { TreeFilter, TreeFilters } from '../explorer/treeFilters'
|
||||
import type { FileCount } from './pickList'
|
||||
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 { buildChangedTree } from '../explorer/changedTree'
|
||||
import { ChangeDecorations } from './changeDecorations'
|
||||
import { buildChangedTree, changeOf } from './changedFiles'
|
||||
import { changeOf } from './changedFiles'
|
||||
import { countingFiles, pickEntries, pickItemText, pickRows, withFileCount } from './pickList'
|
||||
import { columnLabel, REVIEW_COLUMNS_TEXT, reviewableIssue, reviewablePullRequest, reviewDescription, reviewMessage } from './reviewRules'
|
||||
import { loadIssue, loadPullRequest, loadPullRequestFiles, requireRepo, reviewCandidateIssues } from './reviewSource'
|
||||
@@ -16,8 +18,6 @@ import { localWorktree, prepareWorktree } from './reviewWorktree'
|
||||
|
||||
/** workspaceState:正在审查的工单号,重开窗口接着审查。 */
|
||||
const REVIEW_ISSUE_KEY = 'superpowers.codeReader.reviewIssue'
|
||||
/** 上下文键:正在审查工单。标题栏的「显示全部」、行上的注释按钮按它显示 / 藏起来。 */
|
||||
const REVIEWING_CONTEXT = 'superpowers.codeReader.reviewing'
|
||||
|
||||
/**
|
||||
* 怎么进的审查,决定不能审查、出错时怎么收场:
|
||||
@@ -45,8 +45,9 @@ function errorText(err: unknown): string {
|
||||
/**
|
||||
* 侧栏按审查工单筛文件:「选工单」列出看板「审查」「进行中」列里能审查的工单,选中后树里只剩这张工单的 PR 改过的文件(按改动着色),
|
||||
* 点开的是本机这张工单的工作副本里 PR 最新的代码;「显示全部」回到工作区文件树。选的工单按工作区记住。
|
||||
* 和别的筛选(未提交的更改)互斥,见 TreeFilters。
|
||||
*/
|
||||
export class ReviewMode implements Disposable {
|
||||
export class ReviewMode implements TreeFilter, Disposable {
|
||||
private readonly decorations = new ChangeDecorations()
|
||||
private readonly disposables: Disposable[]
|
||||
/** 正在审查的工单号 */
|
||||
@@ -60,25 +61,14 @@ export class ReviewMode implements Disposable {
|
||||
private readonly viewId: string,
|
||||
private readonly view: TreeView<FileNode>,
|
||||
private readonly files: FileTreeProvider,
|
||||
private readonly filters: TreeFilters,
|
||||
) {
|
||||
this.disposables = [this.decorations, window.registerFileDecorationProvider(this.decorations)]
|
||||
// 视图第一次露出来时才接着上次的审查:没打开侧栏就不去拉 PR、动工作副本
|
||||
if (view.visible) {
|
||||
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
|
||||
filters.add(this)
|
||||
this.disposables = [
|
||||
this.decorations,
|
||||
window.registerFileDecorationProvider(this.decorations),
|
||||
filters.whenShown(() => void this.restore()),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,16 +146,13 @@ export class ReviewMode implements Disposable {
|
||||
: []))
|
||||
}
|
||||
|
||||
/** 「显示全部」:回到工作区文件树,不再记着这张工单。 */
|
||||
/** 「显示全部」或换成别的筛选:不再审查、不再记着这张工单,正在进的审查作废;正审查着的回到工作区文件树。 */
|
||||
async exit(): Promise<void> {
|
||||
this.generation++
|
||||
this.reviewing = undefined
|
||||
this.files.showChanges(undefined)
|
||||
this.decorations.show([])
|
||||
this.view.description = undefined
|
||||
this.view.message = undefined
|
||||
await Promise.all([
|
||||
commands.executeCommand('setContext', REVIEWING_CONTEXT, false),
|
||||
this.filters.leave(this),
|
||||
this.context.workspaceState.update(REVIEW_ISSUE_KEY, undefined),
|
||||
])
|
||||
}
|
||||
@@ -261,13 +248,15 @@ export class ReviewMode implements Disposable {
|
||||
const root = Uri.file(worktree)
|
||||
// 工作副本当根,名字照写工作区文件夹的:看的还是这个仓库,只是 PR 最新的代码
|
||||
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.files.showChanges({ folder, tree: buildChangedTree(files) })
|
||||
this.decorations.show(files.map(file => ({ uri: Uri.joinPath(root, file.path), change: changeOf(file.status) })))
|
||||
this.files.showChanges([{ folder, tree: buildChangedTree(changes) }])
|
||||
this.decorations.show(changes.map(file => ({ uri: Uri.joinPath(root, file.path), change: file.change })))
|
||||
this.view.description = reviewDescription(issue.number)
|
||||
this.view.message = reviewMessage(issue, pr, files.length)
|
||||
await Promise.all([
|
||||
commands.executeCommand('setContext', REVIEWING_CONTEXT, true),
|
||||
entering,
|
||||
this.context.workspaceState.update(REVIEW_ISSUE_KEY, issue.number),
|
||||
])
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user