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()) + } +}