Action 등록 및 diff 추출하기

guswls·2024년 8월 6일

플러그인 개발

목록 보기
3/7


들어가기 앞서

  • 오늘 구현할 내용은 위 사진 한장으로 요약된다.

  • 이전 게시글에선 우리가 어떻게 플러그인을 개발해야되는지 간략하게 알아보았다.

  • 그것을 토대로 이번엔 실제 코드레벨로 플러그인의 기능에 대해서 구현할 예정이다.

  • 이번에 구현할 기능은 "커밋 탭에서 커밋할 파일의 변경내역 가져오기"이다.

  • 문제 상황과 이에 대한 해결방법에 대한 설명으로 기술할 예정이며, 이 과정에서 공식문서와 여러 오픈소스를 활용하였다.

  • 이번 게시글에선 SDK측면에서의 기능 구현에 초점을 맞추어 설명하겠다.




1. 어떻게 원하는 위치에 Action을 등록하지?


문제 상황

  • 이전에 공식문서를 살펴보면서 Action을 등록하는 것이 개발의 첫 시작이라는 것을 알 수 있었다.

  • Action을 등록하는 것 자체는 공식문서Code sample을 보면 크게 어렵지 않았다.

  • 하지만, 문제는 "어떻게 내가 원하는 위치(첫번째 사진 참고)에 Action을 삽입할 수 있는가?"였다.



해결 방법: plugin.xml의 <add-to-group> 태그 정의

<idea-plugin>
	<!--생략-->
    <actions>
        <action id="com.github.guswlsdl0121.messagemaker.actions.GenerateCommitMessageAction"
                class="com.github.guswlsdl0121.messagemaker.actions.GenerateCommitMessageAction"
                text="Generate Commit Message"
                description="Generate a commit message based on selected changes"
                icon="/icons/commitIcon.svg">
            <!--해당 그룹의 첫번째에 이 Action을 삽입한다.-->
            <add-to-group group-id="Vcs.MessageActionGroup" anchor="first"/>
        </action>
    </actions>
</idea-plugin>
  • <add-to-group>속성은 위 그림과 같이 plugin.xml<action>태그에 들어가는 속성 중 하나이다.

  • 이 태그를 통해 action을 기존의 action group에 추가할 수 있다.

  • action group이란 말 그대로 여러 action들을 하나의 그룹으로 묶은 것을 의미한다.

  • 만약 구현 과정에 여러 개의 action이 필요하거나, 특정 action들이 같은 기능 내에서 동작한다면 이들은 하나의 그룹으로 묶일 수 있다.

  • 위 코드의 <add-to-group>은 "GenerateCommitMessageActionVcs.MessageActionGroup이라는 action group에 추가한다"는 의미이다.

  • action이 그룹에 추가되면 그 그룹이 속한 UI 컴포넌트에 아이콘과 그에 대한 Action을 삽입할 수 있다.


Vcs.MessageActionGroup은 뭐지?

<!--VcsActions.xml 파일 내부-->
<idea-plugin>
	  <!--생략-->
    <group id="ChangesView.CommitToolbar">
      <action id="Vcs.ToggleAmendCommitMode" class="com.intellij.vcs.commit.ToggleAmendCommitModeAction"/>
      <action id="ChangesView.ShowCommitOptions" class="com.intellij.openapi.vcs.actions.ShowCommitOptionsAction"/>
      <reference id="Vcs.MessageActionGroup"/>
    </group>
  
  	<!--생략-->
    <actions>
      <action id="Vcs.ReformatCommitMessage" class="com.intellij.vcs.commit.message.ReformatCommitMessageAction" use-shortcut-of="ReformatCode"/>
      <group id="Vcs.MessageActionGroup">
        <action id="Vcs.ShowMessageHistory"
                class="com.intellij.openapi.vcs.actions.ShowMessageHistoryAction"
                icon="AllIcons.Vcs.History"/>
      </group>
	</actions>
  	<!--생략-->
<idea-plugin>
  • InteliiJ에서 Vcs.MessageActionGroup을 crtl+click하면 VcsActions.xml로 이동한다.

  • 이 xml의 이름을 봤을 때, IntelliJ에서 Vcs(커밋 탭)와 관련된 Action들을 모아둔 xml로 보인다.

  • ChangeView.CommitToolbar라는 그룹을 살펴보면 커밋과 관련된 Action들이 등록되어있다. 그리고, 우리가 찾는 Vcs.MessageActionGroup을 포함(참조)하고 있음을 알 수 있다.

  • 즉, Vcs.MessageActionGroupChangeView.CommitToolbar에 속한 액션 그룹이라는 것이다.

  • 종합하자면, Vcs.MessageActionGroup커밋 관련 UI 요소들이 모여있는 그룹에 속해있음을 의미한다.


Vcs.MessageActionGroup에 들어있는 Action은 뭐가 있지?

  • Vcs.MessageActionGroup내부엔 Vcs.ShowMessageHistory라는 action이 들어있는데, 이 action은 아래 사진의 시계모양 아이콘에 연결되어있다.

  • 이 시계 모양 아이콘을 클릭하면 두번째 사진과 같이 커밋메시지 기록을 확인할 수 있다.

  • 결론적으로, Vcs.MessageActionGroup에 액션을 추가하는 것은 Vcs.ShowMessageHistory와 같이 커밋 관련 UI, 특히 커밋 메시지 작성 영역 근처에 새로운 기능을 추가하려는 의도를 나타낸다고 볼 수 있다.

  • 이를 통해 개발자는 자신의 플러그인 기능을 IntelliJ의 VCS관련 UI에 자연스럽게 통합할 수 있다.




2. AnAction vs DumbAwareAction


문제 상황

  • plugin.xml에 아이콘과 action을 등록하고나면, 이젠 action의 구현체를 작성해야 한다.

  • Creating a Custom Action이라는 챕터를 살펴보면, AnAction이라는 추상 클래스를 상속받음으로써 구현한다.

  • 하지만, 이전에 위에서 봤던 ShowMessageHistoryAction라는 커밋 메시지 기록을 확인하는 액션을 살펴보면 아래와 같이 구현되어있다.

    package com.intellij.openapi.vcs.actions
    
    class ShowMessageHistoryAction : DumbAwareAction() {
      init {
        isEnabledInModalContext = true
      }
    
      override fun update(e: AnActionEvent) {
    		//로직 수행...
      }
    
      override fun getActionUpdateThread(): ActionUpdateThread {
        return ActionUpdateThread.BGT
      }
    
      override fun actionPerformed(e: AnActionEvent) {
    		//로직 수행
      }
    }
    
  • 보면 AnAction이 아닌 DumbwareAction을 상속받아서 구현하고 있다.

  • 해당 액션은 커밋 기록을 조회한 후 모달창에 커밋메시지 목록을 보여주는 기능을 수행한다.

  • 우리가 구현할 액션도 "커밋"이라는 행위와 밀접하기 때문에 어느정도 유사성이 있다고 볼 수 있다.

  • 이러한 흐름에서 과연 보편적인 AnAction을 써야할지 아니면 ShowMessageHistoryAction를 사용하는 DubmAwareAction을 써야할 지 의문이 들었다.



해결 방법: DumbAwareAction 사용

  • 결론부터 얘기하면, 인덱싱 동작 중에도 수행할 수 있는 동작DumbAwareAction을 상속받아 구현할 수 있다.

  • DumbwareAction을 상속받은 Action은 인덱싱 과정에서도 동작을 수행할 수 있다.

  • 이 부분은 IntelliJ Platform이 제공하는 "인덱싱"에 대해 간략히 알아야 한다.


인텔리제이에서의 인덱싱

  • IntelliJ에서의 인덱싱프로젝트의 코드를 분석하고 구조화된 정보를 생성하는 과정이다.

  • 인덱싱작업을 통해 우리는 IntelliJ에서 Go to Declaration, Find Usages와 같은 빠른 코드 탐색 및 여러 코드베이스에서 일어나는 작업을 빠르게 수행할 수 있다.

  • IntelliJ를 기준으로 인덱싱 작업은 프로젝트를 처음 생성할 때, 그리고 Invalidate and Restart를 할 때 일어나며 CPU와 메모리를 많이 사용하는 작업이다.

  • 내 노트북 기준 맨 처음 생성한 Springboot 프로젝트의 인덱싱 시간은 2~3분정도 걸리는것 같다.

  • 그렇다면, 우리가 만들 플러그인은 과연 이런 무거운 인덱싱 작업이 꼭 선행되어야 할지 따져봐야 한다.


우리의 플러그인은 인덱싱이 필요할까?

  • 현재 만드려고 하는 플러그인은 "커밋"이라는 행위에 의존적이다.

  • 우리가 필요한 Staging 된 파일의 변경 내역은 전적으로 VCS에 의해서 관리된다.

  • 따라서, 우리 플러그인의 경우엔 플랫폼에서 일어나는 인덱싱 작업와 독립적으로 수행할 수 있다.

  • 실제로도 커밋 관련 Action을 살펴보면 ShowMessageHistoryAction과 같이 DumbwareAction을 상속받아 개발하고 있다.

  • 자세한 내용은 공식문서의 Action Implementation항목과 Dumb Mode에서 확인할 수 있다.




3. 어떻게 변경내역을 가져오지?


문제 상황

  • 지금까지 Action을 원하는 위치에 등록하는 방법, 그리고 어떤 클래스를 상속받아서 구현해야하는지 알아냈다.

  • 이제부턴, 위 그림의 3번에 해당하는 "변경 내용 추출"에 대해서 구현을 이어나갈 차례이다.

  • 하지만 "Changes에서 체크된 파일의 변경 내역을 가져오는 행위"가 UI적인 요소로 접근해야 할지 VCS의 요소로 접근해야할 지 감이 안잡혔다.

  • 과연 어떤 방법으로 변경내역을 플러그인을 가져올 수 있을까?



해결 방법: Event로부터 커밋 워크플로우 가져오기

  • "체크한 파일의 변경 내역을 가져오는 행위"는 UI적인 측면과 VCS의 측면을 모두 고려해야한다.

  • 프로젝트를 처음 생성하고 깃을 연결하면 "Add File to Git"이라는 알림창을 본 적이 있을 것이다.

  • 보통 우리는 그 알림창에서 "Add"를 선택하고 넘어간다. 이 선택을 통해 IntelliJ는 파일의 상태 변경을 추적해서 자동으로 Git에 Add를 해준다.

  • 결국 "Changes"에 들어간 파일들은 Staging된 파일, 즉 git add가 수행된 상태의 파일들인 것이다.

  • 하지만, "Changes"에서 항목을 선택한다고 해서 어떤 일이 일어나는 것은 아니다. 선택을 한 후 메시지를 적고 Commit버튼까지 눌러야 비로소 Add에서 Commit이란 상태로 넘어간다.

  • 따라서, Changes에 올라간 요소는 VCS에 의해 컨트롤되지만, 그 중에 체크된 요소만을 가져오는 것은 UI의 영역인 것이다.

  • 이젠 코드 영역으로 넘어가서 이 두 영역에서 어떻게 가져올 수 있는지 알아보자.


AnActionEvent : 컨텍스트 정보가 담긴 객체

  • 우리가 AnAction이나 DumbAwareAction을 상속받으면 몇몇 메서드들을 오버라이드 해야한다.

  • 그 중 actionPerformed(e: AnActionEvent)에서 액션에 대한 실질적인 작업을 구현할 수 있으며, AnActionEvent에선 액션이 발생한 시점의 컨텍스트 정보를 담고 있다.

  • 여기서 말하는 컨텍스트Action의 텍스트나 아이콘과 같은 UI적인 요소부터 액션이 발생한 환경현재 실행중인 프로젝트 정보까지 "Action이 발생한 시점에 대한 대부분의 정보"를 의미한다.

  • 위처럼 한눈에 봐도 가져올 수 있는 정보가 많이 보인다.

  • 우리가 주목해야 할 메서드는 getData()메서드이다.


getData(DataKey) : key를 통해 특정한 데이터 가져오기

  • 우리는 AnActionEvent 객체로부터 getData(Datakey)라는 메서드를 호출함으로써 발생한 이벤트에 대한 특정 정보를 얻어올 수 있다.

  • DataKey는 필드로 String타입의 name만을 갖고 있다.

  • DataContext에서는 Key(String)에 해당하는 데이터를 찾은 후, T에 지정된 타입으로 캐스팅하여 반환한다.

  • 이러한 동작을 통해 우리는 DataKey만 알아도 컨텍스트에서 특정한 데이터를 얻어올 수 있다.

    • 요약하자면, IntelliJ 플랫폼은 개발자에게 DataKey라는 type-safed 식별자를 제공하고.
      DataKey를 사용하여 개발자는 DataContext에서 데이터를 런타임에 안전하고 유연하게 가져올 수 있다.

    • 즉, "Action"이라는 동적인 환경에서 원하는 데이터를 안전하게 가져올 수 있게 DataKeyDataContext가 제공되는 것이다.

그렇다면 이 DataKey로 "체크한 변경내역"을 한번에 가져올 순 없을까?!

  • 놀랍게도, 나와 비슷한 생각을 한 분이 계셨고 들려온 대답은 "직접적으로 연결할 수 있는 키는 없다."였다.

  • 대략 안되는 이유로는 모달, Git Staing, Non-Modal(이건 뭐지?)과 같은 3개의 서로 다른 UI가 복잡하게 되어있기 때문이라고 설명하였다.

  • 감사히도 다른 방법들을 상세히 적어주셨고, "This should work for all modes"라는 1번 방법을 써보기로 했다.


CommitWorkFlow.ui.getIncludedChanges()

  • 첫번째 방법은 VcsDataKeys.COMMIT_WORKFLOW_HANDLER를 사용하는 것이었다.

  • 정확히 말하면, 위 DataKey를 통해 위의 AbstractCommitWorkFlowHandler를 가져오는 것이다.

  • 이 구현체는 멀티 타입 파라미터로 구성되어있으며, AbstractCommitWorkFlowCommitWorkflowUi를 타입으로 받고 있다.

  • 즉, AbstractCommitWorkflowHandlerUI적인 요소Git의 커밋 워크플로우라는 두개의 개별적 요소를 하나의 클래스로 다루기 위해 묶어놓은 핸들러인 것이다.

  • 당장에 이 외의 추가적인 분석이 필요해보이진 않는다. 시간을 지체하지 말고 실제 코드로 작성해보고, 동작을 관찰해보자.




실제 코드

  • 위에서 필요한 거의 대부분의 설명을 다뤘기 때문에 여기선 자세한 설명을 생략하고, 주석으로 설명을 대체한다.

Plugin.xml

<idea-plugin>
    <id>com.github.guswlsdl0121.messagemaker</id>
    <name>MessageMaker</name>
    <vendor email="example@gmail.com" url="https://github.com/example">name</vendor>
    <version>1.0.0</version>

    <description><![CDATA[
    <p>This plugin helps to generate commit messages based on selected changes.</p>
    ]]></description>

    <change-notes><![CDATA[
    <p>Initial release</p>
    ]]></change-notes>

    <idea-version since-build="222.3345.118"/>

    <depends>com.intellij.modules.platform</depends>
    <depends>com.intellij.modules.vcs</depends>

    <resource-bundle>messages.MessageBundle</resource-bundle>

    <actions>
      	<!--Action 구현체를 맵핑한다.-->
        <action id="com.github.guswlsdl0121.messagemaker.actions.CommitMessageGenerateAction"
                class="com.github.guswlsdl0121.messagemaker.actions.CommitMessageGenerateAction"
                text="Generate Commit Message"
                description="Generate a commit message based on selected changes"
                icon="/icons/commitIcon.svg">
            <!--해당 그룹의 첫번째에 이 Action을 삽입한다.-->
            <add-to-group group-id="Vcs.MessageActionGroup" anchor="first"/>
        </action>
    </actions>
</idea-plugin>

CommitMessageGenerateAction 구현

import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.vcs.VcsDataKeys
import com.intellij.openapi.vcs.changes.Change
import com.intellij.vcs.commit.AbstractCommitWorkflowHandler

//indexing이 필요없는 작업, DumbAwareAction을 상속받아 구현
class CommitMessageGenerateAction : DumbAwareAction() {
    private val logger = Logger.getInstance(TestGenerateAction::class.java)

	//액션 재정의
    override fun actionPerformed(e: AnActionEvent) {
        e.project!!

		//핵심!! 특정한 데이터키를 통해 DataContext에서 원하는 데이터를 가져올 수 있다.
        //여기선 데이터가 아닌 CommitWorkFlow 자체를 가져온 후, 핸들러를 통해 "체크된 Changes"를 가져온다.
        val commitWorkflowHandler = e.getData(VcsDataKeys.COMMIT_WORKFLOW_HANDLER) as AbstractCommitWorkflowHandler<*, *>
        val includedChanges: List<Change> = commitWorkflowHandler.ui.getIncludedChanges()
        if (includedChanges.isEmpty()) {
            logger.info("No changes selected")
            return
        }

		//List<Chages>순회하며 간단하게 값 출력
        val diff = buildString {
            includedChanges.forEach { change ->
                val beforePath = change.beforeRevision?.file?.path ?: "unknown"
                val afterPath = change.afterRevision?.file?.path ?: "unknown"
                logger.info("Change: $beforePath -> $afterPath")
                append("Change: $beforePath -> $afterPath\n")
            }
        }

		//로그 출력
        logger.info("Generated commit message from diff:\n $diff")
    }
}

결과 확인

  • 체크된 항목에 대해서 경로가 잘 나오는 것을 확인할 수 있다.



마치며

  • 구현을 할 때는 이 단계까진 하루 좀 덜걸려서 빠르게 구현했던 부분이었는데 막상 원리를 적다보니 너무 깊게 들어간 감이 없지 않아있다.

  • 비록 관련 레퍼런스가 공식문서와 오픈소스밖에 없지만, 사실상 오픈소스 코드에 필요한 대부분의 정보가 나와있어 어렵진 않았다.
    (게다가 마켓에 등록할 때 1차적으로 걸러진다고 하니, 어쩌면 블로그에 돌아다니는 코드보다 오픈소스가 공신력 있을수도,,)

  • 사실 오늘과 같은 단순한 기능 구현은 후딱 넘어가고 테스트하기 쉬운 코드, 구조적인 코드를 위한 리팩토링에 대해서 초점을 맞추고 싶었는데 글이 많이 길어졌다. (다음번엔 다룰 수 있을까?)

  • 확실히 만들면서 배우는 것이 빨리 느는 느낌이었다.

  • 게다가 최근에 학습한 "객체지향의 사실과 오해"에서 다뤘던 "타입"의 중요성이 이 SDK에서 정말 잘 드러나서 관련된 인사이트를 얻을 수 있는 좋은 시간이었다. (DataKey와 DataContext 쪽)

  • 레퍼런스가 많은 분야(Springboot같은)만 개발을 하다가 약간 마이너한 분야를 개발하다보니까 오히려 공신력있는 자료들로만 학습할 수밖에 없어서 굉장히 밀도 높은 학습을 할 수 있었다.

  • 오늘 일을 통해 얻은 인사이트와 학습 방법은 앞으로도 굉장히 유용할 것 같다.

profile
안녕하세요

0개의 댓글