From 0cb62e317779ba5206974138a359ac2caabfede7 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 17 Nov 2025 15:03:57 +0900 Subject: [PATCH 01/74] =?UTF-8?q?docs(README):=20=EB=A9=94=EB=AA=A8=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EA=B8=B0=EB=8A=A5=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6b747d0..96562e4 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,11 @@ ## 핵심 기능 정의 --- -### ☑️ 메모 저장 기능 +### 🏁 메모 저장 기능 - 메모를 저장할 수 있어야 한다. - 메모의 저장 데이터는 다음과 같다. - `int id (not null)`: 메모의 고유한 아이디(PK) - - `String content (nullable)`: 사용자가 입력하는 메모의 내용 + - `String content (not null)`: 사용자가 입력하는 메모의 내용 - `LocalDateTime createdAt (not null)`: 메모가 생성된 시간 - `LocalDateTime updatedAt (not null)`: 메모가 수정된 시간 - `String commitHash (nullable)`: 현재 커밋의 해시값 @@ -19,12 +19,17 @@ - `int visibleStart (nullable)`: 선택한 시작 줄 - 저장은 시간 순서대로 저장이 되어야 한다. -### ⚠️ 메모 저장 기능 예외 상황 +### ⚠️ 메모 저장 기능 제약 상황 +- 메모의 `content`가 blank라면 저장이 되어선 안된다. +- `selectionStart`가 `selectionEnd`보다 앞에 있어야 한다. +- `createdAt`은 자동으로 생성 되어야 한다. +--- ### ☑️ 메모 조회 기능 - 메모의 id를 이용해서 데이터를 조회할 수 있어야 한다. - 전체 메모를 날짜순으로 조회할 수 있어야 한다. -- + +### ⚠️ 메모 조회 기능 예외 상황 --- ### ☑️ 메모 삭제 기능 @@ -93,3 +98,5 @@ - 저장된 노트의 모든 내용이 출력 되어야 한다. - 노트의 변경 내용은 자동 저장 되어야 한다. - 화면의 크기를 벗어나지 않게 내용을 출력해야 한다. + +### ⚠️ 노트 출력 화면 예외 상황 \ No newline at end of file From c86b9cda077e22a70d1b111d5c087a7147a09d63 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Wed, 19 Nov 2025 14:23:05 +0900 Subject: [PATCH 02/74] =?UTF-8?q?test(MemoTest):=20=EB=A9=94=EB=AA=A8=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devlog/domain/memo/domain/MemoTest.kt | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/test/kotlin/com/github/yeoli/devlog/domain/memo/domain/MemoTest.kt 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..01ef4c5 --- /dev/null +++ b/src/test/kotlin/com/github/yeoli/devlog/domain/memo/domain/MemoTest.kt @@ -0,0 +1,58 @@ +package com.github.yeoli.devlog.domain.memo.domain + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class MemoTest { + + @Test + fun test_Memo_생성_성공() { + val memo = Memo( + content = "테스트 메모", + commitHash = "abc123", + filePath = "/path/SampleFile.kt", + selectedCodeSnippet = "val selected = 42", + selectionStart = 5, + selectionEnd = 10, + visibleStart = 1, + visibleEnd = 20 + ) + + 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 = "잘못된 메모", + selectionStart = 10, + selectionEnd = 5 + ) + } + } + + @Test + fun test_Memo_생성_실패_visible_범위() { + assertFailsWith { + Memo( + content = "보이는 영역 오류", + visibleStart = 20, + visibleEnd = 10 + ) + } + } +} From 957c92819f8be1bbca92820f6b2a0dbec690a740 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Wed, 19 Nov 2025 14:23:20 +0900 Subject: [PATCH 03/74] =?UTF-8?q?feat(Memo):=20=EB=A9=94=EB=AA=A8=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yeoli/devlog/domain/memo/domain/Memo.kt | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/main/kotlin/com/github/yeoli/devlog/domain/memo/domain/Memo.kt 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..626b566 --- /dev/null +++ b/src/main/kotlin/com/github/yeoli/devlog/domain/memo/domain/Memo.kt @@ -0,0 +1,43 @@ +package com.github.yeoli.devlog.domain.memo.domain + +import java.time.LocalDateTime + +class Memo( + 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 id: Long = generateId() + val createdAt: LocalDateTime = LocalDateTime.now() + val updatedAt: LocalDateTime = LocalDateTime.now() + + init { + validate() + } + + private fun validate() { + if (selectionStart != null && selectionEnd != null) { + require(selectionStart <= selectionEnd) { + "selectionStart는 selectionEnd 보다 작아야합니다." + } + } + + if (visibleStart != null && visibleEnd != null) { + require(visibleStart <= visibleEnd) { + "visibleStart는 visibleEnd 보다 작아야합니다." + } + } + } + + private fun generateId(): Long { + return System.currentTimeMillis() + } +} \ No newline at end of file From 7d1fd1ff76e15d531ea637a7c394f3272078199b Mon Sep 17 00:00:00 2001 From: yeo-li Date: Wed, 19 Nov 2025 14:23:39 +0900 Subject: [PATCH 04/74] =?UTF-8?q?test(MemoService):=20=EB=A9=94=EB=AA=A8?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/memo/service/MemoServiceTest.kt | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 src/test/kotlin/com/github/yeoli/devlog/domain/memo/service/MemoServiceTest.kt 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..816b289 --- /dev/null +++ b/src/test/kotlin/com/github/yeoli/devlog/domain/memo/service/MemoServiceTest.kt @@ -0,0 +1,169 @@ +package com.github.yeoli.devlog.domain.memo.service + +import com.github.yeoli.devlog.domain.memo.domain.Memo +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 java.awt.Point + +class MemoServiceTest : BasePlatformTestCase() { + + 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().createMemo(memoContent, project) + // 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().createMemo(memoContent, project) + // 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().createMemo("에디터 없음", project) + assertNull(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) + assertTrue("선택할 코드 스니펫을 찾지 못했습니다.", selectionStart >= 0) + 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().createMemo(memoContent, project) + + // 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) + } + } +} From 1bcadddeca4d65716e467058e9c6b31d4618ddda Mon Sep 17 00:00:00 2001 From: yeo-li Date: Wed, 19 Nov 2025 14:23:55 +0900 Subject: [PATCH 05/74] =?UTF-8?q?feat(MemoService):=20=EB=A9=94=EB=AA=A8?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devlog/domain/memo/service/MemoService.kt | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/main/kotlin/com/github/yeoli/devlog/domain/memo/service/MemoService.kt 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..7ce8e7d --- /dev/null +++ b/src/main/kotlin/com/github/yeoli/devlog/domain/memo/service/MemoService.kt @@ -0,0 +1,56 @@ +package com.github.yeoli.devlog.domain.memo.service + +import com.github.yeoli.devlog.domain.memo.domain.Memo +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 git4idea.repo.GitRepositoryManager +import java.awt.Point + +class MemoService { + + fun createMemo(content: String, project: Project): Memo? { + val editor = getActiveEditor(project) ?: return null + + val selectionModel = editor.selectionModel + val document = editor.document + + val selectedCodeSnippet = selectionModel.selectedText + + 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) + + return Memo( + content = content, + commitHash = commitHash, + filePath = filePath, + selectedCodeSnippet = selectedCodeSnippet, + selectionStart = selectionStart, + selectionEnd = selectionEnd, + visibleStart = visibleStartLine, + visibleEnd = visibleEndLine + ) + } + + 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 + } +} \ No newline at end of file From 1cef53130ca97ded53ea88a6a9b79dccdae11bd3 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Wed, 19 Nov 2025 14:39:21 +0900 Subject: [PATCH 06/74] =?UTF-8?q?chore(plugin.xml):=20Git4Idea=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/META-INF/plugin.xml | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 533e524..a8decb7 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -1,15 +1,17 @@ - com.github.yeoli.devlog - dev-log - yeo-li + com.intellij.modules.platform + Git4Idea + + + + - com.intellij.modules.platform + com.github.yeoli.devlog + dev-log - messages.MyBundle + messages.MyBundle - - - - + yeo-li From 6027f0f9bbafacfa44528d69e2266bf145fdb365 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Wed, 19 Nov 2025 14:39:58 +0900 Subject: [PATCH 07/74] =?UTF-8?q?chore(build.gradle):=20Junit,=20Git4Idea?= =?UTF-8?q?=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 6cbe4b2..df4a29b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,9 +34,18 @@ 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") + // 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 +108,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 { From 1b4b3bdd221eac34178b72a4e615006afac0e310 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Wed, 19 Nov 2025 14:40:27 +0900 Subject: [PATCH 08/74] =?UTF-8?q?feat(MemoService):=20createMemo()=20?= =?UTF-8?q?=EB=A1=9C=EA=B1=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devlog/domain/memo/service/MemoService.kt | 45 ++++++++++++++----- 1 file changed, 33 insertions(+), 12 deletions(-) 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 index 7ce8e7d..438465b 100644 --- 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 @@ -1,6 +1,8 @@ package com.github.yeoli.devlog.domain.memo.service import com.github.yeoli.devlog.domain.memo.domain.Memo +import com.ibm.icu.impl.IllegalIcuArgumentException +import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.editor.Editor import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.fileEditor.FileEditorManager @@ -10,8 +12,14 @@ import java.awt.Point class MemoService { + private val logger = Logger.getInstance(MemoService::class.java) + fun createMemo(content: String, project: Project): Memo? { - val editor = getActiveEditor(project) ?: return null + val editor = getActiveEditor(project) + if (editor == null) { + logger.warn("[createMemo] editor가 null이므로 null을 반환합니다.") + return null + } val selectionModel = editor.selectionModel val document = editor.document @@ -32,22 +40,35 @@ class MemoService { val commitHash = getCurrentCommitHash(project) - return Memo( - content = content, - commitHash = commitHash, - filePath = filePath, - selectedCodeSnippet = selectedCodeSnippet, - selectionStart = selectionStart, - selectionEnd = selectionEnd, - visibleStart = visibleStartLine, - visibleEnd = visibleEndLine - ) + 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, + 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 From 200966ea3d143663b33ea3b6568273303abf2c39 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Wed, 19 Nov 2025 14:40:52 +0900 Subject: [PATCH 09/74] =?UTF-8?q?docs(README):=20=EB=A9=94=EB=AA=A8=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 77 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 51 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 96562e4..07935c9 100644 --- a/README.md +++ b/README.md @@ -3,58 +3,70 @@ ## 핵심 기능 정의 --- -### 🏁 메모 저장 기능 + +### ✅메모 저장 기능 + - 메모를 저장할 수 있어야 한다. - 메모의 저장 데이터는 다음과 같다. - - `int id (not null)`: 메모의 고유한 아이디(PK) - - `String content (not null)`: 사용자가 입력하는 메모의 내용 - - `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)`: 선택한 시작 줄 + - `int id (not null)`: 메모의 고유한 아이디(PK) + - `String content (not null)`: 사용자가 입력하는 메모의 내용 + - `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)`: 선택한 시작 줄 - 저장은 시간 순서대로 저장이 되어야 한다. ### ⚠️ 메모 저장 기능 제약 상황 + - 메모의 `content`가 blank라면 저장이 되어선 안된다. - `selectionStart`가 `selectionEnd`보다 앞에 있어야 한다. +- `visibleStart`가 `visibleEnd`보다 앞에 있어야 한다. - `createdAt`은 자동으로 생성 되어야 한다. --- + ### ☑️ 메모 조회 기능 + - 메모의 id를 이용해서 데이터를 조회할 수 있어야 한다. - 전체 메모를 날짜순으로 조회할 수 있어야 한다. ### ⚠️ 메모 조회 기능 예외 상황 --- + ### ☑️ 메모 삭제 기능 + - 저장된 메모를 삭제할 수 있어야 한다. - 메모를 한 번에 여러개 삭제할 수 있어야 한다. ### ⚠️ 메모 삭제 기능 예외 상황 --- + ### ☑️ 메모 수정 기능 + - 저장된 메모의 content를 수정할 수 있어야 한다. ### ⚠️ 메모 수정 기능 예외 상황 --- + ### ☑️ 메모 추출 기능 + - 저장된 메모를 한 개 이상 선택하여 txt 파일로 추출할 수 있어야 한다. - 추출한 단위 메모의 구성 내용은 다음과 같다. - - 메모 순서(시간순) - - `LocalDateTime timestamp`: 메모가 생성된 시간 - - `String content`: 사용자가 입력하는 메모의 내용 - - `String filePath`: 파일 경로 - - `String commitHash`: 현재 커밋의 해시값 - - `int visibleStart`: 선택한 시작 줄 - - `int visibleEnd`: 선택한 종료 줄 + - 메모 순서(시간순) + - `LocalDateTime timestamp`: 메모가 생성된 시간 + - `String content`: 사용자가 입력하는 메모의 내용 + - `String filePath`: 파일 경로 + - `String commitHash`: 현재 커밋의 해시값 + - `int visibleStart`: 선택한 시작 줄 + - `int visibleEnd`: 선택한 종료 줄 - txt 파일 상단에 프로젝트명, 내보낸 시각, 메모의 개수가 있어야 한다. - txt 파일의 이름은 `devlog-{프로젝트명}-{내보낸날짜}-{내보낸시각}.txt` 이다. - 추출할 메모가 없으면 빈 txt 파일을 반환한다. @@ -62,28 +74,34 @@ ### ⚠️ 메모 추출 기능 예외 상황 --- + ### ☑️ 노트 수정/저장 기능 + - 노트를 저장할 수 있어야 한다. - 노트의 수정/저장 데이터는 다음과 같다. - - `String content`: 노트의 내용 - - `LocalDateTime savedAt`: 저장된 시각 + - `String content`: 노트의 내용 + - `LocalDateTime savedAt`: 저장된 시각 ### ⚠️ 노트 수정/저장 기능 예외 상황 --- + ## 화면 요구 사항 --- + ### ☑️ 플러그인 기본 화면 + - 화면의 최상단엔 기본 조작용 버튼이 있어야 한다. - 기본 조작용 버튼은 아래와 같다. - - 메모목록/노트 화면 전환 버튼 - - 메모 전체 선택/선택해제 버튼 - - 선택된 메모 추출 버튼 - - 선택된 메모 삭제 버튼 + - 메모목록/노트 화면 전환 버튼 + - 메모 전체 선택/선택해제 버튼 + - 선택된 메모 추출 버튼 + - 선택된 메모 삭제 버튼 - 메인 컨텐츠를 표시하는 화면이 있어야 한다. ### ☑️ 메모 목록 출력 화면 + - 저장된 메모 전체가 최근순 정렬되어 화면에 보여야 한다. - 각 메모 좌측에는 메모를 선택할 수 있는 체크박스가 있어야 한다. - 화면 하단에는 새 메모를 적을 수 있는 텍스트 입력창이 있어야 한다. @@ -94,9 +112,16 @@ ### ⚠️ 메모 목록 출력 화면 예외 상황 --- + ### ☑️ 노트 출력 화면 + - 저장된 노트의 모든 내용이 출력 되어야 한다. - 노트의 변경 내용은 자동 저장 되어야 한다. - 화면의 크기를 벗어나지 않게 내용을 출력해야 한다. -### ⚠️ 노트 출력 화면 예외 상황 \ No newline at end of file +### ⚠️ 노트 출력 화면 예외 상황 + + +DevLog Plugin is a memo/note tracking plugin that automatically captures your code selection, +file path, commit hash, and editor state, storing it for later use. + \ No newline at end of file From 38a12c88623d7716a93c74db5d790463d4d0447d Mon Sep 17 00:00:00 2001 From: yeo-li Date: Wed, 19 Nov 2025 14:45:51 +0900 Subject: [PATCH 10/74] =?UTF-8?q?chore(ServiceContainerUtil):=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=9A=A9=20JetBrains=20SDK=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../testFramework/ServiceContainerUtil.kt | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 com/intellij/testFramework/ServiceContainerUtil.kt 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 From 9b9b41be0e5a801698710e918b9407b8ecc7d731 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 12:33:52 +0900 Subject: [PATCH 11/74] =?UTF-8?q?test(MemoStateTest):=20Memo=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=B3=80=ED=99=98=20=EC=84=B1=EA=B3=B5=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/memo/repository/MemoStateTest.kt | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/test/kotlin/com/github/yeoli/devlog/domain/memo/repository/MemoStateTest.kt 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) + } +} From 1f7656c7792b2d0a2f84a7d4adfa614785b67858 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 12:34:08 +0900 Subject: [PATCH 12/74] =?UTF-8?q?feat(MemoState):=20MemoState=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/memo/repository/MemoState.kt | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/main/kotlin/com/github/yeoli/devlog/domain/memo/repository/MemoState.kt 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..272f360 --- /dev/null +++ b/src/main/kotlin/com/github/yeoli/devlog/domain/memo/repository/MemoState.kt @@ -0,0 +1,33 @@ +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, + var updatedAt: String, + var content: String = "", + var commitHash: String? = null, + var filePath: String? = null, + var selectedCodeSnippet: 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, + selectionStart = selectionStart, + selectionEnd = selectionEnd, + visibleStart = visibleStart, + visibleEnd = visibleEnd + ) +} From 136a110aa432632c71b7f16775bbf30e87b596c0 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 12:38:44 +0900 Subject: [PATCH 13/74] =?UTF-8?q?feat(Memo):=20id,=20createdAt,=20updatedA?= =?UTF-8?q?t=20=ED=95=84=EB=93=9C=EA=B0=80=20=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=9E=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yeoli/devlog/domain/memo/domain/Memo.kt | 51 ++++++++++++++++--- 1 file changed, 43 insertions(+), 8 deletions(-) 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 index 626b566..e820a7f 100644 --- 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 @@ -1,8 +1,13 @@ package com.github.yeoli.devlog.domain.memo.domain +import com.github.yeoli.devlog.domain.memo.repository.MemoState import java.time.LocalDateTime class Memo( + val id: Long, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, + val content: String, val commitHash: String? = null, @@ -15,14 +20,33 @@ class Memo( val visibleStart: Int? = null, val visibleEnd: Int? = null ) { - val id: Long = generateId() - val createdAt: LocalDateTime = LocalDateTime.now() - val updatedAt: LocalDateTime = LocalDateTime.now() - init { validate() } + constructor( + content: String, + commitHash: String?, + filePath: String?, + selectedCodeSnippet: String?, + selectionStart: Int?, + selectionEnd: Int?, + visibleStart: Int?, + visibleEnd: Int? + ) : 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 + ) + private fun validate() { if (selectionStart != null && selectionEnd != null) { require(selectionStart <= selectionEnd) { @@ -37,7 +61,18 @@ class Memo( } } - private fun generateId(): Long { - return System.currentTimeMillis() - } -} \ No newline at end of file + 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, + selectionStart = this.selectionStart, + selectionEnd = this.selectionEnd, + visibleStart = this.visibleStart, + visibleEnd = this.visibleEnd + ) +} From 327422a852b66c5f27dacfeaba6a7330aa5fb2a6 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 12:39:13 +0900 Subject: [PATCH 14/74] =?UTF-8?q?feat(MemoRepository):=20MemoRepository=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/memo/repository/MemoRepository.kt | 29 +++++++++++++++++++ .../memo/repository/MemoStorageState.kt | 6 ++++ 2 files changed, 35 insertions(+) create mode 100644 src/main/kotlin/com/github/yeoli/devlog/domain/memo/repository/MemoRepository.kt create mode 100644 src/main/kotlin/com/github/yeoli/devlog/domain/memo/repository/MemoStorageState.kt 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..1bb9ee8 --- /dev/null +++ b/src/main/kotlin/com/github/yeoli/devlog/domain/memo/repository/MemoRepository.kt @@ -0,0 +1,29 @@ +package com.github.yeoli.devlog.domain.memo.repository + +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: MemoState) { + state.memos.add(memo) + } + + fun getAll(): List { + return state.memos + } +} 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() +) { +} From d4c3f0ecb4a9e0f427e435bde237506cf460d4a9 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 12:40:02 +0900 Subject: [PATCH 15/74] =?UTF-8?q?test(MemoTest):=20MemoState=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20=EC=84=B1=EA=B3=B5=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devlog/domain/memo/domain/MemoTest.kt | 64 ++++++++++++++++--- 1 file changed, 54 insertions(+), 10 deletions(-) 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 index 01ef4c5..4533a23 100644 --- 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 @@ -1,15 +1,13 @@ package com.github.yeoli.devlog.domain.memo.domain -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertNotNull -import kotlin.test.assertTrue +import com.github.yeoli.devlog.domain.memo.repository.MemoState +import kotlin.test.* class MemoTest { @Test - fun test_Memo_생성_성공() { + fun `test Memo 생성 성공`() { + // given & then val memo = Memo( content = "테스트 메모", commitHash = "abc123", @@ -21,6 +19,7 @@ class MemoTest { visibleEnd = 20 ) + // then assertEquals("테스트 메모", memo.content) assertEquals("abc123", memo.commitHash) assertEquals("/path/SampleFile.kt", memo.filePath) @@ -35,24 +34,69 @@ class MemoTest { } @Test - fun test_Memo_생성_실패_selection_범위() { + fun `test Memo 생성 실패 selection 범위`() { assertFailsWith { Memo( content = "잘못된 메모", + commitHash = "abc123", + filePath = "/path/SampleFile.kt", + selectedCodeSnippet = "val selected = 42", selectionStart = 10, - selectionEnd = 5 + selectionEnd = 5, + visibleStart = null, + visibleEnd = null ) } } @Test - fun test_Memo_생성_실패_visible_범위() { + fun `test Memo 생성 실패 visible 범위`() { + // when & then assertFailsWith { Memo( - content = "보이는 영역 오류", + content = "잘못된 메모", + commitHash = "abc123", + filePath = "/path/SampleFile.kt", + selectedCodeSnippet = "val selected = 42", + 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", + 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) + } } From cee791fb208f7ffc2c2f48fc831177a087633ebb Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 12:40:47 +0900 Subject: [PATCH 16/74] =?UTF-8?q?test(MemoServiceTest):=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=A9=94=EC=84=9C=EB=93=9C=20=ED=98=95?= =?UTF-8?q?=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/memo/service/MemoServiceTest.kt | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) 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 index 816b289..6ee0d2c 100644 --- 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 @@ -14,7 +14,7 @@ import java.awt.Point class MemoServiceTest : BasePlatformTestCase() { - fun test_메모_생성_성공() { + fun `test 메모 생성 성공`() { // given val psiFile = myFixture.configureByText( "SampleFile.kt", @@ -41,7 +41,7 @@ class MemoServiceTest : BasePlatformTestCase() { ).line // when - val memo: Memo? = MemoService().createMemo(memoContent, project) + val memo: Memo? = MemoService(project).createMemo(memoContent) // then if (memo != null) { assertEquals(memoContent, memo.content) @@ -58,7 +58,7 @@ class MemoServiceTest : BasePlatformTestCase() { } - fun test_메모_생성_선택없음() { + fun `test 메모 생성 선택없음`() { // given val psiFile = myFixture.configureByText( "SampleFile.kt", @@ -84,7 +84,7 @@ class MemoServiceTest : BasePlatformTestCase() { ).line // when - val memo: Memo? = MemoService().createMemo(memoContent, project) + val memo: Memo? = MemoService(project).createMemo(memoContent) // then if (memo != null) { assertEquals(memoContent, memo.content) @@ -100,7 +100,7 @@ class MemoServiceTest : BasePlatformTestCase() { } } - fun test_메모_생성_에디터없음_예외() { + fun `test 메모 생성 에디터 없음 예외`() { // given val psiFile = myFixture.configureByText( "SampleFile.kt", @@ -113,11 +113,11 @@ class MemoServiceTest : BasePlatformTestCase() { assertNull("선택된 에디터가 없어야 합니다.", fileEditorManager.selectedTextEditor) // expect - val memo: Memo? = MemoService().createMemo("에디터 없음", project) + val memo: Memo? = MemoService(project).createMemo("에디터 없음") assertNull(memo); } - fun test_메모_생성_파일경로없음() { + fun `test 메모 생성 파일경로 없음`() { // given myFixture.configureByText( "SampleFile.kt", @@ -149,7 +149,7 @@ class MemoServiceTest : BasePlatformTestCase() { try { // when - val memo: Memo? = MemoService().createMemo(memoContent, project) + val memo: Memo? = MemoService(project).createMemo(memoContent) // then if (memo != null) { @@ -166,4 +166,5 @@ class MemoServiceTest : BasePlatformTestCase() { Disposer.dispose(mockDisposable) } } + } From 6a030156c1d644d2ab639a45f212e1fecff2b874 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 12:41:31 +0900 Subject: [PATCH 17/74] =?UTF-8?q?refactor(MemoService):=20=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EC=9E=90=EC=97=90=20Project=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devlog/domain/memo/service/MemoService.kt | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) 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 index 438465b..497471b 100644 --- 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 @@ -1,7 +1,9 @@ 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.components.Service import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.editor.Editor import com.intellij.openapi.fileEditor.FileDocumentManager @@ -10,11 +12,14 @@ import com.intellij.openapi.project.Project import git4idea.repo.GitRepositoryManager import java.awt.Point -class MemoService { +@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, project: Project): Memo? { + fun createMemo(content: String): Memo? { val editor = getActiveEditor(project) if (editor == null) { logger.warn("[createMemo] editor가 null이므로 null을 반환합니다.") @@ -74,4 +79,12 @@ class MemoService { val repo = repoManager.repositories.firstOrNull() ?: return null return repo.currentRevision } + + fun saveMemo(memo: Memo) { + try { + memoRepository.save(memo.toState()) + } catch (e: Exception) { + logger.warn("[saveMemo] 메모 저장 중 알 수 없는 에러가 발생했습니다. ${e.message}", e) + } + } } \ No newline at end of file From bbb1f7cad7b75e37d79e1dab648644df7cfc98c4 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 12:46:23 +0900 Subject: [PATCH 18/74] =?UTF-8?q?chore(Gitignore):=20.idea/=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From e0cbf4b528e70039847cd257014e2c4a08220547 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 12:47:44 +0900 Subject: [PATCH 19/74] =?UTF-8?q?chore(Gradle):=20gradle.xml=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/gradle.xml | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 .idea/gradle.xml 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 From e80f46d8f5269918f0ff2a86bc46ddcd315d0384 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 13:22:27 +0900 Subject: [PATCH 20/74] =?UTF-8?q?docs(README):=20=EB=A9=94=EB=AA=A8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 07935c9..45d3f5a 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ - `int visibleStart (nullable)`: 선택한 시작 줄 - 저장은 시간 순서대로 저장이 되어야 한다. -### ⚠️ 메모 저장 기능 제약 상황 +### ⚠️ 메모 저장 기능 고려 사항 - 메모의 `content`가 blank라면 저장이 되어선 안된다. - `selectionStart`가 `selectionEnd`보다 앞에 있어야 한다. @@ -30,12 +30,14 @@ --- -### ☑️ 메모 조회 기능 +### 🏁 메모 조회 기능 - 메모의 id를 이용해서 데이터를 조회할 수 있어야 한다. - 전체 메모를 날짜순으로 조회할 수 있어야 한다. -### ⚠️ 메모 조회 기능 예외 상황 +### ⚠️ 메모 조회 기능 고려 사항 + +- 각 조건마다 조회할 데이터가 없다면 빈 리스트를 반환해야 한다. --- From 234c96ebda8f4a8cd1f3c0c68dc7ac411ead7538 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 13:32:22 +0900 Subject: [PATCH 21/74] =?UTF-8?q?feat(MemoService):=20=EB=A9=94=EB=AA=A8?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devlog/domain/memo/repository/MemoRepository.kt | 5 +++-- .../yeoli/devlog/domain/memo/service/MemoService.kt | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) 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 index 1bb9ee8..dad3d3d 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -23,7 +24,7 @@ class MemoRepository : PersistentStateComponent { state.memos.add(memo) } - fun getAll(): List { - return state.memos + fun getAll(): List { + return state.memos.map { it.toDomain() } } } 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 index 497471b..3337c01 100644 --- 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 @@ -87,4 +87,14 @@ class MemoService(private val project: Project) { logger.warn("[saveMemo] 메모 저장 중 알 수 없는 에러가 발생했습니다. ${e.message}", e) } } + + fun getAllMemos(): List { + try { + return memoRepository.getAll() + } catch (e: Exception) { + logger.warn("[getAllMemos] 메모 조회 중 알 수 없는 에러가 발생했습니다. ${e.message}", e) + } + + return mutableListOf() + } } \ No newline at end of file From 87d6b44be934a87c0373d937039e627c4c345fd6 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 13:33:29 +0900 Subject: [PATCH 22/74] =?UTF-8?q?refactor(MemoRepositroy):=20=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=20=EC=A0=80=EC=9E=A5=20=EA=B8=B0=EB=8A=A5=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EC=8B=9C,=20Memo=EB=A5=BC=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=EB=A1=9C=20=EB=B0=9B=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yeoli/devlog/domain/memo/repository/MemoRepository.kt | 4 ++-- .../github/yeoli/devlog/domain/memo/service/MemoService.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 index dad3d3d..df5cadb 100644 --- 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 @@ -20,8 +20,8 @@ class MemoRepository : PersistentStateComponent { this.state = state } - fun save(memo: MemoState) { - state.memos.add(memo) + fun save(memo: Memo) { + state.memos.add(memo.toState()) } fun getAll(): List { 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 index 3337c01..fe09bbe 100644 --- 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 @@ -82,7 +82,7 @@ class MemoService(private val project: Project) { fun saveMemo(memo: Memo) { try { - memoRepository.save(memo.toState()) + memoRepository.save(memo) } catch (e: Exception) { logger.warn("[saveMemo] 메모 저장 중 알 수 없는 에러가 발생했습니다. ${e.message}", e) } From 7db961b7ae27d7509f6094b0bb72dda00dfd5678 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 14:05:05 +0900 Subject: [PATCH 23/74] =?UTF-8?q?test(MemoServiceTest):=20=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=20=EC=A0=84=EC=B2=B4=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/memo/service/MemoServiceTest.kt | 76 ++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) 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 index 6ee0d2c..f8c7736 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -10,10 +11,28 @@ 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 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( @@ -132,7 +151,6 @@ class MemoServiceTest : BasePlatformTestCase() { val document = editor.document val snippet = "val selected = 42" val selectionStart = document.text.indexOf(snippet) - assertTrue("선택할 코드 스니펫을 찾지 못했습니다.", selectionStart >= 0) val selectionEnd = selectionStart + snippet.length editor.selectionModel.setSelection(selectionStart, selectionEnd) @@ -167,4 +185,60 @@ class MemoServiceTest : BasePlatformTestCase() { } } + // ========= 메모 조회 기능 ========= + fun `test 메모 전체 조회 기능 성공`() { + // given + val memo1 = Memo( + id = System.currentTimeMillis(), + createdAt = java.time.LocalDateTime.now(), + updatedAt = java.time.LocalDateTime.now(), + content = "메모1", + commitHash = null, + filePath = "/path/to/file1", + selectedCodeSnippet = "snippet1", + selectionStart = 0, + selectionEnd = 5, + visibleStart = 1, + visibleEnd = 3 + ) + + val memo2 = Memo( + id = System.currentTimeMillis() + 1, + createdAt = java.time.LocalDateTime.now(), + updatedAt = java.time.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).getAllMemos() + + // 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).getAllMemos() + + // then + assertTrue(result.isEmpty(), "예외 발생 시 빈 리스트를 반환해야 합니다.") + } + } + From bd7e8beb2a087d9065cf79702eb1b5a097371d72 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 14:05:45 +0900 Subject: [PATCH 24/74] =?UTF-8?q?chore(Build):=20Mokito=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index df4a29b..e178b35 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,6 +37,9 @@ dependencies { 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 { From bc51fdeef78b215d4ecea02730715e05dca83e83 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 14:12:13 +0900 Subject: [PATCH 25/74] =?UTF-8?q?docs(README):=20=EB=A9=94=EB=AA=A8=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 45d3f5a..e60bcce 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,8 @@ --- -### 🏁 메모 조회 기능 +### ✅ 메모 조회 기능 -- 메모의 id를 이용해서 데이터를 조회할 수 있어야 한다. - 전체 메모를 날짜순으로 조회할 수 있어야 한다. ### ⚠️ 메모 조회 기능 고려 사항 @@ -41,13 +40,15 @@ --- -### ☑️ 메모 삭제 기능 +### 🏁메모 삭제 기능 - 저장된 메모를 삭제할 수 있어야 한다. - 메모를 한 번에 여러개 삭제할 수 있어야 한다. ### ⚠️ 메모 삭제 기능 예외 상황 +- 삭제할 메모가 없으면 아무 수행도 해선 안된다. + --- ### ☑️ 메모 수정 기능 From ec249947967196670ba47b72c647b2e9e87d9a23 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 14:38:22 +0900 Subject: [PATCH 26/74] =?UTF-8?q?test(MemoServiceTest):=20=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/memo/service/MemoServiceTest.kt | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) 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 index f8c7736..de59ddf 100644 --- 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 @@ -14,6 +14,7 @@ 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() { @@ -240,5 +241,74 @@ class MemoServiceTest : BasePlatformTestCase() { assertTrue(result.isEmpty(), "예외 발생 시 빈 리스트를 반환해야 합니다.") } + // ========= 메모 삭제 기능 ========= + fun `test 메모 삭제 기능 - 정상 삭제`() { + val now = java.time.LocalDateTime.now() + val memo1 = Memo( + id = 1L, + createdAt = now, + updatedAt = now, + content = "a", + commitHash = null, + filePath = "/path/to/file1", + selectedCodeSnippet = 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, + 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, + 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, "예외가 발생하면 안 됩니다.") + } } From a01f6c5f81d6e223206135d8ca12b62023223b18 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 14:38:56 +0900 Subject: [PATCH 27/74] =?UTF-8?q?feat(MemoService):=20=EB=A9=94=EB=AA=A8?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devlog/domain/memo/repository/MemoRepository.kt | 10 ++++++++++ .../yeoli/devlog/domain/memo/service/MemoService.kt | 11 +++++++++++ 2 files changed, 21 insertions(+) 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 index df5cadb..2d6d123 100644 --- 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 @@ -27,4 +27,14 @@ class MemoRepository : PersistentStateComponent { fun getAll(): List { return state.memos.map { it.toDomain() } } + + private 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/service/MemoService.kt b/src/main/kotlin/com/github/yeoli/devlog/domain/memo/service/MemoService.kt index fe09bbe..50ab391 100644 --- 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 @@ -97,4 +97,15 @@ class MemoService(private val project: Project) { 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) + } + } } \ No newline at end of file From 7708cdf59cde3bc30c0b11a168a2d48583468b1a Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 14:43:33 +0900 Subject: [PATCH 28/74] =?UTF-8?q?docs(README):=20=EB=A9=94=EB=AA=A8=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e60bcce..c99c1d9 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ --- -### 🏁메모 삭제 기능 +### ✅ 메모 삭제 기능 - 저장된 메모를 삭제할 수 있어야 한다. - 메모를 한 번에 여러개 삭제할 수 있어야 한다. From b2794f1518fa7615435d765b337682622e844b4c Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 20:35:40 +0900 Subject: [PATCH 29/74] =?UTF-8?q?docs(README):=20=EB=A9=94=EB=AA=A8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c99c1d9..ca5be4a 100644 --- a/README.md +++ b/README.md @@ -51,12 +51,14 @@ --- -### ☑️ 메모 수정 기능 +### 🏁 메모 수정 기능 - 저장된 메모의 content를 수정할 수 있어야 한다. ### ⚠️ 메모 수정 기능 예외 상황 +- 수정할 메모가 없다면 아무 수행도 해선 안된다. + --- ### ☑️ 메모 추출 기능 From d1230c5d0e2718210f08034f4b9580ae68e50058 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 20:36:01 +0900 Subject: [PATCH 30/74] =?UTF-8?q?test(MemoServiceTest):=20=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=20=EC=88=98=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/memo/service/MemoServiceTest.kt | 97 +++++++++++++++++-- 1 file changed, 91 insertions(+), 6 deletions(-) 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 index de59ddf..5c776cb 100644 --- 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 @@ -191,8 +191,8 @@ class MemoServiceTest : BasePlatformTestCase() { // given val memo1 = Memo( id = System.currentTimeMillis(), - createdAt = java.time.LocalDateTime.now(), - updatedAt = java.time.LocalDateTime.now(), + createdAt = LocalDateTime.now(), + updatedAt = LocalDateTime.now(), content = "메모1", commitHash = null, filePath = "/path/to/file1", @@ -205,8 +205,8 @@ class MemoServiceTest : BasePlatformTestCase() { val memo2 = Memo( id = System.currentTimeMillis() + 1, - createdAt = java.time.LocalDateTime.now(), - updatedAt = java.time.LocalDateTime.now(), + createdAt = LocalDateTime.now(), + updatedAt = LocalDateTime.now(), content = "메모2", commitHash = null, filePath = "/path/to/file2", @@ -243,7 +243,7 @@ class MemoServiceTest : BasePlatformTestCase() { // ========= 메모 삭제 기능 ========= fun `test 메모 삭제 기능 - 정상 삭제`() { - val now = java.time.LocalDateTime.now() + val now = LocalDateTime.now() val memo1 = Memo( id = 1L, createdAt = now, @@ -310,5 +310,90 @@ class MemoServiceTest : BasePlatformTestCase() { } 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", + 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", + 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) + }) + } + +} From 37a80c1978fa6a061d045f5721342e94c40de713 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 20:39:35 +0900 Subject: [PATCH 31/74] =?UTF-8?q?feat(MemoRepository):=20findMemoById=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yeoli/devlog/domain/memo/repository/MemoRepository.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 index 2d6d123..8b49052 100644 --- 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 @@ -24,11 +24,16 @@ class MemoRepository : PersistentStateComponent { 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() } } - private fun removeMemoById(memoId: Long) { + fun removeMemoById(memoId: Long) { state.memos.removeIf { it.id == memoId } } @@ -37,4 +42,5 @@ class MemoRepository : PersistentStateComponent { removeMemoById(memoId) } } + } From 8d931b27ec6d38cb0113472307eee33a5ce19b1f Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 20:40:51 +0900 Subject: [PATCH 32/74] =?UTF-8?q?feat(Memo):=20update=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yeoli/devlog/domain/memo/domain/Memo.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 index e820a7f..1bb1b4f 100644 --- 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 @@ -75,4 +75,22 @@ class Memo( 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, + selectionStart = this.selectionStart, + selectionEnd = this.selectionEnd, + visibleStart = this.visibleStart, + visibleEnd = this.visibleEnd + ) + } } From 3fd78ad943d8bfec82861031206d59dfa99d324d Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 20:41:22 +0900 Subject: [PATCH 33/74] =?UTF-8?q?feat(MemoService):=20=EB=A9=94=EB=AA=A8?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yeoli/devlog/domain/memo/service/MemoService.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 index 50ab391..0874bd2 100644 --- 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 @@ -108,4 +108,15 @@ class MemoService(private val project: Project) { 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) + } + } } \ No newline at end of file From c410995b88f8cb955cb2c507e9ceadf2180ba3be Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 20:42:20 +0900 Subject: [PATCH 34/74] =?UTF-8?q?docs(README):=20=EB=A9=94=EB=AA=A8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ca5be4a..8e75a9a 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ --- -### 🏁 메모 수정 기능 +### ✅ 메모 수정 기능 - 저장된 메모의 content를 수정할 수 있어야 한다. From 2b581cf9dde5248bdd4f42d0df16004ea75b88a8 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 21:22:45 +0900 Subject: [PATCH 35/74] =?UTF-8?q?docs(README):=20=EB=A9=94=EB=AA=A8=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8e75a9a..bc3bce7 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ --- -### ☑️ 메모 추출 기능 +### 🏁 메모 추출 기능 - 저장된 메모를 한 개 이상 선택하여 txt 파일로 추출할 수 있어야 한다. - 추출한 단위 메모의 구성 내용은 다음과 같다. From 2055510b77c785b7f646ac3afc0c6ac85bca6dbb Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 21:24:30 +0900 Subject: [PATCH 36/74] =?UTF-8?q?test(MemoTest):=20=EB=A9=94=EB=AA=A8=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20=ED=98=95=EC=8B=9D=20=EB=B0=98=ED=99=98=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devlog/domain/memo/domain/MemoTest.kt | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) 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 index 4533a23..01dd20d 100644 --- 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 @@ -1,6 +1,7 @@ package com.github.yeoli.devlog.domain.memo.domain import com.github.yeoli.devlog.domain.memo.repository.MemoState +import java.time.LocalDateTime import kotlin.test.* class MemoTest { @@ -99,4 +100,93 @@ class MemoTest { 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")) + assertTrue(block.contains("📅 수정 시간 : $updated")) + 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", + selectionStart = 3, + selectionEnd = 9, + visibleStart = 2, + visibleEnd = 12 + ) + + val block = memo.buildMemoBlock(2) + + assertTrue(block.contains("# Memo 2")) + assertTrue(block.contains("📅 생성 시간 : $created")) + assertTrue(block.contains("📅 수정 시간 : $updated")) + 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, + 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)")) + } } From 5f634970ac1835d7dc3af09e64c09de6538cd4ea Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 21:24:51 +0900 Subject: [PATCH 37/74] =?UTF-8?q?feat(Memo):=20=EB=A9=94=EB=AA=A8=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20=ED=98=95=EC=8B=9D=20=EB=B0=98=ED=99=98=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yeoli/devlog/domain/memo/domain/Memo.kt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) 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 index 1bb1b4f..276a2b4 100644 --- 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 @@ -93,4 +93,25 @@ class Memo( visibleEnd = this.visibleEnd ) } + + fun buildMemoBlock(index: Int): String { + return """ + + # Memo $index + 📅 생성 시간 : ${this.createdAt} + 📅 수정 시간 : ${this.updatedAt} + + 📌 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() + } } From ef05fe01aa29372c47bddf2707f381effc7ff33c Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 21:46:19 +0900 Subject: [PATCH 38/74] =?UTF-8?q?test(MemoTest):=20=EB=A9=94=EB=AA=A8=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20=ED=97=A4=EB=8D=94=20=EB=B0=98=ED=99=98=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/memo/service/MemoServiceTest.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 index 5c776cb..fb1bb09 100644 --- 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 @@ -396,4 +396,22 @@ class MemoServiceTest : BasePlatformTestCase() { }) } + // ========= 메모 추출 기능 ========= + + 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("---------------------------------------")) + } + } From 633a1782047e139875061b85fa1a57600d4fa1ba Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 21:53:52 +0900 Subject: [PATCH 39/74] =?UTF-8?q?feat(Memo):=20=EB=A9=94=EB=AA=A8=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20=ED=97=A4=EB=8D=94=20=EB=B0=98=ED=99=98=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devlog/domain/memo/service/MemoService.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 index 0874bd2..8490acb 100644 --- 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 @@ -11,6 +11,8 @@ import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.project.Project 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) { @@ -119,4 +121,19 @@ class MemoService(private val project: Project) { 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() + } } \ No newline at end of file From beb4c9893dfb8d282da23c3b31cf707d0e161c96 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 21:54:20 +0900 Subject: [PATCH 40/74] =?UTF-8?q?refactor(Memo):=20=EB=A9=94=EB=AA=A8=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20=EA=B8=B0=EB=8A=A5=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=ED=8F=AC=EB=A7=B7=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/github/yeoli/devlog/domain/memo/domain/Memo.kt | 5 +++-- .../github/yeoli/devlog/domain/memo/domain/MemoTest.kt | 9 +++++---- 2 files changed, 8 insertions(+), 6 deletions(-) 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 index 276a2b4..da69aab 100644 --- 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 @@ -2,6 +2,7 @@ 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, @@ -98,8 +99,8 @@ class Memo( return """ # Memo $index - 📅 생성 시간 : ${this.createdAt} - 📅 수정 시간 : ${this.updatedAt} + 📅 생성 시간 : ${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} 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 index 01dd20d..38df2f5 100644 --- 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 @@ -2,6 +2,7 @@ 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 { @@ -123,8 +124,8 @@ class MemoTest { val block = memo.buildMemoBlock(1) assertTrue(block.contains("# Memo 1")) - assertTrue(block.contains("📅 생성 시간 : $created")) - assertTrue(block.contains("📅 수정 시간 : $updated")) + 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")) @@ -155,8 +156,8 @@ class MemoTest { val block = memo.buildMemoBlock(2) assertTrue(block.contains("# Memo 2")) - assertTrue(block.contains("📅 생성 시간 : $created")) - assertTrue(block.contains("📅 수정 시간 : $updated")) + 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")) From ae737de62df7ad6861bfb26552ed0a92f05385f6 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 23:02:42 +0900 Subject: [PATCH 41/74] =?UTF-8?q?test(MemoServiceTest):=20=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=20=EC=A0=84=EC=B2=B4=20=EB=82=B4=EC=9A=A9=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=20=EB=A9=94=EC=84=9C=EB=93=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/memo/service/MemoServiceTest.kt | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) 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 index fb1bb09..f8f7f9e 100644 --- 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 @@ -414,4 +414,133 @@ class MemoServiceTest : BasePlatformTestCase() { 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", + 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, + 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, + 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")) + } + } From 03d5fbe2ae747d6e7eab5b96ddf2897328486b1b Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 23:02:52 +0900 Subject: [PATCH 42/74] =?UTF-8?q?feat(MemoService):=20=EB=A9=94=EB=AA=A8?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4=20=EB=82=B4=EC=9A=A9=20=EB=B0=98=ED=99=98?= =?UTF-8?q?=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devlog/domain/memo/service/MemoService.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 index 8490acb..50629f2 100644 --- 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 @@ -136,4 +136,20 @@ class MemoService(private val project: Project) { --------------------------------------- """.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 + } } \ No newline at end of file From af77d047bfc6979b539c553d1cec3a455dc40749 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 23:13:01 +0900 Subject: [PATCH 43/74] =?UTF-8?q?test(MemoServiceTest):=20=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=20txt=20=ED=8C=8C=EC=9D=BC=EB=A1=9C=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C=20=EC=84=B1=EA=B3=B5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/memo/service/MemoServiceTest.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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 index f8f7f9e..26b0989 100644 --- 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 @@ -543,4 +543,24 @@ class MemoServiceTest : BasePlatformTestCase() { assertTrue(export.contains("3 ~ 8")) } + fun `test exportToTxt - 파일 생성 및 내용 저장`() { + // given + val service = MemoService(project) + val text = "Hello DevLog Export Test" + + // IntelliJ 테스트용 임시 디렉토리 생성 (VirtualFile) + 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) + } + } From 7b6ee16a36009d846aa3cb5a09c8053079bede28 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sat, 22 Nov 2025 23:13:21 +0900 Subject: [PATCH 44/74] =?UTF-8?q?feat(MemoService):=20=EB=A9=94=EB=AA=A8?= =?UTF-8?q?=20txt=20=ED=8C=8C=EC=9D=BC=20=EC=A0=80=EC=9E=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devlog/domain/memo/service/MemoService.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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 index 50629f2..2be41b3 100644 --- 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 @@ -3,12 +3,14 @@ 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 @@ -152,4 +154,17 @@ class MemoService(private val project: Project) { 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 + } } \ No newline at end of file From 3387d0d6897b3aece8e4a97f3e19d3c874449287 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sun, 23 Nov 2025 00:11:21 +0900 Subject: [PATCH 45/74] =?UTF-8?q?test(MemoServiceTest):=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../github/yeoli/devlog/domain/memo/service/MemoServiceTest.kt | 2 -- 1 file changed, 2 deletions(-) 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 index 26b0989..d86b68b 100644 --- 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 @@ -547,8 +547,6 @@ class MemoServiceTest : BasePlatformTestCase() { // given val service = MemoService(project) val text = "Hello DevLog Export Test" - - // IntelliJ 테스트용 임시 디렉토리 생성 (VirtualFile) val exportDir = myFixture.tempDirFixture.findOrCreateDir("exports") // when From f5871e851721ccbdfa9434c0c49d8e0bd418470f Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sun, 23 Nov 2025 22:15:52 +0900 Subject: [PATCH 46/74] =?UTF-8?q?feat(Note):=20Note=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yeoli/devlog/domain/note/domain/Note.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/main/kotlin/com/github/yeoli/devlog/domain/note/domain/Note.kt 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..a0612c9 --- /dev/null +++ b/src/main/kotlin/com/github/yeoli/devlog/domain/note/domain/Note.kt @@ -0,0 +1,17 @@ +package com.github.yeoli.devlog.domain.note.domain + +import java.time.LocalDateTime + +class Note constructor( + val content: String, + val updatedAt: LocalDateTime +) { + + constructor(content: String) : this(content, LocalDateTime.now()) + + fun update(content: String): Note { + return Note( + content = content + ) + } +} \ No newline at end of file From a59fde50d5d196981d879f840906777951593fcc Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sun, 23 Nov 2025 22:16:47 +0900 Subject: [PATCH 47/74] =?UTF-8?q?refactor(Note):=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EC=9E=90=20=EB=AA=85=EB=A0=B9=EC=96=B4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/com/github/yeoli/devlog/domain/note/domain/Note.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index a0612c9..37a46a8 100644 --- 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 @@ -2,7 +2,7 @@ package com.github.yeoli.devlog.domain.note.domain import java.time.LocalDateTime -class Note constructor( +class Note( val content: String, val updatedAt: LocalDateTime ) { From 7b72f5a19cdab6a3d1385224f87c699ced7d48b9 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sun, 23 Nov 2025 22:56:12 +0900 Subject: [PATCH 48/74] =?UTF-8?q?test(NoteTest):=20update=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devlog/domain/note/domain/NoteTest.kt | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/test/kotlin/com/github/yeoli/devlog/domain/note/domain/NoteTest.kt 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..7bcaec7 --- /dev/null +++ b/src/test/kotlin/com/github/yeoli/devlog/domain/note/domain/NoteTest.kt @@ -0,0 +1,53 @@ +package com.github.yeoli.devlog.domain.note.domain + +import org.junit.jupiter.api.Assertions.* +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) + } + + @Test + fun `test 동일한 콘텐츠로 업데이트할 때 updatedAt이 갱신되지 않아야 한다`() { + val now = java.time.LocalDateTime.now() + val original = Note( + content = "same" + ) + + val updated = original.update("same") + + assertEquals(original.updatedAt, updated.updatedAt) + } +} \ No newline at end of file From 3e26479ffc8caf8913185be4771cce98e830df22 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Sun, 23 Nov 2025 22:58:06 +0900 Subject: [PATCH 49/74] =?UTF-8?q?test(NoteTest):=20update=20=EB=8F=99?= =?UTF-8?q?=EC=9D=BC=ED=95=9C=20=EC=BD=98=ED=85=90=EC=B8=A0=EB=A1=9C=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=ED=95=A0=20=EB=95=8C=20upd?= =?UTF-8?q?atedAt=EC=9D=B4=20=EA=B0=B1=EC=8B=A0=EB=90=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yeoli/devlog/domain/note/domain/NoteTest.kt | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) 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 index 7bcaec7..b8927f5 100644 --- 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 @@ -38,16 +38,5 @@ class NoteTest { assertEquals("", updated.content) } - - @Test - fun `test 동일한 콘텐츠로 업데이트할 때 updatedAt이 갱신되지 않아야 한다`() { - val now = java.time.LocalDateTime.now() - val original = Note( - content = "same" - ) - - val updated = original.update("same") - - assertEquals(original.updatedAt, updated.updatedAt) - } -} \ No newline at end of file + +} From 8a81d906993b755543486e4e01b31da00411a710 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 01:21:35 +0900 Subject: [PATCH 50/74] =?UTF-8?q?test(NoteTest):=20toState=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devlog/domain/note/domain/NoteTest.kt | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) 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 index b8927f5..c8333f6 100644 --- 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 @@ -1,6 +1,7 @@ package com.github.yeoli.devlog.domain.note.domain import org.junit.jupiter.api.Assertions.* +import java.time.LocalDateTime import kotlin.test.Test class NoteTest { @@ -38,5 +39,45 @@ class NoteTest { 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) + } } From a07ba3d13d764f346b6160992a055ced990b32b1 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 01:21:55 +0900 Subject: [PATCH 51/74] =?UTF-8?q?feat(Note):=20toState=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/github/yeoli/devlog/domain/note/domain/Note.kt | 8 ++++++++ 1 file changed, 8 insertions(+) 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 index 37a46a8..fc51fe0 100644 --- 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 @@ -1,5 +1,6 @@ package com.github.yeoli.devlog.domain.note.domain +import com.github.yeoli.devlog.domain.note.repository.NoteState import java.time.LocalDateTime class Note( @@ -14,4 +15,11 @@ class Note( content = content ) } + + fun toState(): NoteState { + return NoteState( + content = this.content, + updatedAt = this.updatedAt.toString() + ) + } } \ No newline at end of file From ca75dbd56468301ac7882fd56ca636b149e96066 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 01:25:08 +0900 Subject: [PATCH 52/74] =?UTF-8?q?test(NoteStateTest):=20toDomain=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/note/repository/NoteStateTest.kt | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/test/kotlin/com/github/yeoli/devlog/domain/note/repository/NoteStateTest.kt 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 From d1b5b0885766f50ee150433a94f5a2681cd3099b Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 01:25:26 +0900 Subject: [PATCH 53/74] =?UTF-8?q?feat(NoteState):=20toDomain=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devlog/domain/note/repository/NoteState.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/main/kotlin/com/github/yeoli/devlog/domain/note/repository/NoteState.kt 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..29aaf25 --- /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( + val content: String, + val updatedAt: String +) { + + fun toDomain(): Note { + return Note(content, updatedAt = this.updatedAt.let { LocalDateTime.parse(it) }) + } +} \ No newline at end of file From 120658caa9999f39b41437951ca938797d8399d7 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 01:40:27 +0900 Subject: [PATCH 54/74] =?UTF-8?q?test(NoteServiceTest):=20getNote=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/note/service/NoteServiceTest.kt | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/test/kotlin/com/github/yeoli/devlog/domain/note/service/NoteServiceTest.kt 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..1a0fe36 --- /dev/null +++ b/src/test/kotlin/com/github/yeoli/devlog/domain/note/service/NoteServiceTest.kt @@ -0,0 +1,54 @@ +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.whenever +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) + + @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) + } +} \ No newline at end of file From 5008a28db413fd7bca8da0deabd1d7510abbe9d3 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 01:40:54 +0900 Subject: [PATCH 55/74] =?UTF-8?q?feat(NoteRepository):=20NoteRepository=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/note/repository/NoteRepository.kt | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/main/kotlin/com/github/yeoli/devlog/domain/note/repository/NoteRepository.kt 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..594073b --- /dev/null +++ b/src/main/kotlin/com/github/yeoli/devlog/domain/note/repository/NoteRepository.kt @@ -0,0 +1,36 @@ +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 +import java.time.LocalDateTime + +@State( + name = "DevLogMemoStorage", + storages = [Storage("devlog-memos.xml")] +) +@Service(Service.Level.PROJECT) +class NoteRepository : PersistentStateComponent { + + private var state: NoteState? = NoteState( + content = "", + updatedAt = LocalDateTime.now().toString() + ) + + override fun getState(): NoteState? = state + + override fun loadState(state: NoteState) { + this.state = state + } + + fun getNote(): Note { + if (state == null) { + state = Note( + content = "" + ).toState() + } + return state!!.toDomain() + } +} \ No newline at end of file From 88184bd33ba6270791997cc6c076e66c878ad42a Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 01:41:21 +0900 Subject: [PATCH 56/74] =?UTF-8?q?feat(NoteService):=20getNote=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devlog/domain/note/service/NoteService.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/main/kotlin/com/github/yeoli/devlog/domain/note/service/NoteService.kt 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..089d19e --- /dev/null +++ b/src/main/kotlin/com/github/yeoli/devlog/domain/note/service/NoteService.kt @@ -0,0 +1,18 @@ +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() + } +} + From b8df11331fb0d6758e5dd75b6eccac7db198d8ea Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 02:23:11 +0900 Subject: [PATCH 57/74] =?UTF-8?q?test(NoteServiceTest):=20updateNote=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/note/service/NoteServiceTest.kt | 54 +++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) 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 index 1a0fe36..3d78a15 100644 --- 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 @@ -4,7 +4,7 @@ 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.whenever +import org.mockito.kotlin.* import java.time.LocalDateTime import kotlin.test.Test import kotlin.test.assertEquals @@ -14,8 +14,16 @@ 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_레포지토리와_동일한_노트를_반환한다() { + fun `test 레포지토리와 동일한 노트를 반환한다`() { // given val now = LocalDateTime.now() val expectedNote = Note("Hello Test", now) @@ -35,7 +43,7 @@ class NoteServiceTest { } @Test - fun test_기본_빈_노트를_반환한다() { + fun `test 기본 빈 노트를 반환한다`() { // given val defaultNote = Note("") whenever(noteRepository.getNote()).thenReturn(defaultNote) @@ -51,4 +59,42 @@ class NoteServiceTest { // then assertEquals(defaultNote, actual) } -} \ No newline at end of file + + // ========= 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()) + } +} From b5446a809f28fd7787abc77cfa91373d864b4389 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 02:24:02 +0900 Subject: [PATCH 58/74] =?UTF-8?q?feat(NoteRepository):=20updateNote=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yeoli/devlog/domain/note/repository/NoteRepository.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 index 594073b..12fa924 100644 --- 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 @@ -8,8 +8,8 @@ import com.intellij.openapi.components.Storage import java.time.LocalDateTime @State( - name = "DevLogMemoStorage", - storages = [Storage("devlog-memos.xml")] + name = "DevLogNoteStorage", + storages = [Storage("devlog-note.xml")] ) @Service(Service.Level.PROJECT) class NoteRepository : PersistentStateComponent { @@ -33,4 +33,8 @@ class NoteRepository : PersistentStateComponent { } return state!!.toDomain() } + + fun updateNote(updatedNote: Note) { + this.state = updatedNote.toState() + } } \ No newline at end of file From d57b9c90a7be931eb263a84def31e296efa60043 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 02:24:16 +0900 Subject: [PATCH 59/74] =?UTF-8?q?feat(NoteService):=20updateNote=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yeoli/devlog/domain/note/service/NoteService.kt | 9 +++++++++ 1 file changed, 9 insertions(+) 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 index 089d19e..9173405 100644 --- 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 @@ -14,5 +14,14 @@ class NoteService(private val project: Project) { 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) + } } From 96b44970fda00bfedaa0953bb04f06327ac9786c Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 02:28:13 +0900 Subject: [PATCH 60/74] =?UTF-8?q?docs(README):=20=EC=A7=84=ED=96=89?= =?UTF-8?q?=EC=83=81=ED=99=A9=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index bc3bce7..305d039 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ - 저장된 메모를 삭제할 수 있어야 한다. - 메모를 한 번에 여러개 삭제할 수 있어야 한다. -### ⚠️ 메모 삭제 기능 예외 상황 +### ⚠️ 메모 삭제 기능 고려 사항 - 삭제할 메모가 없으면 아무 수행도 해선 안된다. @@ -55,13 +55,13 @@ - 저장된 메모의 content를 수정할 수 있어야 한다. -### ⚠️ 메모 수정 기능 예외 상황 +### ⚠️ 메모 수정 기능 고려 사항 - 수정할 메모가 없다면 아무 수행도 해선 안된다. --- -### 🏁 메모 추출 기능 +### ✅ 메모 추출 기능 - 저장된 메모를 한 개 이상 선택하여 txt 파일로 추출할 수 있어야 한다. - 추출한 단위 메모의 구성 내용은 다음과 같다. @@ -76,18 +76,22 @@ - txt 파일의 이름은 `devlog-{프로젝트명}-{내보낸날짜}-{내보낸시각}.txt` 이다. - 추출할 메모가 없으면 빈 txt 파일을 반환한다. -### ⚠️ 메모 추출 기능 예외 상황 +### ⚠️ 메모 추출 기능 고려 사항 + +- 메모 추출 기능에서 고려 사항은 없습니다. --- -### ☑️ 노트 수정/저장 기능 +### ✅ 노트 수정/저장 기능 - 노트를 저장할 수 있어야 한다. - 노트의 수정/저장 데이터는 다음과 같다. - `String content`: 노트의 내용 - `LocalDateTime savedAt`: 저장된 시각 -### ⚠️ 노트 수정/저장 기능 예외 상황 +### ⚠️ 노트 수정/저장 기능 고려 사항 + +- 노트가 수정할 내용이 없다면 아무 수행도 해선 안된다. --- From 4cd4edbba0c9947b54265610abe73dd2a083a0b7 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 16:25:38 +0900 Subject: [PATCH 61/74] =?UTF-8?q?refactor(MyToolWindowFactory):=20?= =?UTF-8?q?=EC=9D=B4=EC=A0=84=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=20ui=20=EC=BD=94=EB=93=9C=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A4=EA=B8=B0=20=EB=B0=8F=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yeoli/devlog/event/MemoChangedEvent.kt | 12 + .../devlog/toolWindow/MyToolWindowFactory.kt | 97 +- .../com/github/yeoli/devlog/ui/DevLogPanel.kt | 1000 +++++++++++++++++ .../yeoli/devlog/ui/MemoExportAction.kt | 90 ++ .../yeoli/devlog/ui/MemoExportPipeline.kt | 41 + .../github/yeoli/devlog/ui/MemoListView.kt | 433 +++++++ 6 files changed, 1650 insertions(+), 23 deletions(-) create mode 100644 src/main/kotlin/com/github/yeoli/devlog/event/MemoChangedEvent.kt create mode 100644 src/main/kotlin/com/github/yeoli/devlog/ui/DevLogPanel.kt create mode 100644 src/main/kotlin/com/github/yeoli/devlog/ui/MemoExportAction.kt create mode 100644 src/main/kotlin/com/github/yeoli/devlog/ui/MemoExportPipeline.kt create mode 100644 src/main/kotlin/com/github/yeoli/devlog/ui/MemoListView.kt 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/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..041fbde --- /dev/null +++ b/src/main/kotlin/com/github/yeoli/devlog/ui/DevLogPanel.kt @@ -0,0 +1,1000 @@ +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.intellij.icons.AllIcons +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +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.DumbAwareAction +import com.intellij.openapi.project.Project +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.DocumentAdapter +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.* +import java.awt.event.ActionEvent +import java.awt.event.KeyAdapter +import java.awt.event.KeyEvent +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import javax.swing.* +import javax.swing.text.JTextComponent + +/** + * 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() + + // 하단 작성 영역(코멘트 입력 + Save 버튼). + private val composer = RetrospectComposer(palette, ::handleSaveRequest) + + // 공유 메모 탭에서 사용되는 텍스트 영역. + private val sharedNotes = SharedNotesPanel(palette, ::handleSharedNotesSave) + + // 에디터 선택 상태를 좌측 배너로 보여주는 컴포넌트. + private val selectionStatusPanel = SelectionStatusPanel(palette) + + // 카드 전환으로 띄우는 상세 로그 편집기. + private val recordDetailPanel = RecordDetailPanel( + palette = palette, + onRequestSave = ::handleDetailSave, + onRequestBack = ::handleDetailBack + ) + + // 타임라인에서 체크된 레코드들의 ID 저장소. + private val selectedRecordIds = linkedSetOf() + + // DevLog들을 시간순으로 보여주고 조작하는 메인 뷰. + private val timeline = RetrospectTimelineView( + palette, + RetrospectTimelineView.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() + } + ) + } + + /** + * 스토리지의 최신 데이터를 타임라인/툴바 상태에 반영한다. + */ + private fun refreshMemos() { + val memos = memoService.getAllMemos() + totalRecordCount = memos.size + timeline.render(memos) + refreshToolbarActions() + } + + /** + * 타임라인(위)과 작성기(아래)를 포함하는 메인 분할 레이아웃을 구성한다. + */ + private fun createMainView(): JComponent { + val timelineStack = JBPanel>(BorderLayout()).apply { + background = palette.editorBg + // DevLog 목록 + 현재 에디터 선택 상태 배너를 하나의 패널로 묶는다. + add(timeline.component, BorderLayout.CENTER) + add(selectionStatusPanel.component, BorderLayout.SOUTH) + } + val lowerPanel = JBPanel>(BorderLayout()).apply { + background = palette.editorBg + // 선택한 레코드와 연결하려는 context 영역과 작성기 패널이 붙어 있다. +// add(linkContextPanel.component, BorderLayout.NORTH) + add(composer.component, BorderLayout.CENTER) + } + val splitter = JBSplitter(true, 0.8f).apply { + dividerWidth = 5 + isOpaque = false + // 상단에 타임라인, 하단에 작성기를 배치하고 상단에 비중을 둔다. + firstComponent = timelineStack + secondComponent = lowerPanel + setHonorComponentsMinimumSize(true) + } + return splitter + } + + /** + * 카드 전환/선택/내보내기 등 액션이 모인 상단 툴바를 만든다. + */ + 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 + // ToolWindow 배경과 자연스럽게 섞이도록 여백/색상을 정리. + 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 switchCard(card: String) { + if (currentCard == card) return + currentCard = card + cards.show(contentStack, card) + refreshToolbarActions() + } + + private fun loadSharedNotes() { + sharedNotes.setContent(noteService.getNote().content) + } + + 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 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() + } + } + + /** + * 작성기의 Save 트리거를 처리한다. 선택 영역이 있으면 Draft로, 아니면 일반 로그로 저장한다. + */ + // 저장되는 로직 + private fun handleSaveRequest(rawBody: String) { + val body = rawBody.trim() + if (body.isEmpty()) { + composer.showEmptyBodyMessage() + return + } + val memo = memoService.createMemo(rawBody) + if (memo == null) 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 notifyChange() { + project.messageBus.syncPublisher(MemoChangedEvent.TOPIC).onChanged() + } + + private fun handleSelectionChanged(ids: Set) { + selectedRecordIds.clear() + selectedRecordIds.addAll(ids) + refreshToolbarActions() + } + + private fun getSelectedRecords(): List { + if (selectedRecordIds.isEmpty()) return emptyList() + val selected = selectedRecordIds.toSet() + return memoService.getAllMemos() + .sortedBy { it.createdAt }.filter { it.id in selected } + } + + private fun deleteSelectedRecords(records: List) { + if (records.isEmpty()) return + val ids = records.map { it.id }.toSet() + + // memoService에 removeMemosByIds로 바꾸면 될듯 + if (ids.isEmpty()) return + val memos = memoService.getAllMemos().filter { ids.contains(it.id) } + memoService.removeMemos(memos) + + notifyChange() + selectedRecordIds.removeAll(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 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 : com.intellij.openapi.ui.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 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)) +) + +private class RetrospectComposer( + 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 class RecordDetailPanel( + 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 class SharedNotesPanel( + 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 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 + } +} + +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() + } + } + }) + } +} + +private 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() + } +} + +private 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 + } + } +} diff --git a/src/main/kotlin/com/github/yeoli/devlog/ui/MemoExportAction.kt b/src/main/kotlin/com/github/yeoli/devlog/ui/MemoExportAction.kt new file mode 100644 index 0000000..2d9902d --- /dev/null +++ b/src/main/kotlin/com/github/yeoli/devlog/ui/MemoExportAction.kt @@ -0,0 +1,90 @@ +package com.github.yeoli.devlog.ui + +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.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" + } +} 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..746fd96 --- /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.getAllMemos() + + 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.md" + + return Payload( + content = content, + fileExtension = "md", + defaultFileName = defaultFileName + ) + } +} \ No newline at end of file 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..4952889 --- /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 RetrospectTimelineView( + 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) +} From 5585a7b26e136f03299e1909be5f9d631c221950 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 16:27:27 +0900 Subject: [PATCH 62/74] =?UTF-8?q?fix(NoteRepository):=20devlog-note.xml=20?= =?UTF-8?q?=EB=AF=B8=EC=83=9D=EC=84=B1=20=EB=B2=84=EA=B7=B8=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/note/repository/NoteRepository.kt | 23 ++++++------------- .../domain/note/repository/NoteState.kt | 4 ++-- .../note/repository/NoteStorageState.kt | 6 +++++ 3 files changed, 15 insertions(+), 18 deletions(-) create mode 100644 src/main/kotlin/com/github/yeoli/devlog/domain/note/repository/NoteStorageState.kt 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 index 12fa924..449b7ea 100644 --- 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 @@ -5,36 +5,27 @@ import com.intellij.openapi.components.PersistentStateComponent import com.intellij.openapi.components.Service import com.intellij.openapi.components.State import com.intellij.openapi.components.Storage -import java.time.LocalDateTime @State( name = "DevLogNoteStorage", storages = [Storage("devlog-note.xml")] ) @Service(Service.Level.PROJECT) -class NoteRepository : PersistentStateComponent { +class NoteRepository : PersistentStateComponent { - private var state: NoteState? = NoteState( - content = "", - updatedAt = LocalDateTime.now().toString() - ) + private var state: NoteStorageState = NoteStorageState() - override fun getState(): NoteState? = state + override fun getState(): NoteStorageState? = state - override fun loadState(state: NoteState) { + override fun loadState(state: NoteStorageState) { this.state = state } fun getNote(): Note { - if (state == null) { - state = Note( - content = "" - ).toState() - } - return state!!.toDomain() + return state.noteState.toDomain() } fun updateNote(updatedNote: Note) { - this.state = updatedNote.toState() + this.state.noteState = updatedNote.toState() } -} \ No newline at end of file +} 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 index 29aaf25..bcaac46 100644 --- 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 @@ -4,8 +4,8 @@ import com.github.yeoli.devlog.domain.note.domain.Note import java.time.LocalDateTime data class NoteState( - val content: String, - val updatedAt: String + var content: String = "", + var updatedAt: String = LocalDateTime.now().toString() ) { fun toDomain(): Note { 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 From 039fec82208cb9f70d839b23375950502c3f3edc Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 16:28:14 +0900 Subject: [PATCH 63/74] =?UTF-8?q?feat(Memo):=20fullCodeSnapshot=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../github/yeoli/devlog/domain/memo/domain/Memo.kt | 12 +++++++++--- .../yeoli/devlog/domain/memo/repository/MemoState.kt | 6 ++++-- 2 files changed, 13 insertions(+), 5 deletions(-) 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 index da69aab..c2d3326 100644 --- 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 @@ -19,7 +19,9 @@ class Memo( val selectionEnd: Int? = null, val visibleStart: Int? = null, - val visibleEnd: Int? = null + val visibleEnd: Int? = null, + + val fullCodeSnapshot: String? = null ) { init { validate() @@ -33,7 +35,8 @@ class Memo( selectionStart: Int?, selectionEnd: Int?, visibleStart: Int?, - visibleEnd: Int? + visibleEnd: Int?, + fullCodeSnapshot: String? ) : this( id = System.currentTimeMillis(), createdAt = LocalDateTime.now(), @@ -45,7 +48,8 @@ class Memo( selectionStart = selectionStart, selectionEnd = selectionEnd, visibleStart = visibleStart, - visibleEnd = visibleEnd + visibleEnd = visibleEnd, + fullCodeSnapshot = fullCodeSnapshot ) private fun validate() { @@ -71,6 +75,7 @@ class Memo( commitHash = this.commitHash, filePath = this.filePath, selectedCodeSnippet = this.selectedCodeSnippet, + fullCodeSnapshot = this.fullCodeSnapshot, selectionStart = this.selectionStart, selectionEnd = this.selectionEnd, visibleStart = this.visibleStart, @@ -88,6 +93,7 @@ class Memo( commitHash = this.commitHash, filePath = this.filePath, selectedCodeSnippet = this.selectedCodeSnippet, + fullCodeSnapshot = this.fullCodeSnapshot, selectionStart = this.selectionStart, selectionEnd = this.selectionEnd, visibleStart = this.visibleStart, 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 index 272f360..af408c3 100644 --- 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 @@ -5,12 +5,13 @@ import java.time.LocalDateTime data class MemoState( var id: Long = 0L, - var createdAt: String, - var updatedAt: String, + 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, @@ -25,6 +26,7 @@ data class MemoState( commitHash = commitHash, filePath = filePath, selectedCodeSnippet = selectedCodeSnippet, + fullCodeSnapshot = fullCodeSnapshot, selectionStart = selectionStart, selectionEnd = selectionEnd, visibleStart = visibleStart, From 8d7c0c4f8d8ea64cb0d9b810ec99ee7a4b27af0a Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 16:34:53 +0900 Subject: [PATCH 64/74] =?UTF-8?q?test(Test):=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yeoli/devlog/services/MyProjectService.kt | 17 -------- .../com/github/yeoli/devlog/MyPluginTest.kt | 39 ------------------- .../devlog/domain/memo/domain/MemoTest.kt | 6 +++ .../domain/memo/service/MemoServiceTest.kt | 9 +++++ 4 files changed, 15 insertions(+), 56 deletions(-) delete mode 100644 src/main/kotlin/com/github/yeoli/devlog/services/MyProjectService.kt delete mode 100644 src/test/kotlin/com/github/yeoli/devlog/MyPluginTest.kt 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/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 index 38df2f5..84d3510 100644 --- 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 @@ -15,6 +15,7 @@ class MemoTest { commitHash = "abc123", filePath = "/path/SampleFile.kt", selectedCodeSnippet = "val selected = 42", + fullCodeSnapshot = "full code", selectionStart = 5, selectionEnd = 10, visibleStart = 1, @@ -43,6 +44,7 @@ class MemoTest { commitHash = "abc123", filePath = "/path/SampleFile.kt", selectedCodeSnippet = "val selected = 42", + fullCodeSnapshot = "full code", selectionStart = 10, selectionEnd = 5, visibleStart = null, @@ -60,6 +62,7 @@ class MemoTest { commitHash = "abc123", filePath = "/path/SampleFile.kt", selectedCodeSnippet = "val selected = 42", + fullCodeSnapshot = "full code", selectionStart = 5, selectionEnd = 10, visibleStart = 20, @@ -76,6 +79,7 @@ class MemoTest { commitHash = "abc123", filePath = "/path/SampleFile.kt", selectedCodeSnippet = "val selected = 42", + fullCodeSnapshot = "full code", selectionStart = 5, selectionEnd = 10, visibleStart = 1, @@ -147,6 +151,7 @@ class MemoTest { commitHash = "ff12aa", filePath = "/full/path/file.kt", selectedCodeSnippet = "val x = 10", + fullCodeSnapshot = "full code", selectionStart = 3, selectionEnd = 9, visibleStart = 2, @@ -177,6 +182,7 @@ class MemoTest { commitHash = null, filePath = null, selectedCodeSnippet = null, + fullCodeSnapshot = null, selectionStart = null, selectionEnd = null, visibleStart = null, 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 index d86b68b..1bd941e 100644 --- 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 @@ -197,6 +197,7 @@ class MemoServiceTest : BasePlatformTestCase() { commitHash = null, filePath = "/path/to/file1", selectedCodeSnippet = "snippet1", + fullCodeSnapshot = "full code", selectionStart = 0, selectionEnd = 5, visibleStart = 1, @@ -252,6 +253,7 @@ class MemoServiceTest : BasePlatformTestCase() { commitHash = null, filePath = "/path/to/file1", selectedCodeSnippet = null, + fullCodeSnapshot = null, selectionStart = null, selectionEnd = null, visibleStart = null, @@ -265,6 +267,7 @@ class MemoServiceTest : BasePlatformTestCase() { commitHash = null, filePath = "/path/to/file2", selectedCodeSnippet = null, + fullCodeSnapshot = null, selectionStart = null, selectionEnd = null, visibleStart = null, @@ -295,6 +298,7 @@ class MemoServiceTest : BasePlatformTestCase() { commitHash = null, filePath = "/path/to/file", selectedCodeSnippet = null, + fullCodeSnapshot = null, selectionStart = null, selectionEnd = null, visibleStart = null, @@ -323,6 +327,7 @@ class MemoServiceTest : BasePlatformTestCase() { commitHash = null, filePath = "/path/file", selectedCodeSnippet = "snippet", + fullCodeSnapshot = "full code", selectionStart = 0, selectionEnd = 5, visibleStart = 1, @@ -368,6 +373,7 @@ class MemoServiceTest : BasePlatformTestCase() { commitHash = "abc", filePath = "/path", selectedCodeSnippet = "code", + fullCodeSnapshot = "full code", selectionStart = 10, selectionEnd = 20, visibleStart = 5, @@ -437,6 +443,7 @@ class MemoServiceTest : BasePlatformTestCase() { commitHash = "abc123", filePath = "/path/file", selectedCodeSnippet = "val a = 1", + fullCodeSnapshot = "full code", selectionStart = 0, selectionEnd = 10, visibleStart = 3, @@ -473,6 +480,7 @@ class MemoServiceTest : BasePlatformTestCase() { commitHash = null, filePath = "/f1", selectedCodeSnippet = null, + fullCodeSnapshot = null, selectionStart = 0, selectionEnd = 0, visibleStart = 1, @@ -519,6 +527,7 @@ class MemoServiceTest : BasePlatformTestCase() { commitHash = null, filePath = null, selectedCodeSnippet = null, + fullCodeSnapshot = null, selectionStart = 100, selectionEnd = 200, visibleStart = 3, From afefbeb2975020d08db7c2b1270266d191d8a71c Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 16:40:52 +0900 Subject: [PATCH 65/74] =?UTF-8?q?test(MemoServiceTest):=20findMemoById=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/memo/service/MemoServiceTest.kt | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) 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 index 1bd941e..e317074 100644 --- 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 @@ -187,6 +187,52 @@ class MemoServiceTest : BasePlatformTestCase() { } // ========= 메모 조회 기능 ========= + 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( @@ -242,6 +288,7 @@ class MemoServiceTest : BasePlatformTestCase() { assertTrue(result.isEmpty(), "예외 발생 시 빈 리스트를 반환해야 합니다.") } + // ========= 메모 삭제 기능 ========= fun `test 메모 삭제 기능 - 정상 삭제`() { val now = LocalDateTime.now() From e2a757f1882107f144e1828675e882fe350c57f5 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 16:41:13 +0900 Subject: [PATCH 66/74] =?UTF-8?q?feat(MemoService):=20findMemoById=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yeoli/devlog/domain/memo/service/MemoService.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 index 2be41b3..3778eb5 100644 --- 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 @@ -34,6 +34,8 @@ class MemoService(private val project: Project) { 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 @@ -61,6 +63,7 @@ class MemoService(private val project: Project) { commitHash = commitHash, filePath = filePath, selectedCodeSnippet = selectedCodeSnippet, + fullCodeSnapshot = fullCodeSnapshot, selectionStart = selectionStart, selectionEnd = selectionEnd, visibleStart = visibleStartLine, @@ -167,4 +170,8 @@ class MemoService(private val project: Project) { return file } -} \ No newline at end of file + + fun findMemoById(memoId: Long): Memo? { + return memoRepository.findMemoById(memoId) + } +} From 2956e9440f02c07000ab2e6da699bd56423a0f0f Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 16:43:35 +0900 Subject: [PATCH 67/74] =?UTF-8?q?chore(plugin.xml):=20=ED=92=8D=EC=84=A0?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/META-INF/plugin.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index a8decb7..4f71292 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -3,6 +3,10 @@ com.intellij.modules.platform Git4Idea + From 43e333d3fa4ec0b0e9e34a22fe274ba81a45d1e1 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 17:43:38 +0900 Subject: [PATCH 68/74] =?UTF-8?q?refactor(DevLogPanel):=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/github/yeoli/devlog/ui/DevLogPanel.kt | 789 +++--------------- .../github/yeoli/devlog/ui/MemoDetailPanel.kt | 188 +++++ .../yeoli/devlog/ui/MemoInputComposer.kt | 124 +++ .../github/yeoli/devlog/ui/MemoListView.kt | 2 +- .../com/github/yeoli/devlog/ui/NotePanel.kt | 154 ++++ .../yeoli/devlog/ui/SelectionStatusPanel.kt | 43 + .../devlog/ui/action/DeleteSelectedAction.kt | 57 ++ .../ui/{ => action}/MemoExportAction.kt | 5 +- .../devlog/ui/action/ToggleSelectionAction.kt | 49 ++ 9 files changed, 758 insertions(+), 653 deletions(-) create mode 100644 src/main/kotlin/com/github/yeoli/devlog/ui/MemoDetailPanel.kt create mode 100644 src/main/kotlin/com/github/yeoli/devlog/ui/MemoInputComposer.kt create mode 100644 src/main/kotlin/com/github/yeoli/devlog/ui/NotePanel.kt create mode 100644 src/main/kotlin/com/github/yeoli/devlog/ui/SelectionStatusPanel.kt create mode 100644 src/main/kotlin/com/github/yeoli/devlog/ui/action/DeleteSelectedAction.kt rename src/main/kotlin/com/github/yeoli/devlog/ui/{ => action}/MemoExportAction.kt (97%) create mode 100644 src/main/kotlin/com/github/yeoli/devlog/ui/action/ToggleSelectionAction.kt diff --git a/src/main/kotlin/com/github/yeoli/devlog/ui/DevLogPanel.kt b/src/main/kotlin/com/github/yeoli/devlog/ui/DevLogPanel.kt index 041fbde..e439fd1 100644 --- a/src/main/kotlin/com/github/yeoli/devlog/ui/DevLogPanel.kt +++ b/src/main/kotlin/com/github/yeoli/devlog/ui/DevLogPanel.kt @@ -5,9 +5,10 @@ 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.notification.NotificationGroupManager -import com.intellij.notification.NotificationType import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.* import com.intellij.openapi.editor.Editor @@ -22,13 +23,12 @@ 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.DumbAwareAction 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.DocumentAdapter import com.intellij.ui.JBColor import com.intellij.ui.JBSplitter import com.intellij.ui.components.JBLabel @@ -36,15 +36,12 @@ 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.* -import java.awt.event.ActionEvent -import java.awt.event.KeyAdapter -import java.awt.event.KeyEvent -import java.time.LocalDateTime +import java.awt.BorderLayout +import java.awt.CardLayout +import java.awt.Color import java.time.LocalTime import java.time.format.DateTimeFormatter -import javax.swing.* -import javax.swing.text.JTextComponent +import javax.swing.JComponent /** * Retrospect DevLog UI의 메인 컨테이너. @@ -58,32 +55,30 @@ internal class DevLogPanel( private val memoService = project.getService(MemoService::class.java) private val noteService = project.getService(NoteService::class.java) - // 밝은/어두운 테마 모두에서 사용할 색상 팔레트. private val palette = UiPalette() - // 하단 작성 영역(코멘트 입력 + Save 버튼). - private val composer = RetrospectComposer(palette, ::handleSaveRequest) + // 작성기 영역 (분리 후보) + private val composer = MemoInputComposer(palette, ::handleSaveRequest) - // 공유 메모 탭에서 사용되는 텍스트 영역. - private val sharedNotes = SharedNotesPanel(palette, ::handleSharedNotesSave) + // 공유 노트 영역 (분리 후보) + private val sharedNotes = NotePanel(palette, ::handleSharedNotesSave) - // 에디터 선택 상태를 좌측 배너로 보여주는 컴포넌트. + // 선택 상태 배너 (분리 후보) private val selectionStatusPanel = SelectionStatusPanel(palette) - // 카드 전환으로 띄우는 상세 로그 편집기. - private val recordDetailPanel = RecordDetailPanel( + private val selectedRecordIds = linkedSetOf() + + // 상세 편집기 (분리 후보) + private val recordDetailPanel = MemoDetailPanel( palette = palette, onRequestSave = ::handleDetailSave, onRequestBack = ::handleDetailBack ) - // 타임라인에서 체크된 레코드들의 ID 저장소. - private val selectedRecordIds = linkedSetOf() - - // DevLog들을 시간순으로 보여주고 조작하는 메인 뷰. - private val timeline = RetrospectTimelineView( + // 타임라인 뷰 (분리 후보: MemoListView) + private val timeline = MemoListView( palette, - RetrospectTimelineView.Interactions( + MemoListView.Interactions( onEdit = { record, index -> openEditDialog(record) }, onSnapshot = { openSnapshotInEditor(it) }, onOpenDetail = { record -> openRecordDetail(record) }, @@ -139,49 +134,9 @@ internal class DevLogPanel( ) } - /** - * 스토리지의 최신 데이터를 타임라인/툴바 상태에 반영한다. - */ - private fun refreshMemos() { - val memos = memoService.getAllMemos() - totalRecordCount = memos.size - timeline.render(memos) - refreshToolbarActions() - } - - /** - * 타임라인(위)과 작성기(아래)를 포함하는 메인 분할 레이아웃을 구성한다. - */ - private fun createMainView(): JComponent { - val timelineStack = JBPanel>(BorderLayout()).apply { - background = palette.editorBg - // DevLog 목록 + 현재 에디터 선택 상태 배너를 하나의 패널로 묶는다. - add(timeline.component, BorderLayout.CENTER) - add(selectionStatusPanel.component, BorderLayout.SOUTH) - } - val lowerPanel = JBPanel>(BorderLayout()).apply { - background = palette.editorBg - // 선택한 레코드와 연결하려는 context 영역과 작성기 패널이 붙어 있다. -// add(linkContextPanel.component, BorderLayout.NORTH) - add(composer.component, BorderLayout.CENTER) - } - val splitter = JBSplitter(true, 0.8f).apply { - dividerWidth = 5 - isOpaque = false - // 상단에 타임라인, 하단에 작성기를 배치하고 상단에 비중을 둔다. - firstComponent = timelineStack - secondComponent = lowerPanel - setHonorComponentsMinimumSize(true) - } - return splitter - } - - /** - * 카드 전환/선택/내보내기 등 액션이 모인 상단 툴바를 만든다. - */ + // ---------- UI 빌더 ---------- private fun createNavigationBar(): JComponent { val group = DefaultActionGroup().apply { - // 보기 전환 + 선택 토글 + 내보내기/삭제 액션을 순서대로 배치. add(viewToggleAction) add(selectionToggleAction) add(exportSelectedAction) @@ -191,7 +146,6 @@ internal class DevLogPanel( .createActionToolbar("RetrospectNavToolbar", group, true) .apply { targetComponent = null - // ToolWindow 배경과 자연스럽게 섞이도록 여백/색상을 정리. component.background = palette.editorBg component.border = JBUI.Borders.empty() } @@ -203,9 +157,25 @@ internal class DevLogPanel( } } - /** - * 카드 레이아웃을 전환하고, 액션 활성화 상태를 재계산한다. - */ + 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 @@ -213,84 +183,32 @@ internal class DevLogPanel( refreshToolbarActions() } - private fun loadSharedNotes() { - sharedNotes.setContent(noteService.getNote().content) - } - - 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 refreshMemos() { + val memos = memoService.getAllMemos() + totalRecordCount = memos.size + timeline.render(memos) + refreshToolbarActions() } - /** - * 에디터 선택/포커스 변화 이벤트를 구독해 상태 배너를 갱신한다. - */ - 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 loadSharedNotes() { + sharedNotes.setContent(noteService.getNote().content) } - /** - * 현재 에디터의 선택 영역 유무에 따라 배너 문구를 바꾼다. - */ - 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 fun notifyChange() { + project.messageBus.syncPublisher(MemoChangedEvent.TOPIC).onChanged() } - /** - * 작성기의 Save 트리거를 처리한다. 선택 영역이 있으면 Draft로, 아니면 일반 로그로 저장한다. - */ - // 저장되는 로직 + // ---------- 메모/노트 액션 ---------- private fun handleSaveRequest(rawBody: String) { val body = rawBody.trim() if (body.isEmpty()) { composer.showEmptyBodyMessage() return } - val memo = memoService.createMemo(rawBody) - if (memo == null) return + val memo = memoService.createMemo(rawBody) ?: return memoService.saveMemo(memo) notifyChange() - composer.clear() composer.updateStatus("Saved at ${currentTimeString()}") } @@ -301,53 +219,20 @@ internal class DevLogPanel( sharedNotes.markSaved(currentTimeString()) } - private fun notifyChange() { - project.messageBus.syncPublisher(MemoChangedEvent.TOPIC).onChanged() - } - - private fun handleSelectionChanged(ids: Set) { - selectedRecordIds.clear() - selectedRecordIds.addAll(ids) - refreshToolbarActions() - } - - private fun getSelectedRecords(): List { - if (selectedRecordIds.isEmpty()) return emptyList() - val selected = selectedRecordIds.toSet() - return memoService.getAllMemos() - .sortedBy { it.createdAt }.filter { it.id in selected } + private fun openRecordDetail(record: Memo) { + recordDetailPanel.displayRecord(record) + switchCard(RECORD_DETAIL_CARD) } - private fun deleteSelectedRecords(records: List) { - if (records.isEmpty()) return - val ids = records.map { it.id }.toSet() - - // memoService에 removeMemosByIds로 바꾸면 될듯 - if (ids.isEmpty()) return - val memos = memoService.getAllMemos().filter { ids.contains(it.id) } - memoService.removeMemos(memos) - + private fun handleDetailSave(memoId: Long, updatedContent: String) { + val memo = memoService.findMemoById(memoId) + if (memo == null || memo.content == updatedContent) return + memoService.updateMemo(memoId, updatedContent) notifyChange() - selectedRecordIds.removeAll(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 handleDetailBack() { + switchCard(RETROSPECTS_CARD) } private fun openEditDialog(memo: Memo) { @@ -361,7 +246,7 @@ internal class DevLogPanel( add(JBLabel("Update content:"), BorderLayout.NORTH) add(JBScrollPane(editorArea), BorderLayout.CENTER) } - val dialog = object : com.intellij.openapi.ui.DialogWrapper(project) { + val dialog = object : DialogWrapper(project) { init { title = "Edit Memo" init() @@ -391,6 +276,83 @@ internal class DevLogPanel( } } + private fun deleteSelectedRecords(records: List) { + if (records.isEmpty()) return + val ids = records.map { it.id }.toSet() + if (ids.isEmpty()) return + val memos = memoService.getAllMemos().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.getAllMemos() + .sortedBy { it.createdAt }.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) { @@ -525,476 +487,3 @@ data class UiPalette( val listRowBg: JBColor = JBColor(Color(0xF7, 0xF8, 0xFA), Color(0x2B, 0x2D, 0x30)), val listRowSelectedBg: JBColor = JBColor(Color(0xE3, 0xF2, 0xFD), Color(0x1F, 0x3B, 0x4D)) ) - -private class RetrospectComposer( - 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 class RecordDetailPanel( - 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 class SharedNotesPanel( - 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 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 - } -} - -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() - } - } - }) - } -} - -private 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() - } -} - -private 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 - } - } -} 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/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 index 4952889..eb8a4a0 100644 --- a/src/main/kotlin/com/github/yeoli/devlog/ui/MemoListView.kt +++ b/src/main/kotlin/com/github/yeoli/devlog/ui/MemoListView.kt @@ -24,7 +24,7 @@ private val TITLE_MUTED_COLOR = JBColor(Color(0x88, 0x88, 0x88), Color(0xc0, 0xc 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 RetrospectTimelineView( +class MemoListView( private val palette: UiPalette, private val interactions: Interactions ) { 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/MemoExportAction.kt b/src/main/kotlin/com/github/yeoli/devlog/ui/action/MemoExportAction.kt similarity index 97% rename from src/main/kotlin/com/github/yeoli/devlog/ui/MemoExportAction.kt rename to src/main/kotlin/com/github/yeoli/devlog/ui/action/MemoExportAction.kt index 2d9902d..1a4f244 100644 --- a/src/main/kotlin/com/github/yeoli/devlog/ui/MemoExportAction.kt +++ b/src/main/kotlin/com/github/yeoli/devlog/ui/action/MemoExportAction.kt @@ -1,6 +1,7 @@ -package com.github.yeoli.devlog.ui +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 @@ -87,4 +88,4 @@ class MemoExportAction( 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 From a87528544fe8b2a37c617bfe8cd0ec728c38d9fe Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 17:52:31 +0900 Subject: [PATCH 69/74] =?UTF-8?q?fix(MemoService):=20getAllMemos=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20getAllMemosOrderByCreatedAt?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yeoli/devlog/domain/memo/service/MemoService.kt | 4 ++-- src/main/kotlin/com/github/yeoli/devlog/ui/DevLogPanel.kt | 8 ++++---- .../com/github/yeoli/devlog/ui/MemoExportPipeline.kt | 2 +- .../yeoli/devlog/domain/memo/service/MemoServiceTest.kt | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) 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 index 3778eb5..391b8c1 100644 --- 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 @@ -95,9 +95,9 @@ class MemoService(private val project: Project) { } } - fun getAllMemos(): List { + fun getAllMemosOrderByCreatedAt(): List { try { - return memoRepository.getAll() + return memoRepository.getAll().sortedBy { it.createdAt } } catch (e: Exception) { logger.warn("[getAllMemos] 메모 조회 중 알 수 없는 에러가 발생했습니다. ${e.message}", e) } diff --git a/src/main/kotlin/com/github/yeoli/devlog/ui/DevLogPanel.kt b/src/main/kotlin/com/github/yeoli/devlog/ui/DevLogPanel.kt index e439fd1..c0a421f 100644 --- a/src/main/kotlin/com/github/yeoli/devlog/ui/DevLogPanel.kt +++ b/src/main/kotlin/com/github/yeoli/devlog/ui/DevLogPanel.kt @@ -185,7 +185,7 @@ internal class DevLogPanel( // ---------- 데이터 동기화 ---------- private fun refreshMemos() { - val memos = memoService.getAllMemos() + val memos = memoService.getAllMemosOrderByCreatedAt() totalRecordCount = memos.size timeline.render(memos) refreshToolbarActions() @@ -280,7 +280,7 @@ internal class DevLogPanel( if (records.isEmpty()) return val ids = records.map { it.id }.toSet() if (ids.isEmpty()) return - val memos = memoService.getAllMemos().filter { ids.contains(it.id) } + val memos = memoService.getAllMemosOrderByCreatedAt().filter { ids.contains(it.id) } memoService.removeMemos(memos) notifyChange() selectedRecordIds.removeAll(ids) @@ -290,8 +290,8 @@ internal class DevLogPanel( private fun getSelectedRecords(): List { if (selectedRecordIds.isEmpty()) return emptyList() val selected = selectedRecordIds.toSet() - return memoService.getAllMemos() - .sortedBy { it.createdAt }.filter { it.id in selected } + return memoService.getAllMemosOrderByCreatedAt() + .filter { it.id in selected } } // ---------- 선택/툴바 상태 ---------- diff --git a/src/main/kotlin/com/github/yeoli/devlog/ui/MemoExportPipeline.kt b/src/main/kotlin/com/github/yeoli/devlog/ui/MemoExportPipeline.kt index 746fd96..0d5bd14 100644 --- a/src/main/kotlin/com/github/yeoli/devlog/ui/MemoExportPipeline.kt +++ b/src/main/kotlin/com/github/yeoli/devlog/ui/MemoExportPipeline.kt @@ -19,7 +19,7 @@ class MemoExportPipeline( fun buildPayload(recordsOverride: List? = null): Payload { val memoService = project.getService(MemoService::class.java) val memos: List = recordsOverride - ?: memoService.getAllMemos() + ?: memoService.getAllMemosOrderByCreatedAt() val header = memoService.buildHeader() val body = if (memos.isEmpty()) { 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 index e317074..c1c7b07 100644 --- 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 @@ -267,7 +267,7 @@ class MemoServiceTest : BasePlatformTestCase() { whenever(memoRepository.getAll()).thenReturn(listOf(memo1, memo2)) // when - val result = MemoService(project).getAllMemos() + val result = MemoService(project).getAllMemosOrderByCreatedAt() // then assertEquals(2, result.size) @@ -282,7 +282,7 @@ class MemoServiceTest : BasePlatformTestCase() { whenever(memoRepository.getAll()).thenThrow(RuntimeException("DB error")) // when - val result = MemoService(project).getAllMemos() + val result = MemoService(project).getAllMemosOrderByCreatedAt() // then assertTrue(result.isEmpty(), "예외 발생 시 빈 리스트를 반환해야 합니다.") From db42061ab6adf3481eb60bd1015211accc37a72f Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 18:00:35 +0900 Subject: [PATCH 70/74] =?UTF-8?q?fix(MemoService):=20createMemo=20eiditor?= =?UTF-8?q?=EA=B0=80=20null=EC=9D=BC=EB=95=8C=EB=8F=84=20Memo=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yeoli/devlog/domain/memo/service/MemoService.kt | 13 ++++++++++++- .../devlog/domain/memo/service/MemoServiceTest.kt | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) 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 index 391b8c1..342f726 100644 --- 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 @@ -27,7 +27,18 @@ class MemoService(private val project: Project) { val editor = getActiveEditor(project) if (editor == null) { logger.warn("[createMemo] editor가 null이므로 null을 반환합니다.") - return 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 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 index c1c7b07..c48dff9 100644 --- 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 @@ -134,7 +134,7 @@ class MemoServiceTest : BasePlatformTestCase() { // expect val memo: Memo? = MemoService(project).createMemo("에디터 없음") - assertNull(memo); + assertNotNull(memo) } fun `test 메모 생성 파일경로 없음`() { From 84317c4059a3729f7b4084443daa3c30b2f88e96 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 18:12:16 +0900 Subject: [PATCH 71/74] =?UTF-8?q?fix(MemoExportPipline):=20=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=20=EC=B6=94=EC=B6=9C=20=ED=8C=8C=EC=9D=BC=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=8B=9C,=20md=ED=8C=8C=EC=9D=BC=EC=97=90=EC=84=9C=20?= =?UTF-8?q?txt=ED=8C=8C=EC=9D=BC=EB=A1=9C=20=EB=B0=98=ED=99=98=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/com/github/yeoli/devlog/ui/MemoExportPipeline.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/github/yeoli/devlog/ui/MemoExportPipeline.kt b/src/main/kotlin/com/github/yeoli/devlog/ui/MemoExportPipeline.kt index 0d5bd14..e120207 100644 --- a/src/main/kotlin/com/github/yeoli/devlog/ui/MemoExportPipeline.kt +++ b/src/main/kotlin/com/github/yeoli/devlog/ui/MemoExportPipeline.kt @@ -30,11 +30,11 @@ class MemoExportPipeline( } val content = header + "\n\n" + body val date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")) - val defaultFileName = "devlog-${project.name}-$date.md" + val defaultFileName = "devlog-${project.name}-$date.txt" return Payload( content = content, - fileExtension = "md", + fileExtension = "txt", defaultFileName = defaultFileName ) } From afdc93e44c6ba1ae70fb9c4c8e93d12ec086005e Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 19:11:06 +0900 Subject: [PATCH 72/74] =?UTF-8?q?docs(README):=20=ED=94=8C=EB=9F=AC?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=84=A4=EB=AA=85=20=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?=EB=B0=8F=20=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 148 ++++-------------- src/main/resources/META-INF/pluginIcon.svg | 15 ++ .../resources/META-INF/pluginIcon_dark.svg | 15 ++ 3 files changed, 61 insertions(+), 117 deletions(-) create mode 100644 src/main/resources/META-INF/pluginIcon.svg create mode 100644 src/main/resources/META-INF/pluginIcon_dark.svg diff --git a/README.md b/README.md index 305d039..7fb7035 100644 --- a/README.md +++ b/README.md @@ -1,136 +1,50 @@ # dev-log -## 핵심 기능 정의 - ---- - -### ✅메모 저장 기능 - -- 메모를 저장할 수 있어야 한다. -- 메모의 저장 데이터는 다음과 같다. - - `int id (not null)`: 메모의 고유한 아이디(PK) - - `String content (not null)`: 사용자가 입력하는 메모의 내용 - - `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)`: 선택한 시작 줄 -- 저장은 시간 순서대로 저장이 되어야 한다. - -### ⚠️ 메모 저장 기능 고려 사항 - -- 메모의 `content`가 blank라면 저장이 되어선 안된다. -- `selectionStart`가 `selectionEnd`보다 앞에 있어야 한다. -- `visibleStart`가 `visibleEnd`보다 앞에 있어야 한다. -- `createdAt`은 자동으로 생성 되어야 한다. - ---- - -### ✅ 메모 조회 기능 - -- 전체 메모를 날짜순으로 조회할 수 있어야 한다. - -### ⚠️ 메모 조회 기능 고려 사항 - -- 각 조건마다 조회할 데이터가 없다면 빈 리스트를 반환해야 한다. - ---- - -### ✅ 메모 삭제 기능 - -- 저장된 메모를 삭제할 수 있어야 한다. -- 메모를 한 번에 여러개 삭제할 수 있어야 한다. - -### ⚠️ 메모 삭제 기능 고려 사항 - -- 삭제할 메모가 없으면 아무 수행도 해선 안된다. - ---- - -### ✅ 메모 수정 기능 - -- 저장된 메모의 content를 수정할 수 있어야 한다. - -### ⚠️ 메모 수정 기능 고려 사항 - -- 수정할 메모가 없다면 아무 수행도 해선 안된다. - ---- - -### ✅ 메모 추출 기능 - -- 저장된 메모를 한 개 이상 선택하여 txt 파일로 추출할 수 있어야 한다. -- 추출한 단위 메모의 구성 내용은 다음과 같다. - - 메모 순서(시간순) - - `LocalDateTime timestamp`: 메모가 생성된 시간 - - `String content`: 사용자가 입력하는 메모의 내용 - - `String filePath`: 파일 경로 - - `String commitHash`: 현재 커밋의 해시값 - - `int visibleStart`: 선택한 시작 줄 - - `int visibleEnd`: 선택한 종료 줄 -- txt 파일 상단에 프로젝트명, 내보낸 시각, 메모의 개수가 있어야 한다. -- txt 파일의 이름은 `devlog-{프로젝트명}-{내보낸날짜}-{내보낸시각}.txt` 이다. -- 추출할 메모가 없으면 빈 txt 파일을 반환한다. - -### ⚠️ 메모 추출 기능 고려 사항 - -- 메모 추출 기능에서 고려 사항은 없습니다. - ---- + -### ✅ 노트 수정/저장 기능 +## 🙋🏻 소개 -- 노트를 저장할 수 있어야 한다. -- 노트의 수정/저장 데이터는 다음과 같다. - - `String content`: 노트의 내용 - - `LocalDateTime savedAt`: 저장된 시각 +DevLog는 코드 편집 과정에서 떠오르는 생각과 맥락을 그대로 기록하는 메모·노트 관리 플러그인입니다. +선택한 코드 스니펫, 파일 경로, Git 커밋 해시를 자동으로 캡처해 “당시에 어떤 생각으로 이 코드를 작성했는지” 정확히 복원할 수 있습니다. -### ⚠️ 노트 수정/저장 기능 고려 사항 +메모는 시간순으로 정렬되어 타임라인처럼 확인할 수 있고, +여러 메모를 선택하여 TXT 파일로 내보내기, 일괄 삭제, 단축키 기반 빠른 저장까지 지원합니다. -- 노트가 수정할 내용이 없다면 아무 수행도 해선 안된다. +또한 DevLog는 단순한 메모 도구가 아니라, +개발 중 떠오른 아이디어·이슈 정리·회고 문장을 모아두는 노트 기능을 제공합니다. +노트는 자동 저장되며 언제든지 편집·확장할 수 있습니다. ---- +DevLog를 이용해서 개발 기록을 더욱 편리하게 해보세요! -## 화면 요구 사항 +## 🧩 주요 기능 ---- +✅ 메모 저장 -### ☑️ 플러그인 기본 화면 +- 텍스트와 함께 코드 선택 영역, 파일 경로, 커밋 해시 자동 저장 +- 생성/수정 시각 기록 +- macOS: ⌘ + Enter / Windows: Ctrl + Enter 로 빠른 입력 가능 +- 공백 메모, 잘못된 선택 범위는 자동 차단 -- 화면의 최상단엔 기본 조작용 버튼이 있어야 한다. -- 기본 조작용 버튼은 아래와 같다. - - 메모목록/노트 화면 전환 버튼 - - 메모 전체 선택/선택해제 버튼 - - 선택된 메모 추출 버튼 - - 선택된 메모 삭제 버튼 -- 메인 컨텐츠를 표시하는 화면이 있어야 한다. +✅ 메모 조회 -### ☑️ 메모 목록 출력 화면 +- 모든 메모를 최근순으로 정렬해 리스트 형태로 제공 +- 코드가 포함된 메모는 시각적으로 구분해 표시 -- 저장된 메모 전체가 최근순 정렬되어 화면에 보여야 한다. -- 각 메모 좌측에는 메모를 선택할 수 있는 체크박스가 있어야 한다. -- 화면 하단에는 새 메모를 적을 수 있는 텍스트 입력창이 있어야 한다. -- 에디터 화면에 코드가 선택(드래그) 되어있으면, 코드가 선택되었음을 일목 요연하게 표시해야 한다. -- 저장된 메모 중 선택된 코드가 있는 메모들을 없는 메모와 다르게 표시해야 한다. -- 새로운 메모는 mac에선 커멘드+엔터, window에서는 컨트롤+엔터로 저장 가능해야 한다. +✅ 메모 삭제 -### ⚠️ 메모 목록 출력 화면 예외 상황 +- 단일/다중 삭제 지원 +- 선택된 메모가 없으면 동작하지 않도록 안전 처리 ---- +✅ TXT 파일 내보내기 -### ☑️ 노트 출력 화면 +- 선택한 메모들을 한 번에 TXT로 추출 +- 프로젝트명·내보낸 시각·메모 개수 자동 포함 +- 메모 순서, 타임스탬프, 코드 선택 영역 정보까지 함께 기록 -- 저장된 노트의 모든 내용이 출력 되어야 한다. -- 노트의 변경 내용은 자동 저장 되어야 한다. -- 화면의 크기를 벗어나지 않게 내용을 출력해야 한다. +✅ 노트 관리 -### ⚠️ 노트 출력 화면 예외 상황 +- 개발 중 떠오르는 생각을 문서처럼 관리 +- 자동 저장 지원 +- 화면 크기를 벗어나지 않도록 자연스러운 텍스트 렌더링 - -DevLog Plugin is a memo/note tracking plugin that automatically captures your code selection, -file path, commit hash, and editor state, storing it for later use. - \ No newline at end of file + 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 From 9c55d48cbdf6175fd51f577bb713f773d11d3836 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 19:15:38 +0900 Subject: [PATCH 73/74] =?UTF-8?q?chore(plugin.xml):=20plugin.xml=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/META-INF/plugin.xml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 4f71292..07f2628 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -2,6 +2,12 @@ com.intellij.modules.platform Git4Idea + + + messages.MyBundle - yeo-li + yeo-li + 1.0.0 From 738c00945becbb4861908e96216a4df748c9b9e6 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 19:26:56 +0900 Subject: [PATCH 74/74] =?UTF-8?q?docs(CHANGELOG):=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=201.0.0=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=B0=8F=20=EC=B2=B4=EC=9D=B8=EC=A7=80=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 ++- gradle.properties | 36 ++++++++++---------------- src/main/resources/META-INF/plugin.xml | 6 ++--- 3 files changed, 20 insertions(+), 26 deletions(-) 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/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/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 07f2628..ca85f31 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -15,11 +15,11 @@ isLogByDefault="true"/> + id="DevLog"/> - com.github.yeoli.devlog - dev-log + + DevLog messages.MyBundle