

오늘 구현할 내용은 위 사진 한장으로 요약된다.
이전 게시글에선 우리가 어떻게 플러그인을 개발해야되는지 간략하게 알아보았다.
그것을 토대로 이번엔 실제 코드레벨로 플러그인의 기능에 대해서 구현할 예정이다.
이번에 구현할 기능은 "커밋 탭에서 커밋할 파일의 변경내역 가져오기"이다.
문제 상황과 이에 대한 해결방법에 대한 설명으로 기술할 예정이며, 이 과정에서 공식문서와 여러 오픈소스를 활용하였다.
이번 게시글에선 SDK측면에서의 기능 구현에 초점을 맞추어 설명하겠다.
이전에 공식문서를 살펴보면서 Action을 등록하는 것이 개발의 첫 시작이라는 것을 알 수 있었다.
Action을 등록하는 것 자체는 공식문서와 Code sample을 보면 크게 어렵지 않았다.
하지만, 문제는 "어떻게 내가 원하는 위치(첫번째 사진 참고)에 Action을 삽입할 수 있는가?"였다.
<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>은 "GenerateCommitMessageAction을 Vcs.MessageActionGroup이라는 action group에 추가한다"는 의미이다.
action이 그룹에 추가되면 그 그룹이 속한 UI 컴포넌트에 아이콘과 그에 대한 Action을 삽입할 수 있다.
<!--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.MessageActionGroup은 ChangeView.CommitToolbar에 속한 액션 그룹이라는 것이다.
종합하자면, Vcs.MessageActionGroup은 커밋 관련 UI 요소들이 모여있는 그룹에 속해있음을 의미한다.


Vcs.MessageActionGroup내부엔 Vcs.ShowMessageHistory라는 action이 들어있는데, 이 action은 아래 사진의 시계모양 아이콘에 연결되어있다.
이 시계 모양 아이콘을 클릭하면 두번째 사진과 같이 커밋메시지 기록을 확인할 수 있다.
결론적으로, Vcs.MessageActionGroup에 액션을 추가하는 것은 Vcs.ShowMessageHistory와 같이 커밋 관련 UI, 특히 커밋 메시지 작성 영역 근처에 새로운 기능을 추가하려는 의도를 나타낸다고 볼 수 있다.
이를 통해 개발자는 자신의 플러그인 기능을 IntelliJ의 VCS관련 UI에 자연스럽게 통합할 수 있다.
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을 상속받아 구현할 수 있다.
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에서 확인할 수 있다.

지금까지 Action을 원하는 위치에 등록하는 방법, 그리고 어떤 클래스를 상속받아서 구현해야하는지 알아냈다.
이제부턴, 위 그림의 3번에 해당하는 "변경 내용 추출"에 대해서 구현을 이어나갈 차례이다.
하지만 "Changes에서 체크된 파일의 변경 내역을 가져오는 행위"가 UI적인 요소로 접근해야 할지 VCS의 요소로 접근해야할 지 감이 안잡혔다.
과연 어떤 방법으로 변경내역을 플러그인을 가져올 수 있을까?
"체크한 파일의 변경 내역을 가져오는 행위"는 UI적인 측면과 VCS의 측면을 모두 고려해야한다.
프로젝트를 처음 생성하고 깃을 연결하면 "Add File to Git"이라는 알림창을 본 적이 있을 것이다.
보통 우리는 그 알림창에서 "Add"를 선택하고 넘어간다. 이 선택을 통해 IntelliJ는 파일의 상태 변경을 추적해서 자동으로 Git에 Add를 해준다.

결국 "Changes"에 들어간 파일들은 Staging된 파일, 즉 git add가 수행된 상태의 파일들인 것이다.
하지만, "Changes"에서 항목을 선택한다고 해서 어떤 일이 일어나는 것은 아니다. 선택을 한 후 메시지를 적고 Commit버튼까지 눌러야 비로소 Add에서 Commit이란 상태로 넘어간다.
따라서, Changes에 올라간 요소는 VCS에 의해 컨트롤되지만, 그 중에 체크된 요소만을 가져오는 것은 UI의 영역인 것이다.
이젠 코드 영역으로 넘어가서 이 두 영역에서 어떻게 가져올 수 있는지 알아보자.

우리가 AnAction이나 DumbAwareAction을 상속받으면 몇몇 메서드들을 오버라이드 해야한다.
그 중 actionPerformed(e: AnActionEvent)에서 액션에 대한 실질적인 작업을 구현할 수 있으며, AnActionEvent에선 액션이 발생한 시점의 컨텍스트 정보를 담고 있다.
여기서 말하는 컨텍스트란 Action의 텍스트나 아이콘과 같은 UI적인 요소부터 액션이 발생한 환경과 현재 실행중인 프로젝트 정보까지 "Action이 발생한 시점에 대한 대부분의 정보"를 의미한다.
위처럼 한눈에 봐도 가져올 수 있는 정보가 많이 보인다.
우리가 주목해야 할 메서드는 getData()메서드이다.



우리는 AnActionEvent 객체로부터 getData(Datakey)라는 메서드를 호출함으로써 발생한 이벤트에 대한 특정 정보를 얻어올 수 있다.
DataKey는 필드로 String타입의 name만을 갖고 있다.
DataContext에서는 Key(String)에 해당하는 데이터를 찾은 후, T에 지정된 타입으로 캐스팅하여 반환한다.
이러한 동작을 통해 우리는 DataKey만 알아도 컨텍스트에서 특정한 데이터를 얻어올 수 있다.
- 요약하자면, IntelliJ 플랫폼은 개발자에게
DataKey라는 type-safed 식별자를 제공하고.
이DataKey를 사용하여 개발자는 DataContext에서 데이터를 런타임에 안전하고 유연하게 가져올 수 있다.
- 즉, "Action"이라는 동적인 환경에서 원하는 데이터를 안전하게 가져올 수 있게
DataKey와DataContext가 제공되는 것이다.

놀랍게도, 나와 비슷한 생각을 한 분이 계셨고 들려온 대답은 "직접적으로 연결할 수 있는 키는 없다."였다.
대략 안되는 이유로는 모달, Git Staing, Non-Modal(이건 뭐지?)과 같은 3개의 서로 다른 UI가 복잡하게 되어있기 때문이라고 설명하였다.
감사히도 다른 방법들을 상세히 적어주셨고, "This should work for all modes"라는 1번 방법을 써보기로 했다.

첫번째 방법은 VcsDataKeys.COMMIT_WORKFLOW_HANDLER를 사용하는 것이었다.
정확히 말하면, 위 DataKey를 통해 위의 AbstractCommitWorkFlowHandler를 가져오는 것이다.
이 구현체는 멀티 타입 파라미터로 구성되어있으며, AbstractCommitWorkFlow와 CommitWorkflowUi를 타입으로 받고 있다.
즉, AbstractCommitWorkflowHandler는 UI적인 요소와 Git의 커밋 워크플로우라는 두개의 개별적 요소를 하나의 클래스로 다루기 위해 묶어놓은 핸들러인 것이다.
당장에 이 외의 추가적인 분석이 필요해보이진 않는다. 시간을 지체하지 말고 실제 코드로 작성해보고, 동작을 관찰해보자.
<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>
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같은)만 개발을 하다가 약간 마이너한 분야를 개발하다보니까 오히려 공신력있는 자료들로만 학습할 수밖에 없어서 굉장히 밀도 높은 학습을 할 수 있었다.
오늘 일을 통해 얻은 인사이트와 학습 방법은 앞으로도 굉장히 유용할 것 같다.