凌鲨的vscode插件是基于凌鲨本地API实现的。源代码是开源的。
开发流程
生成本地API的ts代码
本地接口定义使用openapi定义的。我们使用boats来定义和生成openapi的描述文件。
我们使用git subtree 把proto定义引入到我们的插件项目中。然后通过@openapitools/openapi-generator-cli生成ts代码
npx @openapitools/openapi-generator-cli generate -i proto/index_0.1.19.yml -g typescript-axios -o src/proto-gen
搭建代码框架
在extension.ts里面注册相关命令和数据提供器。
export function activate(context: vscode.ExtensionContext) {
//初始化配置命令
vscode.commands.registerCommand('linksaas.initCfg', () => initProjectTokenCfg());
//缺陷数据提供器
const myBugProvider = new MyBugProvider();
vscode.window.registerTreeDataProvider("myBugList", myBugProvider);
vscode.commands.registerCommand('myBugList.sync', () => myBugProvider.sync());
vscode.commands.registerCommand('myBugList.showItem', (item: BugItem) => item.show());
vscode.commands.registerCommand('myBugList.shortNote', (item: BugItem) => item.shortNote());
//任务数据提供器
const myTaskProvider = new MyTaskProvider();
vscode.window.registerTreeDataProvider("myTaskList", myTaskProvider);
vscode.commands.registerCommand('myTaskList.sync', () => myTaskProvider.sync());
vscode.commands.registerCommand('myTaskList.showItem', (item: TaskItem) => item.show());
vscode.commands.registerCommand('myTaskList.shortNote', (item: TaskItem) => item.shortNote());
//微应用数据提供器
const minappProvider = new MinappProvider();
vscode.window.registerTreeDataProvider("minappList", minappProvider);
vscode.commands.registerCommand('minappList.sync', () => minappProvider.sync());
vscode.commands.registerCommand('minappList.showItem', (item: MinappItem) => item.show());
//注册代码评论相关命令
vscode.commands.registerCommand('codeComment.newThread', () => createThread());
const commentController = vscode.comments.createCommentController("linksaas", "linksaas comment controller");
threadManager.init(commentController);
commentController.commentingRangeProvider = {
provideCommentingRanges(document: vscode.TextDocument, _token: vscode.CancellationToken): vscode.ProviderResult<vscode.Range[]> {
threadManager.onDocChange(document);
return null;
}
};
vscode.commands.registerCommand("codeComment.syncThread", (thread: vscode.CommentThread) => threadManager.syncThread(thread));
vscode.commands.registerCommand("codeComment.add", (reply: vscode.CommentReply) => threadManager.addComment(reply));
vscode.commands.registerCommand("codeComment.update", (comment: threadManager.CodeComment) => threadManager.setEditComment(comment));
vscode.commands.registerCommand("codeComment.remove", (comment: threadManager.CodeComment) => threadManager.removeComment(comment));
vscode.commands.registerCommand("codeComment.cancelSave", (comment: threadManager.CodeComment) => threadManager.cancelSaveComment(comment));
vscode.commands.registerCommand("codeComment.save", (comment: threadManager.CodeComment) => threadManager.saveComment(comment));
}
配置界面布局和命令关联
vscode的配置通过扩展了package.json来实现的。
{
"name": "local-api",
"displayName": "linksaas local-api",
"description": "linksaas local-api",
"version": "0.1.2",
"publisher": "linksaas",
"repository": {
"type": "git",
"url": "https://atomgit.com/openlinksaas/vs-extension"
},
"engines": {
"vscode": "^1.75.0"
},
"categories": [
"Other"
],
"main": "./out/extension.js",
"activationEvents": [
"onStartupFinished"
],
"contributes": {
"commands": [
{
"command": "linksaas.initCfg",
"title": "linksaas: init linksaas cfg"
},
{
"command": "myBugList.sync",
"title": "linksaas: sync my bug list",
"icon": {
"light": "resources/light/refresh.svg",
"dark": "resources/dark/refresh.svg"
}
},
{
"command": "myBugList.showItem",
"title": "linksaas: show detail bug",
"icon": "$(link)"
},
{
"command": "myBugList.shortNote",
"title": "linksaas: show bug as short note",
"icon": "$(link-external)"
},
{
"command": "myTaskList.sync",
"title": "linksaas: sync my task list",
"icon": {
"light": "resources/light/refresh.svg",
"dark": "resources/dark/refresh.svg"
}
},
{
"command": "myTaskList.showItem",
"title": "linksaas: show detail task",
"icon": "$(link)"
},
{
"command": "myTaskList.shortNote",
"title": "linksaas: show task as short note",
"icon": "$(link-external)"
},
{
"command": "minappList.sync",
"title": "linksaas: sync minapp list",
"icon": {
"light": "resources/light/refresh.svg",
"dark": "resources/dark/refresh.svg"
}
},
{
"command": "minappList.showItem",
"title": "linksaas: start minapp",
"icon": "$(debug-start)"
},
{
"command": "codeComment.newThread",
"title": "linksaas: create new comment thread"
},
{
"command": "codeComment.syncThread",
"title": "linksaas: sync comments in thread",
"icon": {
"dark": "resources/dark/refresh.svg",
"light": "resources/light/refresh.svg"
}
},
{
"command": "codeComment.cancelSave",
"title": "Cancel"
},
{
"command": "codeComment.save",
"title": "Save"
},
{
"command": "codeComment.add",
"title": "Add",
"enablement": "!commentIsEmpty"
},
{
"command": "codeComment.update",
"title": "Update",
"icon": {
"dark": "resources/dark/edit.svg",
"light": "resources/light/edit.svg"
}
},
{
"command": "codeComment.remove",
"title": "Remove",
"icon": {
"dark": "resources/dark/close.svg",
"light": "resources/light/close.svg"
}
}
],
"viewsContainers": {
"activitybar": [
{
"id": "linksaas",
"title": "linksaas",
"icon": "media/linksaas.svg"
}
]
},
"views": {
"linksaas": [
{
"id": "myTaskList",
"name": "我的任务列表",
"icon": "media/linksaas.svg",
"contextualTitle": "linksaas"
},
{
"id": "myBugList",
"name": "我的缺陷列表",
"icon": "media/linksaas.svg",
"contextualTitle": "linksaas"
},
{
"id": "minappList",
"name": "微应用列表",
"icon": "media/linksaas.svg",
"contextualTitle": "linksaas"
}
]
},
"menus": {
"commandPalette": [
{
"command": "myBugList.showItem",
"when": "false"
},
{
"command": "myBugList.shortNote",
"when": "false"
},
{
"command": "myTaskList.showItem",
"when": "false"
},
{
"command": "myTaskList.shortNote",
"when": "false"
},
{
"command": "minappList.showItem",
"when": "false"
},
{
"command": "codeComment.newThread",
"when": "false"
},
{
"command": "codeComment.syncThread",
"when": "false"
},
{
"command": "codeComment.cancelSave",
"when": "false"
},
{
"command": "codeComment.save",
"when": "false"
},
{
"command": "codeComment.add",
"when": "false"
},
{
"command": "codeComment.update",
"when": "false"
},
{
"command": "codeComment.remove",
"when": "false"
}
],
"view/title": [
{
"command": "myBugList.sync",
"when": "view == myBugList",
"group": "navigation"
},
{
"command": "myTaskList.sync",
"when": "view == myTaskList",
"group": "navigation"
},
{
"command": "minappList.sync",
"when": "view == minappList",
"group": "navigation"
}
],
"view/item/context": [
{
"command": "myTaskList.showItem",
"when": "view == myTaskList && viewItem == task",
"group": "inline"
},
{
"command": "myTaskList.shortNote",
"when": "view == myTaskList && viewItem == task",
"group": "inline"
},
{
"command": "myBugList.showItem",
"when": "view == myBugList && viewItem == bug",
"group": "inline"
},
{
"command": "myBugList.shortNote",
"when": "view == myBugList && viewItem == bug",
"group": "inline"
},
{
"command": "minappList.showItem",
"when": "view == minappList && viewItem == minapp",
"group": "inline"
}
],
"editor/context": [
{
"command": "codeComment.newThread",
"group": "9_cutcopypaste",
"when": "editorTextFocus"
}
],
"comments/commentThread/title": [
{
"command": "codeComment.syncThread",
"group": "navigation"
}
],
"comments/comment/context": [
{
"command": "codeComment.cancelSave",
"group": "inline@2",
"when": "commentController == linksaas"
},
{
"command": "codeComment.save",
"group": "inline@1",
"when": "commentController == linksaas"
}
],
"comments/commentThread/context": [
{
"command": "codeComment.add",
"group": "inline",
"when": "commentController == linksaas"
}
],
"comments/comment/title": [
{
"command": "codeComment.update",
"group": "group@1",
"when": "commentController == linksaas && comment =~ /update/"
},
{
"command": "codeComment.remove",
"group": "group@2",
"when": "commentController == linksaas && comment =~ /remove/"
}
]
}
},
"scripts": {
"gencode": "npx @openapitools/openapi-generator-cli generate -i proto/index_0.1.19.yml -g typescript-axios -o src/proto-gen",
"vscode:prepublish": "npm run compile",
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./",
"pretest": "npm run compile && npm run lint",
"lint": "eslint src --ext ts",
"test": "node ./out/test/runTest.js"
},
"devDependencies": {
"@openapitools/openapi-generator-cli": "^2.7.0",
"@types/glob": "^8.0.0",
"@types/mocha": "^10.0.0",
"@types/node": "16.x",
"@types/vscode": "^1.75.0",
"@typescript-eslint/eslint-plugin": "^5.42.0",
"@typescript-eslint/parser": "^5.42.0",
"@vscode/test-electron": "^2.2.0",
"eslint": "^8.26.0",
"glob": "^8.0.3",
"mocha": "^10.1.0",
"typescript": "^4.8.4"
},
"dependencies": {
"axios": "1.1.0",
"moment": "2.29.4",
"nanoid": "^3.0.0",
"yaml": "^2.1.3"
},
"icon": "linksaas.png"
}
实现命令和数据提供器
数据提供器通过实现vscode.TreeDataProvider来完成的。根据接口返回树状结构数据即可。
下面是微应用的数据提供器
export class MinappProvider implements vscode.TreeDataProvider<MinappItem> {
private _onDidChangeTreeData: vscode.EventEmitter<MinappItem | undefined | void> = new vscode.EventEmitter<MinappItem | undefined | void>();
readonly onDidChangeTreeData: vscode.Event<MinappItem | undefined | void> = this._onDidChangeTreeData.event;
getTreeItem(element: MinappItem): vscode.TreeItem | Thenable<vscode.TreeItem> {
return element;
}
getChildren(element?: MinappItem | undefined): vscode.ProviderResult<MinappItem[]> {
if (element) {
return [];
}
const basePath = getBasePath();
if (basePath == null) {
return [];
}
const api = new MinappApi(new Configuration({
basePath: basePath,
}));
return new Promise<MinappItem[]>((resolve, _) => {
api.minappGet().then(res => {
const result = res.data.map(item => new MinappItem(item));
resolve(result);
}).catch(e => {
console.log(e);
vscode.window.showErrorMessage(`${e}`);
resolve([]);
});
});
}
sync() {
this._onDidChangeTreeData.fire();
}
}
export class MinappItem extends vscode.TreeItem {
constructor(minappInfo: MinappInfo) {
super(minappInfo.minappName ?? "");
this.contextValue = "minapp";
this.id = minappInfo.minappId ?? "";
this.iconPath = new vscode.ThemeIcon("extensions");
}
async show(): Promise<void> {
const basePath = getBasePath();
if (basePath == null) {
return;
}
const api = new MinappApi(new Configuration({
basePath: basePath,
}));
await api.minappMinappIdGet(this.id ?? "");
}
}
实现代码评论
代码评论是通过vscode.CommentController来实现的。维护好vscode.CommentThread和vscode.Comment数据结构即可。
export class CodeComment implements vscode.Comment {
commentId: string;
contextValue?: string;
oldBody?: string | vscode.MarkdownString;
constructor(commentId: string,
public body: string | vscode.MarkdownString,
public mode: vscode.CommentMode,
public author: vscode.CommentAuthorInformation,
public timestamp: Date,
public thread: vscode.CommentThread,
) {
this.commentId = commentId;
}
}
const usageComment: vscode.Comment = {
body: '所有评论都是以markdown的格式显示。评论内容通过凌鲨客户端获取。',
mode: vscode.CommentMode.Preview,
author: {
name: "说明"
},
contextValue: "useage"
};
let commentController: vscode.CommentController | null = null;
let curDocUri = "";
let curDocThreadMap: Map<string, vscode.CommentThread> = new Map();
export function init(controller: vscode.CommentController) {
commentController = controller;
}
export function onDocChange(document: vscode.TextDocument) {
if (commentController == null) {
return;
}
if (document.uri.toString() != curDocUri) {
//清除所有thread
for (const thread of curDocThreadMap.values()) {
thread.dispose();
}
curDocThreadMap = new Map();
curDocUri = document.uri.toString();
}
//分析页面
const newThreadRangeMap = parseDocument(document.getText());
//清除不存在的thread
let curKeys = Array.from(curDocThreadMap.keys());
for (const curKey of curKeys) {
if (!newThreadRangeMap.has(curKey)) {
const thread = curDocThreadMap.get(curKey);
if (thread != undefined) {
thread.dispose();
}
curDocThreadMap.delete(curKey);
}
}
//增加新的thread
for (const newKeyValue of newThreadRangeMap.entries()) {
if (!curDocThreadMap.has(newKeyValue[0])) {
createThread(document.uri, newKeyValue[0], newKeyValue[1]);
}
}
}
async function createThread(docUri: vscode.Uri, threadUri: string, range: vscode.Range) {
if (commentController == null) {
return;
}
const thread = commentController.createCommentThread(docUri, range, [usageComment]);
thread.label = threadUri;
syncThread(thread);
curDocThreadMap.set(threadUri, thread);
}
export async function syncThread(thread: vscode.CommentThread) {
if (thread.label == undefined) {
return;
}
const threadId = thread.label.replace("linksaas://comment/", "");
const basePath = getBasePath();
if (basePath == null) {
return;
}
const api = new ProjectCodeCommentApi(new Configuration({
basePath: basePath,
}));
const tokenInfo = getProjectToken();
if (tokenInfo == null) {
return;
}
const res = await api.projectProjectIdCodeCommentCommentThreadIdGet(tokenInfo.projectId, threadId);
let commentList: vscode.Comment[] = [];
res.data.forEach(item => {
const comment = convertComment(item, thread);
commentList.push(comment);
});
if(commentList.length == 0){
commentList = [usageComment];
}
thread.comments = commentList;
}
function parseDocument(content: string): Map<string, vscode.Range> {
const retMap = new Map();
const regex = /(linksaas:\/\/comment\/[a-zA-Z0-9]{21})/;
content.split("\n").forEach((line: string, lineIndex: number) => {
const match = line.match(regex);
if (match != null && match.index != undefined) {
const range = new vscode.Range(
new vscode.Position(lineIndex, match.index),
new vscode.Position(lineIndex, match.index + match[1].length)
);
retMap.set(match[1], range);
}
})
return retMap;
}
export async function addComment(reply: vscode.CommentReply) {
if (reply.thread.label == undefined) {
return;
}
const threadId = reply.thread.label.replace("linksaas://comment/", "");
const basePath = getBasePath();
if (basePath == null) {
return;
}
const api = new ProjectCodeCommentApi(new Configuration({
basePath: basePath,
}));
const tokenInfo = getProjectToken();
if (tokenInfo == null) {
return;
}
const addRes = await api.projectProjectIdCodeCommentCommentThreadIdPut(tokenInfo?.projectId, threadId,
{
contentType: "markdown",
content: reply.text,
});
const getRes = await api.projectProjectIdCodeCommentCommentThreadIdCommentIdGet(tokenInfo.projectId, threadId,
addRes.data.commentId ?? "");
reply.thread.comments = [...reply.thread.comments, convertComment(getRes.data, reply.thread)];
}
function convertComment(info: CodeCommentInfo, thread: vscode.CommentThread): CodeComment {
const comment = new CodeComment(info.commentId ?? "", new vscode.MarkdownString(info.content ?? ""),
vscode.CommentMode.Preview,
{
name: info.userDisplayName ?? "",
}, new Date(info.updateTime ?? 0), thread);
const canUpdate = info.canUpdate ?? false;
const canRemove = info.canRemove ?? false;
if (canUpdate && canRemove) {
comment.contextValue = "update|remove";
} else if (canUpdate) {
comment.contextValue = "update";
} else if (canRemove) {
comment.contextValue = "remove";
}
return comment;
}
export function setEditComment(comment: CodeComment) {
const comments = comment.thread.comments.map(cmt => {
if ((cmt as CodeComment).commentId == comment.commentId) {
(cmt as CodeComment).oldBody = cmt.body;
cmt.mode = vscode.CommentMode.Editing;
}
return cmt;
})
comment.thread.comments = comments;
}
export function cancelSaveComment(comment: CodeComment) {
const comments = comment.thread.comments.map(cmt => {
if ((cmt as CodeComment).commentId == comment.commentId) {
const oldBody = (cmt as CodeComment).oldBody;
if (oldBody != undefined) {
cmt.body = oldBody;
}
cmt.mode = vscode.CommentMode.Preview;
}
return cmt;
});
comment.thread.comments = comments;
}
export async function saveComment(comment: CodeComment) {
if (comment.thread.label == undefined) {
return;
}
const threadId = comment.thread.label.replace("linksaas://comment/", "");
const basePath = getBasePath();
if (basePath == null) {
return;
}
const api = new ProjectCodeCommentApi(new Configuration({
basePath: basePath,
}));
const tokenInfo = getProjectToken();
if (tokenInfo == null) {
return;
}
await api.projectProjectIdCodeCommentCommentThreadIdCommentIdPost(tokenInfo.projectId, threadId, comment.commentId, {
contentType: "markdown",
content: typeof (comment.body) == "string" ? comment.body : comment.body.value,
});
const comments = comment.thread.comments.map(cmt => {
if ((cmt as CodeComment).commentId == comment.commentId) {
(cmt as CodeComment).oldBody = undefined;
(cmt as CodeComment).timestamp = new Date();
cmt.mode = vscode.CommentMode.Preview;
}
return cmt;
});
comment.thread.comments = comments;
}
export async function removeComment(comment: CodeComment) {
if (comment.thread.label == undefined) {
return;
}
const threadId = comment.thread.label.replace("linksaas://comment/", "");
const basePath = getBasePath();
if (basePath == null) {
return;
}
const api = new ProjectCodeCommentApi(new Configuration({
basePath: basePath,
}));
const tokenInfo = getProjectToken();
if (tokenInfo == null) {
return;
}
await api.projectProjectIdCodeCommentCommentThreadIdCommentIdDelete(tokenInfo.projectId, threadId, comment.commentId);
const comments = comment.thread.comments.filter(cmt => (cmt as CodeComment).commentId != comment.commentId);
comment.thread.comments = comments;
}
本地接口调用流程
调用本地接口需要本地接口服务地址和对应的项目ID。
- 通过~/.linksaas/local_api文件获取接口服务地址
- 通过项目中的.linksaas.yml文件获取项目ID
如果无法获取服务地址或项目ID,则返回空数据。
发布vscode插件
- 通过vsce package命令生成vsix文件
- 在vscode应用市场上传vsix文件
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。