코로 넘어져도 헤딩만 하면 그만

VS code 확장 제작(탭 및 하이라이트 편집기) 본문

Project

VS code 확장 제작(탭 및 하이라이트 편집기)

꼬드리 2025. 5. 16. 17:16

팀 내에서 대용량 텍스트 문서를 검수해야 하는 상황이 있었다.

코드로 정리한 데이터를 수정해야 했는데, 이미 한번 자동 분류를 했으니 이제 틀린 데이터를 수동(...)체크해야 했다.

 

말이 쉽지... 눈으로 보면서 비교하고 오탈자 찾아 검수하는 게 쉬운 일이 아니다. 

오탈자 수정은 그렇다 치고, 공백에 사용된 게 탭인지 스페이스인지 몇백 줄을 일일이 구분해야 했는데... 

예시

눈이 빠지지 않으면 이상한 상황이었다.

일단 메모장보다는 VS code가 낫다. 스페이스는 점으로, 탭은 화살표로 구분된다. 그런데... 너무 작아서 잘 보이지 않고 모니터 속 세상으로 머리를 집어 넣는 꼴이 되었다. 

 

 

💡확장 프로그램 만들기

참다 참다 미쳐버리기 전에 확장 프로그램을 하나 빨리 만들어서 적용하기로 했다. 사실 확장 프로그램은 받아서 써보기만 했지, 직접 만든 적은 없다. 그런데 뭐 ... 어렵겠나 싶기도 하고... 어차피 간단한(그러나 지금 내겐 꼭 필요한) 기능 코드인데... 

 

에디터 필수 기능은 다음과 같다.

1) 공백의 탭과 스페이스를 직관적으로 구분할 것

2) 줄 마지막에 텍스트 대신 공백이 있다면 알려줄 것

3) 현재 선택해서 보고 있는 줄을 하이라이트 해서 알아보기 쉽게 할 것

 

 

- 확장 프로그램 세팅

VS code에서 확장 프로그램을 만드는 건 어렵지 않다. 

npm install -g yo generator-code

 

Node.js에서 yo를 사용해서 확장 파일을 생성한다. 

yo code

간단한 응답이 필요한 질문이 여러 개 나오는데 적당히 프로젝트 이름과 깃 레포 등을 등록한다.

그러면 자동으로 확장을 만들 수 있는 편집기가 열린다. extension.ts에서 자신이 확장 프로그램에 넣길 원하는 기능을 코드로 짜서 개발하면 된다. 이후 F5를 눌러 개발 환경을 띄우면 적용이 잘 되는지 볼 수 있다.

 

 

- 원하는 기능 추가

탭의 경우 보라색, 스페이스는 파란색으로 배경이 구분되도록 설정하였다. 사실 점과 화살표도 보기 좋게 키우고 싶었는데, 이건 VS code  에서 지원하지 않는다고 하더라... before나 after같은 가상 선택자로도 넣어봤지만 결론적으론 색만 구분하기로 했다.

이후 선택한 줄 전체를 노란색으로 하이라이트 추가했다. 마지막으로 줄 맨 뒤의 공백은 무조건 빨간색이 된다.

 

만들다보니 필요한 기능 같아서 명령어로 '맨 뒤 공백들 전체 삭제', '하이라이트 기능 껐다 키기' 를 추가하였다.

import * as vscode from 'vscode';

export function activate(context: vscode.ExtensionContext) {
  const tabDecoration = vscode.window.createTextEditorDecorationType({
    backgroundColor: 'rgba(103, 58, 183, 0.8)', // 보라색
    borderRadius: '1px'
  });

  const spaceDecoration = vscode.window.createTextEditorDecorationType({
    backgroundColor: 'rgba(144, 202, 249, 0.4)', // 파란색
    borderRadius: '1px'
  });

  const trailingWhitespaceDecoration = vscode.window.createTextEditorDecorationType({
    backgroundColor: 'rgba(255, 138, 128, 0.4)', // 빨간색
    borderRadius: '1px'
  });

  const highlightDecoration = vscode.window.createTextEditorDecorationType({
    isWholeLine: true,
    backgroundColor: 'rgba(255, 215, 0, 0.3)', // 노란색
  });

  let whitespaceHighlightEnabled = true;
  let selectionChangeDisposable: vscode.Disposable | undefined;

  const updateHighlight = (editor: vscode.TextEditor | undefined) => {
    if (!editor || !whitespaceHighlightEnabled) {return;}

    const selections = editor.selections;

    const ranges = selections.map(selection => {
      const start = selection.start.line;
      const end = selection.end.line;
      const rangeList: vscode.Range[] = [];

      for (let i = start; i <= end; i++) {
        rangeList.push(new vscode.Range(i, 0, i, 0));
      }

      return rangeList;
    }).flat();

    editor.setDecorations(highlightDecoration, ranges);
  };

  const updateWhitespaceHighlights = (editor: vscode.TextEditor | undefined) => {
    if (!editor || !whitespaceHighlightEnabled) {return;}

    const doc = editor.document;
    const tabRanges: vscode.DecorationOptions[] = [];
    const spaceRanges: vscode.DecorationOptions[] = [];
    const trailingRanges: vscode.DecorationOptions[] = [];

    for (let lineNum = 0; lineNum < doc.lineCount; lineNum++) {
      const line = doc.lineAt(lineNum);
      const text = line.text;

      for (let i = 0; i < text.length; i++) {
        const char = text[i];

        if (char === '\t') {
          tabRanges.push({ range: new vscode.Range(lineNum, i, lineNum, i + 1) });
        }

        if (char === ' ') {
          spaceRanges.push({ range: new vscode.Range(lineNum, i, lineNum, i + 1) });
        }
      }

      const match = text.match(/[\t ]+$/);
      if (match) {
        const start = new vscode.Position(lineNum, text.length - match[0].length);
        const end = new vscode.Position(lineNum, text.length);
        trailingRanges.push({ range: new vscode.Range(start, end) });
      }
    }

    editor.setDecorations(tabDecoration, tabRanges);
    editor.setDecorations(spaceDecoration, spaceRanges);
    editor.setDecorations(trailingWhitespaceDecoration, trailingRanges);
  };

  const editor = vscode.window.activeTextEditor;
  if (editor) {
    updateHighlight(editor);
    updateWhitespaceHighlights(editor);
  }

  // 초기 리스너 등록 (줄 선택 하이라이트)
  selectionChangeDisposable = vscode.window.onDidChangeTextEditorSelection(e => {
    updateHighlight(e.textEditor);
  });
  context.subscriptions.push(selectionChangeDisposable);

  context.subscriptions.push(
    vscode.workspace.onDidChangeTextDocument(e => {
      if (vscode.window.activeTextEditor?.document === e.document) {
        updateWhitespaceHighlights(vscode.window.activeTextEditor);
      }
    }),
    vscode.window.onDidChangeActiveTextEditor(editor => {
      updateHighlight(editor);
      updateWhitespaceHighlights(editor);
    }),
    highlightDecoration,
    tabDecoration,
    spaceDecoration,
    trailingWhitespaceDecoration
  );

  // 줄 끝 공백 제거 커맨드
  const trimCommand = vscode.commands.registerCommand(
    'extension.trimTrailingWhitespace',
    () => {
      const editor = vscode.window.activeTextEditor;
      if (!editor) {return;}

      const doc = editor.document;
      editor.edit(editBuilder => {
        for (let lineNum = 0; lineNum < doc.lineCount; lineNum++) {
          const line = doc.lineAt(lineNum);
          const trimmed = line.text.replace(/[\t ]+$/, '');
          if (trimmed.length < line.text.length) {
            const range = new vscode.Range(
              new vscode.Position(lineNum, 0),
              new vscode.Position(lineNum, line.text.length)
            );
            editBuilder.replace(range, trimmed);
          }
        }
      });
    }
  );

  // 토글 커맨드
  const toggleCommand = vscode.commands.registerCommand(
    'extension.toggleWhitespaceHighlight',
    () => {
      whitespaceHighlightEnabled = !whitespaceHighlightEnabled;
      const editor = vscode.window.activeTextEditor;
      if (!editor) {return;}

      if (!whitespaceHighlightEnabled) {
        editor.setDecorations(tabDecoration, []);
        editor.setDecorations(spaceDecoration, []);
        editor.setDecorations(trailingWhitespaceDecoration, []);
        editor.setDecorations(highlightDecoration, []);
        selectionChangeDisposable?.dispose(); // 리스너 제거
        selectionChangeDisposable = undefined;
      } else {
        updateWhitespaceHighlights(editor);
        updateHighlight(editor);
        selectionChangeDisposable = vscode.window.onDidChangeTextEditorSelection(e => {
          updateHighlight(e.textEditor);
        });
        context.subscriptions.push(selectionChangeDisposable);
      }
    }
  );

  context.subscriptions.push(trimCommand, toggleCommand);
}

export function deactivate() {}

주의할 점...npm tsc로 직접 타입 스크립트 파일 변환 해준 뒤 적용이 되긴 했다.(이거 때문에 삽질 좀 함) 

 

원하는 기능 관련 명령어는 package.json에 command로 등록한다. publisher(배포자)도 등록하면 좋다.

배포자와 LICENCE.txt를 등록하지 않았더니 파일로 내보낼 때 에러가 떴다.

  "contributes": {
	"commands": [
    {
      "command": "extension.trimTrailingWhitespace",
      "title": "Trim Trailing Whitespace"
    },
    {
      "command": "extension.toggleWhitespaceHighlight",
      "title": "Toggle Whitespace Highlight"
    }
  ]}

기능이 다 완성되면 f5를 눌러 실제로 잘 되는지 테스트를 해본다. 

 

완성된 확장을 Extension 등록하려면 개발자 계정 인증도 해야 되고 귀찮아진다.

이번에는 빠르게 파일로 팀원들에게만 배포하기로 했다. 

npm install -g vsce
vsce package

터미널에 해당 명령어로 vsix 파일을 생성한다.

이 파일을 다른 사람에게 공유하고 그쪽에서 VS code 확장 프로그램으로 받아서 설치해주면 적용 완료. 

여기서 vsix 파일을 선택해서 적용 가능하다.

 

만약 확장에 등록을 했는데 기능 수정한 버전으로 재배포해야 한다면,  vsix로 만들기 전에 package.json에서 version을 높인 뒤 파일로 만든다. 버전이 바뀌지 않으면 VS code는 동일 프로그램으로 인지하여 수정본이 덮어 씌워지지 않는다.

굳이 삭제하지 않아도 버전만 높아지면 알아서 기존 버전을 삭제하고 재설치 해준다. 

 

 

🎶결과물

1) 두 번째 줄을 클릭해서 전체 줄이 노란 색으로 색칠되었다. 현재 보고 있는 줄을 바로 알 수 있다.

2) 띄어쓰기(점)은 파란색, 탭(화살표)은 보라색으로 섞어 써도 구분할 수 있게 되었다.

3) 세 번째 줄 0 뒤에 잘못 들어간 무의미한 공백은 붉은 색 에러를 띄워주고 있다. Ctrl+Shift+P 에서 명령어를 입력하면 줄 뒤에 들어간 공백을 한 번에 모두 정리할 수 있다.

4) 전체 하이라이트 끄기 명령어로 사용자가 필요할 때만 에디터 기능을 적용한다. 일반적인 코드를 다룰 때는 확장 기능을 적용하지 않은 것처럼 작업할 수 있다. 매번 프로그램을 지우지 않아도 괜찮아진 것이다. 

 

어쨌든 필요한 기능은 만들어뒀으니 다음에는 눈이 덜 아프겠지... 작업 효율이 올라갈 것을 기대하며 마친다.

 

 

https://github.com/Raros17/whitespace-highlighter

 

 

Comments