✨ feat(vscode): 代码阅读侧栏拆成多栏:常驻未提交的更改、收藏目录各占一栏
This commit is contained in:
1 parent
45e526afa9
commit
b40ddb5e3a
27 files changed
+2427
-674
No files matched your search
@@ -0,0 +1,59 @@
|
||||
import type { Favorite } from './favoriteSlots'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FAVORITE_SLOTS, favoriteCommand, favoriteContext, favoriteViewId, placeFavorite, removeFavorite, renameFavorite } from './favoriteSlots'
|
||||
|
||||
const favorite = (slot: number, name = `收藏${slot}`): Favorite => ({ uri: `file:///ws/dir${slot}`, name, slot })
|
||||
|
||||
/** 栏位 1…count 全占着 */
|
||||
const filled = (count: number): Favorite[] => Array.from({ length: count }, (_, index) => favorite(index + 1))
|
||||
|
||||
describe('新收藏占哪个栏位', () => {
|
||||
it('没有收藏时占第 1 个', () => {
|
||||
expect(placeFavorite([], 'file:///ws/a')).toEqual({ slot: 1 })
|
||||
})
|
||||
|
||||
it('占没被占的里面最小的:中间空出来的先用', () => {
|
||||
expect(placeFavorite([favorite(1), favorite(2)], 'file:///ws/a')).toEqual({ slot: 3 })
|
||||
expect(placeFavorite([favorite(1), favorite(3), favorite(4)], 'file:///ws/a')).toEqual({ slot: 2 })
|
||||
expect(placeFavorite([favorite(5)], 'file:///ws/a')).toEqual({ slot: 1 })
|
||||
})
|
||||
|
||||
it('取消收藏后空出来的栏位给下一个收藏用,别的收藏不挪位置', () => {
|
||||
const left = removeFavorite([favorite(1), favorite(2), favorite(3)], 2)
|
||||
|
||||
expect(left).toEqual([favorite(1), favorite(3)])
|
||||
expect(placeFavorite(left, 'file:///ws/a')).toEqual({ slot: 2 })
|
||||
})
|
||||
|
||||
it(`${FAVORITE_SLOTS.length} 个栏位都占满了:满了`, () => {
|
||||
expect(FAVORITE_SLOTS).toEqual(Array.from({ length: 30 }, (_, index) => index + 1))
|
||||
expect(placeFavorite(filled(FAVORITE_SLOTS.length), 'file:///ws/a')).toBe('full')
|
||||
expect(placeFavorite(filled(FAVORITE_SLOTS.length - 1), 'file:///ws/a')).toEqual({ slot: FAVORITE_SLOTS.length })
|
||||
})
|
||||
|
||||
it('同一个目录收藏过:给出原来那个,不再占栏位;满了也先认出收藏过', () => {
|
||||
const favorites = [favorite(1), favorite(2)]
|
||||
expect(placeFavorite(favorites, 'file:///ws/dir2')).toEqual({ existing: favorite(2) })
|
||||
expect(placeFavorite(filled(FAVORITE_SLOTS.length), 'file:///ws/dir7')).toEqual({ existing: favorite(7) })
|
||||
})
|
||||
})
|
||||
|
||||
describe('重命名、取消收藏', () => {
|
||||
it('重命名只改那一栏的名字,栏位不动', () => {
|
||||
expect(renameFavorite([favorite(1), favorite(4)], 4, '后端接口')).toEqual([favorite(1), favorite(4, '后端接口')])
|
||||
})
|
||||
|
||||
it('取消收藏只去掉那一栏;没占着的栏位什么都不变', () => {
|
||||
expect(removeFavorite([favorite(1), favorite(4)], 1)).toEqual([favorite(4)])
|
||||
expect(removeFavorite([favorite(1), favorite(4)], 2)).toEqual([favorite(1), favorite(4)])
|
||||
})
|
||||
})
|
||||
|
||||
describe('栏位对应的视图、上下文键、命令', () => {
|
||||
it('第 n 个栏位各有自己的视图 id、显示不显示的上下文键、标题栏上重命名 / 取消收藏的命令', () => {
|
||||
expect(favoriteViewId(3)).toBe('superpowers.codeReader.favorite3')
|
||||
expect(favoriteContext(3)).toBe('superpowers.codeReader.hasFavorite3')
|
||||
expect(favoriteCommand('rename', 3)).toBe('superpowers.codeReader.renameFavorite3')
|
||||
expect(favoriteCommand('remove', 30)).toBe('superpowers.codeReader.removeFavorite30')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 收藏栏的栏位:VS Code 的视图要在 package.json 里预先声明,所以声明了固定这么多个收藏栏(superpowers.codeReader.favorite1 … favorite30),
|
||||
* 有收藏占着的才显示。这里是栏位号 1、2、3…
|
||||
*/
|
||||
export const FAVORITE_SLOTS: readonly number[] = Array.from({ length: 30 }, (_, index) => index + 1)
|
||||
|
||||
/** 一个收藏:目录(Uri 字符串)、栏标题上的名字、占着第几个栏位。 */
|
||||
export interface Favorite {
|
||||
uri: string
|
||||
name: string
|
||||
slot: number
|
||||
}
|
||||
|
||||
/** 第 slot 个收藏栏的视图 id */
|
||||
export function favoriteViewId(slot: number): string {
|
||||
return `superpowers.codeReader.favorite${slot}`
|
||||
}
|
||||
|
||||
/** 上下文键:第 slot 个栏位有收藏占着,这一栏才显示(package.json 里视图的 when 按它) */
|
||||
export function favoriteContext(slot: number): string {
|
||||
return `superpowers.codeReader.hasFavorite${slot}`
|
||||
}
|
||||
|
||||
/** 第 slot 个收藏栏标题栏上的按钮命令:rename 重命名、remove 取消收藏。标题栏按钮不知道点的是哪一栏,所以每个栏位各一条。 */
|
||||
export function favoriteCommand(action: 'rename' | 'remove', slot: number): string {
|
||||
return `superpowers.codeReader.${action}Favorite${slot}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 收藏 uri 这个目录要占哪个栏位:收藏过的给出原来那个(不重复收藏),栏位都占满了是 full,别的占没被占的里面最小的。
|
||||
* 一个收藏一直占着它的栏位、删掉才空出来,用户拖动排过的位置 VS Code 按栏位记着,所以不会错乱。
|
||||
*/
|
||||
export function placeFavorite(favorites: readonly Favorite[], uri: string): { existing: Favorite } | { slot: number } | 'full' {
|
||||
const existing = favorites.find(favorite => favorite.uri === uri)
|
||||
if (existing)
|
||||
return { existing }
|
||||
const slot = FAVORITE_SLOTS.find(slot => !favorites.some(favorite => favorite.slot === slot))
|
||||
return slot === undefined ? 'full' : { slot }
|
||||
}
|
||||
|
||||
/** 第 slot 栏的收藏改叫 name,栏位不动。 */
|
||||
export function renameFavorite(favorites: readonly Favorite[], slot: number, name: string): Favorite[] {
|
||||
return favorites.map(favorite => favorite.slot === slot ? { ...favorite, name } : favorite)
|
||||
}
|
||||
|
||||
/** 取消第 slot 栏的收藏,空出这个栏位。 */
|
||||
export function removeFavorite(favorites: readonly Favorite[], slot: number): Favorite[] {
|
||||
return favorites.filter(favorite => favorite.slot !== slot)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { Memento, TreeView } from 'vscode'
|
||||
import type { FileNode, FileTreeDecorator, TreeNode } from '../explorer/fileTree'
|
||||
import type { Favorite } from './favoriteSlots'
|
||||
import * as path from 'node:path'
|
||||
import { commands, FileType, Uri, window, workspace } from 'vscode'
|
||||
import { FileTreeProvider } from '../explorer/fileTree'
|
||||
import { TreeLocator } from '../explorer/treeLocator'
|
||||
import { FAVORITE_SLOTS, favoriteContext, favoriteViewId, placeFavorite, removeFavorite, renameFavorite } from './favoriteSlots'
|
||||
|
||||
/** workspaceState:收藏的目录(Favorite 的数组),按工作区记。 */
|
||||
const FAVORITES_KEY = 'superpowers.codeReader.favorites'
|
||||
|
||||
/** 弹顶部的输入框问收藏栏叫什么(原值整段选中);Esc 或点别处是不改。 */
|
||||
async function askName(title: string, value: string): Promise<string | undefined> {
|
||||
const name = await window.showInputBox({
|
||||
title,
|
||||
value,
|
||||
prompt: '收藏栏标题上显示的名字',
|
||||
validateInput: text => text.trim() ? undefined : '名字不能为空',
|
||||
})
|
||||
return name?.trim()
|
||||
}
|
||||
|
||||
/** 亮出第 slot 个收藏栏(VS Code 给每个视图自带一条 `<视图 id>.focus` 命令)。 */
|
||||
async function focus(slot: number): Promise<void> {
|
||||
await commands.executeCommand(`${favoriteViewId(slot)}.focus`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个收藏栏:这个目录下的完整文件树(自己的文件树和视图),标题是起的名字、后面淡色写目录相对工作区的路径;
|
||||
* 当前文件在这个目录下、这一栏露着时跟过去定位。目录被删了时栏里说一句目录不在了。收藏时建、取消收藏时销毁。
|
||||
*/
|
||||
class FavoritePane {
|
||||
private readonly uri: Uri
|
||||
private readonly view: TreeView<TreeNode>
|
||||
private readonly disposables: { dispose: () => unknown }[]
|
||||
/** 第几次查目录在不在:连着查时只认最后一次 */
|
||||
private round = 0
|
||||
|
||||
constructor(favorite: Favorite, openCommand: string, decorator: FileTreeDecorator) {
|
||||
this.uri = Uri.parse(favorite.uri)
|
||||
const files = new FileTreeProvider(openCommand, decorator, { kind: 'directory', uri: this.uri })
|
||||
this.view = window.createTreeView(favoriteViewId(favorite.slot), { treeDataProvider: files, showCollapseAll: true })
|
||||
this.view.title = favorite.name
|
||||
this.view.description = workspace.asRelativePath(this.uri)
|
||||
this.disposables = [
|
||||
files,
|
||||
this.view,
|
||||
new TreeLocator(files, this.view),
|
||||
// 树整棵重摆时(目录下有增删、工作区文件夹变了)看看目录还在不在
|
||||
files.onDidChangeTreeData((nodes) => {
|
||||
if (!nodes)
|
||||
void this.check()
|
||||
}),
|
||||
]
|
||||
void this.check()
|
||||
}
|
||||
|
||||
rename(name: string): void {
|
||||
this.view.title = name
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const disposable of this.disposables)
|
||||
disposable.dispose()
|
||||
}
|
||||
|
||||
private async check(): Promise<void> {
|
||||
const round = ++this.round
|
||||
const exists = await workspace.fs.stat(this.uri).then(stat => (stat.type & FileType.Directory) !== 0, () => false)
|
||||
if (round === this.round)
|
||||
this.view.message = exists ? undefined : `目录不在了:${workspace.asRelativePath(this.uri)}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧栏的收藏栏:收藏一个目录占一个栏位,栏里是这个目录下的完整文件树,规则同主栏的工作区文件树、不受审查影响(栏位怎么分见 placeFavorite)。
|
||||
* 只给有收藏占着的栏位建视图,上下文键让这一栏显示出来;收藏按工作区记住,打开窗口时照记着的建回来。
|
||||
*/
|
||||
export class FavoriteViews {
|
||||
private favorites: Favorite[]
|
||||
/** 栏位号 → 那一栏 */
|
||||
private readonly panes = new Map<number, FavoritePane>()
|
||||
|
||||
/**
|
||||
* @param state workspaceState
|
||||
* @param openCommand 点文件时执行的命令,参数是文件 Uri
|
||||
* @param decorator 给行添信息(文件注释),和别的栏共用
|
||||
*/
|
||||
constructor(private readonly state: Memento, private readonly openCommand: string, private readonly decorator: FileTreeDecorator) {
|
||||
this.favorites = state.get<Favorite[]>(FAVORITES_KEY) ?? []
|
||||
for (const favorite of this.favorites)
|
||||
this.open(favorite)
|
||||
void Promise.all(this.favorites.map(favorite => commands.executeCommand('setContext', favoriteContext(favorite.slot), true)))
|
||||
}
|
||||
|
||||
/** 目录行上的「收藏目录」:起个名字(默认是目录名)占一个空栏位并亮出那一栏;收藏过的说一声、亮出原来那一栏,满了说一声。 */
|
||||
async add(node: FileNode | undefined): Promise<void> {
|
||||
if (!node)
|
||||
return // 只挂在侧栏的目录行上,别处调用没有目标
|
||||
const uri = node.uri.toString()
|
||||
const placement = placeFavorite(this.favorites, uri)
|
||||
if (placement === 'full') {
|
||||
void window.showWarningMessage(`收藏栏满了(最多 ${FAVORITE_SLOTS.length} 个),先取消一个再收藏`)
|
||||
return
|
||||
}
|
||||
if ('existing' in placement) {
|
||||
void window.showInformationMessage(`这个目录已经收藏过了:「${placement.existing.name}」`)
|
||||
await focus(placement.existing.slot)
|
||||
return
|
||||
}
|
||||
const name = await askName('收藏目录', path.posix.basename(node.uri.path))
|
||||
if (name === undefined)
|
||||
return
|
||||
const favorite: Favorite = { uri, name, slot: placement.slot }
|
||||
this.open(favorite)
|
||||
await Promise.all([
|
||||
this.save([...this.favorites, favorite]),
|
||||
commands.executeCommand('setContext', favoriteContext(favorite.slot), true),
|
||||
])
|
||||
await focus(favorite.slot)
|
||||
}
|
||||
|
||||
/** 收藏栏标题栏的「重命名」:改第 slot 栏的标题。 */
|
||||
async rename(slot: number): Promise<void> {
|
||||
const favorite = this.favorites.find(item => item.slot === slot)
|
||||
if (!favorite)
|
||||
return
|
||||
const name = await askName('重命名收藏', favorite.name)
|
||||
if (name === undefined)
|
||||
return
|
||||
this.panes.get(slot)?.rename(name)
|
||||
await this.save(renameFavorite(this.favorites, slot, name))
|
||||
}
|
||||
|
||||
/** 收藏栏标题栏的「取消收藏」:藏起第 slot 栏、空出栏位,再销毁它的视图(免得露着的栏里闪一下没有数据)。 */
|
||||
async remove(slot: number): Promise<void> {
|
||||
const pane = this.panes.get(slot)
|
||||
this.panes.delete(slot)
|
||||
await Promise.all([
|
||||
this.save(removeFavorite(this.favorites, slot)),
|
||||
commands.executeCommand('setContext', favoriteContext(slot), false),
|
||||
])
|
||||
pane?.dispose()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const pane of this.panes.values())
|
||||
pane.dispose()
|
||||
}
|
||||
|
||||
private open(favorite: Favorite): void {
|
||||
this.panes.set(favorite.slot, new FavoritePane(favorite, this.openCommand, this.decorator))
|
||||
}
|
||||
|
||||
private async save(favorites: Favorite[]): Promise<void> {
|
||||
this.favorites = favorites
|
||||
await this.state.update(FAVORITES_KEY, favorites.length > 0 ? favorites : undefined)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user