diff --git a/.gitignore b/.gitignore
index df6966a..6c11680 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@
.kotlin
.qodana
build
+.idea/
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
deleted file mode 100644
index 05eca02..0000000
--- a/.idea/gradle.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b856e8a..a26dbb6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,5 +3,7 @@
# dev-log Changelog
## [Unreleased]
+
### Added
-- Initial scaffold created from [IntelliJ Platform Plugin Template](https://github.com/JetBrains/intellij-platform-plugin-template)
+
+- 아직 변경 사항이 없습니다.
diff --git a/README.md b/README.md
index 6b747d0..7fb7035 100644
--- a/README.md
+++ b/README.md
@@ -1,95 +1,50 @@
# dev-log
-## 핵심 기능 정의
-
----
-### ☑️ 메모 저장 기능
-- 메모를 저장할 수 있어야 한다.
-- 메모의 저장 데이터는 다음과 같다.
- - `int id (not null)`: 메모의 고유한 아이디(PK)
- - `String content (nullable)`: 사용자가 입력하는 메모의 내용
- - `LocalDateTime createdAt (not null)`: 메모가 생성된 시간
- - `LocalDateTime updatedAt (not null)`: 메모가 수정된 시간
- - `String commitHash (nullable)`: 현재 커밋의 해시값
- - `String filePath (nullable)`: 파일 경로
- - `String selectedCodeSnippet (nullable)`: 선택된 코드
- - `long selectionEnd (nullable)`: 문서에서 선택한 정확한 종료 위치
- - `long selectionStart (nullable)`: 문서에서 선택한 정확한 시작 위치
- - `int visibleEnd (nullable)`: 선택한 종료 줄
- - `int visibleStart (nullable)`: 선택한 시작 줄
-- 저장은 시간 순서대로 저장이 되어야 한다.
-
-### ⚠️ 메모 저장 기능 예외 상황
-
-### ☑️ 메모 조회 기능
-- 메모의 id를 이용해서 데이터를 조회할 수 있어야 한다.
-- 전체 메모를 날짜순으로 조회할 수 있어야 한다.
--
-
----
-### ☑️ 메모 삭제 기능
-- 저장된 메모를 삭제할 수 있어야 한다.
-- 메모를 한 번에 여러개 삭제할 수 있어야 한다.
-
-### ⚠️ 메모 삭제 기능 예외 상황
-
----
-### ☑️ 메모 수정 기능
-- 저장된 메모의 content를 수정할 수 있어야 한다.
-
-### ⚠️ 메모 수정 기능 예외 상황
-
----
-### ☑️ 메모 추출 기능
-- 저장된 메모를 한 개 이상 선택하여 txt 파일로 추출할 수 있어야 한다.
-- 추출한 단위 메모의 구성 내용은 다음과 같다.
- - 메모 순서(시간순)
- - `LocalDateTime timestamp`: 메모가 생성된 시간
- - `String content`: 사용자가 입력하는 메모의 내용
- - `String filePath`: 파일 경로
- - `String commitHash`: 현재 커밋의 해시값
- - `int visibleStart`: 선택한 시작 줄
- - `int visibleEnd`: 선택한 종료 줄
-- txt 파일 상단에 프로젝트명, 내보낸 시각, 메모의 개수가 있어야 한다.
-- txt 파일의 이름은 `devlog-{프로젝트명}-{내보낸날짜}-{내보낸시각}.txt` 이다.
-- 추출할 메모가 없으면 빈 txt 파일을 반환한다.
-
-### ⚠️ 메모 추출 기능 예외 상황
-
----
-### ☑️ 노트 수정/저장 기능
-- 노트를 저장할 수 있어야 한다.
-- 노트의 수정/저장 데이터는 다음과 같다.
- - `String content`: 노트의 내용
- - `LocalDateTime savedAt`: 저장된 시각
-
-### ⚠️ 노트 수정/저장 기능 예외 상황
-
----
-## 화면 요구 사항
-
----
-### ☑️ 플러그인 기본 화면
-- 화면의 최상단엔 기본 조작용 버튼이 있어야 한다.
-- 기본 조작용 버튼은 아래와 같다.
- - 메모목록/노트 화면 전환 버튼
- - 메모 전체 선택/선택해제 버튼
- - 선택된 메모 추출 버튼
- - 선택된 메모 삭제 버튼
-- 메인 컨텐츠를 표시하는 화면이 있어야 한다.
-
-### ☑️ 메모 목록 출력 화면
-- 저장된 메모 전체가 최근순 정렬되어 화면에 보여야 한다.
-- 각 메모 좌측에는 메모를 선택할 수 있는 체크박스가 있어야 한다.
-- 화면 하단에는 새 메모를 적을 수 있는 텍스트 입력창이 있어야 한다.
-- 에디터 화면에 코드가 선택(드래그) 되어있으면, 코드가 선택되었음을 일목 요연하게 표시해야 한다.
-- 저장된 메모 중 선택된 코드가 있는 메모들을 없는 메모와 다르게 표시해야 한다.
-- 새로운 메모는 mac에선 커멘드+엔터, window에서는 컨트롤+엔터로 저장 가능해야 한다.
-
-### ⚠️ 메모 목록 출력 화면 예외 상황
-
----
-### ☑️ 노트 출력 화면
-- 저장된 노트의 모든 내용이 출력 되어야 한다.
-- 노트의 변경 내용은 자동 저장 되어야 한다.
-- 화면의 크기를 벗어나지 않게 내용을 출력해야 한다.
+
+
+## 🙋🏻 소개
+
+DevLog는 코드 편집 과정에서 떠오르는 생각과 맥락을 그대로 기록하는 메모·노트 관리 플러그인입니다.
+선택한 코드 스니펫, 파일 경로, Git 커밋 해시를 자동으로 캡처해 “당시에 어떤 생각으로 이 코드를 작성했는지” 정확히 복원할 수 있습니다.
+
+메모는 시간순으로 정렬되어 타임라인처럼 확인할 수 있고,
+여러 메모를 선택하여 TXT 파일로 내보내기, 일괄 삭제, 단축키 기반 빠른 저장까지 지원합니다.
+
+또한 DevLog는 단순한 메모 도구가 아니라,
+개발 중 떠오른 아이디어·이슈 정리·회고 문장을 모아두는 노트 기능을 제공합니다.
+노트는 자동 저장되며 언제든지 편집·확장할 수 있습니다.
+
+DevLog를 이용해서 개발 기록을 더욱 편리하게 해보세요!
+
+## 🧩 주요 기능
+
+✅ 메모 저장
+
+- 텍스트와 함께 코드 선택 영역, 파일 경로, 커밋 해시 자동 저장
+- 생성/수정 시각 기록
+- macOS: ⌘ + Enter / Windows: Ctrl + Enter 로 빠른 입력 가능
+- 공백 메모, 잘못된 선택 범위는 자동 차단
+
+✅ 메모 조회
+
+- 모든 메모를 최근순으로 정렬해 리스트 형태로 제공
+- 코드가 포함된 메모는 시각적으로 구분해 표시
+
+✅ 메모 삭제
+
+- 단일/다중 삭제 지원
+- 선택된 메모가 없으면 동작하지 않도록 안전 처리
+
+✅ TXT 파일 내보내기
+
+- 선택한 메모들을 한 번에 TXT로 추출
+- 프로젝트명·내보낸 시각·메모 개수 자동 포함
+- 메모 순서, 타임스탬프, 코드 선택 영역 정보까지 함께 기록
+
+✅ 노트 관리
+
+- 개발 중 떠오르는 생각을 문서처럼 관리
+- 자동 저장 지원
+- 화면 크기를 벗어나지 않도록 자연스러운 텍스트 렌더링
+
+
diff --git a/build.gradle.kts b/build.gradle.kts
index 6cbe4b2..e178b35 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -34,9 +34,21 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.opentest4j)
+ testImplementation(kotlin("test"))
+ testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.0")
+ testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.0")
+
+ testImplementation("org.mockito:mockito-core:5.12.0")
+ testImplementation("org.mockito.kotlin:mockito-kotlin:5.2.1")
+
// IntelliJ Platform Gradle Plugin Dependencies Extension - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-dependencies-extension.html
intellijPlatform {
- create(providers.gradleProperty("platformType"), providers.gradleProperty("platformVersion"))
+ create(
+ providers.gradleProperty("platformType"),
+ providers.gradleProperty("platformVersion")
+ )
+
+ bundledPlugins("Git4Idea")
// Plugin Dependencies. Uses `platformBundledPlugins` property from the gradle.properties file for bundled IntelliJ Platform plugins.
bundledPlugins(providers.gradleProperty("platformBundledPlugins").map { it.split(',') })
@@ -99,7 +111,8 @@ intellijPlatform {
// The pluginVersion is based on the SemVer (https://semver.org) and supports pre-release labels, like 2.1.7-alpha.3
// Specify pre-release label to publish the plugin in a custom Release Channel automatically. Read more:
// https://plugins.jetbrains.com/docs/intellij/deployment.html#specifying-a-release-channel
- channels = providers.gradleProperty("pluginVersion").map { listOf(it.substringAfter('-', "").substringBefore('.').ifEmpty { "default" }) }
+ channels = providers.gradleProperty("pluginVersion")
+ .map { listOf(it.substringAfter('-', "").substringBefore('.').ifEmpty { "default" }) }
}
pluginVerification {
diff --git a/com/intellij/testFramework/ServiceContainerUtil.kt b/com/intellij/testFramework/ServiceContainerUtil.kt
new file mode 100644
index 0000000..22d3261
--- /dev/null
+++ b/com/intellij/testFramework/ServiceContainerUtil.kt
@@ -0,0 +1,118 @@
+// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+@file:JvmName("ServiceContainerUtil")
+package com.intellij.testFramework
+
+import com.intellij.ide.plugins.IdeaPluginDescriptorImpl
+import com.intellij.ide.plugins.PluginManagerCore
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.application.Application
+import com.intellij.openapi.components.ComponentManager
+import com.intellij.openapi.components.ServiceDescriptor
+import com.intellij.openapi.extensions.BaseExtensionPointName
+import com.intellij.openapi.extensions.DefaultPluginDescriptor
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.util.Disposer
+import com.intellij.serviceContainer.ComponentManagerImpl
+import com.intellij.util.messages.ListenerDescriptor
+import com.intellij.util.messages.MessageBusOwner
+import org.jetbrains.annotations.TestOnly
+
+private val testDescriptor by lazy { DefaultPluginDescriptor("test") }
+
+@TestOnly
+fun ComponentManager.registerServiceInstance(serviceInterface: Class, instance: T) {
+ (this as ComponentManagerImpl).registerServiceInstance(serviceInterface, instance, testDescriptor)
+}
+
+/**
+ * Unregister service specified by [serviceInterface] if it was registered;
+ * throws [IllegalStateException] if the service was not registered.
+ */
+@TestOnly
+fun ComponentManager.unregisterService(serviceInterface: Class<*>) {
+ (this as ComponentManagerImpl).unregisterService(serviceInterface)
+}
+
+/**
+ * Register a new service or replace an existing service with a specified instance for testing purposes.
+ * Registration will be rolled back when parentDisposable is disposed. In most of the cases,
+ * [com.intellij.testFramework.UsefulTestCase.getTestRootDisposable] should be specified.
+ */
+@TestOnly
+fun ComponentManager.registerOrReplaceServiceInstance(serviceInterface: Class, instance: T, parentDisposable: Disposable) {
+ val previous = this.getService(serviceInterface)
+ if (previous != null) {
+ replaceService(serviceInterface, instance, parentDisposable)
+ }
+ else {
+ (this as ComponentManagerImpl).registerServiceInstance(serviceInterface, instance, testDescriptor)
+ if (instance is Disposable) {
+ Disposer.register(parentDisposable, instance)
+ }
+ else {
+ Disposer.register(parentDisposable) {
+ this.unregisterComponent(serviceInterface)
+ }
+ }
+ }
+}
+
+@TestOnly
+fun ComponentManager.replaceService(serviceInterface: Class, instance: T, parentDisposable: Disposable) {
+ (this as ComponentManagerImpl).replaceServiceInstance(serviceInterface, instance, parentDisposable)
+}
+
+@TestOnly
+fun ComponentManager.registerComponentInstance(componentInterface: Class, instance: T, parentDisposable: Disposable?) {
+ (this as ComponentManagerImpl).replaceComponentInstance(componentInterface, instance, parentDisposable)
+}
+
+@TestOnly
+@JvmOverloads
+fun ComponentManager.registerComponentImplementation(key: Class<*>, implementation: Class<*>, shouldBeRegistered: Boolean = false) {
+ (this as ComponentManagerImpl).registerComponentImplementation(key, implementation, shouldBeRegistered)
+}
+
+@TestOnly
+fun ComponentManager.registerExtension(name: BaseExtensionPointName<*>, instance: T, parentDisposable: Disposable) {
+ extensionArea.getExtensionPoint(name.name).registerExtension(instance, parentDisposable)
+}
+
+@TestOnly
+fun ComponentManager.getServiceImplementationClassNames(prefix: String): List {
+ val result = ArrayList()
+ processAllServiceDescriptors(this) { serviceDescriptor ->
+ val implementation = serviceDescriptor.implementation ?: return@processAllServiceDescriptors
+ if (implementation.startsWith(prefix)) {
+ result.add(implementation)
+ }
+ }
+ return result
+}
+
+fun processAllServiceDescriptors(componentManager: ComponentManager, consumer: (ServiceDescriptor) -> Unit) {
+ for (plugin in PluginManagerCore.loadedPlugins) {
+ val pluginDescriptor = plugin as IdeaPluginDescriptorImpl
+ val containerDescriptor = when (componentManager) {
+ is Application -> pluginDescriptor.appContainerDescriptor
+ is Project -> pluginDescriptor.projectContainerDescriptor
+ else -> pluginDescriptor.moduleContainerDescriptor
+ }
+ containerDescriptor.services.forEach {
+ if ((componentManager as? ComponentManagerImpl)?.isServiceSuitable(it) != false &&
+ (it.os == null || componentManager.isSuitableForOs(it.os))) {
+ consumer(it)
+ }
+ }
+ }
+}
+
+fun createSimpleMessageBusOwner(owner: String): MessageBusOwner {
+ return object : MessageBusOwner {
+ override fun createListener(descriptor: ListenerDescriptor) = throw UnsupportedOperationException()
+
+ override fun isDisposed() = false
+
+ override fun toString() = owner
+ }
+}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index f482bec..f052ec2 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,34 +1,26 @@
# IntelliJ Platform Artifacts Repositories -> https://plugins.jetbrains.com/docs/intellij/intellij-artifacts.html
-
-pluginGroup = com.github.yeoli.devlog
-pluginName = dev-log
-pluginRepositoryUrl = https://github.com/yeo-li/dev-log
+pluginGroup=com.github.yeoli.devlog
+pluginName=dev-log
+pluginRepositoryUrl=https://github.com/yeo-li/dev-log
# SemVer format -> https://semver.org
-pluginVersion = 0.0.1
-
+pluginVersion=1.0.0
# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
-pluginSinceBuild = 243
-
+pluginSinceBuild=243
# IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension
-platformType = IC
-platformVersion = 2024.3.6
-
+platformType=IC
+platformVersion=2024.3.6
# Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html
# Example: platformPlugins = com.jetbrains.php:203.4449.22, org.intellij.scala:2023.3.27@EAP
-platformPlugins =
+platformPlugins=
# Example: platformBundledPlugins = com.intellij.java
-platformBundledPlugins =
+platformBundledPlugins=
# Example: platformBundledModules = intellij.spellchecker
-platformBundledModules =
-
+platformBundledModules=
# Gradle Releases -> https://github.com/gradle/gradle/releases
-gradleVersion = 9.0.0
-
+gradleVersion=9.0.0
# Opt-out flag for bundling Kotlin standard library -> https://jb.gg/intellij-platform-kotlin-stdlib
-kotlin.stdlib.default.dependency = false
-
+kotlin.stdlib.default.dependency=false
# Enable Gradle Configuration Cache -> https://docs.gradle.org/current/userguide/configuration_cache.html
-org.gradle.configuration-cache = true
-
+org.gradle.configuration-cache=true
# Enable Gradle Build Cache -> https://docs.gradle.org/current/userguide/build_cache.html
-org.gradle.caching = true
+org.gradle.caching=true
diff --git a/src/main/kotlin/com/github/yeoli/devlog/domain/memo/domain/Memo.kt b/src/main/kotlin/com/github/yeoli/devlog/domain/memo/domain/Memo.kt
new file mode 100644
index 0000000..c2d3326
--- /dev/null
+++ b/src/main/kotlin/com/github/yeoli/devlog/domain/memo/domain/Memo.kt
@@ -0,0 +1,124 @@
+package com.github.yeoli.devlog.domain.memo.domain
+
+import com.github.yeoli.devlog.domain.memo.repository.MemoState
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+class Memo(
+ val id: Long,
+ val createdAt: LocalDateTime,
+ val updatedAt: LocalDateTime,
+
+ val content: String,
+
+ val commitHash: String? = null,
+ val filePath: String? = null,
+ val selectedCodeSnippet: String? = null,
+
+ val selectionStart: Int? = null,
+ val selectionEnd: Int? = null,
+
+ val visibleStart: Int? = null,
+ val visibleEnd: Int? = null,
+
+ val fullCodeSnapshot: String? = null
+) {
+ init {
+ validate()
+ }
+
+ constructor(
+ content: String,
+ commitHash: String?,
+ filePath: String?,
+ selectedCodeSnippet: String?,
+ selectionStart: Int?,
+ selectionEnd: Int?,
+ visibleStart: Int?,
+ visibleEnd: Int?,
+ fullCodeSnapshot: String?
+ ) : this(
+ id = System.currentTimeMillis(),
+ createdAt = LocalDateTime.now(),
+ updatedAt = LocalDateTime.now(),
+ content = content,
+ commitHash = commitHash,
+ filePath = filePath,
+ selectedCodeSnippet = selectedCodeSnippet,
+ selectionStart = selectionStart,
+ selectionEnd = selectionEnd,
+ visibleStart = visibleStart,
+ visibleEnd = visibleEnd,
+ fullCodeSnapshot = fullCodeSnapshot
+ )
+
+ private fun validate() {
+ if (selectionStart != null && selectionEnd != null) {
+ require(selectionStart <= selectionEnd) {
+ "selectionStart는 selectionEnd 보다 작아야합니다."
+ }
+ }
+
+ if (visibleStart != null && visibleEnd != null) {
+ require(visibleStart <= visibleEnd) {
+ "visibleStart는 visibleEnd 보다 작아야합니다."
+ }
+ }
+ }
+
+ fun toState(): MemoState =
+ MemoState(
+ id = this.id,
+ createdAt = this.createdAt.toString(),
+ updatedAt = this.updatedAt.toString(),
+ content = this.content,
+ commitHash = this.commitHash,
+ filePath = this.filePath,
+ selectedCodeSnippet = this.selectedCodeSnippet,
+ fullCodeSnapshot = this.fullCodeSnapshot,
+ selectionStart = this.selectionStart,
+ selectionEnd = this.selectionEnd,
+ visibleStart = this.visibleStart,
+ visibleEnd = this.visibleEnd
+ )
+
+ fun update(
+ content: String = this.content
+ ): Memo {
+ return Memo(
+ id = this.id,
+ createdAt = this.createdAt,
+ updatedAt = LocalDateTime.now(),
+ content = content,
+ commitHash = this.commitHash,
+ filePath = this.filePath,
+ selectedCodeSnippet = this.selectedCodeSnippet,
+ fullCodeSnapshot = this.fullCodeSnapshot,
+ selectionStart = this.selectionStart,
+ selectionEnd = this.selectionEnd,
+ visibleStart = this.visibleStart,
+ visibleEnd = this.visibleEnd
+ )
+ }
+
+ fun buildMemoBlock(index: Int): String {
+ return """
+
+ # Memo $index
+ 📅 생성 시간 : ${this.createdAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))}
+ 📅 수정 시간 : ${this.updatedAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))}
+
+ 📌 Content
+ ${this.content}
+
+ 🔗 Metadata
+ - Commit: ${this.commitHash ?: "N/A"}
+ - File Path: ${this.filePath ?: "N/A"}
+ - Visible Lines: ${this.visibleStart ?: "?"} ~ ${this.visibleEnd ?: "?"}
+ - Selected Code :
+ ${this.selectedCodeSnippet ?: "(no selected code)"}
+
+ ---------------------------------------
+ """.trimIndent()
+ }
+}
diff --git a/src/main/kotlin/com/github/yeoli/devlog/domain/memo/repository/MemoRepository.kt b/src/main/kotlin/com/github/yeoli/devlog/domain/memo/repository/MemoRepository.kt
new file mode 100644
index 0000000..8b49052
--- /dev/null
+++ b/src/main/kotlin/com/github/yeoli/devlog/domain/memo/repository/MemoRepository.kt
@@ -0,0 +1,46 @@
+package com.github.yeoli.devlog.domain.memo.repository
+
+import com.github.yeoli.devlog.domain.memo.domain.Memo
+import com.intellij.openapi.components.PersistentStateComponent
+import com.intellij.openapi.components.Service
+import com.intellij.openapi.components.State
+import com.intellij.openapi.components.Storage
+
+@State(
+ name = "DevLogMemoStorage",
+ storages = [Storage("devlog-memos.xml")]
+)
+@Service(Service.Level.PROJECT)
+class MemoRepository : PersistentStateComponent {
+ private var state = MemoStorageState()
+
+ override fun getState(): MemoStorageState = state
+
+ override fun loadState(state: MemoStorageState) {
+ this.state = state
+ }
+
+ fun save(memo: Memo) {
+ state.memos.add(memo.toState())
+ }
+
+ fun findMemoById(memoId: Long): Memo? {
+ val memoState = state.memos.find { it.id == memoId }
+ return memoState?.toDomain()
+ }
+
+ fun getAll(): List {
+ return state.memos.map { it.toDomain() }
+ }
+
+ fun removeMemoById(memoId: Long) {
+ state.memos.removeIf { it.id == memoId }
+ }
+
+ fun removeMemosById(memoIds: List) {
+ for (memoId in memoIds) {
+ removeMemoById(memoId)
+ }
+ }
+
+}
diff --git a/src/main/kotlin/com/github/yeoli/devlog/domain/memo/repository/MemoState.kt b/src/main/kotlin/com/github/yeoli/devlog/domain/memo/repository/MemoState.kt
new file mode 100644
index 0000000..af408c3
--- /dev/null
+++ b/src/main/kotlin/com/github/yeoli/devlog/domain/memo/repository/MemoState.kt
@@ -0,0 +1,35 @@
+package com.github.yeoli.devlog.domain.memo.repository
+
+import com.github.yeoli.devlog.domain.memo.domain.Memo
+import java.time.LocalDateTime
+
+data class MemoState(
+ var id: Long = 0L,
+ var createdAt: String = LocalDateTime.now().toString(),
+ var updatedAt: String = LocalDateTime.now().toString(),
+ var content: String = "",
+ var commitHash: String? = null,
+ var filePath: String? = null,
+ var selectedCodeSnippet: String? = null,
+ var fullCodeSnapshot: String? = null,
+ var selectionStart: Int? = null,
+ var selectionEnd: Int? = null,
+ var visibleStart: Int? = null,
+ var visibleEnd: Int? = null
+) {
+ fun toDomain(): Memo =
+ Memo(
+ id = this.id,
+ createdAt = this.createdAt.let { LocalDateTime.parse(it) },
+ updatedAt = this.updatedAt.let { LocalDateTime.parse(it) },
+ content = content,
+ commitHash = commitHash,
+ filePath = filePath,
+ selectedCodeSnippet = selectedCodeSnippet,
+ fullCodeSnapshot = fullCodeSnapshot,
+ selectionStart = selectionStart,
+ selectionEnd = selectionEnd,
+ visibleStart = visibleStart,
+ visibleEnd = visibleEnd
+ )
+}
diff --git a/src/main/kotlin/com/github/yeoli/devlog/domain/memo/repository/MemoStorageState.kt b/src/main/kotlin/com/github/yeoli/devlog/domain/memo/repository/MemoStorageState.kt
new file mode 100644
index 0000000..9a555f2
--- /dev/null
+++ b/src/main/kotlin/com/github/yeoli/devlog/domain/memo/repository/MemoStorageState.kt
@@ -0,0 +1,6 @@
+package com.github.yeoli.devlog.domain.memo.repository
+
+class MemoStorageState(
+ var memos: MutableList = mutableListOf()
+) {
+}
diff --git a/src/main/kotlin/com/github/yeoli/devlog/domain/memo/service/MemoService.kt b/src/main/kotlin/com/github/yeoli/devlog/domain/memo/service/MemoService.kt
new file mode 100644
index 0000000..342f726
--- /dev/null
+++ b/src/main/kotlin/com/github/yeoli/devlog/domain/memo/service/MemoService.kt
@@ -0,0 +1,188 @@
+package com.github.yeoli.devlog.domain.memo.service
+
+import com.github.yeoli.devlog.domain.memo.domain.Memo
+import com.github.yeoli.devlog.domain.memo.repository.MemoRepository
+import com.ibm.icu.impl.IllegalIcuArgumentException
+import com.intellij.openapi.command.WriteCommandAction
+import com.intellij.openapi.components.Service
+import com.intellij.openapi.diagnostic.Logger
+import com.intellij.openapi.editor.Editor
+import com.intellij.openapi.fileEditor.FileDocumentManager
+import com.intellij.openapi.fileEditor.FileEditorManager
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.vfs.VirtualFile
+import git4idea.repo.GitRepositoryManager
+import java.awt.Point
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+@Service(Service.Level.PROJECT)
+class MemoService(private val project: Project) {
+
+ private val memoRepository = project.getService(MemoRepository::class.java)
+
+ private val logger = Logger.getInstance(MemoService::class.java)
+
+ fun createMemo(content: String): Memo? {
+ val editor = getActiveEditor(project)
+ if (editor == null) {
+ logger.warn("[createMemo] editor가 null이므로 null을 반환합니다.")
+
+ return Memo(
+ content = content,
+ commitHash = null,
+ filePath = null,
+ selectedCodeSnippet = null,
+ fullCodeSnapshot = null,
+ selectionStart = null,
+ selectionEnd = null,
+ visibleStart = null,
+ visibleEnd = null
+ )
+ }
+
+ val selectionModel = editor.selectionModel
+ val document = editor.document
+
+ val selectedCodeSnippet = selectionModel.selectedText
+ val hasSelection = selectionModel.hasSelection()
+ val fullCodeSnapshot = if (hasSelection) document.text else null
+
+ val selectionStart = selectionModel.selectionStart
+ val selectionEnd = selectionModel.selectionEnd
+
+ val visibleArea = editor.scrollingModel.visibleAreaOnScrollingFinished
+ val visibleStartLine = editor.xyToLogicalPosition(visibleArea.location).line
+ val visibleEndLine = editor.xyToLogicalPosition(
+ Point(visibleArea.x, visibleArea.y + visibleArea.height)
+ ).line
+
+ val virtualFile = FileDocumentManager.getInstance().getFile(document)
+ val filePath = virtualFile?.path
+
+ val commitHash = getCurrentCommitHash(project)
+
+ if (content.isBlank()) {
+ logger.warn("[createMemo] content가 blanck 이므로 null을 반환합니다.")
+ return null
+ }
+
+ val memo: Memo
+ try {
+ memo = Memo(
+ content = content,
+ commitHash = commitHash,
+ filePath = filePath,
+ selectedCodeSnippet = selectedCodeSnippet,
+ fullCodeSnapshot = fullCodeSnapshot,
+ selectionStart = selectionStart,
+ selectionEnd = selectionEnd,
+ visibleStart = visibleStartLine,
+ visibleEnd = visibleEndLine
+ )
+ } catch (e: IllegalIcuArgumentException) {
+ logger.warn("[createMemo] Memo 생성에 실패하여 null을 반환합니다.(사유: " + e.message + ")")
+ return null;
+ }
+
+ return memo
+ }
+
+ private fun getActiveEditor(project: Project): Editor? {
+ return FileEditorManager.getInstance(project).selectedTextEditor
+ }
+
+ private fun getCurrentCommitHash(project: Project): String? {
+ val repoManager = GitRepositoryManager.getInstance(project)
+ val repo = repoManager.repositories.firstOrNull() ?: return null
+ return repo.currentRevision
+ }
+
+ fun saveMemo(memo: Memo) {
+ try {
+ memoRepository.save(memo)
+ } catch (e: Exception) {
+ logger.warn("[saveMemo] 메모 저장 중 알 수 없는 에러가 발생했습니다. ${e.message}", e)
+ }
+ }
+
+ fun getAllMemosOrderByCreatedAt(): List {
+ try {
+ return memoRepository.getAll().sortedBy { it.createdAt }
+ } catch (e: Exception) {
+ logger.warn("[getAllMemos] 메모 조회 중 알 수 없는 에러가 발생했습니다. ${e.message}", e)
+ }
+
+ return mutableListOf()
+ }
+
+ fun removeMemos(memos: List) {
+ if (memos.isEmpty()) return
+
+ try {
+ val ids: List = memos.map { it.id }
+ memoRepository.removeMemosById(ids)
+ } catch (e: Exception) {
+ logger.warn("[removeMemos] 메모 삭제 중 알 수 없는 에러가 발생했습니다. ${e.message}", e)
+ }
+ }
+
+ fun updateMemo(memoId: Long, newContent: String) {
+ try {
+ val memo: Memo = memoRepository.findMemoById(memoId) ?: return
+ val updated = memo.update(newContent)
+ memoRepository.removeMemoById(memoId)
+ memoRepository.save(updated)
+ } catch (e: Exception) {
+ logger.warn("[updateMemo] 메모 수정 중 알 수 없는 에러가 발생했습니다. ${e.message}", e)
+ }
+ }
+
+ fun buildHeader(): String {
+ val projectName = project.name
+ val now = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
+
+ return """
+ ========== DEV LOG ==========
+
+ # 요약 정보
+ 💻 프로젝트 명: $projectName
+ ⏰ 추출 시간: $now
+
+ ---------------------------------------
+ """.trimIndent()
+ }
+
+ fun buildExportText(selectedMemos: List): String {
+ val header = buildHeader()
+
+ if (selectedMemos.isEmpty()) {
+ return header + "\n(내보낼 메모가 없습니다.)"
+ }
+
+ val body = selectedMemos
+ .mapIndexed { index, memo ->
+ memo.buildMemoBlock(index + 1)
+ }
+ .joinToString(separator = "\n")
+
+ return header + "\n\n" + body
+ }
+
+ fun exportToTxt(text: String, directory: VirtualFile): VirtualFile? {
+ val date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"))
+ val fileName = "devlog-${project.name}-$date.txt"
+
+ var file: VirtualFile? = null
+ WriteCommandAction.runWriteCommandAction(project) {
+ file = directory.createChildData(this, fileName)
+ file!!.setBinaryContent(text.toByteArray(Charsets.UTF_8))
+ }
+
+ return file
+ }
+
+ fun findMemoById(memoId: Long): Memo? {
+ return memoRepository.findMemoById(memoId)
+ }
+}
diff --git a/src/main/kotlin/com/github/yeoli/devlog/domain/note/domain/Note.kt b/src/main/kotlin/com/github/yeoli/devlog/domain/note/domain/Note.kt
new file mode 100644
index 0000000..fc51fe0
--- /dev/null
+++ b/src/main/kotlin/com/github/yeoli/devlog/domain/note/domain/Note.kt
@@ -0,0 +1,25 @@
+package com.github.yeoli.devlog.domain.note.domain
+
+import com.github.yeoli.devlog.domain.note.repository.NoteState
+import java.time.LocalDateTime
+
+class Note(
+ val content: String,
+ val updatedAt: LocalDateTime
+) {
+
+ constructor(content: String) : this(content, LocalDateTime.now())
+
+ fun update(content: String): Note {
+ return Note(
+ content = content
+ )
+ }
+
+ fun toState(): NoteState {
+ return NoteState(
+ content = this.content,
+ updatedAt = this.updatedAt.toString()
+ )
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/github/yeoli/devlog/domain/note/repository/NoteRepository.kt b/src/main/kotlin/com/github/yeoli/devlog/domain/note/repository/NoteRepository.kt
new file mode 100644
index 0000000..449b7ea
--- /dev/null
+++ b/src/main/kotlin/com/github/yeoli/devlog/domain/note/repository/NoteRepository.kt
@@ -0,0 +1,31 @@
+package com.github.yeoli.devlog.domain.note.repository
+
+import com.github.yeoli.devlog.domain.note.domain.Note
+import com.intellij.openapi.components.PersistentStateComponent
+import com.intellij.openapi.components.Service
+import com.intellij.openapi.components.State
+import com.intellij.openapi.components.Storage
+
+@State(
+ name = "DevLogNoteStorage",
+ storages = [Storage("devlog-note.xml")]
+)
+@Service(Service.Level.PROJECT)
+class NoteRepository : PersistentStateComponent {
+
+ private var state: NoteStorageState = NoteStorageState()
+
+ override fun getState(): NoteStorageState? = state
+
+ override fun loadState(state: NoteStorageState) {
+ this.state = state
+ }
+
+ fun getNote(): Note {
+ return state.noteState.toDomain()
+ }
+
+ fun updateNote(updatedNote: Note) {
+ this.state.noteState = updatedNote.toState()
+ }
+}
diff --git a/src/main/kotlin/com/github/yeoli/devlog/domain/note/repository/NoteState.kt b/src/main/kotlin/com/github/yeoli/devlog/domain/note/repository/NoteState.kt
new file mode 100644
index 0000000..bcaac46
--- /dev/null
+++ b/src/main/kotlin/com/github/yeoli/devlog/domain/note/repository/NoteState.kt
@@ -0,0 +1,14 @@
+package com.github.yeoli.devlog.domain.note.repository
+
+import com.github.yeoli.devlog.domain.note.domain.Note
+import java.time.LocalDateTime
+
+data class NoteState(
+ var content: String = "",
+ var updatedAt: String = LocalDateTime.now().toString()
+) {
+
+ fun toDomain(): Note {
+ return Note(content, updatedAt = this.updatedAt.let { LocalDateTime.parse(it) })
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/github/yeoli/devlog/domain/note/repository/NoteStorageState.kt b/src/main/kotlin/com/github/yeoli/devlog/domain/note/repository/NoteStorageState.kt
new file mode 100644
index 0000000..29747b2
--- /dev/null
+++ b/src/main/kotlin/com/github/yeoli/devlog/domain/note/repository/NoteStorageState.kt
@@ -0,0 +1,6 @@
+package com.github.yeoli.devlog.domain.note.repository
+
+class NoteStorageState(
+ var noteState: NoteState = NoteState()
+) {
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/github/yeoli/devlog/domain/note/service/NoteService.kt b/src/main/kotlin/com/github/yeoli/devlog/domain/note/service/NoteService.kt
new file mode 100644
index 0000000..9173405
--- /dev/null
+++ b/src/main/kotlin/com/github/yeoli/devlog/domain/note/service/NoteService.kt
@@ -0,0 +1,27 @@
+package com.github.yeoli.devlog.domain.note.service
+
+import com.github.yeoli.devlog.domain.note.domain.Note
+import com.github.yeoli.devlog.domain.note.repository.NoteRepository
+import com.intellij.openapi.components.Service
+import com.intellij.openapi.project.Project
+
+@Service(Service.Level.PROJECT)
+class NoteService(private val project: Project) {
+
+ private val noteRepository =
+ project.getService(NoteRepository::class.java)
+
+ fun getNote(): Note {
+ return noteRepository.getNote()
+ }
+
+ fun updateNote(content: String) {
+ val note: Note = getNote()
+ if (note.content == content) return
+
+ val updatedNote = note.update(content)
+
+ noteRepository.updateNote(updatedNote)
+ }
+}
+
diff --git a/src/main/kotlin/com/github/yeoli/devlog/event/MemoChangedEvent.kt b/src/main/kotlin/com/github/yeoli/devlog/event/MemoChangedEvent.kt
new file mode 100644
index 0000000..c3191e9
--- /dev/null
+++ b/src/main/kotlin/com/github/yeoli/devlog/event/MemoChangedEvent.kt
@@ -0,0 +1,12 @@
+package com.github.yeoli.devlog.event
+
+import com.intellij.util.messages.Topic
+
+fun interface MemoListener {
+ fun onChanged()
+}
+
+object MemoChangedEvent {
+ val TOPIC: Topic =
+ Topic.create("MemoChanged", MemoListener::class.java)
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/github/yeoli/devlog/services/MyProjectService.kt b/src/main/kotlin/com/github/yeoli/devlog/services/MyProjectService.kt
deleted file mode 100644
index b0d3ed0..0000000
--- a/src/main/kotlin/com/github/yeoli/devlog/services/MyProjectService.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.github.yeoli.devlog.services
-
-import com.intellij.openapi.components.Service
-import com.intellij.openapi.diagnostic.thisLogger
-import com.intellij.openapi.project.Project
-import com.github.yeoli.devlog.MyBundle
-
-@Service(Service.Level.PROJECT)
-class MyProjectService(project: Project) {
-
- init {
- thisLogger().info(MyBundle.message("projectService", project.name))
- thisLogger().warn("Don't forget to remove all non-needed sample code files with their corresponding registration entries in `plugin.xml`.")
- }
-
- fun getRandomNumber() = (1..100).random()
-}
diff --git a/src/main/kotlin/com/github/yeoli/devlog/toolWindow/MyToolWindowFactory.kt b/src/main/kotlin/com/github/yeoli/devlog/toolWindow/MyToolWindowFactory.kt
index 5ece24a..a2fe550 100644
--- a/src/main/kotlin/com/github/yeoli/devlog/toolWindow/MyToolWindowFactory.kt
+++ b/src/main/kotlin/com/github/yeoli/devlog/toolWindow/MyToolWindowFactory.kt
@@ -1,45 +1,96 @@
package com.github.yeoli.devlog.toolWindow
-import com.intellij.openapi.components.service
+
+import com.github.yeoli.devlog.ui.DevLogPanel
+import com.intellij.icons.AllIcons
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.project.Project
-import com.intellij.openapi.wm.ToolWindow
-import com.intellij.openapi.wm.ToolWindowFactory
-import com.intellij.ui.components.JBLabel
-import com.intellij.ui.components.JBPanel
+import com.intellij.openapi.wm.*
+import com.intellij.openapi.wm.ex.ToolWindowManagerListener
+import com.intellij.openapi.wm.impl.content.ToolWindowContentUi
+import com.intellij.ui.JBColor
import com.intellij.ui.content.ContentFactory
-import com.github.yeoli.devlog.MyBundle
-import com.github.yeoli.devlog.services.MyProjectService
-import javax.swing.JButton
-
+import com.intellij.util.ui.JBUI
+import java.awt.Color
+/**
+ * RetrospectPanel을 IntelliJ ToolWindow 영역에 붙여주는 팩토리.
+ * ToolWindow 생성 시 UI와 경계선 스타일을 세팅한다.
+ */
class MyToolWindowFactory : ToolWindowFactory {
init {
- thisLogger().warn("Don't forget to remove all non-needed sample code files with their corresponding registration entries in `plugin.xml`.")
+ thisLogger().info("Yeoli Retrospect ToolWindow ready.")
}
+ /**
+ * ToolWindow가 초기화될 때 호출되어 컨텐츠와 테두리 스타일을 구성한다.
+ */
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
- val myToolWindow = MyToolWindow(toolWindow)
- val content = ContentFactory.getInstance().createContent(myToolWindow.getContent(), null, false)
+
+ toolWindow.setIcon(AllIcons.Actions.Annotate)
+ val panel = DevLogPanel(project, toolWindow.disposable)
+ val content = ContentFactory.getInstance().createContent(panel.component, null, false);
+
toolWindow.contentManager.addContent(content)
+ toolWindow.component.putClientProperty(ToolWindowContentUi.HIDE_ID_LABEL, "false")
+ applyDockBorder(toolWindow)
+ project.messageBus.connect(toolWindow.disposable).subscribe(
+ ToolWindowManagerListener.TOPIC,
+ object : ToolWindowManagerListener {
+ override fun stateChanged(toolWindowManager: ToolWindowManager) {
+ val updated = toolWindowManager.getToolWindow(toolWindow.id) ?: return
+ applyDockBorder(updated)
+ }
+ }
+ )
}
override fun shouldBeAvailable(project: Project) = true
- class MyToolWindow(toolWindow: ToolWindow) {
-
- private val service = toolWindow.project.service()
+ /**
+ * 도킹 상태/위치에 맞춰 ToolWindow 테두리를 설정한다.
+ */
+ private fun applyDockBorder(toolWindow: ToolWindow) {
+ if (toolWindow.type == ToolWindowType.FLOATING ||
+ toolWindow.type == ToolWindowType.WINDOWED ||
+ toolWindow.type == ToolWindowType.SLIDING
+ ) {
+ toolWindow.component.border = JBUI.Borders.empty()
+ toolWindow.component.repaint()
+ return
+ }
- fun getContent() = JBPanel>().apply {
- val label = JBLabel(MyBundle.message("randomLabel", "?"))
+ val sides = when (toolWindow.anchor) {
+ ToolWindowAnchor.LEFT -> BorderSides(0, 0, 0, BORDER_WIDTH)
+ ToolWindowAnchor.RIGHT -> BorderSides(0, BORDER_WIDTH, 0, 0)
+ ToolWindowAnchor.TOP -> BorderSides(0, 0, BORDER_WIDTH, 0)
+ ToolWindowAnchor.BOTTOM -> BorderSides(BORDER_WIDTH, 0, 0, 0)
+ else -> BorderSides(0, 0, 0, 0)
+ }
- add(label)
- add(JButton(MyBundle.message("shuffle")).apply {
- addActionListener {
- label.text = MyBundle.message("randomLabel", service.getRandomNumber())
- }
- })
+ val border = if (sides.isEmpty()) {
+ JBUI.Borders.empty()
+ } else {
+ JBUI.Borders.customLine(
+ DOCK_BORDER_COLOR,
+ sides.top,
+ sides.left,
+ sides.bottom,
+ sides.right
+ )
}
+ toolWindow.component.border = border
+ toolWindow.component.revalidate()
+ toolWindow.component.repaint()
+ }
+
+ companion object {
+ private val DOCK_BORDER_COLOR = JBColor(Color(0x2b, 0x2d, 0x30), Color(0x2b, 0x2d, 0x30))
+ private const val BORDER_WIDTH = 1
+ }
+
+ private data class BorderSides(val top: Int, val left: Int, val bottom: Int, val right: Int) {
+ fun isEmpty() = top + left + bottom + right == 0
}
}
diff --git a/src/main/kotlin/com/github/yeoli/devlog/ui/DevLogPanel.kt b/src/main/kotlin/com/github/yeoli/devlog/ui/DevLogPanel.kt
new file mode 100644
index 0000000..c0a421f
--- /dev/null
+++ b/src/main/kotlin/com/github/yeoli/devlog/ui/DevLogPanel.kt
@@ -0,0 +1,489 @@
+package com.github.yeoli.devlog.ui
+
+import com.github.yeoli.devlog.domain.memo.domain.Memo
+import com.github.yeoli.devlog.domain.memo.service.MemoService
+import com.github.yeoli.devlog.domain.note.service.NoteService
+import com.github.yeoli.devlog.event.MemoChangedEvent
+import com.github.yeoli.devlog.event.MemoListener
+import com.github.yeoli.devlog.ui.action.DeleteSelectedAction
+import com.github.yeoli.devlog.ui.action.MemoExportAction
+import com.github.yeoli.devlog.ui.action.ToggleSelectionAction
+import com.intellij.icons.AllIcons
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.actionSystem.*
+import com.intellij.openapi.editor.Editor
+import com.intellij.openapi.editor.EditorFactory
+import com.intellij.openapi.editor.ScrollType
+import com.intellij.openapi.editor.event.SelectionEvent
+import com.intellij.openapi.editor.event.SelectionListener
+import com.intellij.openapi.editor.markup.HighlighterLayer
+import com.intellij.openapi.editor.markup.HighlighterTargetArea
+import com.intellij.openapi.editor.markup.TextAttributes
+import com.intellij.openapi.fileEditor.FileEditorManager
+import com.intellij.openapi.fileEditor.FileEditorManagerEvent
+import com.intellij.openapi.fileEditor.FileEditorManagerListener
+import com.intellij.openapi.fileEditor.OpenFileDescriptor
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.ui.DialogWrapper
+import com.intellij.openapi.ui.Messages
+import com.intellij.openapi.util.Key
+import com.intellij.openapi.wm.StatusBar
+import com.intellij.testFramework.LightVirtualFile
+import com.intellij.ui.JBColor
+import com.intellij.ui.JBSplitter
+import com.intellij.ui.components.JBLabel
+import com.intellij.ui.components.JBPanel
+import com.intellij.ui.components.JBScrollPane
+import com.intellij.ui.components.JBTextArea
+import com.intellij.util.ui.JBUI
+import java.awt.BorderLayout
+import java.awt.CardLayout
+import java.awt.Color
+import java.time.LocalTime
+import java.time.format.DateTimeFormatter
+import javax.swing.JComponent
+
+/**
+ * Retrospect DevLog UI의 메인 컨테이너.
+ * 타임라인·작성기·공유 메모/상세보기 카드 전환을 모두 여기서 제어한다.
+ */
+internal class DevLogPanel(
+ private val project: Project,
+ parentDisposable: Disposable
+) {
+
+ private val memoService = project.getService(MemoService::class.java)
+ private val noteService = project.getService(NoteService::class.java)
+
+ private val palette = UiPalette()
+
+ // 작성기 영역 (분리 후보)
+ private val composer = MemoInputComposer(palette, ::handleSaveRequest)
+
+ // 공유 노트 영역 (분리 후보)
+ private val sharedNotes = NotePanel(palette, ::handleSharedNotesSave)
+
+ // 선택 상태 배너 (분리 후보)
+ private val selectionStatusPanel = SelectionStatusPanel(palette)
+
+ private val selectedRecordIds = linkedSetOf()
+
+ // 상세 편집기 (분리 후보)
+ private val recordDetailPanel = MemoDetailPanel(
+ palette = palette,
+ onRequestSave = ::handleDetailSave,
+ onRequestBack = ::handleDetailBack
+ )
+
+ // 타임라인 뷰 (분리 후보: MemoListView)
+ private val timeline = MemoListView(
+ palette,
+ MemoListView.Interactions(
+ onEdit = { record, index -> openEditDialog(record) },
+ onSnapshot = { openSnapshotInEditor(it) },
+ onOpenDetail = { record -> openRecordDetail(record) },
+ onDelete = { index, record -> deleteMemo(record) },
+ onSelectionChanged = ::handleSelectionChanged
+ )
+ )
+
+ // shared notes / detail view 등을 전환하기 위한 카드 레이아웃.
+ private val cards = CardLayout()
+ private val contentStack = JBPanel>().apply {
+ layout = cards
+ background = palette.editorBg
+ }
+ private val viewToggleAction = SharedNotesToggleAction()
+ private val selectionToggleAction = ToggleSelectionAction(::selectAllRecords, ::clearSelection)
+ private val exportSelectedAction = MemoExportAction(
+ project = project,
+ recordsProvider = ::getSelectedRecords,
+ actionText = "Export Selected Logs",
+ actionDescription = "Export checked DevLog entries as Markdown text",
+ icon = AllIcons.ToolbarDecorator.Export
+ )
+ private val deleteSelectedAction =
+ DeleteSelectedAction(project, ::getSelectedRecords, ::deleteSelectedRecords)
+ private var currentCard: String? = null
+
+ // 사용자가 특정 레코드에 연결하기 위해 선택해 둔 대상.
+ private var totalRecordCount: Int = 0
+ private var navigationToolbar: ActionToolbar? = null
+
+ val component: JComponent = JBPanel>(BorderLayout()).apply {
+ background = palette.editorBg
+ border = JBUI.Borders.empty()
+ add(createNavigationBar(), BorderLayout.NORTH)
+ contentStack.add(createMainView(), RETROSPECTS_CARD)
+ contentStack.add(sharedNotes.component, SHARED_NOTES_CARD)
+ contentStack.add(recordDetailPanel.component, RECORD_DETAIL_CARD)
+ add(contentStack, BorderLayout.CENTER)
+ switchCard(RETROSPECTS_CARD)
+ }
+
+ init {
+ refreshMemos()
+ loadSharedNotes()
+ setupSelectionTracking(parentDisposable)
+ project.messageBus.connect(parentDisposable).subscribe(
+ MemoChangedEvent.TOPIC,
+ MemoListener {
+ refreshMemos()
+ loadSharedNotes()
+ }
+ )
+ }
+
+ // ---------- UI 빌더 ----------
+ private fun createNavigationBar(): JComponent {
+ val group = DefaultActionGroup().apply {
+ add(viewToggleAction)
+ add(selectionToggleAction)
+ add(exportSelectedAction)
+ add(deleteSelectedAction)
+ }
+ val toolbar = ActionManager.getInstance()
+ .createActionToolbar("RetrospectNavToolbar", group, true)
+ .apply {
+ targetComponent = null
+ component.background = palette.editorBg
+ component.border = JBUI.Borders.empty()
+ }
+ navigationToolbar = toolbar
+ return JBPanel>(BorderLayout()).apply {
+ background = palette.editorBg
+ border = JBUI.Borders.empty(6, 12, 4, 12)
+ add(toolbar.component, BorderLayout.CENTER)
+ }
+ }
+
+ private fun createMainView(): JComponent {
+ val timelineStack = JBPanel>(BorderLayout()).apply {
+ background = palette.editorBg
+ add(timeline.component, BorderLayout.CENTER)
+ add(selectionStatusPanel.component, BorderLayout.SOUTH)
+ }
+ val lowerPanel = JBPanel>(BorderLayout()).apply {
+ background = palette.editorBg
+ add(composer.component, BorderLayout.CENTER)
+ }
+ return JBSplitter(true, 0.8f).apply {
+ dividerWidth = 5
+ isOpaque = false
+ firstComponent = timelineStack
+ secondComponent = lowerPanel
+ setHonorComponentsMinimumSize(true)
+ }
+ }
+
+ private fun switchCard(card: String) {
+ if (currentCard == card) return
+ currentCard = card
+ cards.show(contentStack, card)
+ refreshToolbarActions()
+ }
+
+ // ---------- 데이터 동기화 ----------
+ private fun refreshMemos() {
+ val memos = memoService.getAllMemosOrderByCreatedAt()
+ totalRecordCount = memos.size
+ timeline.render(memos)
+ refreshToolbarActions()
+ }
+
+ private fun loadSharedNotes() {
+ sharedNotes.setContent(noteService.getNote().content)
+ }
+
+ private fun notifyChange() {
+ project.messageBus.syncPublisher(MemoChangedEvent.TOPIC).onChanged()
+ }
+
+ // ---------- 메모/노트 액션 ----------
+ private fun handleSaveRequest(rawBody: String) {
+ val body = rawBody.trim()
+ if (body.isEmpty()) {
+ composer.showEmptyBodyMessage()
+ return
+ }
+ val memo = memoService.createMemo(rawBody) ?: return
+ memoService.saveMemo(memo)
+ notifyChange()
+ composer.clear()
+ composer.updateStatus("Saved at ${currentTimeString()}")
+ }
+
+ private fun handleSharedNotesSave(rawNotes: String) {
+ noteService.updateNote(rawNotes)
+ notifyChange()
+ sharedNotes.markSaved(currentTimeString())
+ }
+
+ private fun openRecordDetail(record: Memo) {
+ recordDetailPanel.displayRecord(record)
+ switchCard(RECORD_DETAIL_CARD)
+ }
+
+ private fun handleDetailSave(memoId: Long, updatedContent: String) {
+ val memo = memoService.findMemoById(memoId)
+ if (memo == null || memo.content == updatedContent) return
+ memoService.updateMemo(memoId, updatedContent)
+ notifyChange()
+ }
+
+ private fun handleDetailBack() {
+ switchCard(RETROSPECTS_CARD)
+ }
+
+ private fun openEditDialog(memo: Memo) {
+ val editorArea = JBTextArea(memo.content, 10, 50).apply {
+ lineWrap = true
+ wrapStyleWord = true
+ border = JBUI.Borders.empty(8)
+ }
+ val panel = JBPanel>(BorderLayout()).apply {
+ border = JBUI.Borders.empty(10)
+ add(JBLabel("Update content:"), BorderLayout.NORTH)
+ add(JBScrollPane(editorArea), BorderLayout.CENTER)
+ }
+ val dialog = object : DialogWrapper(project) {
+ init {
+ title = "Edit Memo"
+ init()
+ }
+
+ override fun createCenterPanel(): JComponent = panel
+ }
+ if (dialog.showAndGet()) {
+ val newContent = editorArea.text.trim()
+ if (newContent != memo.content) {
+ memoService.updateMemo(memo.id, newContent)
+ notifyChange()
+ }
+ }
+ }
+
+ private fun deleteMemo(memo: Memo) {
+ val confirm = Messages.showYesNoDialog(
+ project,
+ "삭제 후엔 복구가 불가능 합니다.\n이 메모를 지우시겠습니까?",
+ "Delete",
+ Messages.getQuestionIcon()
+ )
+ if (confirm == Messages.YES) {
+ memoService.removeMemos(listOf(memo))
+ notifyChange()
+ }
+ }
+
+ private fun deleteSelectedRecords(records: List) {
+ if (records.isEmpty()) return
+ val ids = records.map { it.id }.toSet()
+ if (ids.isEmpty()) return
+ val memos = memoService.getAllMemosOrderByCreatedAt().filter { ids.contains(it.id) }
+ memoService.removeMemos(memos)
+ notifyChange()
+ selectedRecordIds.removeAll(ids)
+ refreshToolbarActions()
+ }
+
+ private fun getSelectedRecords(): List {
+ if (selectedRecordIds.isEmpty()) return emptyList()
+ val selected = selectedRecordIds.toSet()
+ return memoService.getAllMemosOrderByCreatedAt()
+ .filter { it.id in selected }
+ }
+
+ // ---------- 선택/툴바 상태 ----------
+ private fun handleSelectionChanged(ids: Set) {
+ selectedRecordIds.clear()
+ selectedRecordIds.addAll(ids)
+ refreshToolbarActions()
+ }
+
+ private fun selectAllRecords() {
+ timeline.selectAllRecords()
+ }
+
+ private fun clearSelection() {
+ timeline.clearSelection()
+ }
+
+ private fun refreshToolbarActions() {
+ val timelineActive = currentCard == RETROSPECTS_CARD
+ selectionToggleAction.setControlsEnabled(timelineActive)
+ selectionToggleAction.updateState(totalRecordCount, selectedRecordIds.size)
+ exportSelectedAction.setForceDisabled(!timelineActive)
+ deleteSelectedAction.setForceDisabled(!timelineActive)
+ navigationToolbar?.updateActionsImmediately()
+ }
+
+ private fun setupSelectionTracking(parentDisposable: Disposable) {
+ EditorFactory.getInstance().eventMulticaster.addSelectionListener(object :
+ SelectionListener {
+ override fun selectionChanged(e: SelectionEvent) {
+ if (e.editor.project != project) return
+ updateSelectionBanner()
+ }
+ }, parentDisposable)
+
+ project.messageBus.connect(parentDisposable).subscribe(
+ FileEditorManagerListener.FILE_EDITOR_MANAGER,
+ object : FileEditorManagerListener {
+ override fun selectionChanged(event: FileEditorManagerEvent) {
+ updateSelectionBanner()
+ }
+ }
+ )
+
+ updateSelectionBanner()
+ }
+
+ private fun updateSelectionBanner() {
+ val editor = FileEditorManager.getInstance(project).selectedTextEditor
+ val selectionModel = editor?.selectionModel
+ val hasMeaningfulSelection = selectionModel?.hasSelection() == true &&
+ selectionModel.selectionStart != selectionModel.selectionEnd
+ if (hasMeaningfulSelection) {
+ val fileName = editor?.virtualFile?.name?.takeIf { it.isNotBlank() } ?: "current file"
+ selectionStatusPanel.showSelectionActive(fileName)
+ } else {
+ selectionStatusPanel.showIdleState()
+ }
+ }
+
+ // ---------- 카드 전환 액션 ----------
+ private inner class SharedNotesToggleAction :
+ ToggleAction("Memos", "Toggle Memo List / Notes", AllIcons.General.History) {
+
+ override fun isSelected(e: AnActionEvent): Boolean =
+ currentCard == SHARED_NOTES_CARD
+
+ override fun setSelected(
+ e: AnActionEvent,
+ state: Boolean
+ ) {
+ val target = if (state) SHARED_NOTES_CARD else RETROSPECTS_CARD
+ switchCard(target)
+ }
+
+ override fun update(e: AnActionEvent) {
+ super.update(e)
+ val showingShared = currentCard == SHARED_NOTES_CARD
+ if (showingShared) {
+ e.presentation.text = "Show Memos"
+ e.presentation.description = "Switch to memo list"
+ e.presentation.icon = AllIcons.General.History
+ } else {
+ e.presentation.text = "Show Notes"
+ e.presentation.description = "Switch to shared notes"
+ e.presentation.icon = AllIcons.Toolwindows.Documentation
+ }
+ }
+
+ override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
+ }
+
+ private fun currentTimeString(): String =
+ LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm"))
+
+
+ /**
+ * 레코드에 저장된 코드 스냅샷을 읽기 전용 가상 파일로 열고,
+ * 당시에 선택했던 영역을 하이라이트한다.
+ */
+ private fun openSnapshotInEditor(record: Memo) {
+ val fileManager = FileEditorManager.getInstance(project)
+ val existingSnapshot = fileManager.openFiles.firstOrNull {
+ it.getUserData(SNAPSHOT_FILE_KEY) == record.id
+ }
+ if (existingSnapshot != null) {
+ // 이미 열린 가상 파일이 있으면 그대로 포커스만 맞춘다.
+ fileManager.openFile(existingSnapshot, true)
+ return
+ }
+ if (record.fullCodeSnapshot == null || record.fullCodeSnapshot.isBlank()) {
+ // 일반 로그에는 snapshot이 없으므로 사용자에게 안내.
+ StatusBar.Info.set("This entry does not have a captured snapshot.", project)
+ return
+ }
+ val baseNameRaw = record.filePath
+ ?.takeIf { it.isNotBlank() }
+ ?.substringAfterLast('/')
+ ?.takeIf { it.isNotBlank() }
+ ?: "GeneralLog.txt"
+ val sanitizedBaseName = baseNameRaw.replace(Regex("[^0-9A-Za-z._-]"), "_")
+ val extension = sanitizedBaseName.substringAfterLast('.', "txt")
+ val fileName = if (sanitizedBaseName.contains('.')) {
+ "DevLog_${sanitizedBaseName}"
+ } else {
+ "DevLog_${sanitizedBaseName}.$extension"
+ }
+ val header = buildString {
+ appendLine("// DevLog entry captured ${record.createdAt}")
+ appendLine("// Commit: ${record.commitHash ?: "N/A"}")
+ appendLine("// File: ${record.filePath}")
+ appendLine("// Comment: ${record.content.replace("\n", " ")}")
+ appendLine("// Visible lines: ${record.visibleStart}-${record.visibleEnd}")
+ appendLine()
+ }
+ val content = header + record.fullCodeSnapshot
+ val headerLength = header.length
+
+ val virtualFile = LightVirtualFile(fileName, content).apply {
+ isWritable = false
+ putUserData(SNAPSHOT_FILE_KEY, record.id)
+ }
+
+ val descriptor = OpenFileDescriptor(
+ project,
+ virtualFile,
+ (headerLength).coerceIn(0, content.length)
+ )
+
+ val editor = fileManager.openTextEditor(descriptor, true) ?: return
+ val docLength = editor.document.textLength
+ val rawSelectionStart = record.selectionStart ?: 0
+ val rawSelectionEnd = record.selectionEnd ?: 0
+ val selectionStart = (headerLength + rawSelectionStart).coerceIn(0, docLength)
+ val selectionEnd = (headerLength + rawSelectionEnd).coerceIn(0, docLength)
+ // 당시 선택 영역을 복원해 맥락을 쉽게 파악할 수 있게 한다.
+ editor.selectionModel.setSelection(
+ minOf(selectionStart, selectionEnd),
+ maxOf(selectionStart, selectionEnd)
+ )
+ // 자동으로 중앙에 스크롤하여 스냅샷 포커스를 맞춘다.
+ editor.scrollingModel.scrollToCaret(ScrollType.CENTER)
+ highlightCapturedSelection(editor, selectionStart, selectionEnd)
+ }
+
+ private fun highlightCapturedSelection(editor: Editor, start: Int, end: Int) {
+ if (start == end) return
+ val attributes = TextAttributes().apply {
+ backgroundColor = JBColor(Color(198, 239, 206, 170), Color(60, 96, 66, 150))
+ }
+ editor.markupModel.addRangeHighlighter(
+ start,
+ end,
+ HighlighterLayer.SELECTION - 1,
+ attributes,
+ HighlighterTargetArea.EXACT_RANGE
+ )
+ }
+
+ companion object {
+ private const val RETROSPECTS_CARD = "retrospects"
+ private const val SHARED_NOTES_CARD = "shared_notes"
+ private const val RECORD_DETAIL_CARD = "record_detail"
+ private val SNAPSHOT_FILE_KEY = Key.create("YEOLI_RETROSPECT_SNAPSHOT_ID")
+ }
+
+}
+
+data class UiPalette(
+ val editorBg: JBColor = JBColor(Color(247, 248, 250), Color(0x1E, 0x1F, 0x22)),
+ val editorFg: JBColor = JBColor(Color(32, 32, 32), Color(230, 230, 230)),
+ val borderColor: JBColor = JBColor(Color(0x2b, 0x2d, 0x30), Color(0x2b, 0x2d, 0x30)),
+ val listRowBg: JBColor = JBColor(Color(0xF7, 0xF8, 0xFA), Color(0x2B, 0x2D, 0x30)),
+ val listRowSelectedBg: JBColor = JBColor(Color(0xE3, 0xF2, 0xFD), Color(0x1F, 0x3B, 0x4D))
+)
diff --git a/src/main/kotlin/com/github/yeoli/devlog/ui/MemoDetailPanel.kt b/src/main/kotlin/com/github/yeoli/devlog/ui/MemoDetailPanel.kt
new file mode 100644
index 0000000..c1734b7
--- /dev/null
+++ b/src/main/kotlin/com/github/yeoli/devlog/ui/MemoDetailPanel.kt
@@ -0,0 +1,188 @@
+package com.github.yeoli.devlog.ui
+
+import com.github.yeoli.devlog.domain.memo.domain.Memo
+import com.intellij.icons.AllIcons
+import com.intellij.ui.DocumentAdapter
+import com.intellij.ui.JBColor
+import com.intellij.ui.components.JBLabel
+import com.intellij.ui.components.JBPanel
+import com.intellij.ui.components.JBScrollPane
+import com.intellij.ui.components.JBTextArea
+import com.intellij.util.ui.JBUI
+import java.awt.BorderLayout
+import java.awt.FlowLayout
+import java.awt.GraphicsEnvironment
+import java.awt.Toolkit
+import java.awt.event.ActionEvent
+import java.awt.event.KeyAdapter
+import java.awt.event.KeyEvent
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+import javax.swing.*
+import javax.swing.text.JTextComponent
+
+class MemoDetailPanel(
+ private val palette: UiPalette,
+ private val onRequestSave: (Long, String) -> Unit,
+ private val onRequestBack: () -> Unit
+) {
+ private val bodyArea = JBTextArea(12, 40).apply {
+ lineWrap = true
+ wrapStyleWord = true
+ border = JBUI.Borders.empty(8, 12, 8, 12)
+ background = palette.editorBg
+ foreground = palette.editorFg
+ caretColor = palette.editorFg
+ font = font.deriveFont(14f)
+ }
+ private val statusLabel = JBLabel("Autosave ready").apply {
+ foreground = JBColor.gray
+ }
+ private val titleLabel = JBLabel("").apply {
+ foreground = palette.editorFg
+ border = JBUI.Borders.emptyLeft(4)
+ }
+ private val backButton = JButton("Back").apply {
+ icon = AllIcons.Actions.Back
+ addActionListener {
+ triggerManualSave()
+ onRequestBack()
+ }
+ }
+ private val autoSaveTimer = Timer(AUTO_SAVE_DELAY_MS) {
+ performAutoSave()
+ }.apply {
+ isRepeats = false
+ }
+ private var hasUnsavedChanges = false
+ private var suppressEvent = false
+ private var currentRecordId: Long? = null
+
+ val component: JComponent = JBPanel>(BorderLayout()).apply {
+ background = palette.editorBg
+ foreground = palette.editorFg
+ add(createHeader(), BorderLayout.NORTH)
+ add(JBScrollPane(bodyArea).apply {
+ border = JBUI.Borders.empty()
+ background = palette.editorBg
+ viewport.background = palette.editorBg
+ }, BorderLayout.CENTER)
+ add(createFooter(), BorderLayout.SOUTH)
+ }
+
+ init {
+ bodyArea.document.addDocumentListener(object : DocumentAdapter() {
+ override fun textChanged(e: javax.swing.event.DocumentEvent) {
+ if (suppressEvent) return
+ hasUnsavedChanges = true
+ statusLabel.text = "Auto-saving..."
+ scheduleAutoSave()
+ }
+ })
+ registerSaveShortcut(bodyArea) {
+ triggerManualSave()
+ }
+ }
+
+ fun displayRecord(record: Memo) {
+ currentRecordId = record.id
+ titleLabel.text = buildTitle(record)
+ suppressEvent = true
+ bodyArea.text = record.content
+ bodyArea.caretPosition = bodyArea.text.length
+ suppressEvent = false
+ hasUnsavedChanges = false
+ cancelAutoSave()
+ statusLabel.text = "Autosave ready"
+ bodyArea.requestFocusInWindow()
+ }
+
+ private fun createHeader(): JComponent =
+ JBPanel>(BorderLayout()).apply {
+ background = palette.editorBg
+ border = JBUI.Borders.empty(8, 12, 4, 12)
+ val leftGroup = JBPanel>(FlowLayout(FlowLayout.LEFT, 8, 0)).apply {
+ background = palette.editorBg
+ add(backButton)
+ add(titleLabel)
+ }
+ add(leftGroup, BorderLayout.WEST)
+ }
+
+ private fun createFooter(): JComponent = JBPanel>(BorderLayout()).apply {
+ background = palette.editorBg
+ border = JBUI.Borders.empty(8, 12, 12, 12)
+ add(statusLabel, BorderLayout.WEST)
+ }
+
+ private fun buildTitle(record: Memo): String {
+ val fileSegment = record.filePath
+ ?.substringAfterLast('/')
+ ?.takeIf { it.isNotBlank() }
+ ?: "General Log"
+ val formattedTime = runCatching { LocalDateTime.parse(record.createdAt.toString()) }
+ .getOrNull()
+ ?.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
+ ?: record.createdAt.toString()
+ return "$fileSegment · $formattedTime"
+ }
+
+ private fun scheduleAutoSave() {
+ if (!hasUnsavedChanges) return
+ autoSaveTimer.restart()
+ }
+
+ private fun cancelAutoSave() {
+ if (autoSaveTimer.isRunning) {
+ autoSaveTimer.stop()
+ }
+ }
+
+ private fun performAutoSave() {
+ if (!hasUnsavedChanges) return
+ val recordId = currentRecordId ?: return
+ hasUnsavedChanges = false
+ onRequestSave(recordId, bodyArea.text)
+ statusLabel.text = "Saved"
+ }
+
+ private fun triggerManualSave() {
+ if (!hasUnsavedChanges) return
+ cancelAutoSave()
+ performAutoSave()
+ }
+
+ companion object {
+ private const val AUTO_SAVE_DELAY_MS = 1500
+ }
+}
+
+private fun registerSaveShortcut(component: JComponent, action: () -> Unit) {
+ if (GraphicsEnvironment.isHeadless()) return
+ val shortcutMask = Toolkit.getDefaultToolkit().menuShortcutKeyMaskEx
+ val actionKey = "devlog.saveShortcut.${component.hashCode()}"
+ val keyStrokes = listOf(
+ KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, shortcutMask),
+ KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, shortcutMask or KeyEvent.SHIFT_DOWN_MASK)
+ )
+ keyStrokes.forEach { keyStroke ->
+ component.getInputMap(JComponent.WHEN_FOCUSED).put(keyStroke, actionKey)
+ component.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(keyStroke, actionKey)
+ }
+ component.actionMap.put(actionKey, object : AbstractAction() {
+ override fun actionPerformed(e: ActionEvent?) {
+ action()
+ }
+ })
+ if (component is JTextComponent) {
+ component.addKeyListener(object : KeyAdapter() {
+ override fun keyPressed(e: KeyEvent) {
+ val metaCombo = e.isMetaDown || e.isControlDown
+ if (metaCombo && e.keyCode == KeyEvent.VK_ENTER) {
+ e.consume()
+ action()
+ }
+ }
+ })
+ }
+}
diff --git a/src/main/kotlin/com/github/yeoli/devlog/ui/MemoExportPipeline.kt b/src/main/kotlin/com/github/yeoli/devlog/ui/MemoExportPipeline.kt
new file mode 100644
index 0000000..e120207
--- /dev/null
+++ b/src/main/kotlin/com/github/yeoli/devlog/ui/MemoExportPipeline.kt
@@ -0,0 +1,41 @@
+package com.github.yeoli.devlog.ui
+
+import com.github.yeoli.devlog.domain.memo.domain.Memo
+import com.github.yeoli.devlog.domain.memo.service.MemoService
+import com.intellij.openapi.project.Project
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+class MemoExportPipeline(
+ private val project: Project
+) {
+
+ data class Payload(
+ val content: String,
+ val fileExtension: String,
+ val defaultFileName: String
+ )
+
+ fun buildPayload(recordsOverride: List? = null): Payload {
+ val memoService = project.getService(MemoService::class.java)
+ val memos: List = recordsOverride
+ ?: memoService.getAllMemosOrderByCreatedAt()
+
+ val header = memoService.buildHeader()
+ val body = if (memos.isEmpty()) {
+ "(내보낼 메모가 없습니다.)"
+ } else {
+ memos.mapIndexed { index, memo -> memo.buildMemoBlock(index + 1) }
+ .joinToString("\n")
+ }
+ val content = header + "\n\n" + body
+ val date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"))
+ val defaultFileName = "devlog-${project.name}-$date.txt"
+
+ return Payload(
+ content = content,
+ fileExtension = "txt",
+ defaultFileName = defaultFileName
+ )
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/github/yeoli/devlog/ui/MemoInputComposer.kt b/src/main/kotlin/com/github/yeoli/devlog/ui/MemoInputComposer.kt
new file mode 100644
index 0000000..40c9979
--- /dev/null
+++ b/src/main/kotlin/com/github/yeoli/devlog/ui/MemoInputComposer.kt
@@ -0,0 +1,124 @@
+package com.github.yeoli.devlog.ui
+
+import com.intellij.ui.DocumentAdapter
+import com.intellij.ui.JBColor
+import com.intellij.ui.components.JBLabel
+import com.intellij.ui.components.JBPanel
+import com.intellij.ui.components.JBTextArea
+import com.intellij.util.ui.JBUI
+import java.awt.*
+import java.awt.event.ActionEvent
+import java.awt.event.KeyAdapter
+import java.awt.event.KeyEvent
+import javax.swing.AbstractAction
+import javax.swing.JButton
+import javax.swing.JComponent
+import javax.swing.KeyStroke
+import javax.swing.text.JTextComponent
+
+class MemoInputComposer(
+ private val palette: UiPalette,
+ private val onRequestSave: (String) -> Unit
+) {
+ private val descriptionArea = object : JBTextArea(6, 40) {
+ override fun getLocationOnScreen(): Point =
+ if (isShowing) super.getLocationOnScreen() else Point(0, 0)
+ }.apply {
+ lineWrap = true
+ wrapStyleWord = true
+ border = JBUI.Borders.empty(8, 12, 8, 12)
+ background = palette.editorBg
+ foreground = palette.editorFg
+ caretColor = palette.editorFg
+ font = font.deriveFont(14f)
+ emptyText.text = "Write log entry..."
+ }
+ private val statusLabel = JBLabel().apply {
+ foreground = JBColor.gray
+ }
+ private val saveButton = JButton("Save Log").apply {
+ isEnabled = false
+ background = palette.editorBg
+ foreground = palette.editorFg
+ addActionListener { onRequestSave(descriptionArea.text) }
+ }
+
+ val component: JComponent = JBPanel>(BorderLayout()).apply {
+ background = palette.editorBg
+ foreground = palette.editorFg
+ border = JBUI.Borders.empty()
+ add(descriptionArea, BorderLayout.CENTER)
+ add(createControls(), BorderLayout.SOUTH)
+ }
+
+ init {
+ descriptionArea.document.addDocumentListener(object : DocumentAdapter() {
+ override fun textChanged(e: javax.swing.event.DocumentEvent) {
+ val hasText = descriptionArea.text.isNotBlank()
+ saveButton.isEnabled = hasText
+ statusLabel.text = if (hasText) "Unsaved changes" else ""
+ }
+ })
+ registerSaveShortcut(descriptionArea) {
+ if (saveButton.isEnabled) {
+ saveButton.doClick()
+ }
+ }
+ }
+
+ private fun createControls(): JComponent = JBPanel>(BorderLayout()).apply {
+ background = palette.editorBg
+ foreground = palette.editorFg
+ border = JBUI.Borders.empty(0, 12, 8, 12)
+ add(statusLabel, BorderLayout.WEST)
+ val buttonPanel = JBPanel>(FlowLayout(FlowLayout.RIGHT, 0, 0)).apply {
+ background = palette.editorBg
+ foreground = palette.editorFg
+ add(saveButton)
+ }
+ add(buttonPanel, BorderLayout.EAST)
+ }
+
+ fun clear() {
+ descriptionArea.text = ""
+ }
+
+ fun updateStatus(text: String) {
+ statusLabel.text = text
+ }
+
+ fun showEmptyBodyMessage() {
+ statusLabel.text = "Please enter a log entry."
+ }
+
+}
+
+private fun registerSaveShortcut(component: JComponent, action: () -> Unit) {
+ if (GraphicsEnvironment.isHeadless()) return
+ val shortcutMask = Toolkit.getDefaultToolkit().menuShortcutKeyMaskEx
+ val actionKey = "devlog.saveShortcut.${component.hashCode()}"
+ val keyStrokes = listOf(
+ KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, shortcutMask),
+ KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, shortcutMask or KeyEvent.SHIFT_DOWN_MASK)
+ )
+ keyStrokes.forEach { keyStroke ->
+ component.getInputMap(JComponent.WHEN_FOCUSED).put(keyStroke, actionKey)
+ component.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(keyStroke, actionKey)
+ }
+ component.actionMap.put(actionKey, object : AbstractAction() {
+ override fun actionPerformed(e: ActionEvent?) {
+ action()
+ }
+ })
+ if (component is JTextComponent) {
+ component.addKeyListener(object : KeyAdapter() {
+ override fun keyPressed(e: KeyEvent) {
+ val metaCombo = e.isMetaDown || e.isControlDown
+ if (metaCombo && e.keyCode == KeyEvent.VK_ENTER) {
+ e.consume()
+ action()
+ }
+ }
+ })
+ }
+}
diff --git a/src/main/kotlin/com/github/yeoli/devlog/ui/MemoListView.kt b/src/main/kotlin/com/github/yeoli/devlog/ui/MemoListView.kt
new file mode 100644
index 0000000..eb8a4a0
--- /dev/null
+++ b/src/main/kotlin/com/github/yeoli/devlog/ui/MemoListView.kt
@@ -0,0 +1,433 @@
+package com.github.yeoli.devlog.ui
+
+import com.github.yeoli.devlog.domain.memo.domain.Memo
+import com.intellij.icons.AllIcons
+import com.intellij.openapi.util.text.StringUtil
+import com.intellij.ui.JBColor
+import com.intellij.ui.components.JBLabel
+import com.intellij.ui.components.JBPanel
+import com.intellij.ui.components.JBTextArea
+import com.intellij.util.ui.JBFont
+import com.intellij.util.ui.JBUI
+import java.awt.*
+import java.awt.event.MouseAdapter
+import java.awt.event.MouseEvent
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+import javax.swing.*
+
+private const val MAX_SNIPPET_LENGTH = 400
+private val ACTIVATED_ROW_COLOR = JBColor(Color(0x2e, 0x43, 0x6e), Color(0x2e, 0x43, 0x6e))
+private val SELECTED_ROW_COLOR = JBColor(Color(0x43, 0x45, 0x4a), Color(0x43, 0x45, 0x4a))
+private val TITLE_MUTED_COLOR = JBColor(Color(0x88, 0x88, 0x88), Color(0xc0, 0xc0, 0xc0))
+private val DATE_LABEL_BORDER = JBColor(Color(0xe3, 0xe6, 0xed), Color(0x33, 0x34, 0x38))
+private val DATE_LABEL_TEXT = JBColor(Color(0x4d5160), Color(0xb8bcc9))
+
+class MemoListView(
+ private val palette: UiPalette,
+ private val interactions: Interactions
+) {
+
+ data class Interactions(
+ val onEdit: (Memo, Int) -> Unit,
+ val onSnapshot: (Memo) -> Unit,
+ val onOpenDetail: (Memo) -> Unit,
+ val onDelete: (Int, Memo) -> Unit,
+ val onSelectionChanged: (Set) -> Unit
+ )
+
+ private val listPanel = object : JBPanel>(GridBagLayout()), Scrollable {
+ init {
+ border = JBUI.Borders.empty(8)
+ isOpaque = true
+ background = JBColor.PanelBackground
+ }
+
+ override fun getPreferredScrollableViewportSize(): Dimension = preferredSize
+
+ override fun getScrollableUnitIncrement(
+ visibleRect: Rectangle,
+ orientation: Int,
+ direction: Int
+ ): Int =
+ 24
+
+ override fun getScrollableBlockIncrement(
+ visibleRect: Rectangle,
+ orientation: Int,
+ direction: Int
+ ): Int =
+ visibleRect.height
+
+ override fun getScrollableTracksViewportWidth(): Boolean = true
+
+ override fun getScrollableTracksViewportHeight(): Boolean = false
+ }
+ private val scrollPane = JScrollPane(listPanel).apply {
+ border = JBUI.Borders.empty()
+ background = JBColor.PanelBackground
+ viewport.background = JBColor.PanelBackground
+ horizontalScrollBarPolicy = JScrollPane.HORIZONTAL_SCROLLBAR_NEVER
+ verticalScrollBar.unitIncrement = 24
+ }
+ val component: JComponent = scrollPane
+
+ private val rowByIndex = mutableMapOf()
+ private var selectedIndex: Int? = null
+ private var records: List = emptyList()
+ private val selectedRecordIds = linkedSetOf()
+ private var activatedRecordId: Long? = null
+
+ fun render(records: List) {
+ this.records = records
+ listPanel.removeAll()
+ rowByIndex.clear()
+ selectedIndex = null
+ val existingIds = records.map { it.id }.toSet()
+ selectedRecordIds.retainAll(existingIds)
+
+ if (records.isEmpty()) {
+ val placeholder = JBLabel("No DevLog entries recorded yet.").apply {
+ border = JBUI.Borders.empty(32)
+ horizontalAlignment = JBLabel.CENTER
+ foreground = JBColor.gray
+ }
+ listPanel.add(
+ placeholder,
+ GridBagConstraints().apply {
+ gridx = 0
+ gridy = 0
+ weightx = 1.0
+ fill = GridBagConstraints.HORIZONTAL
+ }
+ )
+ } else {
+ var gridY = 0
+ var currentDate: String? = null
+ records.forEachIndexed { index, record ->
+ val recordDate = formatDate(record.updatedAt.toString())
+ if (recordDate != currentDate) {
+ currentDate = recordDate
+ listPanel.add(
+ createDateLabel(recordDate),
+ GridBagConstraints().apply {
+ gridx = 0
+ gridy = gridY++
+ weightx = 1.0
+ fill = GridBagConstraints.HORIZONTAL
+ insets = Insets(12, 0, 6, 0)
+ }
+ )
+ }
+ val row = ListRow(record, index)
+ rowByIndex[index] = row
+ listPanel.add(
+ row,
+ GridBagConstraints().apply {
+ gridx = 0
+ gridy = gridY++
+ weightx = 1.0
+ fill = GridBagConstraints.HORIZONTAL
+ insets = Insets(2, 0, 2, 0)
+ }
+ )
+ }
+ listPanel.add(
+ JPanel(),
+ GridBagConstraints().apply {
+ gridx = 0
+ gridy = gridY
+ weightx = 1.0
+ weighty = 1.0
+ fill = GridBagConstraints.BOTH
+ }
+ )
+ }
+
+ listPanel.revalidate()
+ listPanel.repaint()
+ interactions.onSelectionChanged(selectedRecordIds.toSet())
+ }
+
+ fun selectAllRecords() {
+ selectedRecordIds.clear()
+ selectedRecordIds.addAll(records.map { it.id })
+ rowByIndex.values.forEach { it.setChecked(true) }
+ interactions.onSelectionChanged(selectedRecordIds.toSet())
+ }
+
+ fun clearSelection() {
+ if (selectedRecordIds.isEmpty()) return
+ selectedRecordIds.clear()
+ rowByIndex.values.forEach { it.setChecked(false) }
+ interactions.onSelectionChanged(emptySet())
+ }
+
+ private fun selectRow(index: Int, ensureVisible: Boolean = false) {
+ if (selectedIndex == index) return
+ selectedIndex?.let { rowByIndex[it]?.setSelected(false) }
+ selectedIndex = index
+ rowByIndex[index]?.let {
+ it.setSelected(true)
+ if (ensureVisible) {
+ SwingUtilities.invokeLater { it.scrollIntoView() }
+ }
+ }
+ }
+
+ fun navigateToRecord(recordId: Long): Boolean {
+ val index = records.indexOfFirst { it.id == recordId }
+ if (index == -1) return false
+ selectRow(index, ensureVisible = true)
+ return true
+ }
+
+ private fun createDateLabel(date: String): JComponent =
+ JBPanel>(BorderLayout()).apply {
+ isOpaque = true
+ background = Color(0x25, 0x26, 0x2A)
+ border = JBUI.Borders.empty(6, 0, 6, 0)
+ add(JBLabel(date).apply {
+ font = JBFont.medium()
+ foreground = DATE_LABEL_TEXT
+ border = JBUI.Borders.empty(0, 12, 0, 12)
+ }, BorderLayout.WEST)
+ }
+
+ private inner class ListRow(
+ private val record: Memo,
+ private val recordIndex: Int
+ ) : JBPanel>(BorderLayout()) {
+
+ private val defaultBorder = JBUI.Borders.empty(10, 12)
+ private var isRowSelected = false
+ private lateinit var rowContainer: JBPanel>
+ private lateinit var timeLabel: JBLabel
+ private lateinit var titleLabel: JBLabel
+ private val checkBox: JCheckBox
+ private var suppressToggle = false
+
+ init {
+ isOpaque = true
+ background = palette.listRowBg
+ border = defaultBorder
+
+ checkBox = JCheckBox().apply {
+ isOpaque = false
+ isSelected = selectedRecordIds.contains(record.id)
+ addActionListener {
+ if (!suppressToggle) {
+ toggleSelection(record.id, isSelected)
+ }
+ }
+ }
+
+ val content = JBPanel>(BorderLayout()).apply {
+ isOpaque = false
+ }
+
+ val fileDisplayName =
+ record.filePath?.takeIf { it.isNotBlank() }?.substringAfterLast('/')
+ val timePanel = JBPanel>(FlowLayout(FlowLayout.LEFT, 4, 0)).apply {
+ isOpaque = false
+ }
+ timeLabel = JBLabel(formatTime(record.createdAt.toString())).apply {
+ foreground = JBColor.gray
+ font = JBFont.small()
+ }
+ timePanel.add(timeLabel)
+ if (record.fullCodeSnapshot != null && record.fullCodeSnapshot.isNotBlank()) {
+ timePanel.add(JBLabel(AllIcons.Actions.Preview).apply {
+ toolTipText = "Snapshot included"
+ })
+ }
+ val header = JBPanel>(BorderLayout()).apply {
+ isOpaque = false
+ titleLabel = JBLabel(fileDisplayName ?: "").apply {
+ font = JBFont.medium()
+ foreground = TITLE_MUTED_COLOR
+ border = JBUI.Borders.empty(0, 8, 0, 0)
+ }
+ add(timePanel, BorderLayout.WEST)
+ if (fileDisplayName != null) {
+ add(titleLabel, BorderLayout.CENTER)
+ }
+ }
+
+ val snippet = createSnippetComponent(record.content)
+
+ val meta = JBPanel>(FlowLayout(FlowLayout.LEFT, 12, 0)).apply {
+ isOpaque = false
+ foreground = JBColor.gray
+ }
+
+ val footer = meta
+ content.add(header, BorderLayout.NORTH)
+ content.add(snippet, BorderLayout.CENTER)
+ content.add(footer, BorderLayout.SOUTH)
+
+ rowContainer = JBPanel>(BorderLayout()).apply {
+ isOpaque = true
+ background = palette.listRowBg
+ add(checkBox, BorderLayout.WEST)
+ add(content, BorderLayout.CENTER)
+ }
+
+ add(rowContainer, BorderLayout.CENTER)
+ updateBackground()
+ propagateClicks(content)
+ propagateClicks(snippet)
+ propagateClicks(meta)
+
+ componentPopupMenu = createPopupMenu()
+ addMouseListener(object : MouseAdapter() {
+ override fun mouseClicked(e: MouseEvent) {
+ if (SwingUtilities.isLeftMouseButton(e)) {
+ selectRow(recordIndex)
+ if (e.clickCount == 2) {
+ interactions.onSnapshot(record)
+ interactions.onOpenDetail(record)
+ markActivated(record.id)
+ }
+ } else if (SwingUtilities.isRightMouseButton(e)) {
+ selectRow(recordIndex)
+ }
+ }
+
+ })
+ }
+
+ fun setSelected(selected: Boolean) {
+ isRowSelected = selected
+ border = defaultBorder
+ updateBackground()
+ }
+
+ private fun createPopupMenu() = JPopupMenu().apply {
+ add(JMenuItem("Edit Comment").apply {
+ addActionListener {
+ selectRow(recordIndex, ensureVisible = true)
+ interactions.onEdit(record, recordIndex)
+ }
+ })
+ add(JMenuItem("Delete").apply {
+ addActionListener { interactions.onDelete(recordIndex, record) }
+ })
+ }
+
+ fun scrollIntoView() {
+ SwingUtilities.invokeLater {
+ val container = parent as? JComponent ?: return@invokeLater
+ container.scrollRectToVisible(bounds)
+ }
+ }
+
+ private fun updateBackground() {
+ val base = when {
+ record.id == activatedRecordId -> ACTIVATED_ROW_COLOR
+ isRowSelected -> SELECTED_ROW_COLOR
+ else -> palette.listRowBg
+ }
+ background = base
+ rowContainer.background = base
+ }
+
+ fun setChecked(checked: Boolean) {
+ suppressToggle = true
+ checkBox.isSelected = checked
+ suppressToggle = false
+ }
+
+ fun refreshVisualState() = updateBackground()
+
+ private fun propagateClicks(component: JComponent) {
+ component.addMouseListener(object : MouseAdapter() {
+ override fun mouseClicked(e: MouseEvent) {
+ when {
+ SwingUtilities.isLeftMouseButton(e) -> {
+ this@ListRow.dispatchEvent(
+ SwingUtilities.convertMouseEvent(
+ component,
+ e,
+ this@ListRow
+ )
+ )
+ }
+
+ SwingUtilities.isRightMouseButton(e) -> {
+ selectRow(recordIndex)
+ showPopup(e)
+ }
+ }
+ }
+
+ override fun mousePressed(e: MouseEvent) = showPopup(e)
+
+ override fun mouseReleased(e: MouseEvent) = showPopup(e)
+
+ private fun showPopup(e: MouseEvent) {
+ if (e.isPopupTrigger) {
+ selectRow(recordIndex)
+ this@ListRow.componentPopupMenu?.show(e.component, e.x, e.y)
+ }
+ }
+ })
+ }
+
+ private fun createSnippetComponent(comment: String): JBTextArea =
+ object : JBTextArea(truncate(extractTitleLine(comment))) {
+ init {
+ lineWrap = true
+ wrapStyleWord = true
+ isEditable = false
+ isOpaque = false
+ border = JBUI.Borders.empty(6, 0, 4, 0)
+ foreground = palette.editorFg
+ }
+
+ override fun getMinimumSize(): Dimension {
+ val size = super.getPreferredSize()
+ return Dimension(0, size.height)
+ }
+ }
+ }
+
+ private fun extractTitleLine(comment: String): String {
+ val firstLine = comment.lineSequence().firstOrNull() ?: ""
+ val normalized = firstLine.trim()
+ return normalized.ifEmpty { "(No comment provided)" }
+ }
+
+ private fun truncate(value: String): String =
+ StringUtil.shortenTextWithEllipsis(value, MAX_SNIPPET_LENGTH, 0)
+
+ private fun toggleSelection(recordId: Long, selected: Boolean) {
+ val changed = if (selected) {
+ selectedRecordIds.add(recordId)
+ } else {
+ selectedRecordIds.remove(recordId)
+ }
+ if (changed) {
+ interactions.onSelectionChanged(selectedRecordIds.toSet())
+ }
+ }
+
+ private fun markActivated(recordId: Long) {
+ if (activatedRecordId == recordId) return
+ activatedRecordId = recordId
+ rowByIndex.values.forEach { it.refreshVisualState() }
+ }
+
+ private fun formatDate(timestamp: String): String =
+ runCatching { LocalDate.parse(timestamp.substring(0, 10)) }
+ .getOrNull()
+ ?.format(DateTimeFormatter.ISO_LOCAL_DATE)
+ ?: timestamp.substringBefore('T', timestamp)
+
+ private fun formatTime(timestamp: String): String =
+ runCatching { LocalDateTime.parse(timestamp) }
+ .getOrNull()
+ ?.format(DateTimeFormatter.ofPattern("HH:mm"))
+ ?: timestamp.take(5)
+}
diff --git a/src/main/kotlin/com/github/yeoli/devlog/ui/NotePanel.kt b/src/main/kotlin/com/github/yeoli/devlog/ui/NotePanel.kt
new file mode 100644
index 0000000..2672e26
--- /dev/null
+++ b/src/main/kotlin/com/github/yeoli/devlog/ui/NotePanel.kt
@@ -0,0 +1,154 @@
+package com.github.yeoli.devlog.ui
+
+import com.intellij.ui.DocumentAdapter
+import com.intellij.ui.JBColor
+import com.intellij.ui.components.JBLabel
+import com.intellij.ui.components.JBPanel
+import com.intellij.ui.components.JBScrollPane
+import com.intellij.ui.components.JBTextArea
+import com.intellij.util.ui.JBUI
+import java.awt.BorderLayout
+import java.awt.GraphicsEnvironment
+import java.awt.Toolkit
+import java.awt.event.ActionEvent
+import java.awt.event.KeyAdapter
+import java.awt.event.KeyEvent
+import javax.swing.AbstractAction
+import javax.swing.JComponent
+import javax.swing.KeyStroke
+import javax.swing.Timer
+import javax.swing.text.JTextComponent
+
+class NotePanel(
+ private val palette: UiPalette,
+ private val onRequestSave: (String) -> Unit
+) {
+ private val autoSaveTimer = Timer(AUTO_SAVE_DELAY_MS) {
+ performAutoSave()
+ }.apply {
+ isRepeats = false
+ }
+ private val notesArea = JBTextArea(6, 40).apply {
+ lineWrap = true
+ wrapStyleWord = true
+ border = JBUI.Borders.empty(8, 12, 8, 12)
+ background = palette.editorBg
+ foreground = palette.editorFg
+ caretColor = palette.editorFg
+ }
+ private val statusLabel = JBLabel("Saved").apply {
+ foreground = JBColor.gray
+ }
+ private var hasUnsavedChanges = false
+ private var suppressEvent = false
+
+ val component: JComponent = JBPanel>(BorderLayout()).apply {
+ background = palette.editorBg
+ foreground = palette.editorFg
+ border = JBUI.Borders.empty(12, 0, 0, 0)
+ val header = JBLabel("Notes").apply {
+ border = JBUI.Borders.empty(0, 12, 8, 12)
+ }
+ val scrollPane = JBScrollPane(notesArea).apply {
+ border = JBUI.Borders.empty()
+ background = palette.editorBg
+ viewport.background = palette.editorBg
+ }
+ add(header, BorderLayout.NORTH)
+ add(scrollPane, BorderLayout.CENTER)
+ add(createControls(), BorderLayout.SOUTH)
+ }
+
+ init {
+ notesArea.document.addDocumentListener(object : DocumentAdapter() {
+ override fun textChanged(e: javax.swing.event.DocumentEvent) {
+ if (suppressEvent) return
+ hasUnsavedChanges = true
+ statusLabel.text = "Saving..."
+ scheduleAutoSave()
+ }
+ })
+ registerSaveShortcut(notesArea) {
+ triggerManualSave()
+ }
+ }
+
+ private fun createControls(): JComponent = JBPanel>(BorderLayout()).apply {
+ background = palette.editorBg
+ border = JBUI.Borders.empty(0, 12, 12, 12)
+ add(statusLabel, BorderLayout.WEST)
+ }
+
+ fun setContent(value: String) {
+ suppressEvent = true
+ notesArea.text = value
+ suppressEvent = false
+ hasUnsavedChanges = false
+ cancelAutoSave()
+ statusLabel.text = "Saved"
+ }
+
+ fun markSaved(timestamp: String) {
+ cancelAutoSave()
+ hasUnsavedChanges = false
+ statusLabel.text = "Saved at $timestamp"
+ }
+
+ private fun scheduleAutoSave() {
+ if (!hasUnsavedChanges) return
+ autoSaveTimer.restart()
+ }
+
+ private fun cancelAutoSave() {
+ if (autoSaveTimer.isRunning) {
+ autoSaveTimer.stop()
+ }
+ }
+
+ private fun performAutoSave() {
+ if (!hasUnsavedChanges) return
+ statusLabel.text = "Auto-saving..."
+ hasUnsavedChanges = false
+ onRequestSave(notesArea.text)
+ }
+
+ private fun triggerManualSave() {
+ if (!hasUnsavedChanges) return
+ cancelAutoSave()
+ performAutoSave()
+ }
+
+ companion object {
+ private const val AUTO_SAVE_DELAY_MS = 2000
+ }
+}
+
+private fun registerSaveShortcut(component: JComponent, action: () -> Unit) {
+ if (GraphicsEnvironment.isHeadless()) return
+ val shortcutMask = Toolkit.getDefaultToolkit().menuShortcutKeyMaskEx
+ val actionKey = "devlog.saveShortcut.${component.hashCode()}"
+ val keyStrokes = listOf(
+ KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, shortcutMask),
+ KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, shortcutMask or KeyEvent.SHIFT_DOWN_MASK)
+ )
+ keyStrokes.forEach { keyStroke ->
+ component.getInputMap(JComponent.WHEN_FOCUSED).put(keyStroke, actionKey)
+ component.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(keyStroke, actionKey)
+ }
+ component.actionMap.put(actionKey, object : AbstractAction() {
+ override fun actionPerformed(e: ActionEvent?) {
+ action()
+ }
+ })
+ if (component is JTextComponent) {
+ component.addKeyListener(object : KeyAdapter() {
+ override fun keyPressed(e: KeyEvent) {
+ val metaCombo = e.isMetaDown || e.isControlDown
+ if (metaCombo && e.keyCode == KeyEvent.VK_ENTER) {
+ e.consume()
+ action()
+ }
+ }
+ })
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/github/yeoli/devlog/ui/SelectionStatusPanel.kt b/src/main/kotlin/com/github/yeoli/devlog/ui/SelectionStatusPanel.kt
new file mode 100644
index 0000000..ea589d2
--- /dev/null
+++ b/src/main/kotlin/com/github/yeoli/devlog/ui/SelectionStatusPanel.kt
@@ -0,0 +1,43 @@
+package com.github.yeoli.devlog.ui
+
+import com.intellij.ui.JBColor
+import com.intellij.ui.components.JBLabel
+import com.intellij.ui.components.JBPanel
+import com.intellij.util.ui.JBUI
+import java.awt.BorderLayout
+import javax.swing.JComponent
+
+class SelectionStatusPanel(
+ private val palette: UiPalette
+) {
+ private val infoLabel = JBLabel().apply {
+ foreground = JBColor.gray
+ font = font.deriveFont(font.size2D - 1f)
+ horizontalAlignment = JBLabel.LEFT
+ verticalAlignment = JBLabel.CENTER
+ }
+
+ val component: JComponent = JBPanel>(BorderLayout()).apply {
+ background = palette.editorBg
+ foreground = palette.editorFg
+ border = JBUI.Borders.empty(4, 12, 4, 12)
+ add(infoLabel, BorderLayout.CENTER)
+ val fixedHeight = 30
+ preferredSize = JBUI.size(0, fixedHeight)
+ minimumSize = JBUI.size(0, fixedHeight)
+ }
+
+ init {
+ showIdleState()
+ }
+
+ fun showSelectionActive(fileName: String) {
+ infoLabel.text = "Selected $fileName"
+ infoLabel.foreground = JBColor.gray
+ }
+
+ fun showIdleState() {
+ infoLabel.text = "No code selected."
+ infoLabel.foreground = JBColor.gray
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/github/yeoli/devlog/ui/action/DeleteSelectedAction.kt b/src/main/kotlin/com/github/yeoli/devlog/ui/action/DeleteSelectedAction.kt
new file mode 100644
index 0000000..3be3d68
--- /dev/null
+++ b/src/main/kotlin/com/github/yeoli/devlog/ui/action/DeleteSelectedAction.kt
@@ -0,0 +1,57 @@
+package com.github.yeoli.devlog.ui.action
+
+import com.github.yeoli.devlog.domain.memo.domain.Memo
+import com.intellij.icons.AllIcons
+import com.intellij.notification.NotificationGroupManager
+import com.intellij.notification.NotificationType
+import com.intellij.openapi.actionSystem.AnActionEvent
+import com.intellij.openapi.project.DumbAwareAction
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.ui.Messages
+
+class DeleteSelectedAction(
+ private val project: Project,
+ private val selectionProvider: () -> List,
+ private val onDelete: (List) -> Unit
+) : DumbAwareAction(
+ "Delete Selected Logs",
+ "Delete all checked DevLog entries",
+ AllIcons.Actions.GC
+) {
+
+ private var forceDisabled = false
+
+ fun setForceDisabled(disabled: Boolean) {
+ forceDisabled = disabled
+ }
+
+ override fun actionPerformed(e: AnActionEvent) {
+ if (forceDisabled) return
+ val selected = selectionProvider()
+ if (selected.isEmpty()) {
+ NotificationGroupManager.getInstance()
+ .getNotificationGroup("YeoliRetrospectNotifications")
+ ?.createNotification(
+ "Delete Selected Logs",
+ "No DevLog entries are selected.",
+ NotificationType.WARNING
+ )
+ ?.notify(project)
+ return
+ }
+ val confirmed = Messages.showYesNoDialog(
+ project,
+ "Delete ${selected.size} selected DevLog entries?",
+ "Delete Selected Logs",
+ Messages.getQuestionIcon()
+ )
+ if (confirmed == Messages.YES) {
+ onDelete(selected)
+ }
+ }
+
+ override fun update(e: AnActionEvent) {
+ super.update(e)
+ e.presentation.isEnabled = !forceDisabled && selectionProvider().isNotEmpty()
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/github/yeoli/devlog/ui/action/MemoExportAction.kt b/src/main/kotlin/com/github/yeoli/devlog/ui/action/MemoExportAction.kt
new file mode 100644
index 0000000..1a4f244
--- /dev/null
+++ b/src/main/kotlin/com/github/yeoli/devlog/ui/action/MemoExportAction.kt
@@ -0,0 +1,91 @@
+package com.github.yeoli.devlog.ui.action
+
+import com.github.yeoli.devlog.domain.memo.domain.Memo
+import com.github.yeoli.devlog.ui.MemoExportPipeline
+import com.intellij.icons.AllIcons
+import com.intellij.notification.NotificationGroupManager
+import com.intellij.notification.NotificationType
+import com.intellij.openapi.actionSystem.AnActionEvent
+import com.intellij.openapi.fileChooser.FileChooserFactory
+import com.intellij.openapi.fileChooser.FileSaverDescriptor
+import com.intellij.openapi.ide.CopyPasteManager
+import com.intellij.openapi.project.DumbAwareAction
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.util.io.FileUtil
+import com.intellij.openapi.vfs.VirtualFile
+import java.awt.datatransfer.StringSelection
+import javax.swing.Icon
+
+class MemoExportAction(
+ private val project: Project,
+ private val recordsProvider: (() -> List)? = null,
+ actionText: String = "Download DevLog Logs",
+ actionDescription: String = "Download DevLog entries as Markdown text",
+ icon: Icon = AllIcons.Actions.Download
+) : DumbAwareAction(actionText, actionDescription, icon) {
+
+ private var forceDisabled = false
+
+ fun setForceDisabled(disabled: Boolean) {
+ forceDisabled = disabled
+ }
+
+ override fun actionPerformed(e: AnActionEvent) {
+ if (forceDisabled) return
+ val recordsOverride = recordsProvider?.invoke()
+ if (recordsProvider != null && (recordsOverride == null || recordsOverride.isEmpty())) {
+ notify("No DevLog entries selected to export.", NotificationType.WARNING)
+ return
+ }
+ val pipeline = MemoExportPipeline(project)
+ val payload = pipeline.buildPayload(recordsOverride)
+
+ CopyPasteManager.getInstance().setContents(StringSelection(payload.content))
+
+ val descriptor = FileSaverDescriptor(
+ "Save DevLog Export",
+ "Choose where to save the DevLog log export.",
+ payload.fileExtension
+ )
+ val dialog = FileChooserFactory.getInstance().createSaveFileDialog(descriptor, project)
+ val wrapper = dialog.save(null as VirtualFile?, payload.defaultFileName)
+
+ if (wrapper == null) {
+ notify(
+ "Export cancelled. Markdown remains copied to clipboard.",
+ NotificationType.WARNING
+ )
+ return
+ }
+
+ try {
+ FileUtil.writeToFile(wrapper.file, payload.content)
+ wrapper.virtualFile?.refresh(false, false)
+ notify("Saved DevLog log to ${wrapper.file.path} and copied it to the clipboard.")
+ } catch (ex: Exception) {
+ notify("Failed to save file: ${ex.message}", NotificationType.ERROR)
+ }
+ }
+
+ override fun update(e: AnActionEvent) {
+ super.update(e)
+ if (forceDisabled) {
+ e.presentation.isEnabled = false
+ return
+ }
+ if (recordsProvider != null) {
+ e.presentation.isEnabled = recordsProvider.invoke().isNotEmpty()
+ }
+ }
+
+ private fun notify(message: String, type: NotificationType = NotificationType.INFORMATION) {
+ NotificationGroupManager.getInstance()
+ .getNotificationGroup(NOTIFICATION_GROUP_ID)
+ ?.createNotification("DevLog Export", message, type)
+ ?.notify(project)
+ }
+
+ companion object {
+ private const val NOTIFICATION_GROUP_ID = "YeoliRetrospectNotifications"
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/github/yeoli/devlog/ui/action/ToggleSelectionAction.kt b/src/main/kotlin/com/github/yeoli/devlog/ui/action/ToggleSelectionAction.kt
new file mode 100644
index 0000000..642b4d1
--- /dev/null
+++ b/src/main/kotlin/com/github/yeoli/devlog/ui/action/ToggleSelectionAction.kt
@@ -0,0 +1,49 @@
+package com.github.yeoli.devlog.ui.action
+
+import com.intellij.icons.AllIcons
+import com.intellij.openapi.actionSystem.AnActionEvent
+import com.intellij.openapi.project.DumbAwareAction
+
+class ToggleSelectionAction(
+ private val onSelectAll: () -> Unit,
+ private val onClearSelection: () -> Unit
+) : DumbAwareAction("Select All Logs", "Select every DevLog entry", AllIcons.Actions.Selectall) {
+
+ private var totalItems: Int = 0
+ private var selectedItems: Int = 0
+ private var controlsEnabled: Boolean = true
+
+ fun updateState(total: Int, selected: Int) {
+ totalItems = total
+ selectedItems = selected
+ }
+
+ fun setControlsEnabled(enabled: Boolean) {
+ controlsEnabled = enabled
+ }
+
+ override fun actionPerformed(e: AnActionEvent) {
+ if (!controlsEnabled || totalItems == 0) return
+ if (selectedItems == totalItems) {
+ onClearSelection()
+ } else {
+ onSelectAll()
+ }
+ }
+
+ override fun update(e: AnActionEvent) {
+ super.update(e)
+ val hasRecords = totalItems > 0
+ val allSelected = hasRecords && selectedItems == totalItems
+ e.presentation.isEnabled = controlsEnabled && hasRecords
+ if (allSelected) {
+ e.presentation.text = "Clear Selection"
+ e.presentation.description = "Clear all DevLog selections"
+ e.presentation.icon = AllIcons.Actions.Unselectall
+ } else {
+ e.presentation.text = "Select All"
+ e.presentation.description = "Select every DevLog entry"
+ e.presentation.icon = AllIcons.Actions.Selectall
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml
index 533e524..ca85f31 100644
--- a/src/main/resources/META-INF/plugin.xml
+++ b/src/main/resources/META-INF/plugin.xml
@@ -1,15 +1,28 @@
- com.github.yeoli.devlog
- dev-log
- yeo-li
+ com.intellij.modules.platform
+ Git4Idea
+
+
+
+
+
+
+
+
+ com.github.yeoli.devlog
- com.intellij.modules.platform
+ DevLog
- messages.MyBundle
+ messages.MyBundle
-
-
-
-
+ yeo-li
+ 1.0.0
diff --git a/src/main/resources/META-INF/pluginIcon.svg b/src/main/resources/META-INF/pluginIcon.svg
new file mode 100644
index 0000000..8202c11
--- /dev/null
+++ b/src/main/resources/META-INF/pluginIcon.svg
@@ -0,0 +1,15 @@
+
\ No newline at end of file
diff --git a/src/main/resources/META-INF/pluginIcon_dark.svg b/src/main/resources/META-INF/pluginIcon_dark.svg
new file mode 100644
index 0000000..f94b880
--- /dev/null
+++ b/src/main/resources/META-INF/pluginIcon_dark.svg
@@ -0,0 +1,15 @@
+
\ No newline at end of file
diff --git a/src/test/kotlin/com/github/yeoli/devlog/MyPluginTest.kt b/src/test/kotlin/com/github/yeoli/devlog/MyPluginTest.kt
deleted file mode 100644
index a3c91b9..0000000
--- a/src/test/kotlin/com/github/yeoli/devlog/MyPluginTest.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-package com.github.yeoli.devlog
-
-import com.intellij.ide.highlighter.XmlFileType
-import com.intellij.openapi.components.service
-import com.intellij.psi.xml.XmlFile
-import com.intellij.testFramework.TestDataPath
-import com.intellij.testFramework.fixtures.BasePlatformTestCase
-import com.intellij.util.PsiErrorElementUtil
-import com.github.yeoli.devlog.services.MyProjectService
-
-@TestDataPath("\$CONTENT_ROOT/src/test/testData")
-class MyPluginTest : BasePlatformTestCase() {
-
- fun testXMLFile() {
- val psiFile = myFixture.configureByText(XmlFileType.INSTANCE, "bar")
- val xmlFile = assertInstanceOf(psiFile, XmlFile::class.java)
-
- assertFalse(PsiErrorElementUtil.hasErrors(project, xmlFile.virtualFile))
-
- assertNotNull(xmlFile.rootTag)
-
- xmlFile.rootTag?.let {
- assertEquals("foo", it.name)
- assertEquals("bar", it.value.text)
- }
- }
-
- fun testRename() {
- myFixture.testRename("foo.xml", "foo_after.xml", "a2")
- }
-
- fun testProjectService() {
- val projectService = project.service()
-
- assertNotSame(projectService.getRandomNumber(), projectService.getRandomNumber())
- }
-
- override fun getTestDataPath() = "src/test/testData/rename"
-}
diff --git a/src/test/kotlin/com/github/yeoli/devlog/domain/memo/domain/MemoTest.kt b/src/test/kotlin/com/github/yeoli/devlog/domain/memo/domain/MemoTest.kt
new file mode 100644
index 0000000..84d3510
--- /dev/null
+++ b/src/test/kotlin/com/github/yeoli/devlog/domain/memo/domain/MemoTest.kt
@@ -0,0 +1,199 @@
+package com.github.yeoli.devlog.domain.memo.domain
+
+import com.github.yeoli.devlog.domain.memo.repository.MemoState
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+import kotlin.test.*
+
+class MemoTest {
+
+ @Test
+ fun `test Memo 생성 성공`() {
+ // given & then
+ val memo = Memo(
+ content = "테스트 메모",
+ commitHash = "abc123",
+ filePath = "/path/SampleFile.kt",
+ selectedCodeSnippet = "val selected = 42",
+ fullCodeSnapshot = "full code",
+ selectionStart = 5,
+ selectionEnd = 10,
+ visibleStart = 1,
+ visibleEnd = 20
+ )
+
+ // then
+ assertEquals("테스트 메모", memo.content)
+ assertEquals("abc123", memo.commitHash)
+ assertEquals("/path/SampleFile.kt", memo.filePath)
+ assertEquals("val selected = 42", memo.selectedCodeSnippet)
+ assertEquals(5, memo.selectionStart)
+ assertEquals(10, memo.selectionEnd)
+ assertEquals(1, memo.visibleStart)
+ assertEquals(20, memo.visibleEnd)
+ assertTrue(memo.id > 0)
+ assertNotNull(memo.createdAt)
+ assertNotNull(memo.updatedAt)
+ }
+
+ @Test
+ fun `test Memo 생성 실패 selection 범위`() {
+ assertFailsWith {
+ Memo(
+ content = "잘못된 메모",
+ commitHash = "abc123",
+ filePath = "/path/SampleFile.kt",
+ selectedCodeSnippet = "val selected = 42",
+ fullCodeSnapshot = "full code",
+ selectionStart = 10,
+ selectionEnd = 5,
+ visibleStart = null,
+ visibleEnd = null
+ )
+ }
+ }
+
+ @Test
+ fun `test Memo 생성 실패 visible 범위`() {
+ // when & then
+ assertFailsWith {
+ Memo(
+ content = "잘못된 메모",
+ commitHash = "abc123",
+ filePath = "/path/SampleFile.kt",
+ selectedCodeSnippet = "val selected = 42",
+ fullCodeSnapshot = "full code",
+ selectionStart = 5,
+ selectionEnd = 10,
+ visibleStart = 20,
+ visibleEnd = 10
+ )
+ }
+ }
+
+ @Test
+ fun `test MemoState 변환 성공`() {
+ // given
+ val memo = Memo(
+ content = "테스트 메모",
+ commitHash = "abc123",
+ filePath = "/path/SampleFile.kt",
+ selectedCodeSnippet = "val selected = 42",
+ fullCodeSnapshot = "full code",
+ selectionStart = 5,
+ selectionEnd = 10,
+ visibleStart = 1,
+ visibleEnd = 20
+ )
+
+ // when
+ val memoState: MemoState = memo.toState()
+
+ // then
+ assertEquals(memo.id, memoState.id)
+ assertEquals(memo.createdAt.toString(), memoState.createdAt)
+ assertEquals(memo.updatedAt.toString(), memoState.updatedAt)
+ assertEquals("테스트 메모", memoState.content)
+ assertEquals("abc123", memoState.commitHash)
+ assertEquals("/path/SampleFile.kt", memoState.filePath)
+ assertEquals("val selected = 42", memoState.selectedCodeSnippet)
+ assertEquals(5, memoState.selectionStart)
+ assertEquals(10, memoState.selectionEnd)
+ assertEquals(1, memoState.visibleStart)
+ assertEquals(20, memoState.visibleEnd)
+ assertTrue(memoState.id > 0)
+ assertNotNull(memoState.createdAt)
+ assertNotNull(memoState.updatedAt)
+ }
+
+ @Test
+ fun `test buildMemoBlock - 정상적으로 문자열 생성`() {
+ val created = LocalDateTime.of(2025, 11, 22, 13, 11, 10)
+ val updated = LocalDateTime.of(2025, 11, 22, 13, 12, 0)
+
+ val memo = Memo(
+ id = 1L,
+ createdAt = created,
+ updatedAt = updated,
+ content = "메모 내용입니다.",
+ commitHash = "abc123",
+ filePath = "/path/to/file",
+ selectedCodeSnippet = "println(\"Hello\")",
+ selectionStart = 0,
+ selectionEnd = 5,
+ visibleStart = 1,
+ visibleEnd = 10
+ )
+
+ val block = memo.buildMemoBlock(1)
+
+ assertTrue(block.contains("# Memo 1"))
+ assertTrue(block.contains("📅 생성 시간 : ${created.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))}"))
+ assertTrue(block.contains("📅 수정 시간 : ${updated.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))}"))
+ assertTrue(block.contains("📌 Content"))
+ assertTrue(block.contains("메모 내용입니다."))
+ assertTrue(block.contains("Commit: abc123"))
+ assertTrue(block.contains("File Path: /path/to/file"))
+ assertTrue(block.contains("Visible Lines: 1 ~ 10"))
+ assertTrue(block.contains("println(\"Hello\")"))
+ }
+
+ @Test
+ fun `test buildMemoBlock - 모든 값이 있을 때 정상적으로 표시되는지`() {
+ val created = LocalDateTime.of(2025, 11, 22, 13, 11, 10)
+ val updated = LocalDateTime.of(2025, 11, 22, 13, 15, 30)
+
+ val memo = Memo(
+ id = 2L,
+ createdAt = created,
+ updatedAt = updated,
+ content = "전체 필드 테스트",
+ commitHash = "ff12aa",
+ filePath = "/full/path/file.kt",
+ selectedCodeSnippet = "val x = 10",
+ fullCodeSnapshot = "full code",
+ selectionStart = 3,
+ selectionEnd = 9,
+ visibleStart = 2,
+ visibleEnd = 12
+ )
+
+ val block = memo.buildMemoBlock(2)
+
+ assertTrue(block.contains("# Memo 2"))
+ assertTrue(block.contains("📅 생성 시간 : ${created.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))}"))
+ assertTrue(block.contains("📅 수정 시간 : ${updated.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))}"))
+ assertTrue(block.contains("전체 필드 테스트"))
+ assertTrue(block.contains("Commit: ff12aa"))
+ assertTrue(block.contains("File Path: /full/path/file.kt"))
+ assertTrue(block.contains("Visible Lines: 2 ~ 12"))
+ assertTrue(block.contains("val x = 10"))
+ }
+
+ @Test
+ fun `test buildMemoBlock - null 값들이 기본값으로 표시되는지`() {
+ val created = LocalDateTime.of(2025, 11, 22, 13, 11, 10)
+
+ val memo = Memo(
+ id = 1L,
+ createdAt = created,
+ updatedAt = created,
+ content = "내용",
+ commitHash = null,
+ filePath = null,
+ selectedCodeSnippet = null,
+ fullCodeSnapshot = null,
+ selectionStart = null,
+ selectionEnd = null,
+ visibleStart = null,
+ visibleEnd = null
+ )
+
+ val block = memo.buildMemoBlock(0)
+
+ assertTrue(block.contains("Commit: N/A"))
+ assertTrue(block.contains("File Path: N/A"))
+ assertTrue(block.contains("Visible Lines: ? ~ ?"))
+ assertTrue(block.contains("(no selected code)"))
+ }
+}
diff --git a/src/test/kotlin/com/github/yeoli/devlog/domain/memo/repository/MemoStateTest.kt b/src/test/kotlin/com/github/yeoli/devlog/domain/memo/repository/MemoStateTest.kt
new file mode 100644
index 0000000..9d232a9
--- /dev/null
+++ b/src/test/kotlin/com/github/yeoli/devlog/domain/memo/repository/MemoStateTest.kt
@@ -0,0 +1,48 @@
+package com.github.yeoli.devlog.domain.memo.repository
+
+import com.github.yeoli.devlog.domain.memo.domain.Memo
+import org.junit.jupiter.api.Test
+import java.time.LocalDateTime
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+class MemoStateTest {
+
+ @Test
+ fun `test 도메인 변환 성공`() {
+ // given
+ val memoState = MemoState(
+ id = 0L,
+ createdAt = LocalDateTime.now().toString(),
+ updatedAt = LocalDateTime.now().toString(),
+ content = "테스트 메모",
+ commitHash = "abc123",
+ filePath = "/path/SampleFile.kt",
+ selectedCodeSnippet = "val selected = 42",
+ selectionStart = 5,
+ selectionEnd = 10,
+ visibleStart = 1,
+ visibleEnd = 20
+ )
+
+ // when
+ val memo: Memo = memoState.toDomain()
+
+ // then
+ assertEquals(memo.id, memoState.id)
+ assertEquals(memo.createdAt.toString(), memoState.createdAt)
+ assertEquals(memo.updatedAt.toString(), memoState.updatedAt)
+ assertEquals("테스트 메모", memoState.content)
+ assertEquals("abc123", memoState.commitHash)
+ assertEquals("/path/SampleFile.kt", memoState.filePath)
+ assertEquals("val selected = 42", memoState.selectedCodeSnippet)
+ assertEquals(5, memoState.selectionStart)
+ assertEquals(10, memoState.selectionEnd)
+ assertEquals(1, memoState.visibleStart)
+ assertEquals(20, memoState.visibleEnd)
+ assertTrue(memoState.id > 0)
+ assertNotNull(memoState.createdAt)
+ assertNotNull(memoState.updatedAt)
+ }
+}
diff --git a/src/test/kotlin/com/github/yeoli/devlog/domain/memo/service/MemoServiceTest.kt b/src/test/kotlin/com/github/yeoli/devlog/domain/memo/service/MemoServiceTest.kt
new file mode 100644
index 0000000..c48dff9
--- /dev/null
+++ b/src/test/kotlin/com/github/yeoli/devlog/domain/memo/service/MemoServiceTest.kt
@@ -0,0 +1,620 @@
+package com.github.yeoli.devlog.domain.memo.service
+
+import com.github.yeoli.devlog.domain.memo.domain.Memo
+import com.github.yeoli.devlog.domain.memo.repository.MemoRepository
+import com.intellij.mock.MockFileDocumentManagerImpl
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.editor.EditorFactory
+import com.intellij.openapi.fileEditor.FileDocumentManager
+import com.intellij.openapi.fileEditor.FileEditorManager
+import com.intellij.openapi.util.Disposer
+import com.intellij.testFramework.fixtures.BasePlatformTestCase
+import com.intellij.testFramework.replaceService
+import com.intellij.util.Function
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import java.awt.Point
+import java.time.LocalDateTime
+import kotlin.test.assertTrue
+
+class MemoServiceTest : BasePlatformTestCase() {
+
+ private lateinit var memoRepository: MemoRepository
+
+ override fun setUp() {
+ super.setUp()
+
+ // mock 생성
+ memoRepository = mock()
+
+ project.replaceService(
+ MemoRepository::class.java,
+ memoRepository,
+ testRootDisposable
+ )
+ }
+
+ fun `test 메모 생성 성공`() {
+ // given
+ val psiFile = myFixture.configureByText(
+ "SampleFile.kt",
+ """
+ fun main() {
+ val selected = 42
+ println(selected)
+ }
+ """.trimIndent()
+ )
+ val memoContent = "테스트 메모"
+ val editor = myFixture.editor
+ val document = editor.document
+ val targetSnippet = "val selected = 42"
+ val selectionStart = document.text.indexOf(targetSnippet)
+ assertTrue("선택할 코드 스니펫을 찾지 못했습니다.", selectionStart >= 0)
+ val selectionEnd = selectionStart + targetSnippet.length
+ editor.selectionModel.setSelection(selectionStart, selectionEnd)
+
+ val visibleArea = editor.scrollingModel.visibleAreaOnScrollingFinished
+ val expectedVisibleStart = editor.xyToLogicalPosition(visibleArea.location).line
+ val expectedVisibleEnd = editor.xyToLogicalPosition(
+ Point(visibleArea.x, visibleArea.y + visibleArea.height)
+ ).line
+
+ // when
+ val memo: Memo? = MemoService(project).createMemo(memoContent)
+ // then
+ if (memo != null) {
+ assertEquals(memoContent, memo.content)
+ assertEquals(targetSnippet, memo.selectedCodeSnippet)
+ assertEquals(psiFile.virtualFile.path, memo.filePath)
+ assertEquals(selectionStart, memo.selectionStart)
+ assertEquals(selectionEnd, memo.selectionEnd)
+ assertEquals(expectedVisibleStart, memo.visibleStart)
+ assertEquals(expectedVisibleEnd, memo.visibleEnd)
+ assertNull(memo.commitHash)
+ } else {
+ fail("memo가 null 입니다.")
+ }
+
+ }
+
+ fun `test 메모 생성 선택없음`() {
+ // given
+ val psiFile = myFixture.configureByText(
+ "SampleFile.kt",
+ """
+ fun main() {
+ val selected = 42
+ println(selected)
+ }
+ """.trimIndent()
+ )
+ val memoContent = "선택 없음 메모"
+ val editor = myFixture.editor
+ val document = editor.document
+ val caretTarget = document.text.indexOf("println(selected)")
+ assertTrue("커서를 이동할 코드를 찾지 못했습니다.", caretTarget >= 0)
+ editor.caretModel.moveToOffset(caretTarget)
+ editor.selectionModel.removeSelection()
+
+ val visibleArea = editor.scrollingModel.visibleAreaOnScrollingFinished
+ val expectedVisibleStart = editor.xyToLogicalPosition(visibleArea.location).line
+ val expectedVisibleEnd = editor.xyToLogicalPosition(
+ Point(visibleArea.x, visibleArea.y + visibleArea.height)
+ ).line
+
+ // when
+ val memo: Memo? = MemoService(project).createMemo(memoContent)
+ // then
+ if (memo != null) {
+ assertEquals(memoContent, memo.content)
+ assertNull(memo.selectedCodeSnippet)
+ assertEquals(caretTarget, memo.selectionStart)
+ assertEquals(caretTarget, memo.selectionEnd)
+ assertEquals(psiFile.virtualFile.path, memo.filePath)
+ assertEquals(expectedVisibleStart, memo.visibleStart)
+ assertEquals(expectedVisibleEnd, memo.visibleEnd)
+ assertNull(memo.commitHash)
+ } else {
+ fail("memo가 null 입니다.")
+ }
+ }
+
+ fun `test 메모 생성 에디터 없음 예외`() {
+ // given
+ val psiFile = myFixture.configureByText(
+ "SampleFile.kt",
+ """
+ fun main() {}
+ """.trimIndent()
+ )
+ val fileEditorManager = FileEditorManager.getInstance(project)
+ fileEditorManager.closeFile(psiFile.virtualFile)
+ assertNull("선택된 에디터가 없어야 합니다.", fileEditorManager.selectedTextEditor)
+
+ // expect
+ val memo: Memo? = MemoService(project).createMemo("에디터 없음")
+ assertNotNull(memo)
+ }
+
+ fun `test 메모 생성 파일경로 없음`() {
+ // given
+ myFixture.configureByText(
+ "SampleFile.kt",
+ """
+ fun main() {
+ val selected = 42
+ }
+ """.trimIndent()
+ )
+ val memoContent = "파일 경로 없음"
+ val editor = myFixture.editor
+ val document = editor.document
+ val snippet = "val selected = 42"
+ val selectionStart = document.text.indexOf(snippet)
+ val selectionEnd = selectionStart + snippet.length
+ editor.selectionModel.setSelection(selectionStart, selectionEnd)
+
+ val mockDisposable = Disposer.newDisposable()
+ val mockFileDocumentManager = MockFileDocumentManagerImpl(
+ null,
+ Function { text -> EditorFactory.getInstance().createDocument(text) }
+ )
+ ApplicationManager.getApplication().replaceService(
+ FileDocumentManager::class.java,
+ mockFileDocumentManager,
+ mockDisposable
+ )
+
+ try {
+ // when
+ val memo: Memo? = MemoService(project).createMemo(memoContent)
+
+ // then
+ if (memo != null) {
+ assertEquals(memoContent, memo.content)
+ assertEquals(snippet, memo.selectedCodeSnippet)
+ assertNull("파일 경로가 null 이어야 합니다.", memo.filePath)
+ assertEquals(selectionStart, memo.selectionStart)
+ assertEquals(selectionEnd, memo.selectionEnd)
+ } else {
+ fail("memo가 null 입니다.")
+ }
+
+ } finally {
+ Disposer.dispose(mockDisposable)
+ }
+ }
+
+ // ========= 메모 조회 기능 =========
+ fun `test 메모 단건 조회 - 성공`() {
+ // given
+ val now = LocalDateTime.now()
+ val memo = Memo(
+ id = 100L,
+ createdAt = now,
+ updatedAt = now,
+ content = "hello",
+ commitHash = null,
+ filePath = "/path/file.kt",
+ selectedCodeSnippet = null,
+ fullCodeSnapshot = null,
+ selectionStart = 0,
+ selectionEnd = 0,
+ visibleStart = 0,
+ visibleEnd = 0
+ )
+
+ whenever(memoRepository.findMemoById(100L)).thenReturn(memo)
+
+ val service = MemoService(project)
+
+ // when
+ val result = service.findMemoById(100L)
+
+ // then
+ assertNotNull(result)
+ assertEquals(100L, result!!.id)
+ assertEquals("hello", result.content)
+ org.mockito.kotlin.verify(memoRepository).findMemoById(100L)
+ }
+
+ fun `test 메모 단건 조회 - 없음`() {
+ // given
+ whenever(memoRepository.findMemoById(999L)).thenReturn(null)
+
+ val service = MemoService(project)
+
+ // when
+ val result = service.findMemoById(999L)
+
+ // then
+ assertNull(result)
+ org.mockito.kotlin.verify(memoRepository).findMemoById(999L)
+ }
+
+ fun `test 메모 전체 조회 기능 성공`() {
+ // given
+ val memo1 = Memo(
+ id = System.currentTimeMillis(),
+ createdAt = LocalDateTime.now(),
+ updatedAt = LocalDateTime.now(),
+ content = "메모1",
+ commitHash = null,
+ filePath = "/path/to/file1",
+ selectedCodeSnippet = "snippet1",
+ fullCodeSnapshot = "full code",
+ selectionStart = 0,
+ selectionEnd = 5,
+ visibleStart = 1,
+ visibleEnd = 3
+ )
+
+ val memo2 = Memo(
+ id = System.currentTimeMillis() + 1,
+ createdAt = LocalDateTime.now(),
+ updatedAt = LocalDateTime.now(),
+ content = "메모2",
+ commitHash = null,
+ filePath = "/path/to/file2",
+ selectedCodeSnippet = "snippet2",
+ selectionStart = 10,
+ selectionEnd = 20,
+ visibleStart = 4,
+ visibleEnd = 10
+ )
+
+ whenever(memoRepository.getAll()).thenReturn(listOf(memo1, memo2))
+
+ // when
+ val result = MemoService(project).getAllMemosOrderByCreatedAt()
+
+ // then
+ assertEquals(2, result.size)
+ assertEquals("메모1", result[0].content)
+ assertEquals("메모2", result[1].content)
+ assertEquals("/path/to/file1", result[0].filePath)
+ assertEquals("/path/to/file2", result[1].filePath)
+ }
+
+ fun `test 메모 전체 조회 기능 실패 - 예외 발생시 빈 리스트`() {
+ // given
+ whenever(memoRepository.getAll()).thenThrow(RuntimeException("DB error"))
+
+ // when
+ val result = MemoService(project).getAllMemosOrderByCreatedAt()
+
+ // then
+ assertTrue(result.isEmpty(), "예외 발생 시 빈 리스트를 반환해야 합니다.")
+ }
+
+
+ // ========= 메모 삭제 기능 =========
+ fun `test 메모 삭제 기능 - 정상 삭제`() {
+ val now = LocalDateTime.now()
+ val memo1 = Memo(
+ id = 1L,
+ createdAt = now,
+ updatedAt = now,
+ content = "a",
+ commitHash = null,
+ filePath = "/path/to/file1",
+ selectedCodeSnippet = null,
+ fullCodeSnapshot = null,
+ selectionStart = null,
+ selectionEnd = null,
+ visibleStart = null,
+ visibleEnd = null
+ )
+ val memo2 = Memo(
+ id = 2L,
+ createdAt = now,
+ updatedAt = now,
+ content = "b",
+ commitHash = null,
+ filePath = "/path/to/file2",
+ selectedCodeSnippet = null,
+ fullCodeSnapshot = null,
+ selectionStart = null,
+ selectionEnd = null,
+ visibleStart = null,
+ visibleEnd = null
+ )
+ val memos = listOf(memo1, memo2)
+
+ MemoService(project).removeMemos(memos)
+
+ org.mockito.kotlin.verify(memoRepository)
+ .removeMemosById(listOf(1L, 2L))
+ }
+
+ fun `test 메모 삭제 기능 - 빈 리스트는 Repository를 호출하지 않음`() {
+ MemoService(project).removeMemos(emptyList())
+
+ org.mockito.kotlin.verify(memoRepository, org.mockito.kotlin.never())
+ .removeMemosById(org.mockito.kotlin.any())
+ }
+
+ fun `test 메모 삭제 기능 - Repository 예외 발생해도 서비스는 throw 하지 않음`() {
+ val now = LocalDateTime.now()
+ val memo = Memo(
+ id = 10L,
+ createdAt = now,
+ updatedAt = now,
+ content = "x",
+ commitHash = null,
+ filePath = "/path/to/file",
+ selectedCodeSnippet = null,
+ fullCodeSnapshot = null,
+ selectionStart = null,
+ selectionEnd = null,
+ visibleStart = null,
+ visibleEnd = null
+ )
+
+ whenever(
+ memoRepository.removeMemosById(listOf(10L))
+ ).thenThrow(RuntimeException("DB error"))
+
+ val result = runCatching {
+ MemoService(project).removeMemos(listOf(memo))
+ }
+ assertTrue(result.isSuccess, "예외가 발생하면 안 됩니다.")
+ }
+
+ // ========= 메모 수정 기능 =========
+ fun `test 메모 수정 성공`() {
+ // given
+ val now = LocalDateTime.now()
+ val original = Memo(
+ id = 1L,
+ createdAt = now,
+ updatedAt = now,
+ content = "old",
+ commitHash = null,
+ filePath = "/path/file",
+ selectedCodeSnippet = "snippet",
+ fullCodeSnapshot = "full code",
+ selectionStart = 0,
+ selectionEnd = 5,
+ visibleStart = 1,
+ visibleEnd = 3
+ )
+ whenever(memoRepository.findMemoById(1L)).thenReturn(original)
+
+ val updated = original.update(content = "new")
+ whenever(memoRepository.save(updated)).thenAnswer {}
+
+ // when
+ MemoService(project).updateMemo(1L, "new")
+
+ // then
+ org.mockito.kotlin.verify(memoRepository).removeMemoById(1L)
+ org.mockito.kotlin.verify(memoRepository).save(org.mockito.kotlin.check {
+ assertEquals("new", it.content)
+ })
+ }
+
+ fun `test 조회 실패 시 아무 것도 하지 않음`() {
+ // given
+ whenever(memoRepository.findMemoById(999L)).thenReturn(null)
+
+ // when
+ MemoService(project).updateMemo(999L, "new")
+
+ // then
+ org.mockito.kotlin.verify(memoRepository, org.mockito.kotlin.never())
+ .removeMemoById(org.mockito.kotlin.any())
+ org.mockito.kotlin.verify(memoRepository, org.mockito.kotlin.never())
+ .save(org.mockito.kotlin.any())
+ }
+
+ fun `test udpate 적용된 필드 검증`() {
+ // given
+ val createdAt = LocalDateTime.now().minusDays(1)
+ val original = Memo(
+ id = 1L,
+ createdAt = createdAt,
+ updatedAt = createdAt,
+ content = "before",
+ commitHash = "abc",
+ filePath = "/path",
+ selectedCodeSnippet = "code",
+ fullCodeSnapshot = "full code",
+ selectionStart = 10,
+ selectionEnd = 20,
+ visibleStart = 5,
+ visibleEnd = 15
+ )
+ whenever(memoRepository.findMemoById(1L)).thenReturn(original)
+
+ val service = MemoService(project)
+
+ // when
+ service.updateMemo(1L, "after")
+
+ // then
+ org.mockito.kotlin.verify(memoRepository).save(org.mockito.kotlin.check { updated ->
+ assertEquals(1L, updated.id)
+ assertEquals("after", updated.content)
+
+ assertEquals(original.createdAt, updated.createdAt)
+ assertEquals(original.commitHash, updated.commitHash)
+ assertEquals(original.filePath, updated.filePath)
+ assertEquals(original.selectedCodeSnippet, updated.selectedCodeSnippet)
+ assertEquals(original.selectionStart, updated.selectionStart)
+ assertEquals(original.selectionEnd, updated.selectionEnd)
+ assertEquals(original.visibleStart, updated.visibleStart)
+ assertEquals(original.visibleEnd, updated.visibleEnd)
+ })
+ }
+
+ // ========= 메모 추출 기능 =========
+
+ fun `test buildHeader - 프로젝트명과 시간 포함`() {
+ // given
+ val service = MemoService(project)
+
+ // when
+ val header = service.buildHeader()
+
+ // then
+ assertTrue(header.contains("========== DEV LOG =========="))
+ assertTrue(header.contains("💻 프로젝트 명: ${project.name}"))
+ assertTrue(header.contains("⏰ 추출 시간:"))
+ val regex = Regex("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}")
+ assertTrue(regex.containsMatchIn(header), "날짜 포맷이 잘못되었습니다.")
+ assertTrue(header.contains("---------------------------------------"))
+ }
+
+ fun `test buildExportText - 빈 메모 리스트`() {
+ // given
+ val service = MemoService(project)
+
+ // when
+ val export = service.buildExportText(emptyList())
+
+ // then
+ assertTrue(export.contains("========== DEV LOG =========="))
+ assertTrue(export.contains("(내보낼 메모가 없습니다.)"))
+ }
+
+ fun `test buildExportText - 단일 메모 포함`() {
+ val now = LocalDateTime.of(2025, 1, 1, 10, 20, 30)
+
+ val memo = Memo(
+ id = 1L,
+ createdAt = now,
+ updatedAt = now,
+ content = "hello",
+ commitHash = "abc123",
+ filePath = "/path/file",
+ selectedCodeSnippet = "val a = 1",
+ fullCodeSnapshot = "full code",
+ selectionStart = 0,
+ selectionEnd = 10,
+ visibleStart = 3,
+ visibleEnd = 5
+ )
+
+ val service = MemoService(project)
+
+ // when
+ val export = service.buildExportText(listOf(memo))
+
+ // then: 헤더 포함
+ assertTrue(export.contains("========== DEV LOG =========="))
+ assertTrue(export.contains("💻 프로젝트 명: ${project.name}"))
+
+ // 메모 블록 정보 포함 확인
+ assertTrue(export.contains("# Memo 1"))
+ assertTrue(export.contains("📌 Content"))
+ assertTrue(export.contains("hello"))
+ assertTrue(export.contains("abc123"))
+ assertTrue(export.contains("/path/file"))
+ assertTrue(export.contains("val a = 1"))
+ assertTrue(export.contains("3 ~ 5"))
+ }
+
+ fun `test buildExportText - 여러 메모 순서대로 출력`() {
+ val now = LocalDateTime.of(2025, 1, 1, 9, 0, 0)
+
+ val memo1 = Memo(
+ id = 1L,
+ createdAt = now,
+ updatedAt = now,
+ content = "first",
+ commitHash = null,
+ filePath = "/f1",
+ selectedCodeSnippet = null,
+ fullCodeSnapshot = null,
+ selectionStart = 0,
+ selectionEnd = 0,
+ visibleStart = 1,
+ visibleEnd = 1
+ )
+
+ val memo2 = Memo(
+ id = 2L,
+ createdAt = now.plusHours(1),
+ updatedAt = now.plusHours(1),
+ content = "second",
+ commitHash = null,
+ filePath = "/f2",
+ selectedCodeSnippet = null,
+ selectionStart = 10,
+ selectionEnd = 10,
+ visibleStart = 2,
+ visibleEnd = 2
+ )
+
+ val service = MemoService(project)
+
+ // when
+ val export = service.buildExportText(listOf(memo1, memo2))
+
+ // then
+ assertTrue(export.contains("# Memo 1"))
+ assertTrue(export.contains("# Memo 2"))
+
+ // 순서 보장
+ val index1 = export.indexOf("# Memo 1")
+ val index2 = export.indexOf("# Memo 2")
+ assertTrue(index1 < index2, "Memo 1이 Memo 2보다 먼저 출력되어야 합니다.")
+ }
+
+ fun `test buildExportText - memoBlock 필드 검증`() {
+ val now = LocalDateTime.of(2025, 1, 1, 12, 34, 56)
+
+ val memo = Memo(
+ id = 10L,
+ createdAt = now,
+ updatedAt = now,
+ content = "내용입니다",
+ commitHash = null,
+ filePath = null,
+ selectedCodeSnippet = null,
+ fullCodeSnapshot = null,
+ selectionStart = 100,
+ selectionEnd = 200,
+ visibleStart = 3,
+ visibleEnd = 8
+ )
+
+ val service = MemoService(project)
+
+ // when
+ val export = service.buildExportText(listOf(memo))
+
+ // then
+ // 날짜 체크
+ assertTrue(export.contains("2025-01-01 12:34:56"))
+ // content
+ assertTrue(export.contains("내용입니다"))
+ // null 매핑
+ assertTrue(export.contains("- Commit: N/A"))
+ assertTrue(export.contains("- File Path: N/A"))
+ assertTrue(export.contains("(no selected code)"))
+ // visible range
+ assertTrue(export.contains("3 ~ 8"))
+ }
+
+ fun `test exportToTxt - 파일 생성 및 내용 저장`() {
+ // given
+ val service = MemoService(project)
+ val text = "Hello DevLog Export Test"
+ val exportDir = myFixture.tempDirFixture.findOrCreateDir("exports")
+
+ // when
+ val exported = service.exportToTxt(text, exportDir)
+
+ // then
+ assertNotNull(exported)
+ assertTrue(exported!!.exists())
+ assertTrue(exported.name.startsWith("devlog-${project.name}-"))
+
+ val content = String(exported.contentsToByteArray(), Charsets.UTF_8)
+ assertEquals(text, content)
+ }
+
+}
diff --git a/src/test/kotlin/com/github/yeoli/devlog/domain/note/domain/NoteTest.kt b/src/test/kotlin/com/github/yeoli/devlog/domain/note/domain/NoteTest.kt
new file mode 100644
index 0000000..c8333f6
--- /dev/null
+++ b/src/test/kotlin/com/github/yeoli/devlog/domain/note/domain/NoteTest.kt
@@ -0,0 +1,83 @@
+package com.github.yeoli.devlog.domain.note.domain
+
+import org.junit.jupiter.api.Assertions.*
+import java.time.LocalDateTime
+import kotlin.test.Test
+
+class NoteTest {
+
+ @Test
+ fun `test 업데이트 시 새로운 인스턴스를 반환해야 한다`() {
+ val original = Note(
+ content = "hello"
+ )
+
+ val updated = original.update("new content")
+
+ assertNotSame(original, updated)
+ }
+
+ @Test
+ fun `test 업데이트 시 콘텐츠가 변경되어야 한다`() {
+ val original = Note(
+ content = "old content"
+ )
+
+ val updated = original.update("new content")
+
+ assertEquals("new content", updated.content)
+ assertNotEquals(original.content, updated.content)
+ }
+
+ @Test
+ fun `test 빈 문자열로 업데이트할 때 정상적으로 처리되어야 한다`() {
+ val original = Note(
+ content = "something"
+ )
+
+ val updated = original.update("")
+
+ assertEquals("", updated.content)
+ }
+
+ // =========== toState 테스트 ===========
+ @Test
+ fun `test toState - content와 updatedAt이 올바르게 변환된다`() {
+ // given
+ val now = LocalDateTime.of(2025, 1, 1, 12, 0)
+ val note = Note("Hello", now)
+
+ // when
+ val state = note.toState()
+
+ // then
+ assertEquals("Hello", state.content)
+ assertEquals(now.toString(), state.updatedAt)
+ }
+
+ @Test
+ fun `test toState - empty content도 정상 변환된다`() {
+ // given
+ val note = Note("")
+
+ // when
+ val state = note.toState()
+
+ // then
+ assertEquals("", state.content)
+ assertNotNull(state.updatedAt)
+ }
+
+ @Test
+ fun `test toState - updatedAt이 현재 시간이 아닐 수 있다`() {
+ // given
+ val customTime = LocalDateTime.of(2024, 12, 31, 23, 59)
+ val note = Note("Time test", customTime)
+
+ // when
+ val state = note.toState()
+
+ // then
+ assertEquals(customTime.toString(), state.updatedAt)
+ }
+}
diff --git a/src/test/kotlin/com/github/yeoli/devlog/domain/note/repository/NoteStateTest.kt b/src/test/kotlin/com/github/yeoli/devlog/domain/note/repository/NoteStateTest.kt
new file mode 100644
index 0000000..dfcd291
--- /dev/null
+++ b/src/test/kotlin/com/github/yeoli/devlog/domain/note/repository/NoteStateTest.kt
@@ -0,0 +1,54 @@
+package com.github.yeoli.devlog.domain.note.repository
+
+import org.junit.jupiter.api.Assertions.assertDoesNotThrow
+import org.junit.jupiter.api.Assertions.assertEquals
+import java.time.LocalDateTime
+import kotlin.test.Test
+
+class NoteStateTest {
+ @Test
+ fun `test toDomain - content와 updatedAt이 올바르게 변환된다`() {
+ // given
+ val nowString = "2025-01-01T12:00:00"
+ val state = NoteState(
+ content = "Hello Note",
+ updatedAt = nowString
+ )
+
+ // when
+ val note = state.toDomain()
+
+ // then
+ assertEquals("Hello Note", note.content)
+ assertEquals(LocalDateTime.parse(nowString), note.updatedAt)
+ }
+
+ @Test
+ fun `test toDomain - 빈 content도 정상적으로 변환된다`() {
+ // given
+ val nowString = LocalDateTime.now().toString()
+ val state = NoteState(
+ content = "",
+ updatedAt = nowString
+ )
+
+ // when
+ val note = state.toDomain()
+
+ // then
+ assertEquals("", note.content)
+ assertEquals(LocalDateTime.parse(nowString), note.updatedAt)
+ }
+
+ @Test
+ fun `test toDomain - updatedAt 문자열 포맷이 LocalDateTime으로 파싱 가능해야 한다`() {
+ // given
+ val formattedTime = "2024-12-31T23:59:00"
+ val state = NoteState("TimeCheck", formattedTime)
+
+ // when & then
+ assertDoesNotThrow {
+ state.toDomain()
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/test/kotlin/com/github/yeoli/devlog/domain/note/service/NoteServiceTest.kt b/src/test/kotlin/com/github/yeoli/devlog/domain/note/service/NoteServiceTest.kt
new file mode 100644
index 0000000..3d78a15
--- /dev/null
+++ b/src/test/kotlin/com/github/yeoli/devlog/domain/note/service/NoteServiceTest.kt
@@ -0,0 +1,100 @@
+package com.github.yeoli.devlog.domain.note.service
+
+import com.github.yeoli.devlog.domain.note.domain.Note
+import com.github.yeoli.devlog.domain.note.repository.NoteRepository
+import com.intellij.openapi.project.Project
+import org.mockito.Mockito.mock
+import org.mockito.kotlin.*
+import java.time.LocalDateTime
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class NoteServiceTest {
+
+ private val project: Project = mock(Project::class.java)
+ private val noteRepository: NoteRepository = mock(NoteRepository::class.java)
+
+ private fun createService(): NoteService {
+ val service = NoteService(project)
+ val field = service.javaClass.getDeclaredField("noteRepository")
+ field.isAccessible = true
+ field.set(service, noteRepository)
+ return service
+ }
+
+ @Test
+ fun `test 레포지토리와 동일한 노트를 반환한다`() {
+ // given
+ val now = LocalDateTime.now()
+ val expectedNote = Note("Hello Test", now)
+
+ whenever(noteRepository.getNote()).thenReturn(expectedNote)
+
+ val service = NoteService(project)
+ val noteServiceField = service.javaClass.getDeclaredField("noteRepository")
+ noteServiceField.isAccessible = true
+ noteServiceField.set(service, noteRepository)
+
+ // when
+ val actual = service.getNote()
+
+ // then
+ assertEquals(expectedNote, actual)
+ }
+
+ @Test
+ fun `test 기본 빈 노트를 반환한다`() {
+ // given
+ val defaultNote = Note("")
+ whenever(noteRepository.getNote()).thenReturn(defaultNote)
+
+ val service = NoteService(project)
+ val noteServiceField = service.javaClass.getDeclaredField("noteRepository")
+ noteServiceField.isAccessible = true
+ noteServiceField.set(service, noteRepository)
+
+ // when
+ val actual = service.getNote()
+
+ // then
+ assertEquals(defaultNote, actual)
+ }
+
+ // ========= updateNote 테스트 =========
+
+ @Test
+ fun `test 내용이 변경되면 업데이트한다`() {
+ // given
+ val oldNote = Note("old", LocalDateTime.now())
+ whenever(noteRepository.getNote()).thenReturn(oldNote)
+
+ val service = createService()
+
+ val newContent = "new"
+
+ // when
+ service.updateNote(newContent)
+
+ // then
+ verify(noteRepository, times(1)).updateNote(
+ check { updated ->
+ assertEquals(newContent, updated.content)
+ }
+ )
+ }
+
+ @Test
+ fun `test 내용이 동일하면 업데이트하지 않는다`() {
+ // given
+ val sameNote = Note("same", LocalDateTime.now())
+ whenever(noteRepository.getNote()).thenReturn(sameNote)
+
+ val service = createService()
+
+ // when
+ service.updateNote("same")
+
+ // then
+ verify(noteRepository, never()).updateNote(any())
+ }
+}