package cc.unitmesh.devins.idea.toolwindow.codereview import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.DiffHunk import cc.unitmesh.agent.diff.DiffLine import cc.unitmesh.agent.diff.DiffLineType import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import cc.unitmesh.devins.idea.toolwindow.codereview.CommitInfo import cc.unitmesh.devins.idea.toolwindow.codereview.DiffFileInfo import cc.unitmesh.devins.idea.theme.IdeaAutoDevColors import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.Orientation import org.jetbrains.jewel.ui.component.* internal enum class IdeaFileViewMode { LIST, TREE } @Composable internal fun DiffViewerPanel( diffFiles: List, selectedCommits: List, selectedCommitIndices: Set, isLoadingDiff: Boolean, onViewFile: ((String) -> Unit)? = null, onRefreshIssue: ((Int) -> Unit)? = null, onConfigureToken: () -> Unit = {}, modifier: Modifier = Modifier ) { var viewMode by remember { mutableStateOf(IdeaFileViewMode.LIST) } Column(modifier = modifier.fillMaxSize().background(JewelTheme.globalColors.panelBackground).padding(8.dp)) { if (selectedCommits.isNotEmpty()) { IdeaCommitInfoCard(selectedCommits, selectedCommitIndices.toList(), onRefreshIssue, onConfigureToken) Spacer(modifier = Modifier.height(8.dp)) } DiffFilesHeader(diffFiles.size, viewMode) { viewMode = it } Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) DiffContentArea(diffFiles, selectedCommits, isLoadingDiff, viewMode, onViewFile) } } @Composable private fun DiffFilesHeader(fileCount: Int, viewMode: IdeaFileViewMode, onViewModeChange: (IdeaFileViewMode) -> Unit) { Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Text(text = "Files changed ($fileCount)", style = JewelTheme.defaultTextStyle.copy(fontWeight = FontWeight.Medium, fontSize = 13.sp)) Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { IconButton(onClick = { onViewModeChange(IdeaFileViewMode.LIST) }, modifier = Modifier.size(28.dp)) { Icon(IdeaComposeIcons.List, "List view", tint = if (viewMode == IdeaFileViewMode.LIST) Color(0xFF3F51B5) else JewelTheme.globalColors.text.info, modifier = Modifier.size(16.dp)) } IconButton(onClick = { onViewModeChange(IdeaFileViewMode.TREE) }, modifier = Modifier.size(28.dp)) { Icon(IdeaComposeIcons.AccountTree, "Tree view", tint = if (viewMode == IdeaFileViewMode.TREE) Color(0xFF3F51B5) else JewelTheme.globalColors.text.info, modifier = Modifier.size(16.dp)) } } } } @Composable private fun DiffContentArea(diffFiles: List, selectedCommits: List, isLoadingDiff: Boolean, viewMode: IdeaFileViewMode, onViewFile: ((String) -> Unit)?) { when { isLoadingDiff -> Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { CircularProgressIndicator() Text("Loading diff...", style = JewelTheme.defaultTextStyle.copy(color = JewelTheme.globalColors.text.info)) } } diffFiles.isEmpty() -> Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { Text(if (selectedCommits.isEmpty()) "Select a commit to view diff" else "No file changes in this commit", style = JewelTheme.defaultTextStyle.copy(color = JewelTheme.globalColors.text.info)) } else -> when (viewMode) { IdeaFileViewMode.LIST -> IdeaCompactFileListView(diffFiles, onViewFile) IdeaFileViewMode.TREE -> IdeaFileTreeView(diffFiles, onViewFile) } } } @Composable internal fun IdeaCompactFileListView(files: List, onViewFile: ((String) -> Unit)?) { var expandedFileIndex by remember { mutableStateOf(null) } LazyColumn(state = rememberLazyListState(), modifier = Modifier.fillMaxSize()) { itemsIndexed(files) { index, file -> IdeaCompactFileDiffItem(file, expandedFileIndex == index, { expandedFileIndex = if (expandedFileIndex == index) null else index }, onViewFile) } } } @Composable private fun IdeaCompactFileDiffItem(file: DiffFileInfo, isExpanded: Boolean, onToggleExpand: () -> Unit, onViewFile: ((String) -> Unit)?) { val changeColor = when (file.changeType) { ChangeType.CREATE -> Color(0xFF66BB6A); ChangeType.DELETE -> Color(0xFFEF5350); ChangeType.RENAME -> Color(0xFFFFC107); else -> Color(0xFF42A5F5) } val changeIcon = when (file.changeType) { ChangeType.CREATE -> IdeaComposeIcons.Add; ChangeType.DELETE -> IdeaComposeIcons.Delete; ChangeType.RENAME -> IdeaComposeIcons.DriveFileRenameOutline; else -> IdeaComposeIcons.Edit } Column(modifier = Modifier.fillMaxWidth()) { FileDiffItemHeader(file, isExpanded, changeColor, changeIcon, onToggleExpand, onViewFile) AnimatedVisibility(visible = isExpanded, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) { Column(modifier = Modifier.fillMaxWidth().padding(start = 24.dp, end = 8.dp, bottom = 8.dp).background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f), RoundedCornerShape(4.dp)).padding(8.dp)) { file.hunks.forEachIndexed { i, hunk -> if (i > 0) Spacer(Modifier.height(8.dp)); IdeaDiffHunkView(hunk) } } } Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) } } @Composable private fun FileDiffItemHeader(file: DiffFileInfo, isExpanded: Boolean, changeColor: Color, changeIcon: androidx.compose.ui.graphics.vector.ImageVector, onToggleExpand: () -> Unit, onViewFile: ((String) -> Unit)?) { Row(modifier = Modifier.fillMaxWidth().clickable { onToggleExpand() }.padding(horizontal = 8.dp, vertical = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { Icon(if (isExpanded) IdeaComposeIcons.ExpandMore else IdeaComposeIcons.ChevronRight, if (isExpanded) "Collapse" else "Expand", tint = JewelTheme.globalColors.text.info, modifier = Modifier.size(16.dp)) Icon(changeIcon, file.changeType.name, tint = changeColor, modifier = Modifier.size(14.dp)) Text(file.path.split("/").lastOrNull() ?: file.path, style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp, fontWeight = FontWeight.Medium)) val dir = file.path.substringBeforeLast("/", "") if (dir.isNotEmpty()) Text(dir, style = JewelTheme.defaultTextStyle.copy(fontSize = 11.sp, color = JewelTheme.globalColors.text.info.copy(alpha = 0.6f))) } FileLineCountBadges(file, onViewFile) } } @Composable private fun FileLineCountBadges(file: DiffFileInfo, onViewFile: ((String) -> Unit)?) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { onViewFile?.let { IconButton(onClick = { it(file.path) }, modifier = Modifier.size(24.dp)) { Icon(IdeaComposeIcons.Visibility, "View file", tint = JewelTheme.globalColors.text.info, modifier = Modifier.size(14.dp)) } } val added = file.hunks.sumOf { h -> h.lines.count { it.type == DiffLineType.ADDED } } val deleted = file.hunks.sumOf { h -> h.lines.count { it.type == DiffLineType.DELETED } } if (added > 0) Text("+$added", style = JewelTheme.defaultTextStyle.copy(fontSize = 10.sp, color = Color(0xFF66BB6A), fontWeight = FontWeight.Bold)) if (deleted > 0) Text("-$deleted", style = JewelTheme.defaultTextStyle.copy(fontSize = 10.sp, color = Color(0xFFEF5350), fontWeight = FontWeight.Bold)) } } @Composable internal fun IdeaDiffHunkView(hunk: DiffHunk) { Column(modifier = Modifier.fillMaxWidth()) { Text(hunk.header, style = JewelTheme.defaultTextStyle.copy(fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = Color(0xFF5C6BC0))) Spacer(modifier = Modifier.height(4.dp)) hunk.lines.forEach { line -> if (line.type != DiffLineType.HEADER) IdeaDiffLineView(line) } } } @Composable private fun IdeaDiffLineView(line: DiffLine) { val (bgColor, textColor, prefix) = when (line.type) { DiffLineType.ADDED -> Triple(Color(0xFF66BB6A).copy(alpha = 0.15f), Color(0xFF66BB6A), "+") DiffLineType.DELETED -> Triple(Color(0xFFEF5350).copy(alpha = 0.15f), Color(0xFFEF5350), "-") DiffLineType.CONTEXT -> Triple(Color.Transparent, JewelTheme.globalColors.text.normal, " ") DiffLineType.HEADER -> return } Row(modifier = Modifier.fillMaxWidth().background(bgColor).padding(horizontal = 4.dp, vertical = 1.dp).horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Text(line.oldLineNumber?.toString()?.padStart(4) ?: " ", style = JewelTheme.defaultTextStyle.copy(fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = JewelTheme.globalColors.text.info.copy(alpha = 0.5f))) Text(line.newLineNumber?.toString()?.padStart(4) ?: " ", style = JewelTheme.defaultTextStyle.copy(fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = JewelTheme.globalColors.text.info.copy(alpha = 0.5f))) Text(prefix, style = JewelTheme.defaultTextStyle.copy(fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = textColor, fontWeight = FontWeight.Bold)) Text(line.content, style = JewelTheme.defaultTextStyle.copy(fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = textColor)) } } internal sealed class FileTreeNode { data class Directory(val name: String, val path: String, val files: List) : FileTreeNode() data class File(val file: DiffFileInfo) : FileTreeNode() } private fun buildFileTreeStructure(files: List): List { val result = mutableListOf() val directoryMap = mutableMapOf>() files.forEach { file -> val directory = file.path.substringBeforeLast("/", "") if (directory.isEmpty()) result.add(FileTreeNode.File(file)) else directoryMap.getOrPut(directory) { mutableListOf() }.add(file) } directoryMap.entries.sortedBy { it.key }.forEach { (path, dirFiles) -> result.add(FileTreeNode.Directory(path.split("/").lastOrNull() ?: path, path, dirFiles.sortedBy { it.path })) } return result } @Composable internal fun IdeaFileTreeView(files: List, onViewFile: ((String) -> Unit)?) { val treeNodes = remember(files) { buildFileTreeStructure(files) } var expandedDirs by remember { mutableStateOf(setOf()) } var expandedFilePath by remember { mutableStateOf(null) } LazyColumn(state = rememberLazyListState(), modifier = Modifier.fillMaxSize()) { treeNodes.forEach { node -> when (node) { is FileTreeNode.Directory -> { item(key = "dir_${node.path}") { IdeaDirectoryTreeItem(node, expandedDirs.contains(node.path)) { expandedDirs = if (expandedDirs.contains(node.path)) expandedDirs - node.path else expandedDirs + node.path } } if (expandedDirs.contains(node.path)) { node.files.forEachIndexed { index, file -> item(key = "file_${node.path}_$index") { IdeaFileTreeItemCompact(file, expandedFilePath == file.path, { expandedFilePath = if (expandedFilePath == file.path) null else file.path }, onViewFile, 1) } } } } is FileTreeNode.File -> { item(key = "file_root_${node.file.path}") { IdeaFileTreeItemCompact(node.file, expandedFilePath == node.file.path, { expandedFilePath = if (expandedFilePath == node.file.path) null else node.file.path }, onViewFile, 0) } } } } } } @Composable private fun IdeaDirectoryTreeItem(directory: FileTreeNode.Directory, isExpanded: Boolean, onToggle: () -> Unit) { Row(modifier = Modifier.fillMaxWidth().clickable { onToggle() }.padding(horizontal = 8.dp, vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { Icon(if (isExpanded) IdeaComposeIcons.ExpandMore else IdeaComposeIcons.ChevronRight, if (isExpanded) "Collapse" else "Expand", tint = JewelTheme.globalColors.text.info, modifier = Modifier.size(16.dp)) Icon(if (isExpanded) IdeaComposeIcons.FolderOpen else IdeaComposeIcons.Folder, "Directory", tint = Color(0xFFFFC107), modifier = Modifier.size(16.dp)) Text(directory.name, style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp, fontWeight = FontWeight.Medium)) Text("(${directory.files.size})", style = JewelTheme.defaultTextStyle.copy(fontSize = 11.sp, color = JewelTheme.globalColors.text.info.copy(alpha = 0.6f))) } } @Composable private fun IdeaFileTreeItemCompact(file: DiffFileInfo, isExpanded: Boolean, onToggleExpand: () -> Unit, onViewFile: ((String) -> Unit)?, indentLevel: Int) { val changeColor = when (file.changeType) { ChangeType.CREATE -> Color(0xFF66BB6A); ChangeType.DELETE -> Color(0xFFEF5350); ChangeType.RENAME -> Color(0xFFFFC107); else -> Color(0xFF42A5F5) } Column(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth().clickable { onToggleExpand() }.padding(start = (8 + indentLevel * 16).dp, end = 8.dp, top = 4.dp, bottom = 4.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { Icon(if (isExpanded) IdeaComposeIcons.ExpandMore else IdeaComposeIcons.ChevronRight, if (isExpanded) "Collapse" else "Expand", tint = JewelTheme.globalColors.text.info, modifier = Modifier.size(14.dp)) Icon(IdeaComposeIcons.Description, "File", tint = changeColor, modifier = Modifier.size(14.dp)) Text(file.path.split("/").lastOrNull() ?: file.path, style = JewelTheme.defaultTextStyle.copy(fontSize = 11.sp)) } onViewFile?.let { IconButton(onClick = { it(file.path) }, modifier = Modifier.size(20.dp)) { Icon(IdeaComposeIcons.Visibility, "View file", tint = JewelTheme.globalColors.text.info, modifier = Modifier.size(12.dp)) } } } AnimatedVisibility(visible = isExpanded, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) { Column(modifier = Modifier.fillMaxWidth().padding(start = (24 + indentLevel * 16).dp, end = 8.dp, bottom = 8.dp).background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f), RoundedCornerShape(4.dp)).padding(8.dp)) { file.hunks.forEachIndexed { i, hunk -> if (i > 0) Spacer(Modifier.height(8.dp)); IdeaDiffHunkView(hunk) } } } } }