VS Code Extension 개발하기

이상훈
상훈 Devlog
Published in
19 min readSep 14, 2020
마켓에 등록된 클립보드 Extension

본 내용에 앞서 VS Code Extension을 개발하기 전 필요한 개념을 알아보려면 다음 링크에서 확인 후 읽으면 도움이 될 것이다.

개발/배포된 Extension은 다음 링크에서 확인할 수 있다.

개요

Java, Scala 언어로 개발 시 Jetbrain 사의 Intellij 도구를 자주 사용하곤 했는데 여기에 내장된 기능 중 편리했던 부분이 바로 클립보드 히스토리 기능이였다.

Intellij의 클립보드 히스토리

Ctrl+Shift+V 단축키를 입력하면 클립보드 히스토리 모달 창이 열린다. 상단 목록에서 항목을 선택하면 하단에 해당 클립보드 내용이 전시되고 Paste 버튼 클릭 혹은 Ctrl + V로 복사를 하면 된다. 이 편리한 기능이 VS Code에는 내장되어 있지 않아 직접 Extension을 개발하고 배포해보기로 했다.

프로젝트 초기화

가장 먼저 할 것은 yeoman을 통해 Extension 제너레이터를 설치하는 것이다.

npm install -g yo generator-code

그 후 yo 도구를 통해 code를 실행하고 프로젝트에 대한 몇 가지 질문에 대한 선택을 하면 스카폴딩된 프로젝트가 생성된다.

yo code

제너레이터에 의해 프로젝트가 생성되면 Hello World 메시지를 팝업으로 보여주는 기본 Extension을 확인해볼 수 있다.

커스텀 뷰 만들기

커스텀 뷰라는 것은 VS Code에 내장된 뷰(explorer, scm, debug …)와는 별개로 개발자가 새롭게 추가한 뷰를 의미한다. 먼저 뷰 컨테이너를 정의하고 이 컨테이너에 들어갈 뷰들을 정의하면 된다.

뷰 컨테이너와 뷰

먼저 뷰 컨테이너를 정의해본다. package.jsoncontributes에 다음과 같이 viewsContainers를 추가한다.

  • id: 컨테이너의 식별자(뷰 등록 시 사용된다.)
  • title: 아이콘에 대한 툴팁, 뷰 컨테이너 상단 이름
  • icon: 컨테이너의 아이콘
"viewsContainers": {
"activitybar": [{
"id": "clipboard-history",
"title": "Clipboard History",
"icon": "resources/clipboard-icon.png"
}]
}

그 후, viewsContainers에 등록한 id를 토대로 views를 추가한다. 키 값을 뷰 컨테이너 id로 하면된다.

  • 키: 뷰 컨테이너의 id
  • id: 뷰의 식별자(뷰 생성 시 사용된다.)
  • name: 뷰 상단에 표시될 이름
"views": {
"clipboard-history": [{
"id": "clipboard.history",
"name": "Clipboard History"
}]
},

뷰를 정의했다면 이제 뷰 안에 들어갈 컨텐츠에 대한 정의를 해주어야 한다.

extension.ts 파일 activate() 메소드 내에 vscode api를 사용하여 다음과 같이 뷰를 생성하는 메소드를 정의한다. 이때 첫번째 인자가 package.json에서 정의했던 뷰의 식별자이고 두번째 인자는 뷰 안에 전시할 컨텐츠를 정의하는TreeDataProvider이다.

function createTreeView() {
window.createTreeView('clipboard.history', {
treeDataProvider: new ClipboardProvider()
});
}

이때, ClipboardProvider라는 TreeDataProviderimplements한 프로바이더를 정의해주어야 한다. 이것은 트리 뷰에서 getTreeItem()getChildren()를 구현하여 어떤 아이템들을 전시할 것인가를 설정해주는 것이다.

export class ClipboardProvider implements vscode.TreeDataProvider < Clipboard > {
constructor() {}
getTreeItem(element: Clipboard): TreeItem {
return element;
}
getChildren(element ? : Clipboard): Thenable < Clipboard[] > {
const temp = Object.assign([], clipboardList);
return Promise.resolve(temp.reverse());
}
}

TreeItem을 확장한 Clipboard 클래스는 다음과 같다.

class Clipboard extends TreeItem {
constructor(
public readonly label: string,
public readonly collapsibleState: TreeItemCollapsibleState
) {
super(label, collapsibleState);
}
}

커맨드와 키바인딩 추가하기

command는 Extension의 기능을 트리거 시키는 역할을 한다. command에 단축키를 바인딩할 수 도 있는데 이렇게 되면 사용자가 단축키를 입력할 때 바인딩된 command가 실행되고 Extension 개발자가 정의한 어떠한 기능이 실행되는 것이다. command 역시 package.json파일의 contributes에 정의한 후 등록한다.

"commands": [{
"command": "clipboard.history.copy",
"title": "Copy",
"icon": {
"dark": "resources/dark/copy.png",
"light": "resources/light/copy.png"
}
},
{
"command": "clipboard.history.remove",
"title": "Remove",
"icon": {
"dark": "resources/dark/remove.svg",
"light": "resources/light/remove.svg"
}
}
]

클립보드 히스토리에서 ‘복사’와 ‘항목 삭제’ command를 정의하였다.

  • command: 명령어의 식별자(추후 등록 시 필요하다)
  • title: 컨텍스트 메뉴(우클릭 메뉴), 트리 아이템별 메뉴 등에서 전시할 때 사용되는 명령어 제목이다.
  • icon: title과 마찬가지로 각 메뉴에서 전시될 아이콘 경로이다. 다크모드와 라이트 모드일 때 각각 정할 수 도 있다.

keybindings 정의를 통해 커맨드와 단축키를 바인딩할 수도 있다.

  • command: 커맨드 식별자
  • key: 윈도우나 리눅스에서 사용되는 단축키
  • mac: 맥에서 사용되는 단축키
  • when: 단축키 바인딩이 감지되는 조건
"keybindings": [{
"command": "clipboard.copy",
"key": "ctrl+c",
"mac": "cmd+c",
"when": "editorTextFocus"
},
{
"command": "clipboard.cut",
"key": "ctrl+x",
"mac": "cmd+x",
"when": "editorTextFocus"
},
{
"command": "clipboard.pasteFromClipboard",
"key": "ctrl+shift+v",
"mac": "cmd+shift+v",
"when": "editorTextFocus"
}
]

커맨드와 키바인딩을 정의했다면 이제 등록하면 된다. 먼저 extension.ts에서 클립보드에 아이템을 추가하는 메소드를 구현한다. vscode.env.clipboard 객체를 통해 클립보드 정보를 가져와 clipboardList 배열에 추가한다. 이 때, 중복된 클립보드 내용이 있으면 제외한다.

var clipboardList: Clipboard[] = [];
async function addClipboardItem() {
let copied = await env.clipboard.readText();
copied = copied.replace(/\n/gi, "
");
const item = new Clipboard(copied, TreeItemCollapsibleState.None);
if (clipboardList.find(c => c.label === copied)) {
clipboardList = clipboardList.filter(c => c.label !== copied);
}
clipboardList.push(item);
}

package.json에서 정의했던 clipboard.copy, clipboard.cut, clipboard.pasteFromClipboard, clipboard.history.copy, clipboard.history.remove 5개에 대한 커맨드를 등록한다. 특히 clipboard.pasteFromClipboard일 때는 QuickPick을 띄우고 클립보드 항목을 클릭하면 커서의 위치에서 선택된 값과 교체 될 수 있도록 하였다.

commands.registerCommand('clipboard.copy', () => {
commands.executeCommand("editor.action.clipboardCopyAction").then(() => {
addClipboardItem().then(() => {
window.setStatusBarMessage('copy!');
createTreeView();
});
});
});
commands.registerCommand('clipboard.cut', () => {
commands.executeCommand("editor.action.clipboardCutAction").then(() => {
addClipboardItem().then(() => {
window.setStatusBarMessage('cut!');
createTreeView();
});
});
});
commands.registerCommand('clipboard.pasteFromClipboard', () => {
window.setStatusBarMessage('pasteFromClipboard!');
createTreeView();
const items = clipboardList.map(c => {
return {
label: c.label,
description: ''
};
}).reverse();
window.showQuickPick(items).then(item => {
const label = ((item as QuickPickItem).label as string).replace(/
/gi, "\n");
env.clipboard.writeText(label).then(() => {
window.setStatusBarMessage("copied in history!");
if (!!window.activeTextEditor) {
const editor = window.activeTextEditor;
editor.edit((textInserter => textInserter.delete(editor.selection))).then(() => {
editor.edit((textInserter => textInserter.insert(editor.selection.start, label)));
});
}
});
});
});
commands.registerCommand('clipboard.history.copy', (item: TreeItem) => {
const label = (item.label as string).replace(/
/gi, "\n");
env.clipboard.writeText(label).then(() => {
window.setStatusBarMessage("copied in history!");
});
});
commands.registerCommand('clipboard.history.remove', (item: TreeItem) => {
clipboardList = clipboardList.filter(c => c.label !== item.label);
createTreeView();
window.setStatusBarMessage("removed in history!");
});

메뉴 추가하기

다음과 같이 커스텀 뷰에 클립보드 항목마다 복사와 삭제 메뉴를 추가할 것이다.

package.jsoncontributesmenus를 아래와 같이 추가한다. view/item/context는 뷰의 항목마다 메뉴를 등록하는 것을 의미한다. 이때, group을 통해 메뉴의 위치를 정할 수 있는데 inline은 위의 예시와 같이 항목과 같은 동일한 위치에 있으며 마우스를 올리면 보인다. 1_modification은 컨텍스트 메뉴(우클릭 메뉴) 내에서 순서를 의미하는데 상세 순서 목록은 여기에 정리되어 있다.

컨텍스트 메뉴 그룹 별 위치
"menus": {
"view/item/context": [{
"command": "clipboard.history.copy",
"group": "inline"
},
{
"command": "clipboard.history.remove",
"group": "inline"
},
{
"command": "clipboard.history.remove",
"group": "1_modification"
}
]
}

설정 등록하기

클립보드 히스토리를 무한대로 추가할 수 없으니 최대 저장 수를 설정으로 조절할 수 있도록 한다. package.jsoncontributesconfiguration을 추가한다.

"configuration": [{
"title": "Clipboard",
"properties": {
"clipboard.maximumClips": {
"type": "integer",
"default": 200,
"description": "Maximum number of clips to save"
}
}
}]
  • title: 설정 제목
  • type: 설정 값의 타입
  • default: 기본 설정 값
  • description: 설정에 대한 설명

이렇게 정의하게 되면 다음과 같이 VS Code Settings 기능을 통해 설정 값을 변경할 수 있다.

settings.json UI 편집
settings.json 직접 편집

사용자가 이settings.json을 편집하여 설정 값을 바꾸게 되면 이것을 감지하고 Extension에서도 바로 적용이 가능해야 한다. 이 때 필요한 것이 vscode.workspace.onDidChangeConfiguration 이벤트 핸들러이다. 이것은 모든 설정이 변경될 때마다 호출되기 때문에 clipboard.maximumClips 설정만 바뀌었는지 체크 한 후 maximumClips 변수 값에 새로운 설정 값을 재할당한다.

workspace.onDidChangeConfiguration(event => {
let affected = event.affectsConfiguration("clipboard.maximumClips");
if (affected) {
const config = workspace.getConfiguration("clipboard");
maximumClips = config.get('maximumClips', 200);
}
})

clipboard.maximumClips 설정 값을 가져온 후 addClipboardItem() 메소드에 최대 저장 수 설정 만큼 자르는 구문을 추가한다.

const config = workspace.getConfiguration("clipboard");
let maximumClips = config.get('maximumClips', 200);
async function addClipboardItem() {
...
...

if (maximumClips > 0) {
clipboardList = clipboardList.reverse().slice(0, maximumClips).reverse();
}
}

패키징과 배포하기

배포하기에 앞서 먼저 패키징 작업을 해야한다. 이 때 패키징 포맷은 VS Code에 설치가능하도록 하는 VSIX 이다. 이것을 위해 vsce(VS Code Extension) 도구를 설치하여야 한다.

npm i vsce -g

패키징 하기 전 package.json에 몇가지 항목이 필요하다.

{
"name": "clipboard",
"displayName": "Clipboard",
"description": "Simply clipboard like jetbrain tools",
"icon": "resources/clipboard-brand.png",
"version": "1.0.0",
"engines": {
"vscode": "^1.48.0"
}
}
  • name: Extension의 이름
  • displayName: 마켓에 표시될 이름
  • description: Extension의 설명
  • icon: 마켓에 표시될 아이콘
  • version: 해당 Extension의 버전(업데이트 시 반드시 이 버전을 변경해야한다.)
  • engines.vscode: VS Code 자체에 대한 호환성 버전

이제 프로젝트 최상단 경로에서 vsce 명령어를 통해 패키징한다.

vsce package

프로젝트 최상단 경로에 **.vsix 파일이 생성된 것을 확인 할 수 있다.

마켓에 로그인한 후 Publish extensions 메뉴에 들어가면 배포 관리 페이지가 나온다.

New extension 클릭 후 Visual Studio Code를 클릭하면 다음과 같은 모달 창이 열리고 패키징했던 **.vsix 파일을 업로드하면 된다.

업로드 완료 시 바로 배포되는 것은 아니고 수 분 내로 검토를 거친 후 배포가 된다.

마치며

이전부터 VS Code를 사용하고 있는데, 내가 필요한 기능을 직접 Extension으로 만들어 개발 편리성을 높였다는 점에서 만족스러웠던 프로젝트였다.😆😆

VS Code Extension을 개발하는 측면에서는 여러가지 장단점이 있었다.

VS Code에서 제공하는 UI 형식 외에 완전 커스텀 가능한 수준(?)은 아니였기 때문에 조금 아쉽긴 했었다. 예를 들어 클립보드 히스토리 목록을 모달 창에서 보여주고 싶었는데 현재 VS Code에 모달 UI에 대한 컨셉은 없어 불가능했다. 또한 API 문서가 잘 정리되어 있긴하지만 UI 아키텍쳐에 대한 설명이나 용어가 각 부분 별로 따로따로 설명되어 있어 처음 구조를 이해할 때 어려웠다.

하지만 VS Code Extension 시스템은 굉장히 구조화가 잘되있어 확실히 확장성이 좋다는 것을 느낄 수 있었다. 또한 package.jsoncontributes 설정으로 메타 프로그래밍이 가능했던 부분과 VS Code의 워크벤치와 기능에 대한 풍부한 API 제공은 Extension을 개발하기에 충분히 만족할만한 점이였다.

--

--

이상훈
상훈 Devlog

Frontend Developer 😁😁 #angular #javascript #typescript #scala #node