diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeDiffDialog.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeDiffDialog.kt index 13195f8882..dfd000b750 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeDiffDialog.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeDiffDialog.kt @@ -1,98 +1,276 @@ package cc.unitmesh.devins.idea.toolwindow.changes -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import cc.unitmesh.agent.diff.ChangeType -import cc.unitmesh.agent.diff.DiffUtils import cc.unitmesh.agent.diff.FileChange -import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import com.intellij.diff.DiffContentFactoryEx +import com.intellij.diff.DiffContext +import com.intellij.diff.contents.EmptyContent +import com.intellij.diff.requests.SimpleDiffRequest +import com.intellij.diff.tools.simple.SimpleDiffViewer +import com.intellij.diff.tools.simple.SimpleOnesideDiffViewer +import com.intellij.icons.AllIcons +import com.intellij.openapi.application.runWriteAction +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.project.Project import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.vcs.FilePath +import com.intellij.openapi.vcs.FileStatus +import com.intellij.openapi.vcs.VcsBundle +import com.intellij.openapi.vcs.changes.Change +import com.intellij.openapi.vcs.changes.ContentRevision +import com.intellij.openapi.vcs.changes.CurrentContentRevision +import com.intellij.openapi.vcs.changes.TextRevisionNumber +import com.intellij.openapi.vcs.changes.ui.RollbackWorker +import com.intellij.openapi.vcs.history.VcsRevisionNumber +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI -import org.jetbrains.jewel.bridge.compose -import org.jetbrains.jewel.foundation.theme.JewelTheme -import org.jetbrains.jewel.ui.component.DefaultButton -import org.jetbrains.jewel.ui.component.Icon -import org.jetbrains.jewel.ui.component.OutlinedButton -import org.jetbrains.jewel.ui.component.Text -import org.jetbrains.jewel.ui.icons.AllIconsKeys +import com.intellij.util.ui.UIUtil +import com.intellij.vcsUtil.VcsUtil +import org.jetbrains.annotations.NonNls +import java.awt.BorderLayout import java.awt.Dimension -import javax.swing.JComponent +import java.awt.FlowLayout +import java.io.File +import javax.swing.* -/** - * Dialog for displaying file change diff using IntelliJ's DialogWrapper. - * Uses Jewel Compose for the content rendering. - */ -@Composable -fun IdeaFileChangeDiffDialog( - project: Project, - change: FileChange, - onDismiss: () -> Unit, - onUndo: () -> Unit, - onKeep: () -> Unit -) { - // Show the dialog using DialogWrapper - IdeaFileChangeDiffDialogWrapper.show( - project = project, - change = change, - onUndo = onUndo, - onKeep = onKeep, - onDismiss = onDismiss - ) -} +private val LOG = logger() /** - * DialogWrapper implementation for file change diff dialog. + * Dialog for displaying file change diff using IntelliJ's DialogWrapper. + * Uses IntelliJ's SimpleDiffViewer for proper diff rendering and RollbackWorker for revert. */ class IdeaFileChangeDiffDialogWrapper( private val project: Project, - private val change: FileChange, + private val fileChange: FileChange, private val onUndoCallback: () -> Unit, private val onKeepCallback: () -> Unit, private val onDismissCallback: () -> Unit ) : DialogWrapper(project) { + private val change: Change = FileChangeConverter.toChange(project, fileChange) + private val rollbackWorker = RollbackWorker(project) + init { - title = "Diff: ${change.getFileName()}" + title = "Diff: ${fileChange.getFileName()}" + setOKButtonText("Keep") + setCancelButtonText("Close") init() - contentPanel.border = JBUI.Borders.empty() - rootPane.border = JBUI.Borders.empty() } - override fun createSouthPanel(): JComponent? = null - override fun createCenterPanel(): JComponent { - val dialogPanel = compose { - DiffDialogContent( - change = change, - onDismiss = { - onDismissCallback() - close(CANCEL_EXIT_CODE) - }, - onUndo = { - onUndoCallback() - close(OK_EXIT_CODE) - }, - onKeep = { - onKeepCallback() - close(OK_EXIT_CODE) + val mainPanel = JPanel(BorderLayout()) + mainPanel.preferredSize = Dimension(800, 600) + mainPanel.border = JBUI.Borders.empty(8) + + // Header with file info + val headerPanel = createHeaderPanel() + mainPanel.add(headerPanel, BorderLayout.NORTH) + + // Diff viewer + val diffViewer = createDiffViewer() + mainPanel.add(diffViewer, BorderLayout.CENTER) + + return mainPanel + } + + private fun createHeaderPanel(): JPanel { + val headerPanel = JPanel(BorderLayout()) + headerPanel.border = JBUI.Borders.empty(0, 0, 8, 0) + + val fileIcon = when (fileChange.changeType) { + ChangeType.CREATE -> AllIcons.General.Add + ChangeType.EDIT -> AllIcons.Actions.Edit + ChangeType.DELETE -> AllIcons.General.Remove + ChangeType.RENAME -> AllIcons.Actions.Edit + } + + val fileLabel = JBLabel(fileChange.getFileName()).apply { + icon = fileIcon + font = JBUI.Fonts.label().asBold() + } + + val pathLabel = JBLabel(fileChange.filePath).apply { + foreground = UIUtil.getLabelDisabledForeground() + font = JBUI.Fonts.smallFont() + } + + val leftPanel = JPanel(BorderLayout()).apply { + add(fileLabel, BorderLayout.NORTH) + add(pathLabel, BorderLayout.SOUTH) + } + + // Diff stats + val diffStats = fileChange.getDiffStats() + val statsPanel = JPanel(FlowLayout(FlowLayout.RIGHT, 8, 0)).apply { + val addLabel = JBLabel("+${diffStats.addedLines}").apply { + foreground = JBUI.CurrentTheme.NotificationInfo.foregroundColor() + } + val removeLabel = JBLabel("-${diffStats.deletedLines}").apply { + foreground = JBUI.CurrentTheme.NotificationError.foregroundColor() + } + add(addLabel) + add(removeLabel) + } + + headerPanel.add(leftPanel, BorderLayout.WEST) + headerPanel.add(statsPanel, BorderLayout.EAST) + + return headerPanel + } + + private fun createDiffViewer(): JComponent { + return when (fileChange.changeType) { + ChangeType.CREATE -> { + val diffRequest = createOneSideDiffRequest() + val diffViewer = SimpleOnesideDiffViewer(object : DiffContext() { + override fun getProject() = this@IdeaFileChangeDiffDialogWrapper.project + override fun isWindowFocused() = true + override fun isFocusedInWindow() = true + override fun requestFocusInWindow() = Unit + }, diffRequest) + diffViewer.init() + diffViewer.component + } + ChangeType.DELETE -> { + val diffRequest = createOneSideDiffRequestForDelete() + val diffViewer = SimpleOnesideDiffViewer(object : DiffContext() { + override fun getProject() = this@IdeaFileChangeDiffDialogWrapper.project + override fun isWindowFocused() = true + override fun isFocusedInWindow() = true + override fun requestFocusInWindow() = Unit + }, diffRequest) + diffViewer.init() + diffViewer.component + } + else -> { + val diffRequest = createTwoSideDiffRequest() + val diffViewer = SimpleDiffViewer(object : DiffContext() { + override fun getProject() = this@IdeaFileChangeDiffDialogWrapper.project + override fun isWindowFocused() = true + override fun isFocusedInWindow() = true + override fun requestFocusInWindow() = Unit + }, diffRequest) + diffViewer.init() + diffViewer.component + } + } + } + + private fun createOneSideDiffRequest(): SimpleDiffRequest { + val diffFactory = DiffContentFactoryEx.getInstanceEx() + val newCode = fileChange.newContent ?: "" + val newDocContent = diffFactory.create(newCode) + return SimpleDiffRequest("New File", EmptyContent(), newDocContent, "", "New Content") + } + + private fun createOneSideDiffRequestForDelete(): SimpleDiffRequest { + val diffFactory = DiffContentFactoryEx.getInstanceEx() + val oldCode = fileChange.originalContent ?: "" + val oldDocContent = diffFactory.create(oldCode) + return SimpleDiffRequest("Deleted File", oldDocContent, EmptyContent(), "Original Content", "") + } + + private fun createTwoSideDiffRequest(): SimpleDiffRequest { + val diffFactory = DiffContentFactoryEx.getInstanceEx() + val oldCode = fileChange.originalContent ?: "" + val newCode = fileChange.newContent ?: "" + + val currentDocContent = diffFactory.create(project, oldCode) + val newDocContent = diffFactory.create(newCode) + + return SimpleDiffRequest("Diff", currentDocContent, newDocContent, "Original", "Modified") + } + + override fun createSouthPanel(): JComponent { + val panel = JPanel(FlowLayout(FlowLayout.RIGHT, 8, 0)) + panel.border = JBUI.Borders.empty(8, 0, 0, 0) + + val closeButton = JButton("Close").apply { + addActionListener { + onDismissCallback() + close(CANCEL_EXIT_CODE) + } + } + + val undoButton = JButton("Undo").apply { + icon = AllIcons.Actions.Rollback + addActionListener { + performUndo() + } + } + + val keepButton = JButton("Keep").apply { + icon = AllIcons.Actions.Commit + addActionListener { + onKeepCallback() + close(OK_EXIT_CODE) + } + } + + panel.add(closeButton) + panel.add(undoButton) + panel.add(keepButton) + + return panel + } + + private fun performUndo() { + try { + // Try to use RollbackWorker for proper VCS integration + rollbackWorker.doRollback(listOf(change), false) + onUndoCallback() + close(OK_EXIT_CODE) + } catch (e: Exception) { + LOG.warn("RollbackWorker failed, falling back to manual revert", e) + // Fallback to manual revert + performManualUndo() + } + } + + private fun performManualUndo() { + runWriteAction { + try { + when (fileChange.changeType) { + ChangeType.CREATE -> { + // For created files, delete or clear the content + val virtualFile = LocalFileSystem.getInstance().findFileByPath(fileChange.filePath) + virtualFile?.let { vf -> + val document = FileDocumentManager.getInstance().getDocument(vf) + document?.setText("") + } + } + ChangeType.EDIT, ChangeType.RENAME -> { + // Restore original content + fileChange.originalContent?.let { original -> + val virtualFile = LocalFileSystem.getInstance().findFileByPath(fileChange.filePath) + virtualFile?.let { vf -> + val document = FileDocumentManager.getInstance().getDocument(vf) + document?.setText(original) + } + } + } + ChangeType.DELETE -> { + // For deleted files, recreate them + fileChange.originalContent?.let { original -> + val parentPath = fileChange.filePath.substringBeforeLast('/') + val fileName = fileChange.filePath.substringAfterLast('/') + val parentDir = LocalFileSystem.getInstance().findFileByPath(parentPath) + parentDir?.let { dir -> + val newFile = dir.createChildData(project, fileName) + val document = FileDocumentManager.getInstance().getDocument(newFile) + document?.setText(original) + } + } + } } - ) + onUndoCallback() + close(OK_EXIT_CODE) + } catch (e: Exception) { + LOG.error("Failed to undo change for ${fileChange.filePath}", e) + } } - dialogPanel.preferredSize = Dimension(800, 600) - return dialogPanel } override fun doCancelAction() { @@ -110,7 +288,7 @@ class IdeaFileChangeDiffDialogWrapper( ): Boolean { val dialog = IdeaFileChangeDiffDialogWrapper( project = project, - change = change, + fileChange = change, onUndoCallback = onUndo, onKeepCallback = onKeep, onDismissCallback = onDismiss @@ -120,216 +298,45 @@ class IdeaFileChangeDiffDialogWrapper( } } -@Composable -private fun DiffDialogContent( - change: FileChange, - onDismiss: () -> Unit, - onUndo: () -> Unit, - onKeep: () -> Unit -) { - val scrollState = rememberScrollState() - val diffContent = DiffUtils.generateUnifiedDiff( - oldContent = change.originalContent ?: "", - newContent = change.newContent ?: "", - filePath = change.filePath - ) - - Column( - modifier = Modifier - .fillMaxSize() - .background(JewelTheme.globalColors.panelBackground) - .padding(16.dp) - ) { - // Header - DiffDialogHeader(change = change) - - Spacer(modifier = Modifier.height(12.dp)) - - // Diff content - Box( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .clip(RoundedCornerShape(4.dp)) - .background(AutoDevColors.Neutral.c900) - .padding(8.dp) - ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - ) { - diffContent.lines().forEach { line -> - DiffLine(line = line) - } - } - } - - Spacer(modifier = Modifier.height(12.dp)) - - // Action buttons - DiffDialogActions( - onDismiss = onDismiss, - onUndo = onUndo, - onKeep = onKeep - ) - } -} - -@Composable -private fun DiffDialogHeader(change: FileChange) { - val diffStats = change.getDiffStats() - val iconKey = when (change.changeType) { - ChangeType.CREATE -> AllIconsKeys.General.Add - ChangeType.EDIT -> AllIconsKeys.Actions.Edit - ChangeType.DELETE -> AllIconsKeys.General.Remove - ChangeType.RENAME -> AllIconsKeys.Actions.Edit // Use Edit as fallback for Rename - } - val iconColor = when (change.changeType) { - ChangeType.CREATE -> AutoDevColors.Green.c400 - ChangeType.EDIT -> AutoDevColors.Blue.c400 - ChangeType.DELETE -> AutoDevColors.Red.c400 - ChangeType.RENAME -> AutoDevColors.Indigo.c400 // Use Indigo instead of Purple - } +/** + * Utility object to convert FileChange to IntelliJ's Change API. + * Similar to PatchConverter in core module. + */ +object FileChangeConverter { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - key = iconKey, - contentDescription = change.changeType.name, - modifier = Modifier.size(20.dp), - tint = iconColor - ) - Column { - Text( - text = change.getFileName(), - style = JewelTheme.defaultTextStyle.copy( - fontSize = 14.sp, - fontWeight = FontWeight.Bold - ) - ) - Text( - text = change.filePath, - style = JewelTheme.defaultTextStyle.copy( - fontSize = 11.sp, - color = AutoDevColors.Neutral.c400 - ) - ) - } - } + /** + * Convert a FileChange to IntelliJ's Change object. + */ + fun toChange(project: Project, fileChange: FileChange): Change { + val basePath = project.basePath ?: System.getProperty("user.dir") + val file = File(fileChange.filePath) + val filePath: FilePath = VcsUtil.getFilePath(file, false) - // Diff stats - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "+${diffStats.addedLines}", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 12.sp, - color = AutoDevColors.Green.c400, - fontWeight = FontWeight.Medium - ) - ) - Text( - text = "-${diffStats.deletedLines}", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 12.sp, - color = AutoDevColors.Red.c400, - fontWeight = FontWeight.Medium - ) - ) + val fileStatus = when (fileChange.changeType) { + ChangeType.CREATE -> FileStatus.ADDED + ChangeType.DELETE -> FileStatus.DELETED + ChangeType.EDIT -> FileStatus.MODIFIED + ChangeType.RENAME -> FileStatus.MODIFIED } - } -} -@Composable -private fun DiffLine(line: String) { - val backgroundColor: Color - val textColor: Color - - when { - line.startsWith("+") && !line.startsWith("+++") -> { - backgroundColor = AutoDevColors.Green.c900.copy(alpha = 0.3f) - textColor = AutoDevColors.Green.c300 - } - line.startsWith("-") && !line.startsWith("---") -> { - backgroundColor = AutoDevColors.Red.c900.copy(alpha = 0.3f) - textColor = AutoDevColors.Red.c300 - } - line.startsWith("@@") -> { - backgroundColor = AutoDevColors.Blue.c900.copy(alpha = 0.3f) - textColor = AutoDevColors.Blue.c300 - } - else -> { - backgroundColor = Color.Transparent - textColor = AutoDevColors.Neutral.c300 - } - } + val beforeRevision: ContentRevision? = if (fileStatus != FileStatus.ADDED) { + object : CurrentContentRevision(filePath) { + override fun getRevisionNumber(): VcsRevisionNumber = + TextRevisionNumber(VcsBundle.message("local.version.title")) - Text( - text = line, - style = JewelTheme.defaultTextStyle.copy( - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = textColor - ), - modifier = Modifier - .fillMaxWidth() - .background(backgroundColor) - .padding(horizontal = 4.dp, vertical = 1.dp) - ) -} + override fun getContent(): @NonNls String? = fileChange.originalContent + } + } else null -@Composable -private fun DiffDialogActions( - onDismiss: () -> Unit, - onUndo: () -> Unit, - onKeep: () -> Unit -) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedButton( - onClick = onDismiss, - modifier = Modifier.padding(end = 8.dp) - ) { - Text("Close") - } + val afterRevision: ContentRevision? = if (fileStatus != FileStatus.DELETED) { + object : CurrentContentRevision(filePath) { + override fun getRevisionNumber(): VcsRevisionNumber = + TextRevisionNumber(VcsBundle.message("local.version.title")) - OutlinedButton( - onClick = onUndo, - modifier = Modifier.padding(end = 8.dp) - ) { - Icon( - key = AllIconsKeys.Actions.Rollback, - contentDescription = "Undo", - modifier = Modifier.size(14.dp), - tint = AutoDevColors.Red.c400 - ) - Spacer(modifier = Modifier.width(4.dp)) - Text("Undo", color = AutoDevColors.Red.c400) - } + override fun getContent(): @NonNls String? = fileChange.newContent + } + } else null - DefaultButton( - onClick = onKeep - ) { - Icon( - key = AllIconsKeys.Actions.Checked, - contentDescription = "Keep", - modifier = Modifier.size(14.dp), - tint = AutoDevColors.Green.c400 - ) - Spacer(modifier = Modifier.width(4.dp)) - Text("Keep") - } + return Change(beforeRevision, afterRevision, fileStatus) } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeSummary.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeSummary.kt index b98f48abbc..e052382aed 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeSummary.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeSummary.kt @@ -217,48 +217,65 @@ private fun IdeaChangeSummaryHeader( } /** - * Undo a file change by restoring the original content + * Undo a file change using IntelliJ's RollbackWorker for proper VCS integration. + * Falls back to manual revert if RollbackWorker fails. */ -private fun undoChange(project: Project, change: FileChange) { +private fun undoChange(project: Project, fileChange: FileChange) { ApplicationManager.getApplication().invokeLater { - runWriteAction { - try { - when (change.changeType) { - ChangeType.CREATE -> { - // For created files, delete or clear the content + try { + // Convert FileChange to IntelliJ Change and use RollbackWorker + val change = FileChangeConverter.toChange(project, fileChange) + val rollbackWorker = com.intellij.openapi.vcs.changes.ui.RollbackWorker(project) + rollbackWorker.doRollback(listOf(change), false) + } catch (e: Exception) { + logger.warn("RollbackWorker failed, falling back to manual revert", e) + // Fallback to manual revert + performManualUndo(project, fileChange) + } + } +} + +/** + * Manual undo fallback when RollbackWorker fails. + */ +private fun performManualUndo(project: Project, change: FileChange) { + runWriteAction { + try { + when (change.changeType) { + ChangeType.CREATE -> { + // For created files, delete or clear the content + val virtualFile = LocalFileSystem.getInstance().findFileByPath(change.filePath) + virtualFile?.let { vf -> + val document = FileDocumentManager.getInstance().getDocument(vf) + document?.setText("") + } + } + ChangeType.EDIT, ChangeType.RENAME -> { + // Restore original content + change.originalContent?.let { original -> val virtualFile = LocalFileSystem.getInstance().findFileByPath(change.filePath) virtualFile?.let { vf -> val document = FileDocumentManager.getInstance().getDocument(vf) - document?.setText("") - } - } - ChangeType.EDIT, ChangeType.RENAME -> { - // Restore original content - change.originalContent?.let { original -> - val virtualFile = LocalFileSystem.getInstance().findFileByPath(change.filePath) - virtualFile?.let { vf -> - val document = FileDocumentManager.getInstance().getDocument(vf) - document?.setText(original) - } + document?.setText(original) } } - ChangeType.DELETE -> { - // For deleted files, we would need to recreate them - change.originalContent?.let { original -> - val parentPath = change.filePath.substringBeforeLast('/') - val fileName = change.filePath.substringAfterLast('/') - val parentDir = LocalFileSystem.getInstance().findFileByPath(parentPath) - parentDir?.let { dir -> - val newFile = dir.createChildData(project, fileName) - val document = FileDocumentManager.getInstance().getDocument(newFile) - document?.setText(original) - } + } + ChangeType.DELETE -> { + // For deleted files, we would need to recreate them + change.originalContent?.let { original -> + val parentPath = change.filePath.substringBeforeLast('/') + val fileName = change.filePath.substringAfterLast('/') + val parentDir = LocalFileSystem.getInstance().findFileByPath(parentPath) + parentDir?.let { dir -> + val newFile = dir.createChildData(project, fileName) + val document = FileDocumentManager.getInstance().getDocument(newFile) + document?.setText(original) } } } - } catch (e: Exception) { - logger.error("Failed to undo change for ${change.filePath}", e) } + } catch (e: Exception) { + logger.error("Failed to undo change for ${change.filePath}", e) } } } diff --git a/mpp-vscode/webview/src/components/Timeline.css b/mpp-vscode/webview/src/components/Timeline.css index 8f6dde11a6..3d9cf35bf5 100644 --- a/mpp-vscode/webview/src/components/Timeline.css +++ b/mpp-vscode/webview/src/components/Timeline.css @@ -22,9 +22,11 @@ border: 1px solid var(--vscode-input-border); } +/* Assistant messages: transparent background for unified look */ .timeline-item.message.assistant { - background-color: var(--vscode-editorWidget-background); - border: 1px solid var(--vscode-panel-border); + background-color: transparent; + border: none; + padding: 4px 12px; } .timeline-item.message.system { diff --git a/mpp-vscode/webview/src/components/Timeline.tsx b/mpp-vscode/webview/src/components/Timeline.tsx index 431dc7fd8b..7c1c0b031c 100644 --- a/mpp-vscode/webview/src/components/Timeline.tsx +++ b/mpp-vscode/webview/src/components/Timeline.tsx @@ -130,12 +130,8 @@ const TerminalItemRenderer: React.FC<{ item: TerminalTimelineItem; onAction?: (a ); -const TaskCompleteItemRenderer: React.FC<{ item: { success: boolean; message: string } }> = ({ item }) => ( -
- {item.success ? '✅' : '❌'} - {item.message} -
-); +// Task complete is not displayed - the completion is implicit from the response +const TaskCompleteItemRenderer: React.FC<{ item: { success: boolean; message: string } }> = () => null; const ErrorItemRenderer: React.FC<{ item: { message: string } }> = ({ item }) => (
diff --git a/mpp-vscode/webview/src/components/sketch/DevInRenderer.css b/mpp-vscode/webview/src/components/sketch/DevInRenderer.css new file mode 100644 index 0000000000..2f16f58e45 --- /dev/null +++ b/mpp-vscode/webview/src/components/sketch/DevInRenderer.css @@ -0,0 +1,100 @@ +.devin-renderer { + display: flex; + flex-direction: column; + gap: 8px; +} + +.devin-tool-item { + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + overflow: hidden; + background-color: var(--vscode-editorWidget-background); + border-left: 3px solid var(--vscode-testing-iconPassed, #4caf50); +} + +.devin-tool-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + user-select: none; + transition: background-color 0.2s; +} + +/* Only show pointer cursor when header is clickable (has params) */ +.devin-tool-header.clickable { + cursor: pointer; +} + +.devin-tool-header.clickable:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.devin-tool-icon { + font-size: 14px; + flex-shrink: 0; +} + +.devin-tool-name { + font-weight: 600; + font-size: 12px; + color: var(--vscode-foreground); +} + +.devin-tool-details { + font-size: 11px; + color: var(--vscode-descriptionForeground); + font-family: var(--vscode-editor-font-family, monospace); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.devin-tool-actions { + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; + flex-shrink: 0; +} + +.devin-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + border-radius: 4px; + background: transparent; + cursor: pointer; + font-size: 14px; + color: var(--vscode-descriptionForeground); + transition: background-color 0.2s; +} + +.devin-action-btn:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.devin-action-btn.toggle { + font-size: 10px; +} + +.devin-tool-params { + padding: 8px 12px; + border-top: 1px solid var(--vscode-panel-border); + background-color: var(--vscode-editor-background); +} + +.devin-tool-params pre { + margin: 0; + font-family: var(--vscode-editor-font-family, monospace); + font-size: 11px; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; + color: var(--vscode-editor-foreground); +} + diff --git a/mpp-vscode/webview/src/components/sketch/DevInRenderer.tsx b/mpp-vscode/webview/src/components/sketch/DevInRenderer.tsx new file mode 100644 index 0000000000..f59a1ced78 --- /dev/null +++ b/mpp-vscode/webview/src/components/sketch/DevInRenderer.tsx @@ -0,0 +1,187 @@ +/** + * DevInRenderer - Renders DevIn blocks as tool call items + * + * Parses devin blocks (language id = "devin") and renders them as tool call items + * when the block is complete. Mirrors IdeaDevInBlockRenderer.kt + */ + +import React, { useState, useMemo } from 'react'; +import './DevInRenderer.css'; + +interface DevInRendererProps { + content: string; + isComplete?: boolean; + onAction?: (action: string, data: any) => void; +} + +interface ParsedToolCall { + toolName: string; + params: Record; +} + +/** + * Parse DevIn content to extract tool calls + * Handles format like: /command-name param1="value1" param2="value2" + * + * Note: This parser does not handle escaped quotes within quoted values. + * For example, key="value with \"quote\"" will not parse correctly. + */ +function parseDevInContent(content: string): ParsedToolCall[] { + const toolCalls: ParsedToolCall[] = []; + const lines = content.trim().split('\n'); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + // Match /command-name or command-name at start + const commandMatch = trimmed.match(/^\/?([a-zA-Z][a-zA-Z0-9_-]*)/); + if (!commandMatch) continue; + + const toolName = commandMatch[1]; + const paramsStr = trimmed.slice(commandMatch[0].length).trim(); + const params: Record = {}; + + // Parse key="value" or key=value patterns + // Note: Does not handle escaped quotes within quoted values + const paramRegex = /([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))/g; + let match; + while ((match = paramRegex.exec(paramsStr)) !== null) { + const key = match[1]; + const value = match[2] ?? match[3] ?? match[4] ?? ''; + params[key] = value; + } + + // If no key=value params, treat rest as single param + if (Object.keys(params).length === 0 && paramsStr) { + params['args'] = paramsStr; + } + + toolCalls.push({ toolName, params }); + } + + return toolCalls; +} + +/** + * Format tool call details for display + */ +function formatToolCallDetails(params: Record): string { + return Object.entries(params) + .map(([key, value]) => { + const truncated = value.length > 50 ? value.slice(0, 50) + '...' : value; + return `${key}="${truncated}"`; + }) + .join(' '); +} + +export const DevInRenderer: React.FC = ({ + content, + isComplete = false, + onAction +}) => { + // Skip parsing during streaming to avoid unnecessary work + const toolCalls = useMemo( + () => (isComplete ? parseDevInContent(content) : []), + [content, isComplete] + ); + + // Don't render incomplete or empty devin blocks + if (!isComplete || toolCalls.length === 0) { + return null; + } + + return ( +
+ {toolCalls.map((tc, index) => ( + + ))} +
+ ); +}; + +interface DevInToolItemProps { + toolName: string; + params: Record; + onAction?: (action: string, data: any) => void; +} + +// Tools that support opening files in VSCode +const FILE_TOOLS = ['read-file', 'read_file', 'readFile', 'file', 'open']; + +const DevInToolItem: React.FC = ({ + toolName, + params, + onAction +}) => { + const [isExpanded, setIsExpanded] = useState(false); + const hasParams = Object.keys(params).length > 0; + const details = formatToolCallDetails(params); + + // Check if this is a file-related tool + const isFileTool = FILE_TOOLS.includes(toolName); + const filePath = params['path'] || params['file'] || params['args'] || ''; + + const handleOpenFile = (e: React.MouseEvent) => { + e.stopPropagation(); + if (filePath && onAction) { + onAction('openFile', { path: filePath }); + } + }; + + const handleToggle = (e: React.MouseEvent) => { + e.stopPropagation(); + if (hasParams) { + setIsExpanded(!isExpanded); + } + }; + + return ( +
+
+ + {toolName} + {!isExpanded && details && ( + + {details.length > 60 ? details.slice(0, 60) + '...' : details} + + )} +
+ {isFileTool && filePath && ( + + )} + {hasParams && ( + + )} +
+
+ + {isExpanded && hasParams && ( +
+
{JSON.stringify(params, null, 2)}
+
+ )} +
+ ); +}; + diff --git a/mpp-vscode/webview/src/components/sketch/MarkdownRenderer.tsx b/mpp-vscode/webview/src/components/sketch/MarkdownRenderer.tsx index c78e4c6b57..d50601e29c 100644 --- a/mpp-vscode/webview/src/components/sketch/MarkdownRenderer.tsx +++ b/mpp-vscode/webview/src/components/sketch/MarkdownRenderer.tsx @@ -14,8 +14,26 @@ interface MarkdownRendererProps { content: string; } +/** + * Filter out ... tags and their content from markdown + * These are tool call blocks that should not be displayed as text + */ +function filterDevinTags(content: string): string { + // Remove ... blocks (including multiline) + let filtered = content.replace(/[\s\S]*?<\/devin>/gi, ''); + // Remove unclosed tags at the end (streaming) + // Note: no 'g' flag needed as $ only matches end of string + filtered = filtered.replace(/[\s\S]*$/i, ''); + // Remove standalone tags that appear on their own line + filtered = filtered.replace(/^\s*<\/devin>\s*$/gim, ''); + return filtered.trim(); +} + export const MarkdownRenderer: React.FC = ({ content }) => { - if (!content.trim()) { + const filteredContent = filterDevinTags(content); + + // filterDevinTags already returns trimmed content + if (!filteredContent) { return null; } @@ -108,7 +126,7 @@ export const MarkdownRenderer: React.FC = ({ content }) = ), }} > - {content} + {filteredContent}
); diff --git a/mpp-vscode/webview/src/components/sketch/SketchRenderer.tsx b/mpp-vscode/webview/src/components/sketch/SketchRenderer.tsx index 0d500ab9e5..b112c4451c 100644 --- a/mpp-vscode/webview/src/components/sketch/SketchRenderer.tsx +++ b/mpp-vscode/webview/src/components/sketch/SketchRenderer.tsx @@ -18,6 +18,7 @@ import { DiffRenderer } from './DiffRenderer'; import { ThinkingRenderer } from './ThinkingRenderer'; import { TerminalRenderer } from './TerminalRenderer'; import { MarkdownRenderer } from './MarkdownRenderer'; +import { DevInRenderer } from './DevInRenderer'; import './SketchRenderer.css'; interface SketchRendererProps { @@ -117,12 +118,11 @@ function renderBlock( ); case 'devin': - // TODO: Implement DevInRenderer return ( - ); diff --git a/mpp-vscode/webview/src/components/sketch/index.ts b/mpp-vscode/webview/src/components/sketch/index.ts index 56dba5c9ac..7cdf527ec9 100644 --- a/mpp-vscode/webview/src/components/sketch/index.ts +++ b/mpp-vscode/webview/src/components/sketch/index.ts @@ -1,6 +1,6 @@ /** * Sketch Renderer Components - * + * * Mirrors mpp-ui's SketchRenderer architecture for VSCode Webview * Provides specialized renderers for different content types: * - SketchRenderer: Main dispatcher @@ -10,6 +10,7 @@ * - ThinkingRenderer: Collapsible thinking blocks * - TerminalRenderer: Terminal commands and output * - ToolCallRenderer: Tool call information + * - DevInRenderer: DevIn tool call blocks */ export { SketchRenderer } from './SketchRenderer'; @@ -19,4 +20,5 @@ export { DiffRenderer } from './DiffRenderer'; export { ThinkingRenderer } from './ThinkingRenderer'; export { TerminalRenderer } from './TerminalRenderer'; export { ToolCallRenderer } from './ToolCallRenderer'; +export { DevInRenderer } from './DevInRenderer';