✨ feat(vscode): 「未提交的更改」分暂存区两组、单文件暂存、隐藏测试文件
- 照源代码管理视图分「暂存的更改」「更改」两组(空的不摆,分组行写文件数),部分暂存的文件两组都有 - 「更改」组的文件行上加「暂存」按钮,走 git 扩展的 repository.add - 标题栏「隐藏测试文件 / 显示测试文件」开关,按工作区记住,藏了几个写在标题后面;测试文件的判断集中在 isTestFile - 以上只在「未提交的更改」里有:普通文件树、审查模式的行和菜单不变
This commit is contained in:
1 parent
a3b04a443b
commit
87d4360704
14 files changed
+528
-129
No files matched your search
@@ -28,6 +28,12 @@ describe('行的 contextValue', () => {
|
||||
expect(entryContext({ isDirectory: true, isRoot: false })).toBe('folder')
|
||||
expect(entryContext({ isDirectory: false, isRoot: false })).toBe('file')
|
||||
})
|
||||
|
||||
it('文件行可以带标记(「更改」区能暂存的 file unstaged),目录、根不带', () => {
|
||||
expect(entryContext({ isDirectory: false, isRoot: false }, 'unstaged')).toBe('file unstaged')
|
||||
expect(entryContext({ isDirectory: true, isRoot: false }, 'unstaged')).toBe('folder')
|
||||
expect(entryContext({ isDirectory: true, isRoot: true }, 'unstaged')).toBe('root')
|
||||
})
|
||||
})
|
||||
|
||||
describe('files.exclude', () => {
|
||||
|
||||
@@ -12,9 +12,19 @@ export function sortEntries<T extends DirEntry>(entries: T[]): T[] {
|
||||
|| (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
||||
}
|
||||
|
||||
/** 树上一行的 contextValue:菜单的 when 子句按它分工作区根、目录、文件。 */
|
||||
export function entryContext(entry: { isDirectory: boolean, isRoot: boolean }): 'root' | 'folder' | 'file' {
|
||||
return entry.isRoot ? 'root' : entry.isDirectory ? 'folder' : 'file'
|
||||
/** 分组行(「暂存的更改」「更改」)的 contextValue:不挂任何行菜单。 */
|
||||
export const GROUP_CONTEXT = 'group'
|
||||
|
||||
/**
|
||||
* 树上一行的 contextValue:菜单的 when 子句按它分工作区根、目录、文件。
|
||||
* 文件行可以带一个标记(「更改」区里能暂存的是 `file unstaged`),菜单按它给这些行多挂按钮;目录、根不带。
|
||||
*/
|
||||
export function entryContext(entry: { isDirectory: boolean, isRoot: boolean }, fileMarker?: string): string {
|
||||
if (entry.isRoot)
|
||||
return 'root'
|
||||
if (entry.isDirectory)
|
||||
return 'folder'
|
||||
return fileMarker ? `file ${fileMarker}` : 'file'
|
||||
}
|
||||
|
||||
/** `files.exclude` 的值:true 排除;false 不排除;`{ when }` 只在有对应的兄弟文件时排除。 */
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { createExcludeMatcher, entryContext, sortEntries } from './fileEntries'
|
||||
import { createExcludeMatcher, entryContext, GROUP_CONTEXT, sortEntries } from './fileEntries'
|
||||
|
||||
export interface FileNode {
|
||||
uri: Uri
|
||||
@@ -11,6 +11,8 @@ export interface FileNode {
|
||||
folder: WorkspaceFolder
|
||||
isDirectory: boolean
|
||||
isRoot: boolean
|
||||
/** 只摆改动的文件时所在的分组;普通的树没有 */
|
||||
section?: ChangedSection
|
||||
/** 只摆改动的文件时这个文件怎么改的;普通的树、目录没有 */
|
||||
change?: FileChange
|
||||
}
|
||||
@@ -21,8 +23,29 @@ export interface ChangedRoot {
|
||||
tree: ChangedTree
|
||||
}
|
||||
|
||||
/** 只摆改动的文件时树上摆什么:每个根一个根节点,下面只有改动的文件和通往它们的目录。 */
|
||||
export type ChangedFilesView = readonly ChangedRoot[]
|
||||
/** 只摆改动的文件时的一个分组:有 label 时画一行分组(名字后面淡色的 description),各个根摆在它下面;没有 label 时各个根直接摆在最上面。 */
|
||||
export interface ChangedSection {
|
||||
label?: string
|
||||
description?: string
|
||||
/** 这一组里文件行的 contextValue 带的标记(见 entryContext),菜单按它给这些行多挂按钮 */
|
||||
fileMarker?: string
|
||||
roots: readonly ChangedRoot[]
|
||||
}
|
||||
|
||||
/** 只摆改动的文件时树上摆什么:一个或几个分组,每组下面每个根一个根节点,再往下只有改动的文件和通往它们的目录。 */
|
||||
export type ChangedFilesView = readonly ChangedSection[]
|
||||
|
||||
/** 分组行(「暂存的更改」「更改」)。 */
|
||||
export interface GroupNode {
|
||||
group: ChangedSection
|
||||
}
|
||||
|
||||
/** 树上的一行:分组行,或者目录 / 文件。 */
|
||||
export type TreeNode = GroupNode | FileNode
|
||||
|
||||
export function isGroupNode(node: TreeNode): node is GroupNode {
|
||||
return 'group' in node
|
||||
}
|
||||
|
||||
/** 删掉的文件那一行的悬停提示:它已经不在了,点了打不开。 */
|
||||
const DELETED_TOOLTIP = '已删除,没有内容可打开'
|
||||
@@ -39,18 +62,21 @@ const REFRESH_DELAY_MS = 300
|
||||
/**
|
||||
* 侧栏的工作区文件树:每个工作区文件夹一个根节点,只看和打开。
|
||||
* 节点用 resourceUri,图标、git 改动颜色都由 VS Code 按文件图标主题和 SCM 装饰自动加上。
|
||||
* 筛选时换成只摆改动的文件(showChanges:审查工单时 PR 改过的,未提交的更改时各仓库里没提交的):目录全部展开,删掉的文件列出来但打不开。
|
||||
* 筛选时换成只摆改动的文件(showChanges:审查工单时 PR 改过的,未提交的更改时各仓库里没提交的,分「暂存的更改」「更改」两组):
|
||||
* 目录全部展开,删掉的文件列出来但打不开。
|
||||
*/
|
||||
export class FileTreeProvider implements TreeDataProvider<FileNode> {
|
||||
export class FileTreeProvider implements TreeDataProvider<TreeNode> {
|
||||
/** undefined 是整棵树重画,数组是只重画这几行 */
|
||||
private readonly changed = new EventEmitter<FileNode[] | undefined>()
|
||||
readonly onDidChangeTreeData: Event<FileNode[] | undefined> = this.changed.event
|
||||
private readonly changed = new EventEmitter<TreeNode[] | undefined>()
|
||||
readonly onDidChangeTreeData: Event<TreeNode[] | undefined> = this.changed.event
|
||||
private readonly disposables: { dispose: () => unknown }[]
|
||||
/** 按 Uri 记下交给 VS Code 的节点:只重画几行时要交回同一个对象,VS Code 按对象认行。 */
|
||||
private readonly nodes = new Map<string, FileNode>()
|
||||
/** 按分组、Uri 记下交给 VS Code 的节点:只重画几行时要交回同一个对象,VS Code 按对象认行;同一个文件可以在几个分组里各有一行。 */
|
||||
private readonly nodes = new Map<ChangedSection | undefined, Map<string, FileNode>>()
|
||||
private timer: NodeJS.Timeout | undefined
|
||||
/** 筛选时只摆的那些文件;undefined 是普通的工作区文件树 */
|
||||
private changes: ChangedFilesView | undefined
|
||||
/** 筛选时每个分组的分组行 */
|
||||
private groups: GroupNode[] = []
|
||||
|
||||
/**
|
||||
* @param openCommand 点文件时执行的命令,参数是文件 Uri
|
||||
@@ -80,9 +106,11 @@ export class FileTreeProvider implements TreeDataProvider<FileNode> {
|
||||
this.changed.fire(undefined)
|
||||
}
|
||||
|
||||
getTreeItem(node: FileNode): TreeItem {
|
||||
getTreeItem(node: TreeNode): TreeItem {
|
||||
if (isGroupNode(node))
|
||||
return this.groupItem(node.group)
|
||||
const item = this.baseItem(node)
|
||||
item.contextValue = entryContext(node)
|
||||
item.contextValue = entryContext(node, node.section?.fileMarker)
|
||||
this.decorator.decorate(item, node)
|
||||
return item
|
||||
}
|
||||
@@ -90,14 +118,17 @@ export class FileTreeProvider implements TreeDataProvider<FileNode> {
|
||||
/** 换成只摆改动的文件(undefined 回到工作区文件树),整棵树重画。 */
|
||||
showChanges(view: ChangedFilesView | undefined): void {
|
||||
this.changes = view
|
||||
this.groups = (view ?? []).map(group => ({ group }))
|
||||
this.refresh()
|
||||
}
|
||||
|
||||
async getChildren(node?: FileNode): Promise<FileNode[]> {
|
||||
async getChildren(node?: TreeNode): Promise<TreeNode[]> {
|
||||
if (this.changes)
|
||||
return this.changedChildren(this.changes, node)
|
||||
return this.changedChildren(node)
|
||||
if (!node)
|
||||
return (workspace.workspaceFolders ?? []).map(folder => this.nodeAt(folder.uri, folder, true, true))
|
||||
if (isGroupNode(node))
|
||||
return [] // 分组行只在只摆改动的文件时有
|
||||
|
||||
let entries: [string, FileType][]
|
||||
try {
|
||||
@@ -138,29 +169,49 @@ export class FileTreeProvider implements TreeDataProvider<FileNode> {
|
||||
return item
|
||||
}
|
||||
|
||||
private nodeAt(uri: Uri, folder: WorkspaceFolder, isDirectory: boolean, isRoot: boolean, change?: FileChange): FileNode {
|
||||
/** 分组行:名字、后面淡色的 description,一直展开;contextValue 是 GROUP_CONTEXT,不挂行菜单。 */
|
||||
private groupItem({ label, description }: ChangedSection): TreeItem {
|
||||
const item = new TreeItem(label ?? '', TreeItemCollapsibleState.Expanded)
|
||||
item.description = description
|
||||
item.contextValue = GROUP_CONTEXT
|
||||
return item
|
||||
}
|
||||
|
||||
private nodeAt(uri: Uri, folder: WorkspaceFolder, isDirectory: boolean, isRoot: boolean, section?: ChangedSection, change?: FileChange): FileNode {
|
||||
let nodes = this.nodes.get(section)
|
||||
if (!nodes)
|
||||
this.nodes.set(section, nodes = new Map())
|
||||
const id = uri.toString()
|
||||
const known = this.nodes.get(id)
|
||||
const known = nodes.get(id)
|
||||
if (known && known.folder === folder && known.isDirectory === isDirectory && known.isRoot === isRoot && known.change === change)
|
||||
return known
|
||||
const node: FileNode = { uri, folder, isDirectory, isRoot, ...(change ? { change } : {}) }
|
||||
this.nodes.set(id, node)
|
||||
const node: FileNode = { uri, folder, isDirectory, isRoot, ...(section ? { section } : {}), ...(change ? { change } : {}) }
|
||||
nodes.set(id, node)
|
||||
return node
|
||||
}
|
||||
|
||||
/** 只摆改动的文件时的一层:最上面是各个根,再往下只有改动的文件和通往它们的目录。 */
|
||||
private changedChildren(roots: ChangedFilesView, node?: FileNode): FileNode[] {
|
||||
/**
|
||||
* 只摆改动的文件时的一层:最上面是分组行(没有名字的分组直接摆它的各个根),分组下面是各个根,
|
||||
* 再往下只有改动的文件和通往它们的目录。
|
||||
*/
|
||||
private changedChildren(node?: TreeNode): TreeNode[] {
|
||||
if (!node)
|
||||
return roots.map(({ folder }) => this.nodeAt(folder.uri, folder, true, true))
|
||||
const { folder } = node
|
||||
const tree = roots.find(root => root.folder === folder)?.tree
|
||||
return this.groups.flatMap((group): TreeNode[] => group.group.label !== undefined ? [group] : this.rootsOf(group.group))
|
||||
if (isGroupNode(node))
|
||||
return this.rootsOf(node.group)
|
||||
const { folder, section } = node
|
||||
const tree = section?.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, section, entry.change))
|
||||
}
|
||||
|
||||
private rootsOf(section: ChangedSection): FileNode[] {
|
||||
return section.roots.map(({ folder }) => this.nodeAt(folder.uri, folder, true, true, section))
|
||||
}
|
||||
|
||||
/** 还没展开过的行不用管,展开时自然按最新的画。 */
|
||||
private redraw(uris: readonly Uri[]): void {
|
||||
const nodes = uris.flatMap(uri => this.nodes.get(uri.toString()) ?? [])
|
||||
const nodes = uris.flatMap(uri => [...this.nodes.values()].flatMap(known => known.get(uri.toString()) ?? []))
|
||||
if (nodes.length > 0)
|
||||
this.changed.fire(nodes)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ import { readFileSync } from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { noteDecoration } from '../notes/noteItem'
|
||||
import { UNSTAGED_MARKER } from '../uncommitted/uncommittedFiles'
|
||||
import { COPY_COMMANDS } from './copyText'
|
||||
import { entryContext } from './fileEntries'
|
||||
import { entryContext, GROUP_CONTEXT } from './fileEntries'
|
||||
|
||||
interface MenuItem { command?: string, submenu?: string, when: string, group?: string }
|
||||
interface Manifest {
|
||||
@@ -23,6 +24,9 @@ const EDIT_NOTE = 'superpowers.codeReader.editNote'
|
||||
const file = entryContext({ isDirectory: false, isRoot: false })
|
||||
const folder = entryContext({ isDirectory: true, isRoot: false })
|
||||
const root = entryContext({ isDirectory: true, isRoot: true })
|
||||
/** 「未提交的更改」里「更改」组的文件行 */
|
||||
const unstaged = entryContext({ isDirectory: false, isRoot: false }, UNSTAGED_MARKER)
|
||||
const STAGE = 'superpowers.codeReader.stage'
|
||||
const noted = (context: string): string => noteDecoration(context, 'x', '注释')!.contextValue
|
||||
|
||||
const orderOf = (item: MenuItem): number => Number(item.group?.split('@')[1] ?? 0)
|
||||
@@ -60,6 +64,19 @@ describe('侧栏行尾按钮', () => {
|
||||
expect(itemsFor('view/item/context', 'inline', root)).toEqual([])
|
||||
})
|
||||
|
||||
it('「更改」组的文件行多一个「暂存」,排在最后;别的行都没有', () => {
|
||||
expect(itemsFor('view/item/context', 'inline', unstaged)).toEqual([COPY_MENU, ADD_NOTE, STAGE])
|
||||
expect(itemsFor('view/item/context', 'inline', noted(unstaged))).toEqual([COPY_MENU, EDIT_NOTE, STAGE])
|
||||
for (const context of [file, folder, root, noted(file), noted(folder)])
|
||||
expect(itemsFor('view/item/context', 'inline', context)).not.toContain(STAGE)
|
||||
})
|
||||
|
||||
it('分组行(「暂存的更改」「更改」)什么按钮、菜单都没有', () => {
|
||||
for (const group of ['inline', 'copy', 'notes'])
|
||||
expect(itemsFor('view/item/context', group, GROUP_CONTEXT)).toEqual([])
|
||||
expect(itemsFor(COPY_MENU, 'copy', GROUP_CONTEXT)).toEqual([])
|
||||
})
|
||||
|
||||
it('「复制」是带 copy 图标的子菜单(VS Code 画成点开的下拉按钮),注释按钮的图标是 note / edit', () => {
|
||||
expect(submenus.find(item => item.id === COPY_MENU)).toEqual({ id: COPY_MENU, label: '复制', icon: '$(copy)' })
|
||||
expect(commands.find(item => item.command === ADD_NOTE)?.icon).toBe('$(note)')
|
||||
@@ -69,7 +86,7 @@ describe('侧栏行尾按钮', () => {
|
||||
|
||||
describe('复制菜单', () => {
|
||||
it.each([COPY_MENU, 'view/item/context'])('%s:文件四项、文件夹没有「不带后缀」,有没有注释都一样', (menu) => {
|
||||
for (const context of [file, noted(file)])
|
||||
for (const context of [file, noted(file), unstaged, noted(unstaged)])
|
||||
expect(itemsFor(menu, 'copy', context)).toEqual(FILE_COPIES)
|
||||
for (const context of [folder, noted(folder)])
|
||||
expect(itemsFor(menu, 'copy', context)).toEqual(FOLDER_COPIES)
|
||||
@@ -95,7 +112,7 @@ describe('复制菜单', () => {
|
||||
|
||||
describe('右键菜单的注释', () => {
|
||||
it('没注释的目录 / 文件只有「添加注释」,有注释的只有「编辑注释」,工作区根没有', () => {
|
||||
for (const context of [file, folder]) {
|
||||
for (const context of [file, folder, unstaged]) {
|
||||
expect(itemsFor('view/item/context', 'notes', context)).toEqual([ADD_NOTE])
|
||||
expect(itemsFor('view/item/context', 'notes', noted(context))).toEqual([EDIT_NOTE])
|
||||
}
|
||||
@@ -107,22 +124,44 @@ describe('筛选(审查工单、未提交的更改)', () => {
|
||||
const FILTER = 'superpowers.codeReader.filter'
|
||||
const PICK = 'superpowers.codeReader.pickReviewIssue'
|
||||
const UNCOMMITTED = 'superpowers.codeReader.showUncommitted'
|
||||
const HIDE_TESTS = 'superpowers.codeReader.hideTests'
|
||||
const SHOW_TESTS = 'superpowers.codeReader.showTests'
|
||||
const SHOW_ALL = 'superpowers.codeReader.showAllFiles'
|
||||
const TESTS_HIDDEN = 'superpowers.codeReader.testsHidden'
|
||||
const title = menus['view/title']
|
||||
.filter(item => item.when.startsWith('view == superpowers.codeReader.files'))
|
||||
.sort((a, b) => orderOf(a) - orderOf(b))
|
||||
const whenOf = (command: string): string | undefined => title.find(item => item.command === command)?.when
|
||||
|
||||
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, UNCOMMITTED, SHOW_ALL, 'superpowers.codeReader.refresh'])
|
||||
it('标题栏:选工单、未提交的更改、隐藏 / 显示测试文件、开着筛选时才有的「显示全部」、刷新', () => {
|
||||
expect(title.map(item => item.command)).toEqual([PICK, UNCOMMITTED, HIDE_TESTS, SHOW_TESTS, SHOW_ALL, 'superpowers.codeReader.refresh'])
|
||||
expect(title.every(item => item.group?.startsWith('navigation'))).toBe(true)
|
||||
expect(title.find(item => item.command === SHOW_ALL)?.when).toBe(`view == superpowers.codeReader.files && ${FILTER}`)
|
||||
expect(whenOf(SHOW_ALL)).toBe(`view == superpowers.codeReader.files && ${FILTER}`)
|
||||
for (const command of [PICK, UNCOMMITTED])
|
||||
expect(title.find(item => item.command === command)?.when).not.toContain(FILTER)
|
||||
expect(whenOf(command)).not.toContain(FILTER)
|
||||
})
|
||||
|
||||
it('按钮的字和图标:选工单 git-pull-request,未提交的更改 git-commit,显示全部 close', () => {
|
||||
it('隐藏 / 显示测试文件只在未提交的更改时有,按是否隐藏着换着显示', () => {
|
||||
expect(whenOf(HIDE_TESTS)).toBe(`view == superpowers.codeReader.files && ${FILTER} == uncommitted && !${TESTS_HIDDEN}`)
|
||||
expect(whenOf(SHOW_TESTS)).toBe(`view == superpowers.codeReader.files && ${FILTER} == uncommitted && ${TESTS_HIDDEN}`)
|
||||
const palette = new Map(menus.commandPalette.map(item => [item.command, item.when]))
|
||||
expect(palette.get(HIDE_TESTS)).toBe(`${FILTER} == uncommitted && !${TESTS_HIDDEN}`)
|
||||
expect(palette.get(SHOW_TESTS)).toBe(`${FILTER} == uncommitted && ${TESTS_HIDDEN}`)
|
||||
})
|
||||
|
||||
it('「暂存」只在未提交的更改时挂在「更改」组的文件行上,命令面板里藏起来', () => {
|
||||
const stage = menus['view/item/context'].filter(item => item.command === STAGE)
|
||||
expect(stage).toHaveLength(1)
|
||||
expect(stage[0].when).toContain(`${FILTER} == uncommitted`)
|
||||
expect(menus.commandPalette.find(item => item.command === STAGE)?.when).toBe('false')
|
||||
})
|
||||
|
||||
it('按钮的字和图标', () => {
|
||||
expect(commands.find(item => item.command === PICK)).toMatchObject({ title: '选工单', icon: '$(git-pull-request)' })
|
||||
expect(commands.find(item => item.command === UNCOMMITTED)).toMatchObject({ title: '未提交的更改', icon: '$(git-commit)' })
|
||||
expect(commands.find(item => item.command === HIDE_TESTS)).toMatchObject({ title: '隐藏测试文件', icon: '$(beaker-stop)' })
|
||||
expect(commands.find(item => item.command === SHOW_TESTS)).toMatchObject({ title: '显示测试文件', icon: '$(beaker)' })
|
||||
expect(commands.find(item => item.command === STAGE)).toMatchObject({ title: '暂存', icon: '$(add)' })
|
||||
expect(commands.find(item => item.command === SHOW_ALL)).toMatchObject({ title: '显示全部', icon: '$(close)' })
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TreeView } from 'vscode'
|
||||
import type { FileNode, FileTreeProvider } from './fileTree'
|
||||
import type { FileTreeProvider, TreeNode } from './fileTree'
|
||||
import { commands, Disposable } from 'vscode'
|
||||
|
||||
/** 上下文键:侧栏树开着哪种筛选,没设是全部文件。标题栏的「显示全部」、行上的注释按钮按它显示 / 藏起来。 */
|
||||
@@ -24,7 +24,7 @@ export class TreeFilters {
|
||||
private readonly filters: TreeFilter[] = []
|
||||
private current: TreeFilter | undefined
|
||||
|
||||
constructor(private readonly files: FileTreeProvider, private readonly view: TreeView<FileNode>) {}
|
||||
constructor(private readonly files: FileTreeProvider, private readonly view: TreeView<TreeNode>) {}
|
||||
|
||||
/** 开着的那种筛选;没开是全部文件 */
|
||||
get active(): TreeFilter | undefined {
|
||||
|
||||
Reference in new issue
Block a user