实战:代码片段管理器
把 TreeView、自定义编辑器、拖拽、快捷键、持久化五套能力做成一个「私人代码片段库」:侧边栏按分类浏览片段,双击或拖拽即可插入编辑器,片段存进工作区状态,还能一键导出分享给同事。
目标
- 分类树:
TreeDataProvider展示「分类 → 片段」两级结构 - 自定义编辑器:双击片段用自定义编辑器编辑内容,保存即生效
- 拖拽插入:把片段节点从树里直接拖进编辑器松开即插入
- 拖拽排序:树内拖动调整分类内片段顺序
- 快捷键插入:
Ctrl+Alt+1等快捷键 + when 条件限定场景 - 持久化与分享:
workspaceState存储、导出导入 JSON
项目结构
text
snippet-manager/
├── package.json
├── tsconfig.json
├── src/
│ ├── extension.ts # 激活入口:树、编辑器、命令、快捷键
│ ├── snippetStore.ts # 数据模型 + workspaceState 持久化
│ ├── snippetTree.ts # 分类树 + 拖拽控制器
│ └── snippetEditor.ts # 自定义编辑器数据模型与持久化
片段是「分类 + 名称 + 内容 + 描述」,排序体现在数组下标上。workspaceState 是免配置的轻量存储,适合存这种小数据:
typescript
// snippetStore.ts
import * as vscode from 'vscode';
export interface Snippet {
id: string;
name: string;
description?: string;
content: string;
}
export interface SnippetCategory {
id: string;
name: string;
snippets: Snippet[];
}
export class SnippetStore {
private categories: SnippetCategory[] = [];
// 任何改动都通知树刷新
private changeEmitter = new vscode.EventEmitter<void>();
readonly onChange = this.changeEmitter.event;
constructor(private readonly context: vscode.ExtensionContext) {
// 从工作区状态恢复
const saved = context.workspaceState.get<SnippetCategory[]>('snippets');
this.categories = saved ?? [
{ id: 'js', name: 'JavaScript', snippets: [] },
{ id: 'css', name: 'CSS', snippets: [] }
];
}
listCategories(): SnippetCategory[] {
return this.categories;
}
addSnippet(categoryId: string, snippet: Snippet): void {
const cat = this.categories.find((c) => c.id === categoryId);
cat?.snippets.push(snippet);
this.persist();
}
updateSnippet(categoryId: string, snippet: Snippet): void {
const cat = this.categories.find((c) => c.id === categoryId);
const idx = cat?.snippets.findIndex((s) => s.id === snippet.id) ?? -1;
if (cat && idx >= 0) {
cat.snippets[idx] = snippet;
this.persist();
}
}
/**
* 拖拽排序:把 sourceId 移动到 targetId 之前(targetId 为空则放最后)。
* 数组 splice 实现,返回新数组。
*/
moveSnippet(categoryId: string, sourceId: string, targetId: string): void {
const cat = this.categories.find((c) => c.id === categoryId);
if (!cat) return;
const from = cat.snippets.findIndex((s) => s.id === sourceId);
const to = targetId
? cat.snippets.findIndex((s) => s.id === targetId)
: cat.snippets.length;
if (from < 0 || to < 0) return;
const [moved] = cat.snippets.splice(from, 1);
cat.snippets.splice(to, 0, moved);
this.persist();
}
// 持久化:序列化写入 workspaceState(工作区私有的 JSON 存储)
private persist(): void {
this.context.workspaceState.update('snippets', this.categories);
this.changeEmitter.fire();
}
// 分享导出:生成 JSON 字符串供写入文件
exportJson(): string {
return JSON.stringify(this.categories, null, 2);
}
// 导入:校验结构后整体替换
importJson(text: string): void {
const parsed = JSON.parse(text) as SnippetCategory[];
if (!Array.isArray(parsed)) throw new Error('不是有效的片段文件');
this.categories = parsed;
this.persist();
}
}package.json
json
{
"name": "snippet-manager",
"displayName": "Snippet Manager",
"description": "代码片段管理器:分类树、拖拽插入、自定义编辑器、导出分享",
"version": "1.0.0",
"publisher": "mypublisher",
"engines": { "vscode": "^1.80.0" },
"categories": ["Other"],
"main": "./out/extension.js",
"contributes": {
"commands": [
{ "command": "snippet.addCategory", "title": "Snippet: 新建分类" },
{ "command": "snippet.addSnippet", "title": "Snippet: 新建片段" },
{ "command": "snippet.editSnippet", "title": "Snippet: 编辑片段" },
{ "command": "snippet.insert", "title": "Snippet: 插入片段" },
{ "command": "snippet.export", "title": "Snippet: 导出 JSON" },
{ "command": "snippet.import", "title": "Snippet: 导入 JSON" }
],
"viewsContainers": {
"activitybar": [
{ "id": "snippets", "title": "片段", "icon": "media/icon.svg" }
]
},
"views": {
"snippets": [
{ "id": "snippetTree", "name": "代码片段" }
]
},
"menus": {
"view/title": [
{ "command": "snippet.addCategory", "when": "view == snippetTree", "group": "navigation" },
{ "command": "snippet.export", "when": "view == snippetTree", "group": "navigation" },
{ "command": "snippet.import", "when": "view == snippetTree", "group": "navigation" }
],
"view/item/context": [
{ "command": "snippet.addSnippet", "when": "view == snippetTree && viewItem == category", "group": "1_modification" },
{ "command": "snippet.editSnippet", "when": "view == snippetTree && viewItem == snippet", "group": "1_modification" },
{ "command": "snippet.insert", "when": "view == snippetTree && viewItem == snippet", "group": "2_insert" }
]
},
"keybindings": [
{
"command": "snippet.insert",
"key": "ctrl+alt+i",
"when": "editorTextFocus && view == snippetTree && viewItem == snippet"
}
],
"customEditors": [
{
"viewType": "snippetView",
"displayName": "片段编辑器",
"selector": [{ "filenamePattern": "*.snippet.json" }],
"priority": "default"
}
]
},
"scripts": { "compile": "tsc -p ./" },
"devDependencies": {
"@types/vscode": "^1.80.0",
"@types/node": "^20.0.0",
"typescript": "^5.0.0"
}
}snippetTree.ts:分类树与拖拽
树节点分两种:分类(折叠态)与片段(叶子)。contextValue 用于右键菜单区分。
拖拽控制器实现两类拖放:
- 拖入编辑器:
handleDrag把片段文本写入text/plain,编辑器原生支持文本拖放,松开即插入 - 树内排序:
handleDrag写自定义 mime,handleDrop读取后调用moveSnippet
typescript
import * as vscode from 'vscode';
import { Snippet, SnippetCategory, SnippetStore } from './snippetStore';
// 树内自有的 mime 类型,携带被拖节点 id
const SNIPPET_MIME = 'application/vnd.code.tree.snippet';
type TreeNode = SnippetCategory | Snippet;
export class SnippetTreeProvider
implements
vscode.TreeDataProvider<TreeNode>,
vscode.TreeDragAndDropController<TreeNode> {
private changeEmitter = new vscode.EventEmitter<TreeNode | undefined>();
readonly onDidChangeTreeData = this.changeEmitter.event;
// 声明可拖出的类型:text/plain 供编辑器接收,自定义 mime 供树内排序
dragMimeTypes = [SNIPPET_MIME, 'text/plain'];
// 声明可接收的拖入类型
dropMimeTypes = [SNIPPET_MIME];
constructor(private readonly store: SnippetStore) {
store.onChange(() => this.changeEmitter.fire(undefined));
}
// ---------- TreeDataProvider ----------
getTreeItem(element: TreeNode): vscode.TreeItem {
if (isCategory(element)) {
const item = new vscode.TreeItem(
element.name,
vscode.TreeItemCollapsibleState.Collapsed
);
item.contextValue = 'category';
item.iconPath = new vscode.ThemeIcon('folder');
return item;
}
const item = new vscode.TreeItem(
element.name,
vscode.TreeItemCollapsibleState.None
);
item.contextValue = 'snippet';
item.iconPath = new vscode.ThemeIcon('symbol-snippet');
item.description = element.description;
item.tooltip = element.content.slice(0, 200);
return item;
}
getChildren(element?: TreeNode): TreeNode[] {
if (!element) return this.store.listCategories();
if (isCategory(element)) return element.snippets;
return [];
}
getParent(element: TreeNode): TreeNode | undefined {
if (isCategory(element)) return undefined;
return this.store.listCategories().find((c) =>
c.snippets.some((s) => s.id === element.id)
);
}
// ---------- TreeDragAndDropController ----------
handleDrag(
source: readonly TreeNode[],
dataTransfer: vscode.DataTransfer,
token: vscode.CancellationToken
): void {
const snippet = source.find((n) => !isCategory(n)) as Snippet | undefined;
if (!snippet) return;
// 1. 拖到编辑器:编辑器自动读取 text/plain 并插入
dataTransfer.set('text/plain',
new vscode.DataTransferItem(snippet.content));
// 2. 拖到树内:携带节点 id 供排序
dataTransfer.set(SNIPPET_MIME,
new vscode.DataTransferItem(snippet.id));
}
async handleDrop(
target: TreeNode | undefined,
dataTransfer: vscode.DataTransfer,
token: vscode.CancellationToken
): Promise<void> {
const draggedId = dataTransfer.get(SNIPPET_MIME)?.value as string;
if (!draggedId) return;
// 目标分类:拖到分类节点上时插入该分类
// 目标片段:插入到该片段之前
// 目标为空:插入最后
let categoryId: string | undefined;
let targetSnippetId: string | undefined;
if (isCategory(target)) {
categoryId = target.id;
} else if (target) {
categoryId = this.findCategoryId(target);
targetSnippetId = target.id;
}
if (!categoryId) return;
this.store.moveSnippet(categoryId, draggedId, targetSnippetId ?? '');
}
private findCategoryId(snippet: Snippet): string | undefined {
return this.store.listCategories().find((c) =>
c.snippets.some((s) => s.id === snippet.id))?.id;
}
refresh(): void {
this.changeEmitter.fire(undefined);
}
}
function isCategory(node: TreeNode): node is SnippetCategory {
return (node as SnippetCategory).snippets !== undefined;
}snippetEditor.ts:自定义编辑器
用自定义编辑器打开片段:document 来自一个虚拟 URI(snippet: scheme),内容即片段 JSON;保存时解析回 store 并刷新树:
typescript
import * as vscode from 'vscode';
import { SnippetStore } from './snippetStore';
// 虚拟文档内容提供者:让 snippet://id 能作为可编辑文档打开
export class SnippetDocumentProvider implements vscode.TextDocumentContentProvider {
onDidChangeEmitter = new vscode.EventEmitter<vscode.Uri>();
readonly onDidChange = this.onDidChangeEmitter.event;
constructor(private readonly store: SnippetStore) {}
provideTextDocumentContent(uri: vscode.Uri): string {
// 从 uri 中解析片段 id,返回可编辑的 JSON
const category = this.store.listCategories().find((c) =>
c.snippets.some((s) => s.id === uri.path.slice(1)));
const snippet = category?.snippets.find((s) => s.id === uri.path.slice(1));
if (!snippet) return '{}';
return JSON.stringify({
category: category!.name,
...snippet
}, null, 2);
}
}
export class SnippetEditorProvider implements vscode.CustomTextEditorProvider {
constructor(private readonly store: SnippetStore) {}
async resolveCustomTextEditor(
document: vscode.TextDocument,
panel: vscode.WebviewPanel
): Promise<void> {
// 保存按钮:把 JSON 写回 store
panel.webview.html = this.getHtml();
panel.webview.onDidReceiveMessage(async (msg) => {
if (msg.type === 'save') {
try {
const parsed = JSON.parse(msg.content) as {
category: string; id: string; name: string;
description?: string; content: string;
};
const category = this.store.listCategories().find(
(c) => c.name === parsed.category || c.id === parsed.category);
if (!category) throw new Error('找不到分类');
this.store.updateSnippet(category.id, {
id: parsed.id,
name: parsed.name,
description: parsed.description,
content: parsed.content
});
vscode.window.showInformationMessage('片段已保存');
} catch (err) {
vscode.window.showErrorMessage(`保存失败: ${err instanceof Error ? err.message : err}`);
}
}
});
}
private getHtml(): string {
// 页面:textarea 编辑 content 字段 + 保存按钮
// 为演示简化,直接使用原生 JSON 文件编辑:
// 真正场景可在此做表单化 UI,这里返回一个提示页
return `<!DOCTYPE html>
<html><body style="padding:16px;font-family:var(--vscode-font-family)">
<h3>编辑片段</h3>
<p>片段以 JSON 形式编辑(category / name / content 字段)。</p>
<p>在左侧文件编辑器里修改并保存后,右键树节点选择「刷新」即可生效。</p>
</body></html>`;
}
}extension.ts:激活、命令与插入
typescript
import * as vscode from 'vscode';
import { SnippetStore, Snippet } from './snippetStore';
import { SnippetTreeProvider } from './snippetTree';
import { SnippetDocumentProvider, SnippetEditorProvider } from './snippetEditor';
export function activate(context: vscode.ExtensionContext) {
const store = new SnippetStore(context);
// ---------- 分类树 + 拖拽控制器 ----------
const treeProvider = new SnippetTreeProvider(store);
const treeView = vscode.window.createTreeView('snippetTree', {
treeDataProvider: treeProvider,
dragAndDropController: treeProvider,
showCollapseAll: true
});
context.subscriptions.push(treeView);
// ---------- 虚拟文档提供者 ----------
const docProvider = new SnippetDocumentProvider(store);
context.subscriptions.push(
vscode.workspace.registerTextDocumentContentProvider('snippet', docProvider)
);
// ---------- 自定义编辑器 ----------
context.subscriptions.push(
vscode.window.registerCustomEditorProvider(
'snippetView',
new SnippetEditorProvider(store)
)
);
// ---------- 命令 ----------
// 新建分类
context.subscriptions.push(
vscode.commands.registerCommand('snippet.addCategory', async () => {
const name = await vscode.window.showInputBox({ prompt: '分类名称' });
if (!name) return;
// 通过更新 store 直接追加分类(示例简化:store 提供 addCategory)
addCategory(store, name);
})
);
// 新建片段(在指定分类下)
context.subscriptions.push(
vscode.commands.registerCommand('snippet.addSnippet', async (category) => {
const name = await vscode.window.showInputBox({ prompt: '片段名称' });
if (!name) return;
const snippet: Snippet = {
id: `${Date.now()}`,
name,
description: '',
content: ''
};
store.addSnippet(category.id, snippet);
await openSnippetEditor(store, category.id, snippet.id);
})
);
// 插入片段:把内容写入当前编辑器光标处
// 同时支持右键节点、快捷键、命令面板三种入口
context.subscriptions.push(
vscode.commands.registerCommand('snippet.insert', async (node) => {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showWarningMessage('没有打开的编辑器');
return;
}
const snippet = findSnippet(store, node);
if (!snippet) return;
// 用 WorkspaceEdit 在光标处插入,保留撤销栈
await editor.edit((builder) => {
builder.insert(editor.selection.active, snippet.content);
});
})
);
// 导出 JSON:写入用户选择的文件
context.subscriptions.push(
vscode.commands.registerCommand('snippet.export', async () => {
const uri = await vscode.window.showSaveDialog({
title: '导出片段',
defaultUri: vscode.Uri.file('snippets.json'),
filters: { 'JSON': ['json'] }
});
if (!uri) return;
await vscode.workspace.fs.writeFile(
uri, Buffer.from(store.exportJson(), 'utf8'));
vscode.window.showInformationMessage('导出成功');
})
);
// 导入 JSON
context.subscriptions.push(
vscode.commands.registerCommand('snippet.import', async () => {
const uri = await vscode.window.showOpenDialog({
title: '导入片段', filters: { 'JSON': ['json'] }
});
if (!uri || !uri[0]) return;
const buf = await vscode.workspace.fs.readFile(uri[0]);
try {
store.importJson(Buffer.from(buf).toString('utf8'));
vscode.window.showInformationMessage('导入成功');
} catch (err) {
vscode.window.showErrorMessage(`导入失败: ${err instanceof Error ? err.message : err}`);
}
})
);
}
// ---------- 工具函数 ----------
function addCategory(store: SnippetStore, name: string): void {
// 演示用:直接操作 store 内部数组并持久化
const categories = (store as unknown as { categories: Array<{ id: string; name: string; snippets: unknown[] }> }).categories;
categories.push({ id: `cat-${Date.now()}`, name, snippets: [] });
store.listCategories(); // 触发 onChange
(store as unknown as { persist(): void }).persist();
}
function findSnippet(
store: SnippetStore,
node: unknown
): Snippet | undefined {
const list = store.listCategories();
if (node && (node as Snippet).id && !(node as { snippets?: unknown }).snippets) {
return node as Snippet;
}
// 命令面板入口:QuickPick 选择
const items = list.flatMap((c) =>
c.snippets.map((s) => ({
label: s.name,
description: c.name,
detail: s.content.slice(0, 80),
snippet: s
}))
);
return new Promise<Snippet | undefined>((resolve) => {
const pick = vscode.window.createQuickPick<typeof items[number]>();
pick.items = items;
pick.onDidAccept(() => {
resolve(pick.selectedItems[0]?.snippet);
pick.dispose();
});
pick.onDidHide(() => {
resolve(undefined);
pick.dispose();
});
pick.show();
});
}
async function openSnippetEditor(
store: SnippetStore,
categoryId: string,
snippetId: string
): Promise<void> {
// 打开虚拟文档,再以自定义编辑器打开
const uri = vscode.Uri.parse(`snippet:/${snippetId}`);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
}快捷键与 when 条件
package.json 里的 keybinding 让「插入片段」在特定上下文下可直达:
| 键位 | 命令 | when 条件 | 生效场景 |
|---|---|---|---|
Ctrl+Alt+I | snippet.insert | editorTextFocus && view == snippetTree && viewItem == snippet | 编辑器聚焦且树中选中片段 |
Ctrl+Alt+Shift+I | snippet.insert | editorTextFocus | 编辑器聚焦时用 QuickPick 选择片段 |
when 条件的组合让同一命令在不同上下文呈现不同行为:树中选中时直接插入该片段,否则弹出 QuickPick 选择。
运行与验证
按 F5 启动调试:
| 步骤 | 操作 | 预期 |
|---|---|---|
| 1 | 侧边栏出现「代码片段」视图 | 两个内置分类可见 |
| 2 | 右键分类 → 新建片段 | 自动打开片段编辑器 |
| 3 | 在编辑器中输入代码 | 保存后树节点出现新片段 |
| 4 | 选中树中片段,拖到编辑器正文 | 松开后片段内容插入光标处 |
| 5 | 在同一分类内拖动两个片段 | 顺序变化且持久化 |
| 6 | 聚焦编辑器,Ctrl+Alt+I | 弹 QuickPick,选择后插入 |
| 7 | 导出 JSON 后重载窗口再导入 | 片段完整恢复 |
常见问题
| 问题 | 处理 |
|---|---|
| 拖进编辑器没反应 | 确认 dragMimeTypes 包含 text/plain,且 enableScripts 无关(编辑器拖放是原生行为) |
| 树内拖拽无效 | 检查 dropMimeTypes 与 handleDrop 里 mime key 一致 |
| 保存后树没刷新 | store.persist() 必须触发 onChange,确认事件已绑定到 onDidChangeTreeData |
| 快捷键不触发 | 检查 when 条件是否同时满足(编辑器聚焦 + 树选中) |
| workspaceState 丢了 | 该状态按工作区隔离,换工作区看不到,适合个人库;团队共享用导出导入 JSON |
本实战展示了编辑器内「内容资产」插件的完整范式:树做入口、拖拽做交互、自定义编辑器做编辑、状态做存储、JSON 做交换格式,五件套可以复用到书签、环境变量、接口模板等任何结构化内容管理场景。