From 4cd4edbba0c9947b54265610abe73dd2a083a0b7 Mon Sep 17 00:00:00 2001 From: yeo-li Date: Mon, 24 Nov 2025 16:25:38 +0900 Subject: [PATCH 1/7] =?UTF-8?q?refactor(MyToolWindowFactory):=20=EC=9D=B4?= =?UTF-8?q?=EC=A0=84=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=EC=97=90?= =?UTF-8?q?=EC=84=9C=20ui=20=EC=BD=94=EB=93=9C=20=EA=B0=80=EC=A0=B8?= =?UTF-8?q?=EC=98=A4=EA=B8=B0=20=EB=B0=8F=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=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 2/7] =?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 3/7] =?UTF-8?q?feat(Memo):=20fullCodeSnapshot=20=ED=95=84?= =?UTF-8?q?=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 4/7] =?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 5/7] =?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 6/7] =?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 7/7] =?UTF-8?q?chore(plugin.xml):=20=ED=92=8D=EC=84=A0=20?= =?UTF-8?q?=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 +