Skip to content

Commit de78a26

Browse files
committed
feat(core): add code file link support in task description #331
- Add CodeFileLink data class to represent code file links - Update AgentPlanStep to include codeFileLinks property - Modify MarkdownPlanParser to extract and handle code file links - Update TaskPanel to render code file links as hyperlinks - Add tests for code file link parsing and rendering
1 parent 1d2adb8 commit de78a26

File tree

4 files changed

+213
-12
lines changed

4 files changed

+213
-12
lines changed

core/src/main/kotlin/cc/unitmesh/devti/observer/plan/AgentPlanStep.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ import kotlinx.serialization.Serializable
88
* @property completed 任务是否已完成
99
* @property status 任务状态(COMPLETED, FAILED, IN_PROGRESS, TODO)
1010
* @property subSteps 子任务列表,用于支持嵌套任务结构
11+
* @property codeFileLinks 代码文件链接列表
1112
*/
1213
@Serializable
1314
class AgentPlanStep(
1415
val step: String,
1516
var completed: Boolean = false,
1617
var status: TaskStatus = TaskStatus.TODO,
17-
var subSteps: MutableList<AgentPlanStep> = mutableListOf()
18+
var subSteps: MutableList<AgentPlanStep> = mutableListOf(),
19+
var codeFileLinks: List<CodeFileLink> = emptyList()
1820
) {
1921
companion object {
2022
private val COMPLETED_PATTERN = Regex("^\\[(✓|x|X)\\]\\s*(.*)")

core/src/main/kotlin/cc/unitmesh/devti/observer/plan/MarkdownPlanParser.kt

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cc.unitmesh.devti.observer.plan
22

33
import com.intellij.openapi.diagnostic.logger
4+
import kotlinx.serialization.Serializable
45
import org.intellij.markdown.IElementType
56
import org.intellij.markdown.MarkdownElementTypes
67
import org.intellij.markdown.ast.ASTNode
@@ -113,6 +114,7 @@ object MarkdownPlanParser {
113114
val TASK_PATTERN = Regex("^\\s*-\\s*\\[\\s*([xX!*✓]?)\\s*\\]\\s*(.*)")
114115
val SECTION_PATTERN = Regex("^(\\d+)\\.\\s*(?:\\[([xX!*✓]?)\\]\\s*)?(.+?)(?:\\s*\\[([xX!*✓]?)\\])?$")
115116
val UNORDERED_ITEM_CLEANER = Regex("^[\\-\\*]\\s+")
117+
val CODE_FILE_LINK = Regex("\\[(.*?)\\]\\((.*?)\\)")
116118
}
117119

118120
fun interpretPlan(content: String): List<AgentTaskEntry> {
@@ -294,17 +296,29 @@ object MarkdownPlanParser {
294296
if (taskMatch != null) {
295297
val statusMarker = taskMatch.groupValues[1]
296298
val description = taskMatch.groupValues[2].trim()
299+
300+
// Extract code file links from the description
301+
val codeFileLinks = extractCodeFileLinks(description)
297302
val task = AgentPlanStep(
298303
description,
299304
TaskMarkers.isCompleted(statusMarker),
300-
TaskMarkers.determineStatus(statusMarker)
305+
TaskMarkers.determineStatus(statusMarker),
306+
codeFileLinks = codeFileLinks
301307
)
302308

303309
taskList.add(task)
304310
} else {
305311
val cleanDescription = taskLine.replace(PatternMatcher.UNORDERED_ITEM_CLEANER, "").trim()
306312
if (cleanDescription.isNotEmpty()) {
307-
taskList.add(AgentPlanStep(cleanDescription, false, TaskStatus.TODO))
313+
val codeFileLinks = extractCodeFileLinks(cleanDescription)
314+
taskList.add(
315+
AgentPlanStep(
316+
cleanDescription,
317+
false,
318+
TaskStatus.TODO,
319+
codeFileLinks = codeFileLinks
320+
)
321+
)
308322
}
309323
}
310324
item.children.forEach { childNode ->
@@ -314,6 +328,19 @@ object MarkdownPlanParser {
314328
}
315329
}
316330
}
331+
332+
private fun extractCodeFileLinks(text: String): List<CodeFileLink> {
333+
val links = mutableListOf<CodeFileLink>()
334+
val matches = PatternMatcher.CODE_FILE_LINK.findAll(text)
335+
336+
matches.forEach { match ->
337+
val displayText = match.groupValues[1]
338+
val filePath = match.groupValues[2]
339+
links.add(CodeFileLink(displayText, filePath))
340+
}
341+
342+
return links
343+
}
317344
}
318345

319346
fun parse(content: String): List<AgentTaskEntry> = interpretPlan(content)
@@ -322,7 +349,19 @@ object MarkdownPlanParser {
322349
entries.forEachIndexed { index, entry ->
323350
stringBuilder.append("${index + 1}. ${entry.title}\n")
324351
entry.steps.forEach { step ->
325-
stringBuilder.append(" - [${if (step.completed) "x" else " "}] ${step.step}\n")
352+
val stepText = if (step.codeFileLinks.isNotEmpty()) {
353+
var text = step.step
354+
step.codeFileLinks.forEach { link ->
355+
text = text.replace(
356+
"[${link.displayText}](${link.filePath})",
357+
"[${link.displayText}](${link.filePath})"
358+
)
359+
}
360+
text
361+
} else {
362+
step.step
363+
}
364+
stringBuilder.append(" - [${if (step.completed) "x" else " "}] $stepText\n")
326365
}
327366
}
328367
return stringBuilder.toString()
@@ -347,3 +386,9 @@ data class DetailedSectionInfo(
347386
val completed: Boolean,
348387
val status: TaskStatus
349388
)
389+
390+
@Serializable
391+
data class CodeFileLink(
392+
val displayText: String,
393+
val filePath: String
394+
)

core/src/main/kotlin/cc/unitmesh/devti/sketch/ui/plan/TaskPanel.kt

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,23 @@ import cc.unitmesh.devti.observer.plan.AgentPlanStep
77
import cc.unitmesh.devti.observer.plan.TaskStatus
88
import com.intellij.icons.AllIcons
99
import com.intellij.openapi.project.Project
10+
import com.intellij.openapi.vfs.LocalFileSystem
1011
import com.intellij.ui.components.JBCheckBox
1112
import com.intellij.ui.components.JBPanel
1213
import com.intellij.util.ui.JBUI
1314
import java.awt.Dimension
14-
import java.awt.FlowLayout
1515
import javax.swing.BorderFactory
1616
import javax.swing.BoxLayout
1717
import javax.swing.JButton
1818
import javax.swing.JComponent
19+
import javax.swing.JEditorPane
1920
import javax.swing.JLabel
2021
import javax.swing.JMenuItem
2122
import javax.swing.JPopupMenu
23+
import javax.swing.event.HyperlinkEvent
24+
import javax.swing.text.html.HTMLDocument
25+
import javax.swing.text.html.HTMLEditorKit
26+
import javax.swing.text.html.StyleSheet
2227

2328
/**
2429
* Task Panel UI Component responsible for rendering and handling interactions for a single task
@@ -28,7 +33,7 @@ class TaskPanel(
2833
private val task: AgentPlanStep,
2934
private val onStatusChange: () -> Unit
3035
) : JBPanel<TaskPanel>() {
31-
private val taskLabel: JLabel
36+
private val taskLabel: JEditorPane
3237

3338
init {
3439
layout = BoxLayout(this, BoxLayout.X_AXIS)
@@ -81,19 +86,56 @@ class TaskPanel(
8186
}
8287
}
8388

84-
private fun createStyledTaskLabel(): JLabel {
89+
private fun createStyledTaskLabel(): JEditorPane {
8590
val labelText = getLabelTextByStatus()
86-
return JLabel("<html>$labelText</html>").apply {
91+
val editorKit = HTMLEditorKit()
92+
val styleSheet = StyleSheet()
93+
styleSheet.addRule("a { color: #4A90E2; text-decoration: none; }")
94+
styleSheet.addRule("a:hover { text-decoration: underline; }")
95+
editorKit.styleSheet = styleSheet
96+
97+
val document = HTMLDocument()
98+
document.putProperty("IgnoreCharsetDirective", true)
99+
project.basePath?.let {
100+
val url: String? = LocalFileSystem.getInstance().findFileByPath(it)?.url
101+
document.putProperty("Base", url)
102+
}
103+
104+
return JEditorPane().apply {
105+
this.editorKit = editorKit
106+
this.document = document
107+
text = "<html>$labelText</html>"
87108
border = JBUI.Borders.emptyLeft(5)
109+
isEditable = false
110+
background = JBUI.CurrentTheme.ToolWindow.background()
111+
addHyperlinkListener { e ->
112+
if (e.eventType == HyperlinkEvent.EventType.ACTIVATED) {
113+
val url = e.url
114+
val filePath = url.path
115+
val virtualFile = LocalFileSystem.getInstance().findFileByPath(filePath)
116+
if (virtualFile != null) {
117+
com.intellij.openapi.fileEditor.FileEditorManager.getInstance(project)
118+
.openFile(virtualFile, true)
119+
}
120+
}
121+
}
88122
}
89123
}
90124

91125
private fun getLabelTextByStatus(): String {
126+
var text = task.step
127+
task.codeFileLinks.forEach { link ->
128+
text = text.replace(
129+
"[${link.displayText}](${link.filePath})",
130+
"<a href='${link.filePath}'>${link.displayText}</a>"
131+
)
132+
}
133+
92134
return when (task.status) {
93-
TaskStatus.COMPLETED -> "<strike>${task.step}</strike>"
94-
TaskStatus.FAILED -> "<span style='color:red'>${task.step}</span>"
95-
TaskStatus.IN_PROGRESS -> "<span style='color:blue;font-style:italic'>${task.step}</span>"
96-
TaskStatus.TODO -> task.step
135+
TaskStatus.COMPLETED -> "<strike>$text</strike>"
136+
TaskStatus.FAILED -> "<span style='color:red'>$text</span>"
137+
TaskStatus.IN_PROGRESS -> "<span style='color:blue;font-style:italic'>$text</span>"
138+
TaskStatus.TODO -> text
97139
}
98140
}
99141

core/src/test/kotlin/cc/unitmesh/devti/observer/plan/MarkdownPlanParserTest.kt

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,4 +342,116 @@ class MarkdownPlanParserTest {
342342
assertEquals("数据库表结构确认", plans[0].title)
343343
assertEquals(TaskStatus.IN_PROGRESS, plans[1].status)
344344
}
345+
346+
@Test
347+
fun should_parse_code_file_links_in_tasks() {
348+
// Given
349+
val markdownContent = """
350+
1. 分析现有代码结构并识别重构点
351+
- [x] 发现 Blog 功能分散在 entity/service/dto 中,存在贫血模型特征(业务逻辑在Service层)
352+
- [x] 识别出关键类:[Blog.java](src/main/java/cc/unitmesh/untitled/demo/domain/Blog.java) 是贫血模型,[BlogPost.java](src/main/java/cc/unitmesh/untitled/demo/entity/BlogPost.java) 存在重复概念
353+
- [x] 确认当前分层架构不符合 DDD 规范(Repository 直接依赖 Spring Data)
354+
""".trimIndent()
355+
356+
// When
357+
val planItems = MarkdownPlanParser.parse(markdownContent)
358+
359+
// Then
360+
Assertions.assertThat(planItems).hasSize(1)
361+
Assertions.assertThat(planItems[0].title).isEqualTo("分析现有代码结构并识别重构点")
362+
Assertions.assertThat(planItems[0].steps).hasSize(3)
363+
364+
// 验证第一个任务没有代码文件链接
365+
val firstTask = planItems[0].steps[0]
366+
Assertions.assertThat(firstTask.codeFileLinks).isEmpty()
367+
Assertions.assertThat(firstTask.step).isEqualTo("发现 Blog 功能分散在 entity/service/dto 中,存在贫血模型特征(业务逻辑在Service层)")
368+
369+
// 验证第二个任务包含两个代码文件链接
370+
val secondTask = planItems[0].steps[1]
371+
Assertions.assertThat(secondTask.codeFileLinks).hasSize(2)
372+
Assertions.assertThat(secondTask.codeFileLinks[0].displayText).isEqualTo("Blog.java")
373+
Assertions.assertThat(secondTask.codeFileLinks[0].filePath).isEqualTo("src/main/java/cc/unitmesh/untitled/demo/domain/Blog.java")
374+
Assertions.assertThat(secondTask.codeFileLinks[1].displayText).isEqualTo("BlogPost.java")
375+
Assertions.assertThat(secondTask.codeFileLinks[1].filePath).isEqualTo("src/main/java/cc/unitmesh/untitled/demo/entity/BlogPost.java")
376+
377+
// 验证第三个任务没有代码文件链接
378+
val thirdTask = planItems[0].steps[2]
379+
Assertions.assertThat(thirdTask.codeFileLinks).isEmpty()
380+
Assertions.assertThat(thirdTask.step).isEqualTo("确认当前分层架构不符合 DDD 规范(Repository 直接依赖 Spring Data)")
381+
}
382+
383+
@Test
384+
fun should_preserve_code_file_links_when_formatting_to_markdown() {
385+
// Given
386+
val markdownContent = """
387+
1. 代码重构计划
388+
- [ ] 重构 [UserService.java](src/main/java/com/example/service/UserService.java) 中的用户认证逻辑
389+
- [x] 优化 [DatabaseConfig.java](src/main/java/com/example/config/DatabaseConfig.java) 的连接池配置
390+
""".trimIndent()
391+
392+
// When
393+
val planItems = MarkdownPlanParser.parse(markdownContent)
394+
val formattedMarkdown = MarkdownPlanParser.formatPlanToMarkdown(planItems.toMutableList())
395+
396+
// Then
397+
Assertions.assertThat(formattedMarkdown).contains("[UserService.java](src/main/java/com/example/service/UserService.java)")
398+
Assertions.assertThat(formattedMarkdown).contains("[DatabaseConfig.java](src/main/java/com/example/config/DatabaseConfig.java)")
399+
}
400+
401+
@Test
402+
fun should_handle_code_file_links_with_special_characters() {
403+
// Given
404+
val markdownContent = """
405+
1. 特殊字符测试
406+
- [ ] 检查 [User-DTO.java](src/main/java/com/example/dto/User-DTO.java) 中的字段命名
407+
- [ ] 验证 [AuthService.java](src/main/java/com/example/service/AuthService.java) 的认证逻辑
408+
""".trimIndent()
409+
410+
// When
411+
val planItems = MarkdownPlanParser.parse(markdownContent)
412+
413+
// Then
414+
Assertions.assertThat(planItems).hasSize(1)
415+
Assertions.assertThat(planItems[0].steps).hasSize(2)
416+
417+
val firstTask = planItems[0].steps[0]
418+
Assertions.assertThat(firstTask.codeFileLinks).hasSize(1)
419+
Assertions.assertThat(firstTask.codeFileLinks[0].displayText).isEqualTo("User-DTO.java")
420+
Assertions.assertThat(firstTask.codeFileLinks[0].filePath).isEqualTo("src/main/java/com/example/dto/User-DTO.java")
421+
422+
val secondTask = planItems[0].steps[1]
423+
Assertions.assertThat(secondTask.codeFileLinks).hasSize(1)
424+
Assertions.assertThat(secondTask.codeFileLinks[0].displayText).isEqualTo("AuthService.java")
425+
Assertions.assertThat(secondTask.codeFileLinks[0].filePath).isEqualTo("src/main/java/com/example/service/AuthService.java")
426+
}
427+
428+
@Test
429+
fun should_handle_multiple_code_file_links_in_single_task() {
430+
// Given
431+
val markdownContent = """
432+
1. 多文件关联分析
433+
- [ ] 分析 [Controller.java](src/main/java/com/example/Controller.java) 和 [Service.java](src/main/java/com/example/Service.java) 之间的依赖关系
434+
- [ ] 检查 [Repository.java](src/main/java/com/example/Repository.java) 的实现
435+
""".trimIndent()
436+
437+
// When
438+
val planItems = MarkdownPlanParser.parse(markdownContent)
439+
440+
// Then
441+
Assertions.assertThat(planItems).hasSize(1)
442+
Assertions.assertThat(planItems[0].steps).hasSize(2)
443+
444+
val firstTask = planItems[0].steps[0]
445+
Assertions.assertThat(firstTask.codeFileLinks).hasSize(2)
446+
Assertions.assertThat(firstTask.codeFileLinks.map { it.displayText }).containsExactly("Controller.java", "Service.java")
447+
Assertions.assertThat(firstTask.codeFileLinks.map { it.filePath }).containsExactly(
448+
"src/main/java/com/example/Controller.java",
449+
"src/main/java/com/example/Service.java"
450+
)
451+
452+
val secondTask = planItems[0].steps[1]
453+
Assertions.assertThat(secondTask.codeFileLinks).hasSize(1)
454+
Assertions.assertThat(secondTask.codeFileLinks[0].displayText).isEqualTo("Repository.java")
455+
Assertions.assertThat(secondTask.codeFileLinks[0].filePath).isEqualTo("src/main/java/com/example/Repository.java")
456+
}
345457
}

0 commit comments

Comments
 (0)