✨ feat(vscode): 代码阅读侧栏拆成多栏:常驻未提交的更改、收藏目录各占一栏

This commit is contained in:
cruldra committed 2026-10-12 14:29:48 +11:00
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)
}
}