✨ feat(vscode): 代码阅读侧栏跟着当前文件走,目录链接在侧栏树上定位
This commit is contained in:
1 parent
ff713db6e7
commit
c841ae678a
10 files changed
+464
-7
No files matched your search
@@ -145,7 +145,7 @@ export type ExtensionToWebview
|
||||
* selectAskProfile:「问 AI」换 profile(id 是 profile 名,「默认」是 ''),扩展端记下后发 askProfiles 给所有预览
|
||||
* occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发;
|
||||
* 扩展端按它设 when 子句用的上下文键
|
||||
* openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在资源管理器里定位目录
|
||||
* openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在代码阅读侧栏的树上定位目录
|
||||
* checkRepoLinks:预览里这批仓库路径,问哪些文件或目录没有。id 标出是哪一次问
|
||||
* checkRepoImages:预览里这批仓库图片,问哪些文件在,在的要预览里能直接用的地址。id 标出是哪一次问
|
||||
*/
|
||||
|
||||
@@ -79,6 +79,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
|
||||
readonly diffBases: DiffBases,
|
||||
/** 哪些文件是项目写明的入口、预览里哪些名字的块标成入口 */
|
||||
readonly entries: Pick<WorkspaceEntries, 'namesOf' | 'onDidChange'>,
|
||||
/** 在代码阅读侧栏的树上定位一个目录(仓库链接指到目录时);树上没有它时返回 false */
|
||||
readonly revealInTree: (uri: Uri) => Promise<boolean>,
|
||||
) {}
|
||||
|
||||
/** 侧栏「问 AI」打开预览,并把光标放进顶部输入框。已经开着的就揭到前面再聚焦。 */
|
||||
@@ -704,7 +706,7 @@ class Preview {
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开注释里的仓库链接:目录在资源管理器里定位,文件用编辑器打开。
|
||||
* 打开注释里的仓库链接:目录在代码阅读侧栏的树上定位,文件用编辑器打开。
|
||||
* 带了符号就按文档符号的名字跳过去;文件或符号没有就不打开。
|
||||
*/
|
||||
private async openRepoLink(rel: string, symbol?: string): Promise<void> {
|
||||
@@ -721,7 +723,8 @@ class Preview {
|
||||
this.markRepoLinkBroken(rel, symbol)
|
||||
return
|
||||
}
|
||||
await commands.executeCommand('revealInExplorer', uri)
|
||||
if (!await this.owner.revealInTree(uri))
|
||||
void window.showInformationMessage(`代码阅读:侧栏的树上没有 ${rel}`)
|
||||
this.markRepoLinkOpened(rel)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { WorkspaceFolder } from 'vscode'
|
||||
import type { ChangedSection, FileNode, FileTreeDecorator, TreeNode } from './fileTree'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Uri } from 'vscode'
|
||||
import { buildChangedTree } from './changedTree'
|
||||
import { FileTreeProvider, isGroupNode } from './fileTree'
|
||||
|
||||
/** 假的工作区:目录路径 → 下面一层的 [名字, 类型];files.exclude 的值 */
|
||||
const disk = vi.hoisted(() => ({
|
||||
dirs: new Map<string, [string, number][]>(),
|
||||
folders: [] as unknown[],
|
||||
exclude: {} as Record<string, boolean>,
|
||||
}))
|
||||
|
||||
vi.mock('vscode', async () => {
|
||||
const { posix } = await import('node:path')
|
||||
class FakeUri {
|
||||
constructor(readonly scheme: string, readonly authority: string, readonly path: string) {}
|
||||
static file(path: string): FakeUri {
|
||||
return new FakeUri('file', '', path)
|
||||
}
|
||||
|
||||
static joinPath(base: FakeUri, ...segments: string[]): FakeUri {
|
||||
return base.with({ path: posix.join(base.path, ...segments) })
|
||||
}
|
||||
|
||||
with(change: { path?: string }): FakeUri {
|
||||
return new FakeUri(this.scheme, this.authority, change.path ?? this.path)
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `${this.scheme}://${this.authority}${this.path}`
|
||||
}
|
||||
}
|
||||
const listen = (): { dispose: () => void } => ({ dispose: () => {} })
|
||||
return {
|
||||
EventEmitter: class {
|
||||
event = listen
|
||||
fire(): void {}
|
||||
dispose(): void {}
|
||||
},
|
||||
FileType: { File: 1, Directory: 2 },
|
||||
ThemeIcon: { File: {}, Folder: {} },
|
||||
TreeItem: class {},
|
||||
TreeItemCollapsibleState: { None: 0, Collapsed: 1, Expanded: 2 },
|
||||
Uri: FakeUri,
|
||||
workspace: {
|
||||
createFileSystemWatcher: () => ({ onDidCreate: listen, onDidDelete: listen, dispose: () => {} }),
|
||||
onDidChangeWorkspaceFolders: listen,
|
||||
onDidChangeConfiguration: listen,
|
||||
get workspaceFolders() {
|
||||
return disk.folders
|
||||
},
|
||||
fs: {
|
||||
readDirectory: async (uri: FakeUri) => {
|
||||
const entries = disk.dirs.get(uri.path)
|
||||
if (!entries)
|
||||
throw new Error(`没有 ${uri.path}`)
|
||||
return entries
|
||||
},
|
||||
},
|
||||
getConfiguration: () => ({ get: () => disk.exclude }),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const FILE = 1
|
||||
const DIR = 2
|
||||
const decorator = { onDidChange: () => ({ dispose: () => {} }), decorate: () => {} } as unknown as FileTreeDecorator
|
||||
let folder: WorkspaceFolder
|
||||
|
||||
beforeEach(() => {
|
||||
folder = { uri: Uri.file('/ws'), name: 'ws', index: 0 }
|
||||
disk.folders = [folder]
|
||||
disk.exclude = {}
|
||||
disk.dirs = new Map([
|
||||
['/ws', [['backend', DIR], ['dist', DIR], ['README.md', FILE]]],
|
||||
['/ws/backend', [['src', DIR]]],
|
||||
['/ws/backend/src', [['luffy', DIR], ['gate.py', FILE]]],
|
||||
['/ws/backend/src/luffy', [['visibility.py', FILE]]],
|
||||
['/ws/dist', [['bundle.js', FILE]]],
|
||||
])
|
||||
})
|
||||
|
||||
async function childNamed(files: FileTreeProvider, parent: TreeNode, name: string): Promise<FileNode> {
|
||||
const child = (await files.getChildren(parent)).find(node => !isGroupNode(node) && node.uri.path.endsWith(`/${name}`))
|
||||
if (!child || isGroupNode(child))
|
||||
throw new Error(`树上没有 ${name}`)
|
||||
return child
|
||||
}
|
||||
|
||||
/** 从 node 顺着 getParent 走到最上面,每一层写成路径(分组行写它的名字) */
|
||||
function chainUp(files: FileTreeProvider, node: TreeNode | undefined): string[] {
|
||||
const chain: string[] = []
|
||||
for (let current = node; current; current = files.getParent(current))
|
||||
chain.push(isGroupNode(current) ? `分组:${current.group.label}` : current.uri.path)
|
||||
return chain
|
||||
}
|
||||
|
||||
/** 从 node 顺着 getParent 走到的最上面那一行 */
|
||||
function topOf(files: FileTreeProvider, node: TreeNode): TreeNode {
|
||||
let top = node
|
||||
for (let parent = files.getParent(top); parent; parent = files.getParent(top))
|
||||
top = parent
|
||||
return top
|
||||
}
|
||||
|
||||
describe('工作区文件树:按 uri 找到那一行、一层层找上一层', () => {
|
||||
it('多层目录:getParent 交回 getChildren 给过的同一个对象,根的上一层没有', async () => {
|
||||
const files = new FileTreeProvider('open', decorator)
|
||||
const [root] = await files.getChildren()
|
||||
const backend = await childNamed(files, root, 'backend')
|
||||
const src = await childNamed(files, backend, 'src')
|
||||
const luffy = await childNamed(files, src, 'luffy')
|
||||
const visibility = await childNamed(files, luffy, 'visibility.py')
|
||||
|
||||
expect(files.getParent(visibility)).toBe(luffy)
|
||||
expect(files.getParent(luffy)).toBe(src)
|
||||
expect(files.getParent(src)).toBe(backend)
|
||||
expect(files.getParent(backend)).toBe(root)
|
||||
expect(files.getParent(root)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('nodeOf 一层层读目录找到文件,就是树上摆的那一行', async () => {
|
||||
const files = new FileTreeProvider('open', decorator)
|
||||
const found = await files.nodeOf(Uri.file('/ws/backend/src/luffy/visibility.py'))
|
||||
|
||||
expect(found?.isDirectory).toBe(false)
|
||||
expect(chainUp(files, found)).toEqual(['/ws/backend/src/luffy/visibility.py', '/ws/backend/src/luffy', '/ws/backend/src', '/ws/backend', '/ws'])
|
||||
expect(await files.getChildren(files.getParent(found!))).toContain(found)
|
||||
expect(topOf(files, found!)).toBe((await files.getChildren())[0])
|
||||
})
|
||||
|
||||
it('目录也能找到', async () => {
|
||||
const files = new FileTreeProvider('open', decorator)
|
||||
const found = await files.nodeOf(Uri.file('/ws/backend/src'))
|
||||
|
||||
expect(found?.isDirectory).toBe(true)
|
||||
expect(found?.isRoot).toBe(false)
|
||||
expect(chainUp(files, found)).toEqual(['/ws/backend/src', '/ws/backend', '/ws'])
|
||||
})
|
||||
|
||||
it('树上没有的找不到:工作区外、被 files.exclude 藏了的目录里、盘上没有的', async () => {
|
||||
disk.exclude = { '**/dist': true }
|
||||
const files = new FileTreeProvider('open', decorator)
|
||||
|
||||
expect(await files.nodeOf(Uri.file('/elsewhere/a.py'))).toBeUndefined()
|
||||
expect(await files.nodeOf(Uri.file('/ws/dist/bundle.js'))).toBeUndefined()
|
||||
expect(await files.nodeOf(Uri.file('/ws/backend/missing.py'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('只摆改动的文件时:在树上就找到,不在就没有', () => {
|
||||
function sections(): { staged: ChangedSection, unstaged: ChangedSection } {
|
||||
return {
|
||||
staged: { label: '暂存的更改', roots: [{ folder, tree: buildChangedTree([{ path: 'backend/src/gate.py', change: 'M' }]) }] },
|
||||
unstaged: { label: '更改', roots: [{ folder, tree: buildChangedTree([
|
||||
{ path: 'backend/src/luffy/visibility.py', change: 'M' },
|
||||
{ path: 'backend/src/gate.py', change: 'M' },
|
||||
]) }] },
|
||||
}
|
||||
}
|
||||
|
||||
it('找到那个分组里的那一行,上一层一直走到分组行', async () => {
|
||||
const files = new FileTreeProvider('open', decorator)
|
||||
const { staged, unstaged } = sections()
|
||||
files.showChanges([staged, unstaged])
|
||||
const found = await files.nodeOf(Uri.file('/ws/backend/src/luffy/visibility.py'))
|
||||
|
||||
expect(found?.section).toBe(unstaged)
|
||||
expect(found?.change).toBe('M')
|
||||
expect(chainUp(files, found)).toEqual(['/ws/backend/src/luffy/visibility.py', '/ws/backend/src/luffy', '/ws/backend/src', '/ws/backend', '/ws', '分组:更改'])
|
||||
expect(topOf(files, found!)).toBe((await files.getChildren())[1])
|
||||
expect(await files.getChildren(files.getParent(found!))).toContain(found)
|
||||
})
|
||||
|
||||
it('没摆出来的文件、目录找不到', async () => {
|
||||
const files = new FileTreeProvider('open', decorator)
|
||||
const { staged, unstaged } = sections()
|
||||
files.showChanges([staged, unstaged])
|
||||
|
||||
expect(await files.nodeOf(Uri.file('/ws/README.md'))).toBeUndefined()
|
||||
expect(await files.nodeOf(Uri.file('/ws/dist'))).toBeUndefined()
|
||||
expect(await files.nodeOf(Uri.file('/ws/backend/src/other.py'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('同一个文件摆在两个分组里时取最先摆的', async () => {
|
||||
const files = new FileTreeProvider('open', decorator)
|
||||
const { staged, unstaged } = sections()
|
||||
files.showChanges([staged, unstaged])
|
||||
|
||||
expect((await files.nodeOf(Uri.file('/ws/backend/src/gate.py')))?.section).toBe(staged)
|
||||
})
|
||||
|
||||
it('分组没有名字时根就在最上面一层,根的上一层没有', async () => {
|
||||
const files = new FileTreeProvider('open', decorator)
|
||||
files.showChanges([{ roots: [{ folder, tree: buildChangedTree([{ path: 'backend/src/gate.py', change: 'A' }]) }] }])
|
||||
const found = await files.nodeOf(Uri.file('/ws/backend/src/gate.py'))
|
||||
const [root] = await files.getChildren()
|
||||
|
||||
expect(chainUp(files, found)).toEqual(['/ws/backend/src/gate.py', '/ws/backend/src', '/ws/backend', '/ws'])
|
||||
expect(topOf(files, found!)).toBe(root)
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import type { ExcludeSetting } from './fileEntries'
|
||||
import * as path from 'node:path'
|
||||
import { EventEmitter, FileType, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri, workspace } from 'vscode'
|
||||
import { createExcludeMatcher, entryContext, GROUP_CONTEXT, sortEntries } from './fileEntries'
|
||||
import { pathRelation } from './treeReveal'
|
||||
|
||||
export interface FileNode {
|
||||
uri: Uri
|
||||
@@ -64,6 +65,7 @@ const REFRESH_DELAY_MS = 300
|
||||
* 节点用 resourceUri,图标、git 改动颜色都由 VS Code 按文件图标主题和 SCM 装饰自动加上。
|
||||
* 筛选时换成只摆改动的文件(showChanges:审查工单时 PR 改过的,未提交的更改时各仓库里没提交的,分「暂存的更改」「更改」两组):
|
||||
* 目录全部展开,删掉的文件列出来但打不开。
|
||||
* 能按 Uri 找到树上那一行和它的上一层(nodeOf、getParent),侧栏定位到某个文件 / 目录(TreeLocator)靠它。
|
||||
*/
|
||||
export class FileTreeProvider implements TreeDataProvider<TreeNode> {
|
||||
/** undefined 是整棵树重画,数组是只重画这几行 */
|
||||
@@ -145,6 +147,28 @@ export class FileTreeProvider implements TreeDataProvider<TreeNode> {
|
||||
.map(entry => this.nodeAt(Uri.joinPath(node.uri, entry.name), node.folder, entry.isDirectory, false))
|
||||
}
|
||||
|
||||
/**
|
||||
* 一行的上一层,交回 getChildren 给过的同一个对象(`TreeView.reveal` 按对象认行):
|
||||
* 文件、目录的上一层是所在目录,根节点的上一层是它所在的分组行(只摆改动的文件、分组有名字时),别的没有。
|
||||
*/
|
||||
getParent(node: TreeNode): TreeNode | undefined {
|
||||
if (isGroupNode(node))
|
||||
return undefined
|
||||
if (node.isRoot)
|
||||
return this.groups.find(({ group }) => group === node.section && group.label !== undefined)
|
||||
const parentPath = path.posix.dirname(node.uri.path)
|
||||
const isRoot = path.posix.relative(node.folder.uri.path, parentPath) === ''
|
||||
return this.nodeAt(isRoot ? node.folder.uri : node.uri.with({ path: parentPath }), node.folder, true, isRoot, node.section)
|
||||
}
|
||||
|
||||
/**
|
||||
* 树上 uri 这一行(文件或目录):从最上面一层层往下找,只进通往它的目录,路上的行按 getChildren 摆出来的记下。
|
||||
* 不在树上(工作区外、被 files.exclude 藏了、只摆改动的文件时没摆它)时没有;同一个文件摆在几个分组里时取最先摆的那行。
|
||||
*/
|
||||
async nodeOf(uri: Uri): Promise<FileNode | undefined> {
|
||||
return this.findBelow(undefined, uri)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
clearTimeout(this.timer)
|
||||
for (const disposable of this.disposables)
|
||||
@@ -209,6 +233,22 @@ export class FileTreeProvider implements TreeDataProvider<TreeNode> {
|
||||
return section.roots.map(({ folder }) => this.nodeAt(folder.uri, folder, true, true, section))
|
||||
}
|
||||
|
||||
private async findBelow(parent: TreeNode | undefined, uri: Uri): Promise<FileNode | undefined> {
|
||||
for (const child of await this.getChildren(parent)) {
|
||||
if (!isGroupNode(child)) {
|
||||
const relation = pathRelation(child.uri, uri)
|
||||
if (relation === 'self')
|
||||
return child
|
||||
if (relation !== 'ancestor' || !child.isDirectory)
|
||||
continue
|
||||
}
|
||||
const found = await this.findBelow(child, uri)
|
||||
if (found)
|
||||
return found
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 还没展开过的行不用管,展开时自然按最新的画。 */
|
||||
private redraw(uris: readonly Uri[]): void {
|
||||
const nodes = uris.flatMap(uri => [...this.nodes.values()].flatMap(known => known.get(uri.toString()) ?? []))
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Disposable, TreeView, Uri } from 'vscode'
|
||||
import type { ExcludeSetting } from './fileEntries'
|
||||
import type { FileNode, FileTreeProvider, TreeNode } from './fileTree'
|
||||
import * as path from 'node:path'
|
||||
import { TabInputCustom, TabInputNotebook, TabInputNotebookDiff, TabInputText, TabInputTextDiff, window, workspace } from 'vscode'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { pathPreviewOf } from '../editor/pathPreview'
|
||||
import { createExcludeMatcher } from './fileEntries'
|
||||
import { followsActiveFile, revealExcluded } from './treeReveal'
|
||||
|
||||
/**
|
||||
* 激活的标签页开着哪个文件:文本编辑器、代码阅读预览这种自定义编辑器、笔记本是它开的文件,对比编辑器取改后的那边(同 VS Code 资源管理器),
|
||||
* 路径页(问文件夹的预览)还原成那个文件夹;终端、设置页这些没有。
|
||||
*/
|
||||
function activeTabUri(): Uri | undefined {
|
||||
const input = window.tabGroups.activeTabGroup.activeTab?.input
|
||||
const uri = input instanceof TabInputText || input instanceof TabInputCustom || input instanceof TabInputNotebook
|
||||
? input.uri
|
||||
: input instanceof TabInputTextDiff || input instanceof TabInputNotebookDiff ? input.modified : undefined
|
||||
return uri && (pathPreviewOf(uri)?.uri ?? uri)
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧栏的树定位到某个文件 / 目录:展开路上的目录、选中那一行(目录顺带展开),焦点留在编辑器。
|
||||
* 跟着当前文件走:激活的标签页换成一个文件就定位过去,点链接、跳到定义、在标签栏切换、侧栏点开都走这一条;
|
||||
* 规则同 VS Code 资源管理器:`explorer.autoReveal` 关掉不跟,`explorer.autoRevealExclude` 里的不跟;
|
||||
* 树上没有的(工作区外、被 files.exclude 藏了、筛选时没摆它)不动,也不退出筛选。
|
||||
* 侧栏这棵树没露出来时不跟(reveal 会把它亮出来,抢了用户的侧栏),露出来时补上。
|
||||
*/
|
||||
export class TreeLocator {
|
||||
private readonly disposables: Disposable[]
|
||||
/** 上一次跟过去的文件:同一个标签页别的变化(改了没保存、标题变了)不再定位,用户在树上折起来的不给展开回去 */
|
||||
private followed: string | undefined
|
||||
/** 第几次定位:找那一行要读目录,连着切几个标签页、中间点了目录链接时,跟的只认最后一次 */
|
||||
private round = 0
|
||||
|
||||
constructor(private readonly files: FileTreeProvider, private readonly view: TreeView<TreeNode>) {
|
||||
this.disposables = [
|
||||
window.tabGroups.onDidChangeTabs(() => this.follow()),
|
||||
window.tabGroups.onDidChangeTabGroups(() => this.follow()),
|
||||
view.onDidChangeVisibility(({ visible }) => {
|
||||
if (visible)
|
||||
this.follow()
|
||||
else
|
||||
this.followed = undefined
|
||||
}),
|
||||
]
|
||||
this.follow()
|
||||
}
|
||||
|
||||
/**
|
||||
* 在树上定位 uri(仓库链接指到的目录);侧栏这棵树没露出来时 VS Code 把它亮出来,点了链接就是要看。
|
||||
* @returns 树上没有它(工作区外、被藏了、筛选时没摆它)时 false
|
||||
*/
|
||||
async reveal(uri: Uri): Promise<boolean> {
|
||||
// 要看的是这一行:正在跟的作废,树因此亮出来时也不再跟回当前文件
|
||||
this.followed = activeTabUri()?.toString()
|
||||
this.round++
|
||||
const node = await this.files.nodeOf(uri)
|
||||
if (!node)
|
||||
return false
|
||||
await this.show(node)
|
||||
return true
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const disposable of this.disposables)
|
||||
disposable.dispose()
|
||||
}
|
||||
|
||||
private follow(): void {
|
||||
if (!this.view.visible || !followsActiveFile(workspace.getConfiguration('explorer').get('autoReveal')))
|
||||
return
|
||||
const uri = activeTabUri()
|
||||
const key = uri?.toString()
|
||||
if (key === this.followed)
|
||||
return
|
||||
this.followed = key
|
||||
const round = ++this.round
|
||||
if (uri)
|
||||
void this.followTo(uri, round)
|
||||
}
|
||||
|
||||
private async followTo(uri: Uri, round: number): Promise<void> {
|
||||
const node = await this.files.nodeOf(uri)
|
||||
if (!node || round !== this.round || !this.view.visible || this.excluded(node))
|
||||
return
|
||||
try {
|
||||
await this.show(node)
|
||||
}
|
||||
catch (err) {
|
||||
// 找到行之后树刚好刷新了(文件增删、换了筛选),这一行已经不是树上的那个对象
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.add({ level: 'warn', source: 'codeReader', message: `侧栏没能定位到 ${uri.fsPath}:${message}` })
|
||||
}
|
||||
}
|
||||
|
||||
/** `explorer.autoRevealExclude` 按这一行所在的根算相对路径 */
|
||||
private excluded(node: FileNode): boolean {
|
||||
const setting = workspace.getConfiguration('explorer', node.folder.uri).get<ExcludeSetting>('autoRevealExclude')
|
||||
return revealExcluded(createExcludeMatcher(setting), path.posix.relative(node.folder.uri.path, node.uri.path))
|
||||
}
|
||||
|
||||
private async show(node: FileNode): Promise<void> {
|
||||
await this.view.reveal(node, { select: true, focus: false, expand: node.isDirectory })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createExcludeMatcher } from './fileEntries'
|
||||
import { followsActiveFile, pathRelation, revealExcluded } from './treeReveal'
|
||||
|
||||
const file = (path: string): { scheme: string, authority: string, path: string } => ({ scheme: 'file', authority: '', path })
|
||||
|
||||
describe('树上一行和要找的路径的关系', () => {
|
||||
it('同一个路径是它自己', () => {
|
||||
expect(pathRelation(file('/ws/backend/a.py'), file('/ws/backend/a.py'))).toBe('self')
|
||||
})
|
||||
|
||||
it('上面任何一层目录都是往里找的那层', () => {
|
||||
expect(pathRelation(file('/ws'), file('/ws/backend/src/a.py'))).toBe('ancestor')
|
||||
expect(pathRelation(file('/ws/backend'), file('/ws/backend/src/a.py'))).toBe('ancestor')
|
||||
expect(pathRelation(file('/'), file('/ws/a.py'))).toBe('ancestor')
|
||||
})
|
||||
|
||||
it('名字开头相同的兄弟目录、下面的路径、别的协议都不相干', () => {
|
||||
expect(pathRelation(file('/ws/back'), file('/ws/backend/a.py'))).toBeUndefined()
|
||||
expect(pathRelation(file('/ws/backend/a.py'), file('/ws/backend'))).toBeUndefined()
|
||||
expect(pathRelation(file('/ws/other'), file('/ws/backend/a.py'))).toBeUndefined()
|
||||
expect(pathRelation({ scheme: 'git', authority: '', path: '/ws' }, file('/ws/a.py'))).toBeUndefined()
|
||||
expect(pathRelation({ scheme: 'file', authority: 'host', path: '/ws' }, file('/ws/a.py'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('explorer.autoReveal 的几种值', () => {
|
||||
it('true 跟,false 不跟', () => {
|
||||
expect(followsActiveFile(true)).toBe(true)
|
||||
expect(followsActiveFile(false)).toBe(false)
|
||||
})
|
||||
|
||||
it('focusNoScroll 当作开着(插件的 reveal 选中就会滚过去)', () => {
|
||||
expect(followsActiveFile('focusNoScroll')).toBe(true)
|
||||
})
|
||||
|
||||
it('没读到时照 VS Code 的默认,开着', () => {
|
||||
expect(followsActiveFile(undefined)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('explorer.autoRevealExclude', () => {
|
||||
const excluded = createExcludeMatcher({ '**/node_modules': true, '**/bower_components': true, 'build': false })
|
||||
|
||||
it('它自己或路上任何一层目录命中都不跟', () => {
|
||||
expect(revealExcluded(excluded, 'node_modules/react/index.js')).toBe(true)
|
||||
expect(revealExcluded(excluded, 'packages/web/node_modules/vue/dist/vue.js')).toBe(true)
|
||||
expect(revealExcluded(excluded, 'node_modules')).toBe(true)
|
||||
})
|
||||
|
||||
it('没命中、或那条关掉了的照跟', () => {
|
||||
expect(revealExcluded(excluded, 'backend/src/visibility.py')).toBe(false)
|
||||
expect(revealExcluded(excluded, 'build/out.js')).toBe(false)
|
||||
expect(revealExcluded(excluded, 'src/node_modules_helper.ts')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ExcludeMatcher } from './fileEntries'
|
||||
|
||||
/** Uri 里比路径用到的几样:vscode 的 Uri 就有这几样,测试里不用起 VS Code。 */
|
||||
export interface UriPath {
|
||||
scheme: string
|
||||
authority: string
|
||||
path: string
|
||||
}
|
||||
|
||||
/** 树上一行和要找的 target 的关系:self 就是它,ancestor 是 target 上面的某层目录(往它里面找),undefined 不相干。 */
|
||||
export type PathRelation = 'self' | 'ancestor' | undefined
|
||||
|
||||
export function pathRelation(row: UriPath, target: UriPath): PathRelation {
|
||||
if (row.scheme !== target.scheme || row.authority !== target.authority)
|
||||
return undefined
|
||||
if (row.path === target.path)
|
||||
return 'self'
|
||||
const dir = row.path.endsWith('/') ? row.path : `${row.path}/`
|
||||
return target.path.startsWith(dir) ? 'ancestor' : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* `explorer.autoReveal` 的值 → 侧栏要不要跟着当前文件走,同 VS Code 资源管理器:只有 false 不跟。
|
||||
* `focusNoScroll`(选中但不滚动)当作开着:插件能用的 `TreeView.reveal` 没有不滚动的选项,选中就会滚过去。
|
||||
*/
|
||||
export function followsActiveFile(autoReveal: unknown): boolean {
|
||||
return autoReveal !== false
|
||||
}
|
||||
|
||||
/**
|
||||
* 相对根的路径命中 `explorer.autoRevealExclude` 时不跟过去,同 VS Code 资源管理器:它自己或路上任何一层目录命中都算,
|
||||
* 所以 `node_modules` 这一条管到它里面所有的文件。规则里看同级有没有某个文件的 `when` 不认(这里没有同级的名字)。
|
||||
*/
|
||||
export function revealExcluded(excluded: ExcludeMatcher, relativePath: string): boolean {
|
||||
const segments = relativePath.split('/')
|
||||
return segments.some((_, index) => excluded(segments.slice(0, index + 1).join('/'), new Set()))
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { FileTreeProvider } from './explorer/fileTree'
|
||||
import { registerPathAsk } from './explorer/pathAskPanel'
|
||||
import { RowDecorator } from './explorer/rowDecorator'
|
||||
import { TreeFilters } from './explorer/treeFilters'
|
||||
import { TreeLocator } from './explorer/treeLocator'
|
||||
import { createBashAdapter } from './languages/bash'
|
||||
import { createDockerfileAdapter } from './languages/dockerfile'
|
||||
import { createPythonAdapter } from './languages/python'
|
||||
@@ -102,11 +103,12 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
const entries = new WorkspaceEntries()
|
||||
const files = new FileTreeProvider(OPEN_COMMAND, new RowDecorator(notes, entries))
|
||||
const view = window.createTreeView(FILES_VIEW, { treeDataProvider: files, showCollapseAll: true })
|
||||
const locator = new TreeLocator(files, view)
|
||||
const filters = new TreeFilters(files, view, context.workspaceState)
|
||||
const review = new ReviewMode(context, FILES_VIEW, view, files, filters)
|
||||
const uncommitted = new UncommittedMode(context, FILES_VIEW, view, files, filters)
|
||||
// 侧栏开着的筛选定预览对比什么:未提交的更改对比上一次提交,审查工单对比 PR 的目标分支
|
||||
const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, namesIn, (index, key) => indexes.find(index, key), filters, entries)
|
||||
const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, namesIn, (index, key) => indexes.find(index, key), filters, entries, uri => locator.reveal(uri))
|
||||
|
||||
context.subscriptions.push(
|
||||
qa,
|
||||
@@ -132,6 +134,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
window.registerCustomEditorProvider(PREVIEW_VIEW_TYPE, previews),
|
||||
files,
|
||||
view,
|
||||
locator,
|
||||
filters,
|
||||
review,
|
||||
uncommitted,
|
||||
|
||||
Reference in new issue
Block a user