[VS Code擴充套件]寫一個程式碼片段管理外掛(二):功能實現

林晓lx發表於2024-08-22

@

目錄
  • 建立和插入程式碼片段
  • 程式碼片段列表
  • 程式碼片段預覽
  • 程式碼片段編輯
  • 自定義對映
  • 預設對映
  • 自動完成
  • 專案地址

建立和插入程式碼片段

VS Code擴充套件提供了資料儲存,其中globalState是使用全域性儲存的Key-Value方式來儲存使用者狀態,支援在不同計算機上保留某些使用者狀態,詳情請參考官方文件

若在編輯器區域有選中的文字,點選右鍵選單中點選建立Snippet,則呼叫extension.snippetCraft.createSnipp命令,執行建立程式碼片段。

在這裡插入圖片描述

建立服務類 SnippService.ts,程式碼如下

export async function AddSnipp(context: ExtensionContext, state: Partial<ISnipp>) {
  const content = await getSnippText();
  const trimmedName = content?.text?.trim().substring(0, 20) || '';
  await _addOrUpdateSnipp(context, { ...state, name: trimmedName }, content)
}

_addOrUpdateSnipp方法中對snipps進行更新操作

async function _addOrUpdateSnipp(context: ExtensionContext, state: Partial<ISnipp>, content?: {
  text: string | undefined;
  type: string | undefined;
}, snippIndex?: number) {
   
  ...
  context.globalState.update("snipps", updatedSnipps);

若在編輯器區域右鍵選單中點選插入Snippet,或在程式碼片段檢視中點選條目,則呼叫extension.snippetCraft.insertSnipps命令,它會呼叫InsertSnipp方法執行插入程式碼片段操作。

在服務類 SnippService.ts,插入如下程式碼

export async function InsertSnipp(context: ExtensionContext, snipp: ISnipp) {
  const editor = window.activeTextEditor;
  if (editor && SnippDataProvider.isSnipp(snipp)) {
    const position = editor?.selection.active;
    editor.edit(async (edit) => {

      edit.insert(position, snipp.content || '');
    });
  } 
}

程式碼片段列表

程式碼片段顯示為一個樹形結構,根據建立時的檔案內容型別,分組顯示程式碼片段條目

在這裡插入圖片描述

建立程式碼片段和分組條目的介面型別

import * as vscode from "vscode";

export interface ISnipp {
  name: string;
  content: string;
  contentType: string;
  created: Date;
  lastUsed: Date;
}

export interface IGroup {
  name: string;
  contentType: string | undefined;
}

在SnippItem中建立獲取所有分組型別的get訪問器,和獲取分組下的條目getChildren方法


export class SnippItem {
  constructor(
    readonly view: string,
    private context: vscode.ExtensionContext
  ) { }

  public get roots(): Thenable<IGroup[]> {
    const snipps = this.context?.globalState?.get("snipps", []);
    const types = snipps
      .map((snipp: ISnipp) => snipp.contentType)
      .filter((value, index, self) => self.indexOf(value) === index)
      .map((type) => ({ name: type, contentType: undefined }));
    return Promise.resolve(types);
  }

  public getChildren(node: IGroup): Thenable<ISnipp[]> {
    const snipps = this.context?.globalState
      ?.get("snipps", [])
      .filter((snipp: ISnipp) => {
        return snipp.contentType === node.name;
      })
      .sort((a: ISnipp, b: ISnipp) => a.name.localeCompare(b.name));

    return Promise.resolve(snipps);
  }



export class GroupItem { }

VS Code擴充套件的側邊欄中顯示內容需為樹形結構,透過實現TreeDataProvider為內容提供資料,請參考官方說明

實現getChildren方法

export class SnippDataProvider
  implements
    vscode.TreeDataProvider<ISnipp | IGroup>
{
  
  public getChildren(
    element?: ISnipp | IGroup
  ): ISnipp[] | Thenable<ISnipp[]> | IGroup[] | Thenable<IGroup[]> {
    return element ? this.model.getChildren(element) : this.model.roots;
  }

}

程式碼片段預覽

實現getTreeItem方法,顯示預覽

點選時呼叫extension.snippetCraft.insertEntry命令實現插入程式碼片段,command部分在上一章節有介紹。

滑鼠移動到程式碼片段條目上時,顯示tooltip預覽

在這裡插入圖片描述

程式碼如下:

public getTreeItem(element: ISnipp | IGroup): vscode.TreeItem {
    const t = element.name;
    const isSnip = SnippDataProvider.isSnipp(element);
    const snippcomm = {
      command: "extension.snippetCraft.insertEntry",
      title: '',
      arguments: [element],
    };

    let snippetInfo: string = `[${element.contentType}] ${element.name}`;

    return {
      // @ts-ignore
      label: isSnip ? element.name : element.name,
      command: isSnip ? snippcomm : undefined,
      iconPath:isSnip ? new ThemeIcon("code"):new ThemeIcon("folder"),
      tooltip: isSnip
        ? new vscode.MarkdownString(
            // @ts-ignore
            `**標題:**${snippetInfo}\n\n**修改時間:**${element.created}\n\n**最近使用:**${element.lastUsed}\n\n**預覽:**\n\`\`\`${element.contentType}\n${element.content}\n\`\`\``
          )
        : undefined,
      collapsibleState: !isSnip
        ? vscode.TreeItemCollapsibleState.Collapsed
        : undefined,
    };
  }

程式碼片段編輯

編輯器是一個輸入框,由於VS Code的輸入框不支援多行輸入,所以需要使用webview實現多行輸入。同時需要提交按鈕與取消按鈕

在這裡插入圖片描述

首先建立一個多行文字框的WebView,
在服務類 SnippService.ts,建立一個函式getWebviewContent,返回一個HTML字串,用於建立一個多行輸入框。

function getWebviewContent(placeholder: string, initialValue: string): string {
  return `
      <!DOCTYPE html>
      <html lang="en">
      <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Multiline Input</title>
      </head>
      <body>
        <textarea id="inputBox" rows="10" cols="50" placeholder="${placeholder}">${initialValue}</textarea>
        <br>
        <button onclick="submitText()">提交</button>
        <button onclick="cancel()">取消</button>
        <script>
          const vscode = acquireVsCodeApi();
          function submitText() {
            const text = document.getElementById('inputBox').value;
            vscode.postMessage({ command: 'submit', text: text });
          }
          function cancel() {
            vscode.postMessage({ command: 'cancel' });
          }
        </script>
      </body>
      </html>
    `;
}

新增處理函式,當使用者點選“提交“時,將文字輸入框中的內容返回,同時關閉輸入框視窗。


async function showInputBoxWithMultiline(context: ExtensionContext, placeholder: string, initialValue: string): Promise<string | undefined> {
  const panel = window.createWebviewPanel(
    'multilineInput',
    'Multiline Input',
    ViewColumn.One,
    {
      enableScripts: true
    }
  );

  panel.webview.html = getWebviewContent(placeholder, initialValue);

  return new Promise<string | undefined>((resolve) => {
    panel.webview.onDidReceiveMessage(
      message => {
        switch (message.command) {
          case 'submit':
            resolve(message.text);
            panel.dispose();
            return;
          case 'cancel':
            resolve(undefined);
            panel.dispose();
            return;
        }
      },
      undefined,
      context.subscriptions
    );
  });
}

在新增程式碼片段和編輯程式碼片段時觸發函式


export async function AddSnippFromEditor(context: ExtensionContext, state: Partial<ISnipp>) {
  const content = await showInputBoxWithMultiline(context, '請輸入Snippet內容', '');
  if (content) {
    _addOrUpdateSnipp(context, state, { text: content, type: "TEXT" })

  }
}

export async function EditSnipp(context: ExtensionContext, state: Partial<ISnipp>, snippIndex: number) {
  const content = await showInputBoxWithMultiline(context, '請輸入Snippet內容', state.content ?? '');
  if (content) {
    _addOrUpdateSnipp(context, state, { text: content, type: state.contentType ?? "TEXT" }, snippIndex)

  }
}

自定義對映

對映是插入程式碼片段時,自動替換的變數,他們透過Key-Value形式儲存於globalState中。

程式碼片段中透過設定佔位符(如${AUTHOR}),在插入程式碼片段時,將自動替換為全域性變數中的值。

當自定義對映值未設定或者不可用時,將直接顯示變數佔位符

擴充套件初始化時,插入了三個常用的自定義對映,你可以自由更改或新增自定義對映。

  • ${AUTHOR}: 作者姓名
  • ${COMPANY}: 公司名稱
  • ${MAIL}: 郵箱地址

擴充套件中所有的自定義對映,呈現於“對映表”樹檢視中。

在這裡插入圖片描述

示例:

程式碼片段內容

value of 'AUTHOR' is: ${AUTHOR}
value of 'COMPANY' is: ${COMPANY}
value of 'MAIL' is: ${MAIL}
value of 'FOOBAR' (non-exist) is: ${FOOBAR}

插入程式碼片段後,顯示如下:

value of 'AUTHOR' is: 林曉lx
value of 'COMPANY' is: my-company
value of 'MAIL' is: jevonsflash@qq.com
value of 'FOOBAR' (non-exist) is: ${FOOBAR}

首先定義KVItem類:

export class KVItem extends vscode.TreeItem {
    constructor(
      public readonly key: string,
      public readonly value: string | undefined
    ) {
      super(key, vscode.TreeItemCollapsibleState.None);
      this.tooltip = `${this.key}: ${this.value}`;
      this.description = this.value;
      this.contextValue = 'kvItem';
    }
  }

“對映表”樹檢視中顯示內容需為樹形結構,同樣需要定義KVTreeDataProvider,在此實現重新整理、新增、刪除、獲取子節點等方法。

export class KVTreeDataProvider implements vscode.TreeDataProvider<KVItem> {
  private _onDidChangeTreeData: vscode.EventEmitter<KVItem | undefined> = new vscode.EventEmitter<KVItem | undefined>();
  readonly onDidChangeTreeData: vscode.Event<KVItem | undefined> = this._onDidChangeTreeData.event;

  constructor(private globalState: vscode.Memento) {}

  getTreeItem(element: KVItem): vscode.TreeItem {
    return element;
  }

  getChildren(element?: KVItem): Thenable<KVItem[]> {
    if (element) {
      return Promise.resolve([]);
    } else {
      const kvObject = this.globalState.get<{ [key: string]: string }>('key-value', {});
      const keys = Object.keys(kvObject);
      return Promise.resolve(keys.map(key => new KVItem(key, kvObject[key])));
    }
  }

  refresh(): void {
    this._onDidChangeTreeData.fire(undefined);
  }

  addOrUpdateKey(key: string, value: string): void {
    const kvObject = this.globalState.get<{ [key: string]: string }>('key-value', {});
    kvObject[key] = value;
    this.globalState.update('key-value', kvObject);
    this.refresh();
  }

  deleteKey(key: string): void {
    const kvObject = this.globalState.get<{ [key: string]: string }>('key-value', {});
    delete kvObject[key];
    this.globalState.update('key-value', kvObject);
    this.refresh();
  }
}

預設對映

預設對映是擴充套件內建的對映功能,可用的對映如下

檔案和編輯器相關:

  • TM_SELECTED_TEXT: 當前選定的文字或空字串
  • TM_CURRENT_LINE: 當前行的內容
  • TM_CURRENT_WORD: 游標下的單詞或空字串的內容
  • TM_LINE_INDEX: 基於零索引的行號
  • TM_LINE_NUMBER: 基於一個索引的行號
  • TM_FILENAME: 當前文件的檔名
  • TM_FILENAME_BASE: 當前文件的檔名(不含副檔名)
  • TM_DIRECTORY: 當前文件的目錄
  • TM_FILEPATH: 當前文件的完整檔案路徑
  • RELATIVE_FILEPATH: 當前文件的相對檔案路徑(相對於開啟的工作區或資料夾)
  • CLIPBOARD: 剪貼簿的內容
  • WORKSPACE_NAME: 開啟的工作區或資料夾的名稱
  • WORKSPACE_FOLDER: 開啟的工作區或資料夾的路徑
  • CURSOR_INDEX: 基於零索引的遊標編號
  • CURSOR_NUMBER: 基於單索引的遊標編號

時間相關:

  • CURRENT_YEAR: 本年度
  • CURRENT_YEAR_SHORT: 當年的最後兩位數字
  • CURRENT_MONTH: 兩位數字的月份(例如“02”)
  • CURRENT_MONTH_NAME: 月份的全名(例如“July”)
  • CURRENT_MONTH_NAME_SHORT: 月份的簡短名稱(例如“Jul”)
  • CURRENT_DATE: 以兩位數字表示的月份中的某一天(例如“08”)
  • CURRENT_DAY_NAME: 日期的名稱(例如“星期一”)
  • CURRENT_DAY_NAME_SHORT: 當天的簡短名稱(例如“Mon”)
  • CURRENT_HOUR24: 小時制格式的當前小時
  • CURRENT_MINUTE: 兩位數的當前分鐘數
  • CURRENT_SECOND: 當前秒數為兩位數
  • CURRENT_SECONDS_UNIX: 自 Unix 紀元以來的秒數
  • CURRENT_TIMEZONE_OFFSET當前 UTC 時區偏移量為 +HH:MM 或者 -HH:MM (例如“-07:00”)。

其他:

  • RANDOM6: 個隨機 Base-10 數字
  • RANDOM_HEX6: 個隨機 Base-16 數字
  • UUID: 第四版UUID

這些專案參考至VS Code 程式碼片段變數,請檢視VSCode官方文件

與自定義對映一樣,當預設對映值未設定或者不可用時,將直接顯示變數佔位符

實現方法如下:


export async function ReplacePlaceholders(text: string, context: ExtensionContext): Promise<string> {
  const editor = window.activeTextEditor;
  const clipboard = await env.clipboard.readText();
  const workspaceFolders = workspace.workspaceFolders;
  const currentDate = new Date();
  const kvObject = context.globalState.get<{ [key: string]: string }>('key-value', {});

  const replacements: { [key: string]: string } = {
    '${TM_SELECTED_TEXT}': editor?.document.getText(editor.selection) || '',
    '${TM_CURRENT_LINE}': editor?.document.lineAt(editor.selection.active.line).text || '',
    '${TM_CURRENT_WORD}': editor?.document.getText(editor.document.getWordRangeAtPosition(editor.selection.active)) || '',
    '${TM_LINE_INDEX}': (editor?.selection.active.line ?? 0).toString(),
    '${TM_LINE_NUMBER}': ((editor?.selection.active.line ?? 0) + 1).toString(),
    '${TM_FILENAME}': editor ? path.basename(editor.document.fileName) : '',
    '${TM_FILENAME_BASE}': editor ? path.basename(editor.document.fileName, path.extname(editor.document.fileName)) : '',
    '${TM_DIRECTORY}': editor ? path.dirname(editor.document.fileName) : '',
    '${TM_FILEPATH}': editor?.document.fileName || '',
    '${RELATIVE_FILEPATH}': editor && workspaceFolders ? path.relative(workspaceFolders[0].uri.fsPath, editor.document.fileName) : '',
    '${CLIPBOARD}': clipboard,
    '${WORKSPACE_NAME}': workspaceFolders ? workspaceFolders[0].name : '',
    '${WORKSPACE_FOLDER}': workspaceFolders ? workspaceFolders[0].uri.fsPath : '',
    '${CURSOR_INDEX}': (editor?.selections.indexOf(editor.selection) ?? 0).toString(),
    '${CURSOR_NUMBER}': ((editor?.selections.indexOf(editor.selection) ?? 0) + 1).toString(),
    '${CURRENT_YEAR}': currentDate.getFullYear().toString(),
    '${CURRENT_YEAR_SHORT}': currentDate.getFullYear().toString().slice(-2),
    '${CURRENT_MONTH}': (currentDate.getMonth() + 1).toString().padStart(2, '0'),
    '${CURRENT_MONTH_NAME}': currentDate.toLocaleString('default', { month: 'long' }),
    '${CURRENT_MONTH_NAME_SHORT}': currentDate.toLocaleString('default', { month: 'short' }),
    '${CURRENT_DATE}': currentDate.getDate().toString().padStart(2, '0'),
    '${CURRENT_DAY_NAME}': currentDate.toLocaleString('default', { weekday: 'long' }),
    '${CURRENT_DAY_NAME_SHORT}': currentDate.toLocaleString('default', { weekday: 'short' }),
    '${CURRENT_HOUR}': currentDate.getHours().toString().padStart(2, '0'),
    '${CURRENT_MINUTE}': currentDate.getMinutes().toString().padStart(2, '0'),
    '${CURRENT_SECOND}': currentDate.getSeconds().toString().padStart(2, '0'),
    '${CURRENT_SECONDS_UNIX}': Math.floor(currentDate.getTime() / 1000).toString(),
    '${CURRENT_TIMEZONE_OFFSET}': formatTimezoneOffset(currentDate.getTimezoneOffset()),
    '${RANDOM}': Math.random().toString().slice(2, 8),
    '${RANDOM_HEX}': Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0'),
    '${UUID}': generateUUID()
  };

  Object.keys(kvObject).forEach(key => {
    replacements[`$\{${key}\}`] = kvObject[key];
  });

  return text.replace(/\$\{(\w+)\}/g, (match, key) => {
    return replacements[match] || match;
  });
}

自動完成

自動完成是VS Code編輯器提供的一個功能,用於在編輯器中顯示自動提示和補全內容。擴充套件提供了基於程式碼片段的自動完成功能。

在這裡插入圖片描述

CompletionItemProvider用於註冊自動完成的規則,提供者約定了在指定的文件型別下,當輸入的字元匹配時,將出現自動完成上下文選單。

上下文選單中列出所有可用的自動完成條目,每個條目由CompletionItem定義,點選對應條目後,將處理後的字串返回,填寫到編輯器當前游標處。

languages.registerCompletionItemProvider用於註冊自動完成的規則提供者。

extension.ts中註冊初始化時,所有的自動完成條目

const providers = contentTypes
  .filter((value, index, self) => self.indexOf(value) === index)
  .map(type =>
    languages.registerCompletionItemProvider(type, {
      provideCompletionItems(
        document: TextDocument,
        position: Position,
        token: CancellationToken,
        context: CompletionContext
      ) {
        return new Promise<CompletionItem[]>((resolve, reject) => {

          var result = snipps
            .filter((snipp: ISnipp) => {
              return snipp.contentType === type;
            })
            .map(async (snipp: ISnipp) => {
              const replacedContentText = await ReplacePlaceholders(snipp.content, extensionContext);

              const commandCompletion = new CompletionItem(snipp.name);
              commandCompletion.insertText = replacedContentText || '';
              return commandCompletion;
            });

          Promise.all(result).then(resolve);
        });
      }
    })
  );

context.subscriptions.push(...providers);

SnippService.ts_addOrUpdateSnipp方法中配置修改或新增的自動完成條目


  if (content?.type && state.name) {
    languages.registerCompletionItemProvider(content.type, {
      provideCompletionItems(
        document: TextDocument,
        position: Position,
        token: CancellationToken,
        context: CompletionContext
      ) {
        return new Promise<CompletionItem[]>((resolve, reject) => {
          ReplacePlaceholders(state.content || '', extensionContext).then(res => {
            const replacedContentText = res;
            const commandCompletion = new CompletionItem(state.name || '');
            commandCompletion.insertText = replacedContentText || '';
            resolve([commandCompletion]);
          });


        });
      }
    });
  }

專案地址

Github:snippet-craft

相關文章