实战:智能书签与代码导航插件
把前几篇涉及的编辑器能力串成一个真正能用的插件:智能书签。在任意代码行打上书签,装饰标记行号与背景;按下快捷键跳转下一个书签;打开列表快速导航;悬停查看书签说明;书签随工作区持久化,重启不丢。
能力清单
| 能力 | 实现机制 | 对应知识点 |
|---|---|---|
| 书签标记 | createTextEditorDecorationType | 装饰与 GutterIconPath |
| 添加/移除书签 | toggleBookmark 命令 | 命令系统与 Range 计算 |
| 列表导航 | showQuickPick + revealRange | 选区与滚动定位 |
| 书签操作菜单 | CodeActionProvider | CodeAction 与重构 |
| 悬停信息 | HoverProvider | Hover 语言服务 |
| 持久化 | workspaceState | 状态存储 |
package.json 贡献点
json
{
"name": "smart-bookmark",
"displayName": "智能书签",
"version": "0.0.1",
"engines": { "vscode": "^1.85.0" },
"activationEvents": [],
"main": "./out/extension.js",
"contributes": {
"commands": [
{ "command": "smartBookmark.toggle", "title": "书签:添加/移除" },
{ "command": "smartBookmark.clearAll", "title": "书签:清除全部" },
{ "command": "smartBookmark.navigate", "title": "书签:打开列表导航" },
{ "command": "smartBookmark.next", "title": "书签:下一个" },
{ "command": "smartBookmark.previous", "title": "书签:上一个" }
],
"keybindings": [
{
"command": "smartBookmark.toggle",
"key": "ctrl+alt+b",
"when": "editorTextFocus"
},
{
"command": "smartBookmark.next",
"key": "ctrl+alt+down",
"when": "editorTextFocus"
},
{
"command": "smartBookmark.previous",
"key": "ctrl+alt+up",
"when": "editorTextFocus"
}
],
"menus": {
"editor/context": [
{
"command": "smartBookmark.toggle",
"group": "navigation@1"
}
]
}
},
"scripts": {
"vscode:prepublish": "npm run compile",
"compile": "tsc -p ./"
},
"devDependencies": {
"@types/vscode": "^1.85.0",
"typescript": "^5.0.0"
}
}数据模型与持久化
书签按「文件路径 -> 行号集合」组织。注意:行号是易变数据,直接存行号会在文件编辑后失准,这里存「行号」并接受小偏差(进阶可改为存行文本锚点)。
typescript
// bookmark-store.ts
import * as vscode from 'vscode';
// 书签数据:文件路径 -> 有序行号集合
export class BookmarkStore implements vscode.Disposable {
private bookmarks = new Map<string, Set<number>>();
private readonly _onDidChange = new vscode.EventEmitter<void>();
readonly onDidChange = this._onDidChange.event;
constructor(private readonly context: vscode.ExtensionContext) {
// 从 workspaceState 恢复
const raw = this.context.workspaceState.get<Record<string, number[]>>(
'bookmarks', {}
);
for (const [file, lines] of Object.entries(raw)) {
this.bookmarks.set(file, new Set(lines));
}
}
has(uri: vscode.Uri, line: number): boolean {
return this.bookmarks.get(uri.fsPath)?.has(line) ?? false;
}
toggle(uri: vscode.Uri, line: number): boolean {
let lines = this.bookmarks.get(uri.fsPath);
if (!lines) {
lines = new Set();
this.bookmarks.set(uri.fsPath, lines);
}
if (lines.has(line)) {
lines.delete(line);
} else {
lines.add(line);
}
this.persist();
this._onDidChange.fire();
return lines.has(line);
}
clearAll(): void {
this.bookmarks.clear();
this.persist();
this._onDidChange.fire();
}
// 返回某个文件按行号排序的书签
linesOf(uri: vscode.Uri): number[] {
const lines = this.bookmarks.get(uri.fsPath);
return lines ? Array.from(lines).sort((a, b) => a - b) : [];
}
// 全部书签(用于列表导航)
all(): { uri: vscode.Uri; line: number }[] {
const result: { uri: vscode.Uri; line: number }[] = [];
for (const [file, lines] of this.bookmarks) {
const uri = vscode.Uri.file(file);
for (const line of lines) {
result.push({ uri, line });
}
}
return result;
}
private persist(): void {
const raw: Record<string, number[]> = {};
for (const [file, lines] of this.bookmarks) {
raw[file] = Array.from(lines).sort((a, b) => a - b);
}
// workspaceState:随项目持久化,VS Code 重启后保留
void this.context.workspaceState.update('bookmarks', raw);
}
dispose(): void {
this._onDidChange.dispose();
}
}书签装饰:GutterIconPath + 背景高亮
书签行用边距图标加整行淡黄背景标记。图标文件放在 media/bookmark.svg:
typescript
// decoration.ts
import * as vscode from 'vscode';
import { BookmarkStore } from './bookmark-store';
export function createBookmarkDecoration(
extensionUri: vscode.Uri
): vscode.TextEditorDecorationType {
return vscode.window.createTextEditorDecorationType({
// 行号右侧的书签图标(双主题)
gutterIconPath: vscode.Uri.joinPath(extensionUri, 'media', 'bookmark.svg'),
// 整行淡黄背景
backgroundColor: 'rgba(255, 193, 7, 0.15)',
isWholeLine: true,
// 滚动条预览条上的标记
overviewRulerColor: '#ffc107',
overviewRulerLane: vscode.OverviewRulerLane.Right
});
}
// 应用到编辑器
export function applyBookmarks(
editor: vscode.TextEditor | undefined,
store: BookmarkStore,
decoration: vscode.TextEditorDecorationType
): void {
if (!editor) {
return;
}
const lines = store.linesOf(editor.document.uri);
const ranges = lines.map((line) => {
const pos = new vscode.Position(line, 0);
return new vscode.Range(pos, pos); // 整行装饰,范围只需行首
});
editor.setDecorations(decoration, ranges);
}toggleBookmark 命令
typescript
// commands.ts
import * as vscode from 'vscode';
import { BookmarkStore } from './bookmark-store';
import { applyBookmarks } from './decoration';
import { jumpToLine } from './navigation';
export function registerCommands(
context: vscode.ExtensionContext,
store: BookmarkStore,
decoration: vscode.TextEditorDecorationType
): void {
// 添加/移除当前行书签
context.subscriptions.push(
vscode.commands.registerCommand('smartBookmark.toggle', () => {
const editor = vscode.window.activeTextEditor;
if (!editor) {
return;
}
const line = editor.selection.active.line;
const added = store.toggle(editor.document.uri, line);
// 状态栏反馈
vscode.window.setStatusBarMessage(
added ? `已添加书签:行 ${line + 1}` : `已移除书签:行 ${line + 1}`,
2000
);
applyBookmarks(editor, store, decoration);
})
);
// 清除全部书签(带确认)
context.subscriptions.push(
vscode.commands.registerCommand('smartBookmark.clearAll', async () => {
const count = store.all().length;
if (count === 0) {
vscode.window.showInformationMessage('当前没有书签');
return;
}
const ok = await vscode.window.showWarningMessage(
`确定清除全部 ${count} 个书签?`,
{ modal: true },
'清除'
);
if (ok === '清除') {
store.clearAll();
// 刷新所有可见编辑器
vscode.window.visibleTextEditors.forEach((ed) =>
applyBookmarks(ed, store, decoration)
);
}
})
);
// 下一个/上一个书签
context.subscriptions.push(
vscode.commands.registerCommand('smartBookmark.next', () =>
jumpRelative(context, store, decoration, 1)),
vscode.commands.registerCommand('smartBookmark.previous', () =>
jumpRelative(context, store, decoration, -1))
);
}
// 相对当前行跳转
function jumpRelative(
context: vscode.ExtensionContext,
store: BookmarkStore,
decoration: vscode.TextEditorDecorationType,
direction: 1 | -1
): void {
const editor = vscode.window.activeTextEditor;
if (!editor) {
return;
}
const lines = store.linesOf(editor.document.uri);
if (lines.length === 0) {
vscode.window.showInformationMessage('当前文件没有书签');
return;
}
const current = editor.selection.active.line;
let target: number;
if (direction === 1) {
// 找第一个大于当前行的
target = lines.find((l) => l > current) ?? lines[0];
} else {
// 找最后一个小于当前行的
const lower = lines.filter((l) => l < current);
target = lower.length > 0 ? lower[lower.length - 1] : lines[lines.length - 1];
}
jumpToLine(editor, target);
}QuickPick 列表导航
打开书签列表,支持模糊搜索文件名与行号,选中后跳转:
typescript
// navigation.ts
import * as vscode from 'vscode';
import { BookmarkStore } from './bookmark-store';
export async function openBookmarkPicker(
store: BookmarkStore
): Promise<void> {
const all = store.all();
if (all.length === 0) {
vscode.window.showInformationMessage('还没有书签');
return;
}
// 预取每个文件的显示名与代码行文本
interface PickItem extends vscode.QuickPickItem {
uri: vscode.Uri;
line: number;
}
const items: PickItem[] = [];
for (const b of all) {
const base = vscode.workspace.asRelativePath(b.uri, false);
let code = '';
try {
const doc = await vscode.workspace.openTextDocument(b.uri);
code = doc.lineAt(b.line).text.trim().slice(0, 60);
} catch {
// 文件被删除时跳过
continue;
}
items.push({
label: `${base}:${b.line + 1}`,
description: code,
uri: b.uri,
line: b.line
});
}
const picked = await vscode.window.showQuickPick(items, {
placeHolder: '选择要跳转的书签',
matchOnDescription: true
});
if (!picked) {
return;
}
// 打开文件并居中跳转
const editor = await vscode.window.showTextDocument(picked.uri, {
preview: true
});
jumpToLine(editor, picked.line);
}
// 统一的跳转工具
export function jumpToLine(
editor: vscode.TextEditor,
line: number
): void {
const pos = new vscode.Position(line, 0);
const range = new vscode.Range(pos, pos);
editor.revealRange(range, vscode.TextEditorRevealType.InCenter);
editor.selection = new vscode.Selection(pos, pos);
}逐个书签 openTextDocument 会为每个书签打开文档,书签量很大时开销偏高;几十个书签的规模下用 description 展示代码预览,体验更好。
CodeAction 书签操作
在书签行上提供操作菜单:移除该书签、查看书签信息:
typescript
// bookmark-code-actions.ts
import * as vscode from 'vscode';
import { BookmarkStore } from './bookmark-store';
export class BookmarkCodeActionProvider implements vscode.CodeActionProvider {
static readonly providedCodeActionKinds = [
vscode.CodeActionKind.QuickFix
];
constructor(private readonly store: BookmarkStore) {}
provideCodeActions(
document: vscode.TextDocument,
range: vscode.Range
): vscode.CodeAction[] {
const actions: vscode.CodeAction[] = [];
const line = range.start.line;
if (this.store.has(document.uri, line)) {
// 移除当前行书签
const remove = new vscode.CodeAction(
'移除当前行书签',
vscode.CodeActionKind.QuickFix
);
remove.command = {
command: 'smartBookmark.toggle',
title: '移除书签',
arguments: []
};
actions.push(remove);
} else {
// 添加当前行书签
const add = new vscode.CodeAction(
'在此行添加书签',
vscode.CodeActionKind.QuickFix
);
add.command = {
command: 'smartBookmark.toggle',
title: '添加书签',
arguments: []
};
actions.push(add);
}
return actions;
}
}Hover 显示书签信息
光标悬停在书签行上,显示书签状态与行内容:
typescript
// bookmark-hover.ts
import * as vscode from 'vscode';
import { BookmarkStore } from './bookmark-store';
export class BookmarkHoverProvider implements vscode.HoverProvider {
constructor(private readonly store: BookmarkStore) {}
provideHover(
document: vscode.TextDocument,
position: vscode.Position
): vscode.Hover | undefined {
if (!this.store.has(document.uri, position.line)) {
return undefined;
}
const text = document.lineAt(position.line).text.trim();
const md = new vscode.MarkdownString();
md.appendMarkdown('**🔖 书签**\n\n');
md.appendCodeblock(text || '(空行)', document.languageId);
return new vscode.Hover(md, new vscode.Range(
position.line, 0,
position.line, document.lineAt(position.line).text.length
));
}
}激活入口与联动
activate 把 store、装饰、命令、Provider 组装起来,并让 store 变化时自动刷新所有编辑器装饰:
typescript
// extension.ts
import * as vscode from 'vscode';
import { BookmarkStore } from './bookmark-store';
import { createBookmarkDecoration, applyBookmarks } from './decoration';
import { registerCommands } from './commands';
import { openBookmarkPicker } from './navigation';
import { BookmarkCodeActionProvider } from './bookmark-code-actions';
import { BookmarkHoverProvider } from './bookmark-hover';
export function activate(context: vscode.ExtensionContext): void {
// 1. 数据层
const store = new BookmarkStore(context);
context.subscriptions.push(store);
// 2. 装饰类型
const decoration = createBookmarkDecoration(context.extensionUri);
context.subscriptions.push(decoration);
// 3. 打开已有书签文件时立即渲染
context.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor((editor) =>
applyBookmarks(editor, store, decoration)),
vscode.workspace.onDidOpenTextDocument(() => {
const editor = vscode.window.activeTextEditor;
applyBookmarks(editor, store, decoration);
}),
// store 变化(toggle/clear)时刷新全部可见编辑器
store.onDidChange(() => {
vscode.window.visibleTextEditors.forEach((ed) =>
applyBookmarks(ed, store, decoration));
})
);
// 4. 命令
registerCommands(context, store, decoration);
// 5. 导航命令
context.subscriptions.push(
vscode.commands.registerCommand('smartBookmark.navigate', () =>
openBookmarkPicker(store))
);
// 6. 语言服务:CodeAction 与 Hover
context.subscriptions.push(
vscode.languages.registerCodeActionsProvider(
'*',
new BookmarkCodeActionProvider(store),
{ providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] }
),
vscode.languages.registerHoverProvider(
'*',
new BookmarkHoverProvider(store)
)
);
}
export function deactivate(): void {
// store 与装饰已随 subscriptions 清理
}运行流程
| 步骤 | 操作 | 预期结果 |
|---|---|---|
| 添加书签 | Ctrl+Alt+B | 行号区出现图标,整行淡黄背景 |
| 悬停 | 鼠标悬停书签行 | 显示书签信息 Hover |
| 下一个 | Ctrl+Alt+↓ | 跳到下一个书签并居中 |
| 列表导航 | 命令面板执行「书签:打开列表导航」 | QuickPick 列出全部书签 |
| 行内操作 | 光标在书签行按 Ctrl+. | 出现「移除当前行书签」 |
| 重启验证 | 重启 VS Code 打开同一工作区 | 书签仍在 |
扩展点:行号漂移的改进方向
当前实现把行号直接持久化,文件增删行后书签会错位。两个进阶思路:
| 方案 | 原理 | 复杂度 |
|---|---|---|
| 文本锚点 | 存「行首前 N 字符的哈希」,变更后重新定位 | 中 |
| 符号锚点 | 结合语法树存符号名,跟随定义移动 | 高 |
常见问题
| 问题 | 处理 |
|---|---|
| 图标不显示 | 确认 svg 路径正确、装饰类型未被 dispose |
| 重启后书签丢失 | 确认使用 workspaceState 而非内存变量 |
| 行号错位 | 接受偏差,或升级文本锚点方案 |
| 删除文件后残留书签 | all() 时捕获 openTextDocument 异常并清理 |
| 装饰不刷新 | store.onDidChange 订阅后统一 applyBookmarks |
从数据模型到视觉反馈、从命令到语言服务,这个插件把装饰、选区、CodeAction、Hover、持久化全部打通。书签虽小,却是完整插件架构的缩影:数据驱动渲染、事件驱动刷新、能力分层组装。