Skip to content

Commit a8aaab4

Browse files
committed
feat(codereview): add vertical split pane for diff and tests #463
Introduce a vertical resizable split pane in DiffCenterView to display the file list and quality review panel together. Improve test filtering to exclude invalid or parse-error test cases.
1 parent 24f694f commit a8aaab4

File tree

4 files changed

+301
-53
lines changed

4 files changed

+301
-53
lines changed
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
package cc.unitmesh.devins.ui.compose.agent
2+
3+
import androidx.compose.animation.core.animateFloatAsState
4+
import androidx.compose.animation.core.tween
5+
import androidx.compose.foundation.background
6+
import androidx.compose.foundation.gestures.detectDragGestures
7+
import androidx.compose.foundation.hoverable
8+
import androidx.compose.foundation.interaction.MutableInteractionSource
9+
import androidx.compose.foundation.interaction.collectIsHoveredAsState
10+
import androidx.compose.foundation.layout.*
11+
import androidx.compose.runtime.saveable.rememberSaveable
12+
import androidx.compose.material3.MaterialTheme
13+
import androidx.compose.runtime.*
14+
import androidx.compose.ui.Modifier
15+
import androidx.compose.ui.draw.alpha
16+
import androidx.compose.ui.graphics.Color
17+
import androidx.compose.ui.input.pointer.PointerIcon
18+
import androidx.compose.ui.input.pointer.pointerHoverIcon
19+
import androidx.compose.ui.input.pointer.pointerInput
20+
import androidx.compose.ui.layout.Layout
21+
import androidx.compose.ui.unit.Constraints
22+
import androidx.compose.ui.unit.dp
23+
import kotlin.math.roundToInt
24+
25+
/**
26+
* A high-performance resizable split pane that divides two composables vertically
27+
* Optimized for smooth dragging and excellent visual feedback
28+
*
29+
* @param modifier The modifier to apply to this layout
30+
* @param initialSplitRatio The initial split ratio (0.0 to 1.0) for the top pane
31+
* @param minRatio The minimum split ratio for the top pane
32+
* @param maxRatio The maximum split ratio for the top pane
33+
* @param dividerHeight The height of the divider in dp
34+
* @param saveKey Optional key for saving/restoring split ratio across app sessions. If null, state is not persisted.
35+
* @param top The first composable (top side)
36+
* @param bottom The second composable (bottom side)
37+
*/
38+
@Composable
39+
fun VerticalResizableSplitPane(
40+
modifier: Modifier = Modifier,
41+
initialSplitRatio: Float = 0.5f,
42+
minRatio: Float = 0.2f,
43+
maxRatio: Float = 0.8f,
44+
dividerHeight: Int = 8,
45+
saveKey: String? = null,
46+
top: @Composable () -> Unit,
47+
bottom: @Composable () -> Unit
48+
) {
49+
// Use rememberSaveable when saveKey is provided for persistent state
50+
var splitRatio by if (saveKey != null) {
51+
rememberSaveable(key = saveKey) { mutableStateOf(initialSplitRatio.coerceIn(minRatio, maxRatio)) }
52+
} else {
53+
remember { mutableStateOf(initialSplitRatio.coerceIn(minRatio, maxRatio)) }
54+
}
55+
var isDragging by remember { mutableStateOf(false) }
56+
var containerHeight by remember { mutableStateOf(0) }
57+
58+
// Track hover state for smooth animations
59+
val interactionSource = remember { MutableInteractionSource() }
60+
val isHovered by interactionSource.collectIsHoveredAsState()
61+
62+
// Smooth opacity animation for hover effect
63+
val dividerAlpha by animateFloatAsState(
64+
targetValue = when {
65+
isDragging -> 1f
66+
isHovered -> 0.8f
67+
else -> 0.4f
68+
},
69+
animationSpec = tween(durationMillis = 150),
70+
label = "dividerAlpha"
71+
)
72+
73+
// Smooth scale animation for better visual feedback
74+
val dividerScale by animateFloatAsState(
75+
targetValue = when {
76+
isDragging -> 1.2f
77+
isHovered -> 1.1f
78+
else -> 1f
79+
},
80+
animationSpec = tween(durationMillis = 150),
81+
label = "dividerScale"
82+
)
83+
84+
Layout(
85+
modifier = modifier,
86+
content = {
87+
// Top pane
88+
Box(modifier = Modifier.fillMaxWidth()) {
89+
top()
90+
}
91+
92+
// Enhanced divider with visual feedback
93+
Box(
94+
modifier = Modifier
95+
.height(dividerHeight.dp)
96+
.fillMaxWidth()
97+
.hoverable(interactionSource)
98+
.pointerHoverIcon(PointerIcon.Crosshair) // Better cursor for resizing
99+
) {
100+
// Background layer with subtle gradient effect
101+
Spacer(
102+
modifier = Modifier
103+
.fillMaxSize()
104+
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
105+
)
106+
107+
// Highlighted center strip for grabbing
108+
Spacer(
109+
modifier = Modifier
110+
.height((dividerHeight * dividerScale).dp)
111+
.fillMaxWidth()
112+
.alpha(dividerAlpha)
113+
.background(
114+
when {
115+
isDragging -> MaterialTheme.colorScheme.primary
116+
isHovered -> MaterialTheme.colorScheme.primary.copy(alpha = 0.7f)
117+
else -> MaterialTheme.colorScheme.outlineVariant
118+
}
119+
)
120+
.pointerInput(containerHeight) {
121+
detectDragGestures(
122+
onDragStart = {
123+
isDragging = true
124+
},
125+
onDragEnd = {
126+
isDragging = false
127+
},
128+
onDragCancel = {
129+
isDragging = false
130+
}
131+
) { change, dragAmount ->
132+
change.consume()
133+
134+
// Calculate delta based on total container height for accurate dragging
135+
if (containerHeight > 0) {
136+
val delta = dragAmount.y / containerHeight
137+
138+
// Optimize: Only update if change is meaningful (reduces recomposition)
139+
val newRatio = (splitRatio + delta).coerceIn(minRatio, maxRatio)
140+
if (kotlin.math.abs(newRatio - splitRatio) > 0.001f) {
141+
splitRatio = newRatio
142+
}
143+
}
144+
}
145+
}
146+
)
147+
}
148+
149+
// Bottom pane
150+
Box(modifier = Modifier.fillMaxWidth()) {
151+
bottom()
152+
}
153+
}
154+
) { measurables, constraints ->
155+
// Store container height for accurate drag calculations
156+
containerHeight = constraints.maxHeight
157+
158+
// Pre-calculate dimensions once for better performance
159+
val dividerHeightPx = (dividerHeight.dp).roundToPx()
160+
val availableHeight = constraints.maxHeight - dividerHeightPx
161+
162+
// Calculate pane heights
163+
val topHeight = (availableHeight * splitRatio).roundToInt().coerceAtLeast(0)
164+
val bottomHeight = (availableHeight - topHeight).coerceAtLeast(0)
165+
166+
// Measure all children with fixed constraints
167+
val topPlaceable = measurables[0].measure(
168+
Constraints.fixed(constraints.maxWidth, topHeight)
169+
)
170+
171+
val dividerPlaceable = measurables[1].measure(
172+
Constraints.fixed(constraints.maxWidth, dividerHeightPx)
173+
)
174+
175+
val bottomPlaceable = measurables[2].measure(
176+
Constraints.fixed(constraints.maxWidth, bottomHeight)
177+
)
178+
179+
// Place children in layout
180+
layout(constraints.maxWidth, constraints.maxHeight) {
181+
topPlaceable.placeRelative(0, 0)
182+
dividerPlaceable.placeRelative(0, topHeight)
183+
bottomPlaceable.placeRelative(0, topHeight + dividerHeightPx)
184+
}
185+
}
186+
}

mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/codereview/QualityReviewPanel.kt

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,23 @@ fun QualityReviewPanel(
2727
) {
2828
var expanded by remember { mutableStateOf(true) }
2929

30+
// Filter out test files with parse errors and filter invalid test cases
31+
val validTestFiles = remember(testFiles) {
32+
testFiles
33+
.filter { it.parseError == null } // Exclude files with parse errors
34+
.map { testFile ->
35+
// Filter out invalid test cases (empty names, "unknown", etc.)
36+
val validTestCases = testFile.testCases.filter { testCase ->
37+
testCase.name.isNotBlank() &&
38+
testCase.name.lowercase() != "unknown" &&
39+
!testCase.name.startsWith("<") &&
40+
!testCase.name.startsWith("$")
41+
}
42+
testFile.copy(testCases = validTestCases)
43+
}
44+
.filter { it.testCases.isNotEmpty() } // Only keep files with valid test cases
45+
}
46+
3047
Card(
3148
modifier = modifier.fillMaxWidth(),
3249
colors = CardDefaults.cardColors(
@@ -57,14 +74,14 @@ fun QualityReviewPanel(
5774
modifier = Modifier.size(20.dp)
5875
)
5976
Text(
60-
text = "Quality Review",
77+
text = "Quality Review - Test Coverage",
6178
style = MaterialTheme.typography.titleMedium,
6279
fontWeight = FontWeight.Bold,
6380
color = MaterialTheme.colorScheme.onSurface
6481
)
65-
if (testFiles.isNotEmpty()) {
82+
if (validTestFiles.isNotEmpty()) {
6683
Text(
67-
text = "(${testFiles.size} test ${if (testFiles.size == 1) "file" else "files"})",
84+
text = "(${validTestFiles.size} test ${if (validTestFiles.size == 1) "file" else "files"})",
6885
style = MaterialTheme.typography.bodySmall,
6986
color = MaterialTheme.colorScheme.onSurfaceVariant
7087
)
@@ -86,7 +103,7 @@ fun QualityReviewPanel(
86103
thickness = 1.dp
87104
)
88105

89-
if (testFiles.isEmpty()) {
106+
if (validTestFiles.isEmpty()) {
90107
// No tests found
91108
Box(
92109
modifier = Modifier
@@ -124,31 +141,12 @@ fun QualityReviewPanel(
124141
.padding(12.dp),
125142
verticalArrangement = Arrangement.spacedBy(8.dp)
126143
) {
127-
testFiles.forEach { testFile ->
144+
validTestFiles.forEach { testFile ->
128145
TestFileCard(
129146
testFile = testFile,
130147
onFileClick = onTestFileClick
131148
)
132149
}
133-
134-
// Run tests button (placeholder)
135-
Button(
136-
onClick = { /* TODO: Implement run tests */ },
137-
modifier = Modifier.fillMaxWidth(),
138-
colors = ButtonDefaults.buttonColors(
139-
containerColor = AutoDevColors.Indigo.c600,
140-
contentColor = MaterialTheme.colorScheme.onPrimary
141-
),
142-
enabled = false // Disabled for now
143-
) {
144-
Icon(
145-
imageVector = AutoDevComposeIcons.PlayArrow,
146-
contentDescription = "Run tests",
147-
modifier = Modifier.size(18.dp)
148-
)
149-
Spacer(modifier = Modifier.width(8.dp))
150-
Text("Run Tests (Coming Soon)")
151-
}
152150
}
153151
}
154152
}

mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/codereview/TestFinder.kt

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -100,16 +100,27 @@ abstract class BaseTestFinder : TestFinder {
100100

101101
/**
102102
* Build hierarchical test tree from flat list of code nodes
103+
* Filters out invalid nodes with empty or "unknown" names
103104
*/
104105
private fun buildTestTree(nodes: List<CodeNode>): List<TestCaseNode> {
105-
// Find all test classes (top-level and nested)
106+
// Helper function to check if a name is valid
107+
fun isValidName(name: String): Boolean {
108+
return name.isNotBlank() &&
109+
name.lowercase() != "unknown" &&
110+
!name.startsWith("<") &&
111+
!name.startsWith("$")
112+
}
113+
114+
// Find all test classes (top-level and nested) - filter out invalid ones
106115
val classNodes = nodes.filter {
107-
it.type == CodeElementType.CLASS || it.type == CodeElementType.INTERFACE
116+
(it.type == CodeElementType.CLASS || it.type == CodeElementType.INTERFACE) &&
117+
isValidName(it.name)
108118
}
109119

110-
// Find all test methods/functions
120+
// Find all test methods/functions - filter out invalid ones
111121
val methodNodes = nodes.filter {
112-
it.type == CodeElementType.METHOD || it.type == CodeElementType.FUNCTION
122+
(it.type == CodeElementType.METHOD || it.type == CodeElementType.FUNCTION) &&
123+
isValidName(it.name)
113124
}
114125

115126
// If no classes found, return methods as top-level nodes (for languages like Python)
@@ -125,7 +136,7 @@ abstract class BaseTestFinder : TestFinder {
125136
}
126137

127138
// Build tree: classes with their methods as children
128-
return classNodes.map { classNode ->
139+
return classNodes.mapNotNull { classNode ->
129140
val classMethods = methodNodes.filter { method ->
130141
// Check if method belongs to this class based on line ranges
131142
method.startLine >= classNode.startLine && method.endLine <= classNode.endLine
@@ -140,14 +151,19 @@ abstract class BaseTestFinder : TestFinder {
140151
)
141152
}
142153

143-
TestCaseNode(
144-
name = classNode.name,
145-
type = TestNodeType.CLASS,
146-
children = childMethods,
147-
startLine = classNode.startLine,
148-
endLine = classNode.endLine,
149-
qualifiedName = classNode.qualifiedName
150-
)
154+
// Only include class nodes that have valid children or are valid themselves
155+
if (childMethods.isNotEmpty() || isValidName(classNode.name)) {
156+
TestCaseNode(
157+
name = classNode.name,
158+
type = TestNodeType.CLASS,
159+
children = childMethods,
160+
startLine = classNode.startLine,
161+
endLine = classNode.endLine,
162+
qualifiedName = classNode.qualifiedName
163+
)
164+
} else {
165+
null
166+
}
151167
}
152168
}
153169

0 commit comments

Comments
 (0)