diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt index 7a1225cd392bf..75551a3e68b13 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt @@ -15,7 +15,7 @@ import kotlin.math.max import kotlin.math.min internal class ReviewDiffCanvasDrawing(context: Context) { - private val density = context.resources.displayMetrics.density + val density = context.resources.displayMetrics.density var theme: DiffTheme = DiffTheme.fallback("light") val backgroundPaint = Paint() @@ -70,90 +70,186 @@ internal class ReviewDiffCanvasDrawing(context: Context) { ) } - fun drawDisclosureChevron( + fun drawFileHeader( canvas: Canvas, + row: DiffRow, rect: RectF, - color: Int, - collapsed: Boolean + style: DiffStyle, + state: FileHeaderDrawingState ) { - borderPaint.style = Paint.Style.STROKE - borderPaint.color = color - borderPaint.strokeWidth = 2f * density - borderPaint.strokeCap = Paint.Cap.ROUND - borderPaint.strokeJoin = Paint.Join.ROUND - val path = Path() - if (collapsed) { - path.moveTo(rect.left + rect.width() * 0.4f, rect.top + rect.height() * 0.28f) - path.lineTo(rect.left + rect.width() * 0.6f, rect.centerY()) - path.lineTo(rect.left + rect.width() * 0.4f, rect.bottom - rect.height() * 0.28f) + val top = rect.top.toInt() + val bottom = rect.bottom.toInt() + val width = rect.width().toInt() + backgroundPaint.color = theme.headerBackground + canvas.drawRect(rect, backgroundPaint) + + val chevronRect = fileHeaderChevronRect(top, bottom, style) + val iconRect = fileHeaderIconRect(top, bottom, style) + val checkboxRect = fileHeaderCheckboxRect(top, bottom, width, style) + drawDisclosureChevron( + canvas, + chevronRect, + theme.mutedText, + collapsed = state.collapsed, + ) + drawFileIcon(canvas, iconRect, row.changeType) + if (state.showViewedCheckbox) { + val saved = canvas.saveLayerAlpha(checkboxRect, if (state.viewedCheckboxEnabled) 255 else 102) + drawViewedCheckbox( + canvas, + checkboxRect, + state.viewed, + ) + canvas.restoreToCount(saved) + } + + configureUiPaint( + paint = uiPaint, + color = theme.deleteText, + size = style.fileHeaderMetaFontSizePx, + weight = style.fileHeaderMetaFontWeight, + ) + val deleteText = "-${row.deletions}" + val deleteWidth = uiPaint.measureText(deleteText) + val addText = "+${row.additions}" + val addWidth = uiPaint.measureText(addText) + val countGap = 4f * density + val countsRight = if (row.fileAction != null) { + width - 110f * density + } else if (state.showViewedCheckbox) { + checkboxRect.left - 10f * density } else { - path.moveTo(rect.left + rect.width() * 0.28f, rect.top + rect.height() * 0.42f) - path.lineTo(rect.centerX(), rect.top + rect.height() * 0.62f) - path.lineTo(rect.right - rect.width() * 0.28f, rect.top + rect.height() * 0.42f) + width - 16f * density } - canvas.drawPath(path, borderPaint) - borderPaint.style = Paint.Style.FILL + val countsX = countsRight - deleteWidth - countGap - addWidth + val baseline = centeredBaseline(top, bottom, uiPaint) + canvas.drawText(deleteText, countsX, baseline, uiPaint) + uiPaint.color = theme.addText + canvas.drawText(addText, countsX + deleteWidth + countGap, baseline, uiPaint) + + val pathLayout = fileHeaderPathLayout(row, iconRect, countsX, style) + val maxPathOffset = maxHeaderPathOffset(pathLayout) + val pathOffset = state.pathOffset + .coerceIn(0, maxPathOffset) + drawFileHeaderPath(canvas, row, pathLayout, pathOffset, maxPathOffset) + drawFileHeaderAction(canvas, row, rect) + borderPaint.color = theme.border + borderPaint.strokeWidth = density + canvas.drawLine(0f, bottom - density / 2f, width.toFloat(), bottom - density / 2f, borderPaint) + } + + private fun drawFileHeaderPath( + canvas: Canvas, + row: DiffRow, + pathLayout: FileHeaderPathLayout, + pathOffset: Int, + maxPathOffset: Int + ) { + canvas.save() + canvas.clipRect(pathLayout.rect) + val metrics = uiPaint.fontMetrics + val baseline = pathLayout.rect.centerY() - (metrics.ascent + metrics.descent) / 2f + if (row.renameSegments.isNotEmpty()) { + drawRenamePath(canvas, row, pathLayout, pathOffset, baseline) + } else { + canvas.drawText(pathLayout.displayPath, pathLayout.rect.left - pathOffset, baseline, uiPaint) + } + canvas.restore() + drawFileHeaderPathScrollFades(canvas, pathLayout.rect, pathOffset, maxPathOffset) } - fun drawFileIcon(canvas: Canvas, rect: RectF, changeType: String) { - val color = when (changeType) { - "new" -> theme.addText - "deleted" -> theme.deleteText - else -> theme.hunkText + private fun drawRenamePath( + canvas: Canvas, + row: DiffRow, + pathLayout: FileHeaderPathLayout, + pathOffset: Int, + baseline: Float + ) { + var x = pathLayout.rect.left - pathOffset + for (segment in row.renameSegments) { + val segmentWidth = uiPaint.measureText(segment.text) + if (segment.kind == "removed" || segment.kind == "added") { + backgroundPaint.color = + if (segment.kind == "removed") theme.deleteBackground else theme.addBackground + canvas.drawRect( + x, + pathLayout.rect.top, + x + segmentWidth, + pathLayout.rect.bottom, + backgroundPaint + ) + } + uiPaint.color = if (segment.kind == "context") theme.mutedText else theme.text + canvas.drawText(segment.text, x, baseline, uiPaint) + x += segmentWidth } - borderPaint.style = Paint.Style.STROKE - borderPaint.color = color - borderPaint.strokeWidth = 2f * density - canvas.drawRoundRect(rect, 6f * density, 6f * density, borderPaint) + } - if (changeType == "rename-pure" || changeType == "rename-changed" || changeType == "renamed") { - drawRenameChevronIcon(canvas, rect, color) - } else { - backgroundPaint.color = color - canvas.drawCircle(rect.centerX(), rect.centerY(), 3f * density, backgroundPaint) + private fun drawFileHeaderAction(canvas: Canvas, row: DiffRow, rect: RectF) { + val top = rect.top.toInt() + val bottom = rect.bottom.toInt() + val width = rect.width().toInt() + row.fileAction?.let { label -> + val rect = RectF( + width - 96 * density, + top + 7 * density, + width - 10 * density, + bottom - 7 * density + ) + backgroundPaint.color = theme.border + backgroundPaint.style = Paint.Style.STROKE + backgroundPaint.strokeWidth = density + canvas.drawRoundRect(rect, 6 * density, 6 * density, backgroundPaint) + backgroundPaint.style = Paint.Style.FILL + configureUiPaint( + uiPaint, + if (row.fileActionDisabled) theme.mutedText else theme.hunkText, + 12 * density, + "semibold" + ) + canvas.drawText( + label, + rect.centerX() - uiPaint.measureText(label) / 2, + centeredBaseline(top, bottom, uiPaint), + uiPaint + ) } - borderPaint.style = Paint.Style.FILL } - fun drawViewedCheckbox(canvas: Canvas, rect: RectF, checked: Boolean) { - if (checked) { - backgroundPaint.color = theme.hunkText - canvas.drawRoundRect(rect, 6f * density, 6f * density, backgroundPaint) - } - borderPaint.style = Paint.Style.STROKE - borderPaint.color = if (checked) theme.hunkText else theme.mutedText - borderPaint.strokeWidth = 1.8f * density - canvas.drawRoundRect(rect, 6f * density, 6f * density, borderPaint) - if (checked) { - borderPaint.color = theme.background - borderPaint.strokeWidth = 2f * density - borderPaint.strokeCap = Paint.Cap.ROUND - borderPaint.strokeJoin = Paint.Join.ROUND - val path = Path().apply { - moveTo(rect.left + rect.width() * 0.28f, rect.centerY()) - lineTo(rect.left + rect.width() * 0.44f, rect.bottom - rect.height() * 0.3f) - lineTo(rect.right - rect.width() * 0.25f, rect.top + rect.height() * 0.3f) - } - canvas.drawPath(path, borderPaint) + fun fileHeaderPathLayout( + row: DiffRow, + iconRect: RectF, + countsX: Float, + style: DiffStyle + ): FileHeaderPathLayout { + configureUiPaint( + paint = uiPaint, + color = theme.text, + size = style.fileHeaderFontSizePx, + weight = style.fileHeaderFontWeight, + ) + val pathX = iconRect.right + 10f * density + val pathWidth = max(24f * density, countsX - pathX - 12f * density) + val centerY = iconRect.centerY() + val displayPath = if (row.renameSegments.isNotEmpty()) { + row.renameSegments.joinToString("") { it.text } + } else { + row.filePath } - borderPaint.style = Paint.Style.FILL - } - - fun drawNoticeIcon(canvas: Canvas, rect: RectF, color: Int) { - borderPaint.style = Paint.Style.STROKE - borderPaint.color = color - borderPaint.strokeWidth = 1.7f * density - borderPaint.strokeCap = Paint.Cap.ROUND - canvas.drawOval(rect, borderPaint) - canvas.drawLine( - rect.centerX(), - rect.top + rect.height() * 0.3f, - rect.centerX(), - rect.top + rect.height() * 0.58f, - borderPaint, + return FileHeaderPathLayout( + displayPath = displayPath, + rect = RectF(pathX, centerY - 10f * density, pathX + pathWidth, centerY + 10f * density), ) - borderPaint.style = Paint.Style.FILL - canvas.drawCircle(rect.centerX(), rect.bottom - rect.height() * 0.24f, density, borderPaint) + } + + private fun maxHeaderPathOffset(layout: FileHeaderPathLayout): Int = max( + 0, + ceil(uiPaint.measureText(layout.displayPath) - layout.rect.width()).toInt(), + ) + + private fun centeredBaseline(top: Int, bottom: Int, paint: Paint): Float { + val metrics = paint.fontMetrics + return (top + bottom) / 2f - (metrics.ascent + metrics.descent) / 2f } fun configureUiPaint(paint: Paint, color: Int, size: Float, weight: String) { @@ -467,25 +563,6 @@ internal class ReviewDiffCanvasDrawing(context: Context) { } } - private fun drawRenameChevronIcon(canvas: Canvas, rect: RectF, color: Int) { - borderPaint.style = Paint.Style.STROKE - borderPaint.color = color - borderPaint.strokeWidth = 1.8f * density - borderPaint.strokeCap = Paint.Cap.ROUND - borderPaint.strokeJoin = Paint.Join.ROUND - val chevronWidth = 3.6f * density - val chevronHeight = 8f * density - val gap = 2.4f * density - val startX = rect.centerX() - (chevronWidth * 2f + gap) / 2f - val path = Path() - for (x in listOf(startX, startX + chevronWidth + gap)) { - path.moveTo(x, rect.centerY() - chevronHeight / 2f) - path.lineTo(x + chevronWidth, rect.centerY()) - path.lineTo(x, rect.centerY() + chevronHeight / 2f) - } - canvas.drawPath(path, borderPaint) - } - private fun configureTextPaint(paint: Paint, color: Int, size: Float) { paint.textSize = size paint.color = color @@ -532,3 +609,16 @@ private object ReviewDiffTypefaces { else -> regular } } + +internal data class FileHeaderDrawingState( + val collapsed: Boolean, + val viewed: Boolean, + val showViewedCheckbox: Boolean, + val viewedCheckboxEnabled: Boolean, + val pathOffset: Int +) + +internal data class FileHeaderPathLayout( + val displayPath: String, + val rect: RectF +) diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCommentDrawing.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCommentDrawing.kt new file mode 100644 index 0000000000000..169c796ea766b --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCommentDrawing.kt @@ -0,0 +1,97 @@ +package expo.modules.t3reviewdiff + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF + +internal fun ReviewDiffCanvasDrawing.drawCommentRow( + canvas: Canvas, + row: DiffRow, + rect: RectF, + style: DiffStyle, + collapsed: Boolean +) { + val top = rect.top.toInt() + val bottom = rect.bottom.toInt() + val width = rect.width().toInt() + backgroundPaint.color = theme.background + canvas.drawRect(rect, backgroundPaint) + val cardRect = RectF( + 8f * density, + top + 5f * density, + width - 8f * density, + bottom - 5f * density, + ) + backgroundPaint.color = theme.headerBackground + canvas.drawRoundRect(cardRect, 10f * density, 10f * density, backgroundPaint) + borderPaint.style = Paint.Style.STROKE + borderPaint.color = commentColorAlpha(theme.border, 217) + borderPaint.strokeWidth = density + canvas.drawRoundRect(cardRect, 10f * density, 10f * density, borderPaint) + borderPaint.style = Paint.Style.FILL + + val chevronRect = RectF( + cardRect.left + 10f * density, + cardRect.top + 11f * density, + cardRect.left + 26f * density, + cardRect.top + 27f * density, + ) + drawDisclosureChevron(canvas, chevronRect, theme.mutedText, collapsed) + configureUiPaint( + paint = textPaint, + color = theme.mutedText, + size = style.fileHeaderSubtextFontSizePx, + weight = style.fileHeaderSubtextFontWeight, + ) + val title = + row.commentResolvedSummary ?: "Comment on ${row.commentRangeLabel.ifEmpty { "line" }}" + val titleRight = cardRect.right - (if (row.commentPending) 98f else 20f) * density + canvas.drawText( + ellipsizeDiffText(title, textPaint, titleRight - chevronRect.right), + chevronRect.right + 10f * density, + cardRect.top + 22f * density, + textPaint, + ) + if (row.commentPending) drawCommentPendingBadge(canvas, cardRect) + if (!collapsed) { + textPaint.color = theme.text + val bodyX = cardRect.left + 18f * density + canvas.drawText( + ellipsizeDiffText( + row.commentText.ifEmpty { "Comment" }, + textPaint, + cardRect.right - bodyX - 18f * density + ), + bodyX, + cardRect.top + 58f * density, + textPaint, + ) + } +} + +private fun ReviewDiffCanvasDrawing.drawCommentPendingBadge(canvas: Canvas, cardRect: RectF) { + val badge = RectF( + cardRect.right - 88f * density, + cardRect.top + 7f * density, + cardRect.right - 10f * density, + cardRect.top + 31f * density + ) + val amber = Color.rgb(217, 119, 6) + backgroundPaint.color = commentColorAlpha(amber, 35) + canvas.drawRoundRect(badge, 5f * density, 5f * density, backgroundPaint) + borderPaint.color = amber + borderPaint.style = Paint.Style.STROKE + borderPaint.strokeWidth = density + val cx = badge.left + 12f * density + val cy = badge.top + 12f * density + canvas.drawCircle(cx, cy, 6f * density, borderPaint) + canvas.drawLine(cx, cy - 3f * density, cx, cy, borderPaint) + canvas.drawLine(cx, cy, cx + 3f * density, cy + density, borderPaint) + borderPaint.style = Paint.Style.FILL + textPaint.color = amber + canvas.drawText("Pending", badge.left + 23f * density, badge.top + 17f * density, textPaint) +} + +private fun commentColorAlpha(color: Int, alpha: Int): Int = + Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)) diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffHeaderDrawing.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffHeaderDrawing.kt new file mode 100644 index 0000000000000..5778ddce2aabb --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffHeaderDrawing.kt @@ -0,0 +1,115 @@ +package expo.modules.t3reviewdiff + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF + +internal fun ReviewDiffCanvasDrawing.drawDisclosureChevron( + canvas: Canvas, + rect: RectF, + color: Int, + collapsed: Boolean +) { + borderPaint.style = Paint.Style.STROKE + borderPaint.color = color + borderPaint.strokeWidth = 2f * density + borderPaint.strokeCap = Paint.Cap.ROUND + borderPaint.strokeJoin = Paint.Join.ROUND + val path = Path() + if (collapsed) { + path.moveTo(rect.left + rect.width() * 0.4f, rect.top + rect.height() * 0.28f) + path.lineTo(rect.left + rect.width() * 0.6f, rect.centerY()) + path.lineTo(rect.left + rect.width() * 0.4f, rect.bottom - rect.height() * 0.28f) + } else { + path.moveTo(rect.left + rect.width() * 0.28f, rect.top + rect.height() * 0.42f) + path.lineTo(rect.centerX(), rect.top + rect.height() * 0.62f) + path.lineTo(rect.right - rect.width() * 0.28f, rect.top + rect.height() * 0.42f) + } + canvas.drawPath(path, borderPaint) + borderPaint.style = Paint.Style.FILL +} + +internal fun ReviewDiffCanvasDrawing.drawFileIcon(canvas: Canvas, rect: RectF, changeType: String) { + val color = when (changeType) { + "new" -> theme.addText + "deleted" -> theme.deleteText + else -> theme.hunkText + } + borderPaint.style = Paint.Style.STROKE + borderPaint.color = color + borderPaint.strokeWidth = 2f * density + canvas.drawRoundRect(rect, 6f * density, 6f * density, borderPaint) + + if (changeType == "rename-pure" || changeType == "rename-changed" || changeType == "renamed") { + drawRenameChevronIcon(canvas, rect, color) + } else { + backgroundPaint.color = color + canvas.drawCircle(rect.centerX(), rect.centerY(), 3f * density, backgroundPaint) + } + borderPaint.style = Paint.Style.FILL +} + +internal fun ReviewDiffCanvasDrawing.drawViewedCheckbox( + canvas: Canvas, + rect: RectF, + checked: Boolean +) { + if (checked) { + backgroundPaint.color = theme.hunkText + canvas.drawRoundRect(rect, 6f * density, 6f * density, backgroundPaint) + } + borderPaint.style = Paint.Style.STROKE + borderPaint.color = if (checked) theme.hunkText else theme.mutedText + borderPaint.strokeWidth = 1.8f * density + canvas.drawRoundRect(rect, 6f * density, 6f * density, borderPaint) + if (checked) { + borderPaint.color = theme.background + borderPaint.strokeWidth = 2f * density + borderPaint.strokeCap = Paint.Cap.ROUND + borderPaint.strokeJoin = Paint.Join.ROUND + val path = Path().apply { + moveTo(rect.left + rect.width() * 0.28f, rect.centerY()) + lineTo(rect.left + rect.width() * 0.44f, rect.bottom - rect.height() * 0.3f) + lineTo(rect.right - rect.width() * 0.25f, rect.top + rect.height() * 0.3f) + } + canvas.drawPath(path, borderPaint) + } + borderPaint.style = Paint.Style.FILL +} + +internal fun ReviewDiffCanvasDrawing.drawNoticeIcon(canvas: Canvas, rect: RectF, color: Int) { + borderPaint.style = Paint.Style.STROKE + borderPaint.color = color + borderPaint.strokeWidth = 1.7f * density + borderPaint.strokeCap = Paint.Cap.ROUND + canvas.drawOval(rect, borderPaint) + canvas.drawLine( + rect.centerX(), + rect.top + rect.height() * 0.3f, + rect.centerX(), + rect.top + rect.height() * 0.58f, + borderPaint, + ) + borderPaint.style = Paint.Style.FILL + canvas.drawCircle(rect.centerX(), rect.bottom - rect.height() * 0.24f, density, borderPaint) +} + +private fun ReviewDiffCanvasDrawing.drawRenameChevronIcon(canvas: Canvas, rect: RectF, color: Int) { + borderPaint.style = Paint.Style.STROKE + borderPaint.color = color + borderPaint.strokeWidth = 1.8f * density + borderPaint.strokeCap = Paint.Cap.ROUND + borderPaint.strokeJoin = Paint.Join.ROUND + val chevronWidth = 3.6f * density + val chevronHeight = 8f * density + val gap = 2.4f * density + val startX = rect.centerX() - (chevronWidth * 2f + gap) / 2f + val path = Path() + for (x in listOf(startX, startX + chevronWidth + gap)) { + path.moveTo(x, rect.centerY() - chevronHeight / 2f) + path.lineTo(x + chevronWidth, rect.centerY()) + path.lineTo(x, rect.centerY() + chevronHeight / 2f) + } + canvas.drawPath(path, borderPaint) +} diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffHunkDrawing.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffHunkDrawing.kt new file mode 100644 index 0000000000000..bf5e7a133536c --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffHunkDrawing.kt @@ -0,0 +1,140 @@ +package expo.modules.t3reviewdiff + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import kotlin.math.max + +internal fun ReviewDiffCanvasDrawing.drawHunkRow( + canvas: Canvas, + row: DiffRow, + rect: RectF, + style: DiffStyle, + horizontalOffset: Int +) { + val top = rect.top.toInt() + val bottom = rect.bottom.toInt() + val width = rect.width().toInt() + backgroundPaint.color = theme.hunkBackground + canvas.drawRect(rect, backgroundPaint) + configureMonospacePaint( + color = theme.hunkText, + size = style.hunkFontSizePx, + weight = style.hunkFontWeight, + ) + canvas.save() + if (row.hunkAction != null) { + canvas.clipRect( + 0f, + top.toFloat(), + width - (if (row.hunkRevertAction) 224 else 112) * density, + bottom.toFloat() + ) + } + val gutterEnd = style.changeBarWidthPx + style.gutterWidthPx + val codeX = gutterEnd + style.codePaddingPx - horizontalOffset + canvas.save() + canvas.clipRect(gutterEnd, top.toFloat(), width.toFloat(), bottom.toFloat()) + canvas.drawText( + row.text.ifEmpty { row.content }, + codeX, + centeredBaseline(top, bottom, textPaint), + textPaint, + ) + canvas.restore() + canvas.restore() + row.hunkAction?.let { label -> + drawHunkActionButton( + canvas, + label, + RectF(width - 106 * density, top + 6 * density, width - 6 * density, bottom - 6 * density), + if (row.hunkActionDisabled) theme.mutedText else theme.hunkText, + ) + } + if (row.hunkRevertAction) { + drawHunkActionButton( + canvas, + "Revert hunk", + RectF( + width - 218 * density, + top + 6 * density, + width - 118 * density, + bottom - 6 * density + ), + if (row.hunkRevertActionDisabled) theme.mutedText else theme.deleteText, + ) + } +} + +internal fun ReviewDiffCanvasDrawing.drawNoticeRow( + canvas: Canvas, + row: DiffRow, + rect: RectF, + style: DiffStyle +) { + val top = rect.top.toInt() + val bottom = rect.bottom.toInt() + val width = rect.width().toInt() + backgroundPaint.color = theme.background + canvas.drawRect(rect, backgroundPaint) + val iconSize = 16f * density + val iconRect = RectF( + style.fileHeaderHorizontalPaddingPx + 2f * density, + (top + bottom - iconSize) / 2f, + style.fileHeaderHorizontalPaddingPx + 2f * density + iconSize, + (top + bottom + iconSize) / 2f, + ) + drawNoticeIcon(canvas, iconRect, theme.mutedText) + configureUiPaint( + paint = textPaint, + color = theme.mutedText, + size = style.fileHeaderSubtextFontSizePx, + weight = style.fileHeaderSubtextFontWeight, + ) + val textX = iconRect.right + 10f * density + canvas.drawText( + ellipsizeDiffText(row.text, textPaint, width - textX - style.fileHeaderHorizontalPaddingPx), + textX, + centeredBaseline(top, bottom, textPaint), + textPaint, + ) + borderPaint.color = theme.border + borderPaint.strokeWidth = density + canvas.drawLine(0f, bottom - density / 2f, width.toFloat(), bottom - density / 2f, borderPaint) +} + +private fun ReviewDiffCanvasDrawing.drawHunkActionButton( + canvas: Canvas, + label: String, + rect: RectF, + color: Int +) { + backgroundPaint.color = theme.headerBackground + canvas.drawRoundRect(rect, 6 * density, 6 * density, backgroundPaint) + configureUiPaint(uiPaint, color, 13 * density, "semibold") + val metrics = uiPaint.fontMetrics + canvas.drawText( + label, + rect.centerX() - uiPaint.measureText(label) / 2, + rect.centerY() - (metrics.ascent + metrics.descent) / 2f, + uiPaint + ) +} + +private fun centeredBaseline(top: Int, bottom: Int, paint: Paint): Float { + val metrics = paint.fontMetrics + return (top + bottom) / 2f - (metrics.ascent + metrics.descent) / 2f +} + +internal fun ellipsizeDiffText(value: String, paint: Paint, width: Float): String { + if (paint.measureText(value) <= width) return value + val suffix = "..." + val available = max(0f, width - paint.measureText(suffix)) + var end = paint.breakText(value, true, available, null) + if ( + end > 0 && end < value.length && Character.isSurrogatePair(value[end - 1], value[end]) + ) { + end -= 1 + } + return value.substring(0, end) + suffix +} diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffLineRange.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffLineRange.kt new file mode 100644 index 0000000000000..46333705780ff --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffLineRange.kt @@ -0,0 +1,99 @@ +package expo.modules.t3reviewdiff + +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +/** A native selection stays within its file while its handles cross or skip hunk headers. */ +internal class ReviewDiffLineRange { + var startIndex: Int? = null + private set + var endIndex: Int? = null + private set + var isDragging = false + var selectedRowIds: Set = emptySet() + private set + private var lineIndices = emptyList() + + fun begin(index: Int, rows: List): Boolean { + if (index !in rows.indices) return false + val fileId = rows[index].resolvedFileId + lineIndices = rows.indices.filter { + rows[it].kind == "line" && rows[it].resolvedFileId == fileId + } + isDragging = true + return true + } + + fun dragHandle(movingStart: Boolean): Boolean { + val start = startIndex + val end = endIndex + if (start == null || end == null) return false + startIndex = if (movingStart) max(start, end) else min(start, end) + endIndex = if (movingStart) min(start, end) else max(start, end) + isDragging = true + return true + } + + fun update(start: Int, end: Int, rows: List): Pair? { + if (start !in rows.indices || end !in rows.indices) return null + val validEnd = rows[end].kind == "line" && + rows[start].resolvedFileId == rows[end].resolvedFileId + return if (!validEnd || (startIndex == start && endIndex == end)) { + null + } else { + startIndex = start + endIndex = end + val first = min(start, end) + val last = max(start, end) + selectedRowIds = lineIndices.asSequence() + .filter { it in first..last }.map { rows[it].id }.toSet() + rows[first].id to rows[last].id + } + } + + fun nearestLine(index: Int): Int { + if (lineIndices.isEmpty()) return index + val target = index.coerceIn(lineIndices.first(), lineIndices.last()) + val at = lineIndices.binarySearch(target) + return lineIndices[if (at >= 0) at else (-at - 1).coerceAtMost(lineIndices.lastIndex)] + } + + fun clear() { + isDragging = false + startIndex = null + endIndex = null + lineIndices = emptyList() + selectedRowIds = emptySet() + } + + fun centers( + rows: List, + rowOffsets: IntArray, + verticalOffset: Int, + x: Float + ): Pair, Pair>? = startIndex?.let { start -> + endIndex?.let { end -> + if (start in rows.indices && end in rows.indices) { + val bottomIndex = (max(start, end) + 1).coerceAtMost(rowOffsets.lastIndex) + Pair(x, (rowOffsets[min(start, end)] - verticalOffset).toFloat()) to + Pair(x, (rowOffsets[bottomIndex] - verticalOffset).toFloat()) + } else { + null + } + } + } + + fun handleAt( + x: Float, + y: Float, + radius: Float, + centers: Pair, Pair>? + ): Boolean? = centers?.let { (start, end) -> + when { + abs(x - start.first) <= radius && abs(y - start.second) <= radius -> true + abs(x - end.first) <= radius && abs(y - end.second) <= radius -> false + else -> null + } + } +} diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt index 63ab13dde3d12..4c2054a0f46e4 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt @@ -3,56 +3,14 @@ package expo.modules.t3reviewdiff import expo.modules.kotlin.Promise import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.views.ViewDefinitionBuilder class T3ReviewDiffModule : Module() { override fun definition() = ModuleDefinition { Name("T3ReviewDiffSurface") View(T3ReviewDiffView::class) { - Prop("tokensResetKey") { view: T3ReviewDiffView, tokensResetKey: String -> - view.setTokensResetKey(tokensResetKey) - } - Prop("contentResetKey") { view: T3ReviewDiffView, contentResetKey: String -> - view.setContentResetKey(contentResetKey) - } - Prop("collapsedFileIdsJson") { view: T3ReviewDiffView, collapsedFileIdsJson: String -> - view.setCollapsedFileIdsJson(collapsedFileIdsJson) - } - Prop("viewedFileIdsJson") { view: T3ReviewDiffView, viewedFileIdsJson: String -> - view.setViewedFileIdsJson(viewedFileIdsJson) - } - Prop("selectedRowIdsJson") { view: T3ReviewDiffView, selectedRowIdsJson: String -> - view.setSelectedRowIdsJson(selectedRowIdsJson) - } - Prop("collapsedCommentIdsJson") { view: T3ReviewDiffView, collapsedCommentIdsJson: String -> - view.setCollapsedCommentIdsJson(collapsedCommentIdsJson) - } - Prop("appearanceScheme") { view: T3ReviewDiffView, appearanceScheme: String -> - view.setAppearanceScheme(appearanceScheme) - } - Prop("themeJson") { view: T3ReviewDiffView, themeJson: String -> - view.setThemeJson(themeJson) - } - Prop("styleJson") { view: T3ReviewDiffView, styleJson: String -> - view.setStyleJson(styleJson) - } - Prop("rowHeight") { view: T3ReviewDiffView, rowHeight: Double -> - view.setRowHeight(rowHeight.toFloat()) - } - // Accept the shared prop while deriving Android's scroll range from the drawn text. - Prop("contentWidth") { _: T3ReviewDiffView, _: Double -> } - Prop("contentInsetBottom") { view: T3ReviewDiffView, value: Double -> - view.setContentInsetBottom(value.toFloat()) - } - Prop("scrollEnabled") { view: T3ReviewDiffView, enabled: Boolean -> - view.setScrollEnabled(enabled) - } - Prop("initialRowIndex") { view: T3ReviewDiffView, initialRowIndex: Double -> - view.setInitialRowIndex(initialRowIndex) - } - Prop("initialFileId") { view: T3ReviewDiffView, fileId: String -> - view.setInitialFileId(fileId) - } + reviewProps() Events( "onDebug", @@ -60,6 +18,9 @@ class T3ReviewDiffModule : Module() { "onToggleFile", "onToggleViewedFile", "onPressLine", + "onSelectLineRange", + "onPressHunk", + "onPressFileAction", "onToggleComment", ) @@ -89,4 +50,60 @@ class T3ReviewDiffModule : Module() { } } } + + private fun ViewDefinitionBuilder.reviewProps() { + Prop("tokensResetKey") { view: T3ReviewDiffView, tokensResetKey: String -> + view.setTokensResetKey(tokensResetKey) + } + Prop("contentResetKey") { view: T3ReviewDiffView, contentResetKey: String -> + view.setContentResetKey(contentResetKey) + } + Prop("collapsedFileIdsJson") { view: T3ReviewDiffView, collapsedFileIdsJson: String -> + view.setCollapsedFileIdsJson(collapsedFileIdsJson) + } + Prop("viewedFileIdsJson") { view: T3ReviewDiffView, viewedFileIdsJson: String -> + view.setViewedFileIdsJson(viewedFileIdsJson) + } + Prop("selectedRowIdsJson") { view: T3ReviewDiffView, selectedRowIdsJson: String -> + view.setSelectedRowIdsJson(selectedRowIdsJson) + } + Prop("enableLineRangeSelection") { view: T3ReviewDiffView, enabled: Boolean -> + view.setEnableLineRangeSelection(enabled) + } + Prop("showViewedCheckbox") { view: T3ReviewDiffView, shown: Boolean -> + view.setShowViewedCheckbox(shown) + } + Prop("viewedCheckboxEnabled") { view: T3ReviewDiffView, enabled: Boolean -> + view.setViewedCheckboxEnabled(enabled) + } + Prop("collapsedCommentIdsJson") { view: T3ReviewDiffView, collapsedCommentIdsJson: String -> + view.setCollapsedCommentIdsJson(collapsedCommentIdsJson) + } + Prop("appearanceScheme") { view: T3ReviewDiffView, appearanceScheme: String -> + view.setAppearanceScheme(appearanceScheme) + } + Prop("themeJson") { view: T3ReviewDiffView, themeJson: String -> + view.setThemeJson(themeJson) + } + Prop("styleJson") { view: T3ReviewDiffView, styleJson: String -> + view.setStyleJson(styleJson) + } + Prop("rowHeight") { view: T3ReviewDiffView, rowHeight: Double -> + view.setRowHeight(rowHeight.toFloat()) + } + // Accept the shared prop while deriving Android's scroll range from the drawn text. + Prop("contentWidth") { _: T3ReviewDiffView, _: Double -> } + Prop("contentInsetBottom") { view: T3ReviewDiffView, value: Double -> + view.setContentInsetBottom(value.toFloat()) + } + Prop("scrollEnabled") { view: T3ReviewDiffView, enabled: Boolean -> + view.setScrollEnabled(enabled) + } + Prop("initialRowIndex") { view: T3ReviewDiffView, initialRowIndex: Double -> + view.setInitialRowIndex(initialRowIndex) + } + Prop("initialFileId") { view: T3ReviewDiffView, fileId: String -> + view.setInitialFileId(fileId) + } + } } diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt index 54ff86c611a8e..0117a0c6280ad 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt @@ -29,7 +29,10 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont private val onVisibleFileChange by EventDispatcher() private val onToggleFile by EventDispatcher() private val onToggleViewedFile by EventDispatcher() + private val onPressHunk by EventDispatcher() + private val onPressFileAction by EventDispatcher() private val onPressLine by EventDispatcher() + private val onSelectLineRange by EventDispatcher() private val onToggleComment by EventDispatcher() private var rows: List = emptyList() private var visibleRows: List = emptyList() @@ -73,6 +76,9 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont canvasView.onVerticalAnchorRestored = { verticalScroller.forceFinished(true) } canvasView.onRowTap = { row, gesture, target -> handleRowTap(row, gesture, target) } canvasView.onViewportSizeChanged = { applyPendingInitialScroll() } + canvasView.onSelectLineRange = { start, end -> + onSelectLineRange(mapOf("startRowId" to start, "endRowId" to end)) + } canvasView.onVisibleRowsChanged = { first, last -> onDebug( mapOf( @@ -134,6 +140,18 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont canvasView.selectedRowIds = selectedRowIds } + fun setEnableLineRangeSelection(enabled: Boolean) { + canvasView.enableLineRangeSelection = enabled + } + + fun setShowViewedCheckbox(shown: Boolean) { + canvasView.showViewedCheckbox = shown + } + + fun setViewedCheckboxEnabled(enabled: Boolean) { + canvasView.viewedCheckboxEnabled = enabled + } + fun setCollapsedCommentIdsJson(value: String) { collapsedCommentIds = parseStringSet(value) canvasView.collapsedCommentIds = collapsedCommentIds @@ -268,6 +286,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont @Suppress("NestedBlockDepth", "ReturnCount") override fun onInterceptTouchEvent(event: MotionEvent): Boolean { + if (canvasView.isRangeDragging) return false when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { verticalScroller.forceFinished(true) @@ -423,9 +442,19 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont private fun handleRowTap(row: DiffRow, gesture: String, target: RowTapTarget) { when (row.kind) { + "hunk" -> if (gesture == "tap") handleHunkTap(row, target) "file" -> { if (gesture != "tap") return - if (target == RowTapTarget.VIEWED_CHECKBOX) { + if (target == RowTapTarget.FILE_ACTION) { + if (!row.fileActionDisabled) { + onPressFileAction( + mapOf( + "fileId" to row.resolvedFileId, + "rowId" to row.id + ) + ) + } + } else if (target == RowTapTarget.VIEWED_CHECKBOX) { onToggleViewedFile(mapOf("fileId" to row.resolvedFileId)) } else { onToggleFile(mapOf("fileId" to row.resolvedFileId)) @@ -446,6 +475,14 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont } } + private fun handleHunkTap(row: DiffRow, target: RowTapTarget) { + if (target == RowTapTarget.HUNK_REVERT && !row.hunkRevertActionDisabled) { + onPressHunk(mapOf("rowId" to row.id, "action" to "revert")) + } else if (target == RowTapTarget.HUNK_ACTION && !row.hunkActionDisabled) { + onPressHunk(mapOf("rowId" to row.id, "action" to "stage")) + } + } + @Suppress("ReturnCount") private fun emitVisibleFile(firstVisibleIndex: Int) { if (visibleRows.isEmpty()) return @@ -504,7 +541,10 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont private enum class RowTapTarget { ROW, - VIEWED_CHECKBOX + VIEWED_CHECKBOX, + HUNK_ACTION, + HUNK_REVERT, + FILE_ACTION } private enum class HorizontalPanKind { @@ -534,12 +574,23 @@ internal data class DiffRow( val wordDiffRanges: List, val commentText: String, val commentRangeLabel: String, - val commentSectionTitle: String + val commentSectionTitle: String, + val commentPending: Boolean = false, + val commentResolvedSummary: String? = null, + val renameSegments: List = emptyList(), + val hunkAction: String? = null, + val hunkActionDisabled: Boolean = false, + val hunkRevertAction: Boolean = false, + val hunkRevertActionDisabled: Boolean = false, + val fileAction: String? = null, + val fileActionDisabled: Boolean = false ) { val resolvedFileId: String get() = fileId.ifEmpty { id } val isPlainAscii: Boolean = content.all { it in ' '..'~' } } +internal data class DiffRenameSegment(val text: String, val kind: String) + internal data class DiffWordDiffRange( val start: Int, val end: Int @@ -746,15 +797,39 @@ private class DiffCanvasView(context: Context) : View(context) { override fun onSingleTapUp(event: MotionEvent): Boolean { rowHitAt(event.y)?.let { hit -> - val target = if ( - hit.row.kind == "file" && + if (enableLineRangeSelection && hit.row.kind == "line" && lineRange.startIndex != null) { + val index = rows.indexOf(hit.row) + if (rows[lineRange.startIndex!!].resolvedFileId == hit.row.resolvedFileId) { + updateNativeRange(lineRange.startIndex!!, index) + return@let + } + } + if (hit.row.kind == "line") clearNativeRange() + val target = if (hit.row.kind == "hunk" && hit.row.hunkAction != null && + event.x >= width - 112 * density + ) { + RowTapTarget.HUNK_ACTION + } else if (hit.row.kind == "hunk" && hit.row.hunkRevertAction && + event.x >= width - 224 * density + ) { + RowTapTarget.HUNK_REVERT + } else if (hit.row.kind == "file" && hit.row.fileAction != null && + event.x >= width - 100 * density + ) { + RowTapTarget.FILE_ACTION + } else if ( + showViewedCheckbox && hit.row.kind == "file" && drawing.fileHeaderCheckboxRect( hit.top, hit.bottom, width, style - ).contains(event.x, event.y) + ).apply { + inset(-12f * density, -12f * density) + intersect(0f, hit.top.toFloat(), width.toFloat(), hit.bottom.toFloat()) + }.contains(event.x, event.y) ) { + if (!viewedCheckboxEnabled) return@let RowTapTarget.VIEWED_CHECKBOX } else { RowTapTarget.ROW @@ -765,9 +840,13 @@ private class DiffCanvasView(context: Context) : View(context) { } override fun onLongPress(event: MotionEvent) { - rowHitAt(event.y)?.row - ?.takeIf { it.kind == "line" } - ?.let { onRowTap?.invoke(it, "longPress", RowTapTarget.ROW) } + val row = rowHitAt(event.y)?.row?.takeIf { it.kind == "line" } ?: return + if (enableLineRangeSelection) { + beginNativeRange(rows.indexOf(row)) + parent?.requestDisallowInterceptTouchEvent(true) + } else { + onRowTap?.invoke(row, "longPress", RowTapTarget.ROW) + } } }, ) @@ -784,6 +863,7 @@ private class DiffCanvasView(context: Context) : View(context) { private var viewportEventsPaused = true var rows: List = emptyList() set(value) { + clearNativeRange() field = value fileHeaderIndices = indexFileHeaders(value) headerPathOffsetsByFileId.keys.retainAll( @@ -822,10 +902,33 @@ private class DiffCanvasView(context: Context) : View(context) { rebuildOffsets() } var selectedRowIds: Set = emptySet() + set(value) { + field = value + if (value.isEmpty() && !isRangeDragging) clearNativeRange() + invalidate() + } + var enableLineRangeSelection = false + set(value) { + field = value + if (!value) clearNativeRange() + } + var showViewedCheckbox = true + set(value) { + field = value + clampHeaderPathOffsets() + invalidate() + } + var viewedCheckboxEnabled = true set(value) { field = value invalidate() } + private val lineRange = ReviewDiffLineRange() + var isRangeDragging: Boolean + get() = lineRange.isDragging + private set(value) { + lineRange.isDragging = value + } var theme: DiffTheme = DiffTheme.fallback("light") set(value) { field = value @@ -839,6 +942,7 @@ private class DiffCanvasView(context: Context) : View(context) { rebuildOffsets() } var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null + var onSelectLineRange: ((String, String) -> Unit)? = null var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null var onContentHeightChanged: ((Int) -> Unit)? = null var onVerticalAnchorRestored: (() -> Unit)? = null @@ -883,11 +987,70 @@ private class DiffCanvasView(context: Context) : View(context) { ) } drawStickyFileHeader(canvas, first) + rangeHandleCenters()?.let { (start, end) -> + backgroundPaint.color = Color.rgb(48, 118, 237) + canvas.drawCircle(start.first, start.second, 5f * density, backgroundPaint) + canvas.drawCircle(end.first, end.second, 5f * density, backgroundPaint) + } drawHorizontalScrollIndicator(canvas) emitVisibleRange() } - override fun onTouchEvent(event: MotionEvent): Boolean = gestureDetector.onTouchEvent(event) + override fun onTouchEvent(event: MotionEvent): Boolean { + val startedDrag = if (enableLineRangeSelection && + event.actionMasked == MotionEvent.ACTION_DOWN + ) { + rangeHandleAt(event.x, event.y)?.let { lineRange.dragHandle(it) } == true + } else { + false + } + return if (startedDrag) { + parent?.requestDisallowInterceptTouchEvent(true) + true + } else if (isRangeDragging) { + when (event.actionMasked) { + MotionEvent.ACTION_MOVE -> { + val start = lineRange.startIndex + val index = rowIndexAt(verticalOffset + event.y.toInt()) + if (start != null) updateNativeRange(start, nearestRangeLine(index)) + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + isRangeDragging = false + parent?.requestDisallowInterceptTouchEvent(false) + val cancel = MotionEvent.obtain(event) + cancel.action = MotionEvent.ACTION_CANCEL + gestureDetector.onTouchEvent(cancel) + cancel.recycle() + } + } + true + } else { + gestureDetector.onTouchEvent(event) + } + } + + private fun beginNativeRange(index: Int) { + if (lineRange.begin(index, rows)) updateNativeRange(index, index) + } + + private fun updateNativeRange(start: Int, end: Int) { + lineRange.update(start, end, rows)?.let { (first, last) -> + onSelectLineRange?.invoke(first, last) + invalidate() + } + } + + private fun nearestRangeLine(index: Int): Int = lineRange.nearestLine(index) + + private fun clearNativeRange() = lineRange.clear() + + private fun rangeHandleCenters(): Pair, Pair>? { + val x = max(12f * density, style.changeBarWidthPx + style.gutterWidthPx - 6f * density) + return lineRange.centers(rows, rowOffsets, verticalOffset, x) + } + + private fun rangeHandleAt(x: Float, y: Float): Boolean? = + lineRange.handleAt(x, y, 22f * density, rangeHandleCenters()) fun rowTop(index: Int): Int = rowOffsets[index.coerceIn(0, max(0, rowOffsets.size - 2))] @@ -984,12 +1147,19 @@ private class DiffCanvasView(context: Context) : View(context) { private fun rowHeight(row: DiffRow): Int = when (row.kind) { "file" -> style.fileHeaderHeightPx.toInt() "notice" -> max((style.rowHeightPx * 2f).toInt(), (44 * density).toInt()) - "comment" -> if (collapsedCommentIds.contains(row.id)) { + "comment" -> if (collapsedCommentIds.contains(row.id) || row.commentResolvedSummary != null) { (44 * density).toInt() } else { (124 * density).toInt() } "line" -> codeWrap.rowHeight(row, style.rowHeightPx.toInt()) + "hunk" -> if (row.hunkAction != + null + ) { + max(style.rowHeightPx.toInt(), (44 * density).toInt()) + } else { + style.rowHeightPx.toInt() + } else -> style.rowHeightPx.toInt() }.coerceAtLeast(1) @@ -1042,91 +1212,26 @@ private class DiffCanvasView(context: Context) : View(context) { } private fun drawFileRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { - fill(canvas, theme.headerBackground, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) - - val chevronRect = drawing.fileHeaderChevronRect(top, bottom, style) - val iconRect = drawing.fileHeaderIconRect(top, bottom, style) - val checkboxRect = drawing.fileHeaderCheckboxRect(top, bottom, width, style) - drawing.drawDisclosureChevron( - canvas, - chevronRect, - theme.mutedText, - collapsed = collapsedFileIds.contains(row.resolvedFileId), - ) - drawing.drawFileIcon(canvas, iconRect, row.changeType) - drawing.drawViewedCheckbox( - canvas, - checkboxRect, - viewedFileIds.contains(row.resolvedFileId), - ) - - drawing.configureUiPaint( - paint = boldTextPaint, - color = theme.deleteText, - size = style.fileHeaderMetaFontSizePx, - weight = style.fileHeaderMetaFontWeight, - ) - val deleteText = "-${row.deletions}" - val deleteWidth = boldTextPaint.measureText(deleteText) - val addText = "+${row.additions}" - val addWidth = boldTextPaint.measureText(addText) - val countGap = 4f * density - val countsX = checkboxRect.left - 10f * density - deleteWidth - countGap - addWidth - val baseline = centeredBaseline(top, bottom, boldTextPaint) - canvas.drawText(deleteText, countsX, baseline, boldTextPaint) - boldTextPaint.color = theme.addText - canvas.drawText(addText, countsX + deleteWidth + countGap, baseline, boldTextPaint) - - val pathLayout = fileHeaderPathLayout(row, top, bottom, iconRect, countsX) - val maxPathOffset = maxHeaderPathOffset(pathLayout) - val pathOffset = (headerPathOffsetsByFileId[row.resolvedFileId] ?: 0) - .coerceIn(0, maxPathOffset) - canvas.save() - canvas.clipRect(pathLayout.rect) - canvas.drawText( - pathLayout.displayPath, - pathLayout.rect.left - pathOffset, - centeredBaseline(top, bottom, boldTextPaint), - boldTextPaint, - ) - canvas.restore() - drawing.drawFileHeaderPathScrollFades( + drawing.drawFileHeader( canvas, - pathLayout.rect, - pathOffset, - maxPathOffset, + row, + RectF(0f, top.toFloat(), width.toFloat(), bottom.toFloat()), + style, + FileHeaderDrawingState( + collapsed = collapsedFileIds.contains(row.resolvedFileId), + viewed = viewedFileIds.contains(row.resolvedFileId), + showViewedCheckbox = showViewedCheckbox, + viewedCheckboxEnabled = viewedCheckboxEnabled, + pathOffset = headerPathOffsetsByFileId[row.resolvedFileId] ?: 0, + ), ) - drawBottomBorder(canvas, bottom) } private fun fileHeaderPathLayout( row: DiffRow, - top: Int, - bottom: Int, iconRect: RectF, countsX: Float - ): FileHeaderPathLayout { - drawing.configureUiPaint( - paint = boldTextPaint, - color = theme.text, - size = style.fileHeaderFontSizePx, - weight = style.fileHeaderFontWeight, - ) - val pathX = iconRect.right + 10f * density - val pathWidth = max(24f * density, countsX - pathX - 12f * density) - val centerY = (top + bottom) / 2f - val displayPath = if ( - !row.previousPath.isNullOrEmpty() && row.previousPath != row.filePath - ) { - "${row.previousPath} -> ${row.filePath}" - } else { - row.filePath - } - return FileHeaderPathLayout( - displayPath = displayPath, - rect = RectF(pathX, centerY - 10f * density, pathX + pathWidth, centerY + 10f * density), - ) - } + ): FileHeaderPathLayout = drawing.fileHeaderPathLayout(row, iconRect, countsX, style) private fun maxHeaderPathOffset(fileId: String): Int { val rowIndex = fileHeaderIndices.firstOrNull { rows[it].resolvedFileId == fileId } ?: return 0 @@ -1141,10 +1246,17 @@ private class DiffCanvasView(context: Context) : View(context) { size = style.fileHeaderMetaFontSizePx, weight = style.fileHeaderMetaFontWeight, ) - val countsX = checkboxRect.left - 10f * density - + val countsRight = if (row.fileAction != null) { + width - 110f * density + } else if (showViewedCheckbox) { + checkboxRect.left - 10f * density + } else { + width - 16f * density + } + val countsX = countsRight - boldTextPaint.measureText("-${row.deletions}") - 4f * density - boldTextPaint.measureText("+${row.additions}") - return maxHeaderPathOffset(fileHeaderPathLayout(row, top, bottom, iconRect, countsX)) + return maxHeaderPathOffset(fileHeaderPathLayout(row, iconRect, countsX)) } private fun maxHeaderPathOffset(layout: FileHeaderPathLayout): Int = max( @@ -1172,99 +1284,32 @@ private class DiffCanvasView(context: Context) : View(context) { } private fun drawHunkRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { - fill(canvas, theme.hunkBackground, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) - drawing.configureMonospacePaint( - color = theme.hunkText, - size = style.hunkFontSizePx, - weight = style.hunkFontWeight, + drawing.drawHunkRow( + canvas, + row, + RectF(0f, top.toFloat(), width.toFloat(), bottom.toFloat()), + style, + horizontalOffset, ) - drawScrollableCode(canvas, top, bottom) { codeX -> - canvas.drawText( - row.text.ifEmpty { row.content }, - codeX, - centeredBaseline(top, bottom, textPaint), - textPaint, - ) - } } private fun drawNoticeRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { - fill(canvas, theme.background, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) - val iconSize = 16f * density - val iconRect = RectF( - style.fileHeaderHorizontalPaddingPx + 2f * density, - (top + bottom - iconSize) / 2f, - style.fileHeaderHorizontalPaddingPx + 2f * density + iconSize, - (top + bottom + iconSize) / 2f, - ) - drawing.drawNoticeIcon(canvas, iconRect, theme.mutedText) - drawing.configureUiPaint( - paint = textPaint, - color = theme.mutedText, - size = style.fileHeaderSubtextFontSizePx, - weight = style.fileHeaderSubtextFontWeight, - ) - val textX = iconRect.right + 10f * density - canvas.drawText( - ellipsize(row.text, textPaint, width - textX - style.fileHeaderHorizontalPaddingPx), - textX, - centeredBaseline(top, bottom, textPaint), - textPaint, + drawing.drawNoticeRow( + canvas, + row, + RectF(0f, top.toFloat(), width.toFloat(), bottom.toFloat()), + style, ) - drawBottomBorder(canvas, bottom) } private fun drawCommentRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { - fill(canvas, theme.background, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) - val cardRect = RectF( - 8f * density, - top + 5f * density, - width - 8f * density, - bottom - 5f * density, - ) - backgroundPaint.color = theme.headerBackground - canvas.drawRoundRect(cardRect, 10f * density, 10f * density, backgroundPaint) - borderPaint.style = Paint.Style.STROKE - borderPaint.color = withAlpha(theme.border, 217) - borderPaint.strokeWidth = density - canvas.drawRoundRect(cardRect, 10f * density, 10f * density, borderPaint) - borderPaint.style = Paint.Style.FILL - - val collapsed = collapsedCommentIds.contains(row.id) - val chevronRect = RectF( - cardRect.left + 10f * density, - cardRect.top + 11f * density, - cardRect.left + 26f * density, - cardRect.top + 27f * density, - ) - drawing.drawDisclosureChevron(canvas, chevronRect, theme.mutedText, collapsed) - drawing.configureUiPaint( - paint = textPaint, - color = theme.mutedText, - size = style.fileHeaderSubtextFontSizePx, - weight = style.fileHeaderSubtextFontWeight, - ) - val title = "Comment on ${row.commentRangeLabel.ifEmpty { "line" }}" - canvas.drawText( - ellipsize(title, textPaint, cardRect.right - chevronRect.right - 20f * density), - chevronRect.right + 10f * density, - cardRect.top + 22f * density, - textPaint, + drawing.drawCommentRow( + canvas, + row, + RectF(0f, top.toFloat(), width.toFloat(), bottom.toFloat()), + style, + collapsedCommentIds.contains(row.id) || row.commentResolvedSummary != null, ) - if (!collapsed) { - textPaint.color = theme.text - val bodyX = cardRect.left + 18f * density - canvas.drawText( - ellipsize( - row.commentText.ifEmpty { "Comment" }, - textPaint, - cardRect.right - bodyX - 18f * density - ), - bodyX, - cardRect.top + 58f * density, - textPaint, - ) - } } @Suppress("CyclomaticComplexMethod") @@ -1294,8 +1339,16 @@ private class DiffCanvasView(context: Context) : View(context) { ) } } - val selected = selectedRowIds.contains(row.id) + val selected = selectedRowIds.contains(row.id) || lineRange.selectedRowIds.contains(row.id) if (selected) { + fill( + canvas, + withAlpha(Color.rgb(48, 118, 237), 25), + 0f, + top.toFloat(), + width.toFloat(), + bottom.toFloat() + ) fill( canvas, withAlpha(theme.hunkText, 242), @@ -1402,10 +1455,9 @@ private class DiffCanvasView(context: Context) : View(context) { canvas.drawRect(left, top, right, bottom, backgroundPaint) } - private fun drawBottomBorder(canvas: Canvas, bottom: Int) { - borderPaint.color = theme.border - borderPaint.strokeWidth = density - canvas.drawLine(0f, bottom - density / 2f, width.toFloat(), bottom - density / 2f, borderPaint) + private fun centeredBaseline(top: Int, bottom: Int, paint: Paint): Float { + val metrics = paint.fontMetrics + return (top + bottom) / 2f - (metrics.ascent + metrics.descent) / 2f } private data class StickyFileHeader( @@ -1419,11 +1471,6 @@ private class DiffCanvasView(context: Context) : View(context) { val top: Int, val bottom: Int ) - - private data class FileHeaderPathLayout( - val displayPath: String, - val rect: RectF - ) } private fun centeredBaseline(top: Int, bottom: Int, paint: Paint): Float { @@ -1431,26 +1478,6 @@ private fun centeredBaseline(top: Int, bottom: Int, paint: Paint): Float { return (top + bottom) / 2f - (metrics.ascent + metrics.descent) / 2f } -// A single-line preview never shows this many characters, so longer text is cut before measuring. -private const val ELLIPSIZE_MAX_CHARS = 500 - -private fun ellipsize(value: String, paint: Paint, width: Float): String { - val truncated = value.length > ELLIPSIZE_MAX_CHARS - val capEnd = surrogateSafeEnd(value, ELLIPSIZE_MAX_CHARS) - val text = if (truncated) value.substring(0, capEnd) else value - if (!truncated && paint.measureText(text) <= width) return value - val suffix = "..." - val available = max(0f, width - paint.measureText(suffix)) - val end = surrogateSafeEnd(text, paint.breakText(text, true, available, null)) - return text.substring(0, end) + suffix -} - -private fun surrogateSafeEnd(value: String, end: Int): Int { - val splitsPair = end in 1 until value.length && - Character.isSurrogatePair(value[end - 1], value[end]) - return if (splitsPair) end - 1 else end -} - private fun indexFileHeaders(rows: List): IntArray { val headers = ArrayList() rows.forEachIndexed { index, row -> @@ -1476,10 +1503,22 @@ private fun parseRows(value: String, isCancelled: () -> Boolean): List fileId = row.optString("fileId"), filePath = row.optString("filePath"), previousPath = row.optNullableString("previousPath"), + renameSegments = row.optJSONArray("renameSegments")?.let { parts -> + List(parts.length()) { index -> + val part = parts.getJSONObject(index) + DiffRenameSegment(part.getString("text"), part.getString("kind")) + } + }.orEmpty(), changeType = row.optString("changeType"), additions = row.optInt("additions"), deletions = row.optInt("deletions"), text = row.optString("text"), + hunkAction = row.optNullableString("hunkAction"), + hunkActionDisabled = row.optBoolean("hunkActionDisabled", false), + hunkRevertAction = row.optBoolean("hunkRevertAction", false), + hunkRevertActionDisabled = row.optBoolean("hunkRevertActionDisabled", false), + fileAction = row.optNullableString("fileAction"), + fileActionDisabled = row.optBoolean("fileActionDisabled", false), content = row.optString("content"), change = row.optString("change", "context"), oldLineNumber = row.optNullableInt("oldLineNumber"), @@ -1487,6 +1526,8 @@ private fun parseRows(value: String, isCancelled: () -> Boolean): List wordDiffRanges = row.optJSONArray("wordDiffRanges")?.let(::parseWordDiffRanges).orEmpty(), commentText = row.optString("commentText"), commentRangeLabel = row.optString("commentRangeLabel"), + commentPending = row.optBoolean("commentPending", false), + commentResolvedSummary = row.optNullableString("commentResolvedSummary"), commentSectionTitle = row.optString("commentSectionTitle"), ) } diff --git a/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt b/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt index 0446997423141..e7f710f3685b7 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt @@ -3,6 +3,7 @@ package expo.modules.t3reviewdiff import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color +import android.graphics.Paint import android.graphics.Typeface import android.text.Spanned import android.text.TextPaint @@ -41,6 +42,44 @@ class ReviewDiffCodeLayoutTest { } } + @Test + fun expandedCommentEllipsisBoundsMeasurementAndKeepsSurrogatePairs() { + val comment = "\uD83D\uDE00" + "a".repeat(65534) + val measuringPaint = CountingPaint() + val result = ellipsizeDiffText(comment, measuringPaint, 4f) + assertTrue( + "one comment must not measure progressively shorter prefixes", + measuringPaint.measuredCharacters <= comment.length * 2L + 3, + ) + assertEquals("...", result) + assertEquals("short", ellipsizeDiffText("short", measuringPaint, 5f)) + assertEquals("ab...", ellipsizeDiffText("abcdef", measuringPaint, 5f)) + } + + private class CountingPaint : Paint() { + var measuredCharacters = 0L + + override fun measureText(text: String): Float { + measuredCharacters += text.length + return text.length.toFloat() + } + + override fun measureText(text: String, start: Int, end: Int): Float { + measuredCharacters += end - start + return (end - start).toFloat() + } + + override fun breakText( + text: String, + measureForwards: Boolean, + maxWidth: Float, + measuredWidth: FloatArray? + ): Int { + measuredCharacters += text.length + return maxWidth.toInt().coerceIn(0, text.length) + } + } + private fun assertLinesFit(layout: android.text.StaticLayout, fixture: String, width: Int) { val text = layout.text for (line in 0 until layout.lineCount) { diff --git a/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffLineRangeTest.kt b/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffLineRangeTest.kt new file mode 100644 index 0000000000000..14e3d301b4d3f --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffLineRangeTest.kt @@ -0,0 +1,106 @@ +package expo.modules.t3reviewdiff + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReviewDiffLineRangeTest { + private val rows = listOf( + row("first", "a"), + row("hunk", "a", kind = "hunk"), + row("last", "a"), + row("other", "b"), + ) + + @Test + fun selectsLinesAcrossHunksInEitherDirection() { + val range = ReviewDiffLineRange() + assertTrue(range.begin(0, rows)) + assertEquals("first" to "last", range.update(0, 2, rows)) + assertEquals(setOf("first", "last"), range.selectedRowIds) + assertNull(range.update(0, 2, rows)) + assertEquals("first" to "last", range.update(2, 0, rows)) + assertEquals(setOf("first", "last"), range.selectedRowIds) + } + + @Test + fun refusesOtherFilesAndNonLineEndpointsWithoutLosingTheSelection() { + val range = ReviewDiffLineRange() + range.begin(0, rows) + range.update(0, 2, rows) + assertNull(range.update(0, 3, rows)) + assertNull(range.update(0, 1, rows)) + assertNull(range.update(-1, 2, rows)) + assertEquals(0, range.startIndex) + assertEquals(2, range.endIndex) + assertEquals(setOf("first", "last"), range.selectedRowIds) + } + + @Test + fun draggingTheStartHandleAnchorsTheOtherEndAndSkipsHeaders() { + val range = ReviewDiffLineRange() + range.begin(0, rows) + range.update(0, 2, rows) + range.isDragging = false + assertTrue(range.dragHandle(movingStart = true)) + assertEquals(2, range.startIndex) + assertEquals(0, range.endIndex) + assertEquals(2, range.nearestLine(1)) + assertEquals(2, range.nearestLine(99)) + assertEquals(0, range.nearestLine(-1)) + assertTrue(range.dragHandle(movingStart = false)) + assertEquals(0, range.startIndex) + assertEquals(2, range.endIndex) + } + + @Test + fun handleHitAreasFollowScrolledGeometryAndPreferTheStartWhenTheyOverlap() { + val range = ReviewDiffLineRange() + range.begin(0, rows) + range.update(0, 2, rows) + val centers = range.centers(rows, intArrayOf(0, 20, 40, 60, 80), 10, 12f) + assertEquals(Pair(12f, -10f) to Pair(12f, 50f), centers) + assertEquals(true, range.handleAt(12f, -10f, 22f, centers)) + assertEquals(false, range.handleAt(12f, 50f, 22f, centers)) + assertNull(range.handleAt(100f, 100f, 22f, centers)) + range.update(0, 0, rows) + val overlapping = range.centers(rows, intArrayOf(0, 20, 40, 60, 80), 0, 12f) + assertEquals(true, range.handleAt(12f, 10f, 22f, overlapping)) + } + + @Test + fun clearingRowsRetiresTheRangeAndItsDragHandles() { + val range = ReviewDiffLineRange() + range.begin(0, rows) + range.update(0, 2, rows) + range.clear() + assertNull(range.startIndex) + assertNull(range.endIndex) + assertTrue(range.selectedRowIds.isEmpty()) + assertFalse(range.isDragging) + assertFalse(range.dragHandle(movingStart = true)) + assertNull(range.centers(rows, intArrayOf(0, 20, 40, 60, 80), 0, 12f)) + } + + private fun row(id: String, fileId: String, kind: String = "line") = DiffRow( + kind = kind, + id = id, + fileId = fileId, + filePath = "$fileId.ts", + previousPath = null, + changeType = "modified", + additions = 0, + deletions = 0, + text = "", + content = "", + change = "context", + oldLineNumber = null, + newLineNumber = null, + wordDiffRanges = emptyList(), + commentText = "", + commentRangeLabel = "", + commentSectionTitle = "", + ) +} diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift index 2fcb8a4801809..0634097141437 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift @@ -33,6 +33,18 @@ public class T3ReviewDiffModule: Module { view.setSelectedRowIdsJson(selectedRowIdsJson) } + Prop("enableLineRangeSelection") { (view: T3ReviewDiffView, enabled: Bool) in + view.setEnableLineRangeSelection(enabled) + } + + Prop("showViewedCheckbox") { (view: T3ReviewDiffView, shown: Bool) in + view.setShowViewedCheckbox(shown) + } + + Prop("viewedCheckboxEnabled") { (view: T3ReviewDiffView, enabled: Bool) in + view.setViewedCheckboxEnabled(enabled) + } + Prop("collapsedCommentIdsJson") { (view: T3ReviewDiffView, collapsedCommentIdsJson: String) in view.setCollapsedCommentIdsJson(collapsedCommentIdsJson) } @@ -87,6 +99,9 @@ public class T3ReviewDiffModule: Module { "onToggleFile", "onToggleViewedFile", "onPressLine", + "onSelectLineRange", + "onPressHunk", + "onPressFileAction", "onToggleComment", "onPullToRefresh" ) diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift index a80a391380750..4a361493ec713 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift @@ -7,6 +7,7 @@ private struct ReviewDiffNativeRow: Decodable, Sendable { let fileId: String? let filePath: String? let previousPath: String? + let renameSegments: [ReviewDiffRenameSegment]? let changeType: String? let additions: Int? let deletions: Int? @@ -18,7 +19,20 @@ private struct ReviewDiffNativeRow: Decodable, Sendable { let wordDiffRanges: [ReviewDiffNativeWordDiffRange]? let commentText: String? let commentRangeLabel: String? + let commentPending: Bool? + let commentResolvedSummary: String? let commentSectionTitle: String? + let hunkAction: String? + let hunkActionDisabled: Bool? + let hunkRevertAction: Bool? + let hunkRevertActionDisabled: Bool? + let fileAction: String? + let fileActionDisabled: Bool? +} + +private struct ReviewDiffRenameSegment: Decodable, Sendable { + let text: String + let kind: String } private struct ReviewDiffNativeWordDiffRange: Decodable, Sendable { @@ -349,6 +363,9 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { let onToggleFile = EventDispatcher() let onToggleViewedFile = EventDispatcher() let onPressLine = EventDispatcher() + let onSelectLineRange = EventDispatcher() + let onPressHunk = EventDispatcher() + let onPressFileAction = EventDispatcher() let onToggleComment = EventDispatcher() let onPullToRefresh = EventDispatcher() @@ -380,6 +397,15 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { contentView.onPressLine = { [weak self] payload in self?.onPressLine(payload) } + contentView.onSelectLineRange = { [weak self] payload in + self?.onSelectLineRange(payload) + } + contentView.onPressHunk = { [weak self] rowId, action in + self?.onPressHunk(["rowId": rowId, "action": action]) + } + contentView.onPressFileAction = { [weak self] fileId, rowId in + self?.onPressFileAction(["fileId": fileId, "rowId": rowId]) + } contentView.onToggleComment = { [weak self] commentId in self?.onToggleComment(["commentId": commentId]) } @@ -387,6 +413,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { self?.emitDebug("draw-metrics", metrics) } scrollView.addSubview(contentView) + contentView.attachScrollView(scrollView) } deinit { @@ -671,6 +698,18 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { contentView.selectedRowIds = decodeFileIdSet(selectedRowIdsJson) } + func setEnableLineRangeSelection(_ enabled: Bool) { + contentView.enableLineRangeSelection = enabled + } + + func setShowViewedCheckbox(_ shown: Bool) { + contentView.showViewedCheckbox = shown + } + + func setViewedCheckboxEnabled(_ enabled: Bool) { + contentView.viewedCheckboxEnabled = enabled + } + func setCollapsedCommentIdsJson(_ collapsedCommentIdsJson: String) { contentView.collapsedCommentIds = decodeFileIdSet(collapsedCommentIdsJson) updateContentMetrics() @@ -1058,7 +1097,8 @@ private struct ReviewDiffGeometryConfiguration: Equatable { if row.kind == "file" { return style.fileHeaderHeight } if collapsedFileIds.contains(row.resolvedFileId) { return 0 } if row.kind == "notice" { return max(style.rowHeight * 2, 44) } - if row.kind == "comment" { return collapsedCommentIds.contains(row.id) ? 44 : 124 } + if row.kind == "comment" { return collapsedCommentIds.contains(row.id) || row.commentResolvedSummary != nil ? 44 : 124 } + if row.kind == "hunk", row.hunkAction != nil { return max(style.rowHeight, 44) } return style.rowHeight } } @@ -1095,6 +1135,7 @@ private extension ReviewDiffNativeRow { private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { var rows: [ReviewDiffNativeRow] = [] { didSet { + clearNativeRange() stopHorizontalDeceleration() horizontalOffsetsByFileId.removeAll() headerPathOffsetsByFileId.removeAll() @@ -1102,6 +1143,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { activePanKind = nil codeDecorationVersion += 1 tokenAttributedStringsByRowId.removeAll() + renameAttributedStringsByRowId.removeAll() if let geometry = preparedRowGeometry { preparedRowGeometry = nil applyRowGeometry(geometry) @@ -1116,6 +1158,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { didSet { codeDecorationVersion += 1 tokenAttributedStringsByRowId.removeAll() + renameAttributedStringsByRowId.removeAll() clampHorizontalOffsets() setNeedsDisplayForVisibleBounds() } @@ -1140,9 +1183,22 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { } var selectedRowIds: Set = [] { didSet { + if selectedRowIds.isEmpty && !isRangeDragging { clearNativeRange() } setNeedsDisplayForVisibleBounds() } } + var enableLineRangeSelection = false { + didSet { if !enableLineRangeSelection { clearNativeRange() } } + } + var showViewedCheckbox = true { + didSet { + clampHorizontalOffsets() + setNeedsDisplayForVisibleBounds() + } + } + var viewedCheckboxEnabled = true { + didSet { setNeedsDisplayForVisibleBounds() } + } var collapsedCommentIds: Set = [] { didSet { rebuildRowLayout() @@ -1153,6 +1209,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { didSet { codeDecorationVersion += 1 tokenAttributedStringsByRowId.removeAll() + renameAttributedStringsByRowId.removeAll() rebuildRowLayout() clampHorizontalOffsets() setNeedsDisplayForVisibleBounds() @@ -1174,6 +1231,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { tokenColorsByHex.removeAll() codeDecorationVersion += 1 tokenAttributedStringsByRowId.removeAll() + renameAttributedStringsByRowId.removeAll() setNeedsDisplayForVisibleBounds() } } @@ -1183,6 +1241,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { private var fileHeaderRowIndices: [Int] = [] private var contentWidthsByFileId: [String: CGFloat] = [:] private var tokenColorsByHex: [String: UIColor] = [:] + private var renameAttributedStringsByRowId: [String: NSAttributedString] = [:] private var tokenAttributedStringsByRowId: [String: NSAttributedString] = [:] private var codeCharacterWidth: CGFloat = 8 private var codeWrapColumns: Int? @@ -1209,10 +1268,19 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { private var horizontalVelocity: CGFloat = 0 private var lastDecelerationTimestamp: CFTimeInterval = 0 private var lastDrawMetricsTimestamp: CFTimeInterval = 0 + private weak var parentScrollView: UIScrollView? + private var rangeStartIndex: Int? + private var rangeEndIndex: Int? + private var rangeLineIndices: [Int] = [] + private var nativeSelectedRowIds: Set = [] + private var isRangeDragging = false var isVerticalScrollActive = false var onToggleFile: ((String) -> Void)? var onToggleViewedFile: ((String) -> Void)? var onPressLine: (([String: Any]) -> Void)? + var onSelectLineRange: (([String: Any]) -> Void)? + var onPressHunk: ((String, String) -> Void)? + var onPressFileAction: ((String, String) -> Void)? var onToggleComment: ((String) -> Void)? var onDrawMetrics: (([String: Any]) -> Void)? @@ -1483,6 +1551,17 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { return gesture }() + private lazy var rangeHandlePanGesture: UIPanGestureRecognizer = { + let gesture = UIPanGestureRecognizer(target: self, action: #selector(handleRangeHandlePan(_:))) + gesture.delegate = self + return gesture + }() + + func attachScrollView(_ scrollView: UIScrollView) { + parentScrollView = scrollView + scrollView.panGestureRecognizer.require(toFail: rangeHandlePanGesture) + } + override init(frame: CGRect) { super.init(frame: frame) isOpaque = true @@ -1490,6 +1569,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { addGestureRecognizer(horizontalPanGesture) addGestureRecognizer(tapGesture) addGestureRecognizer(longPressGesture) + addGestureRecognizer(rangeHandlePanGesture) tapGesture.require(toFail: longPressGesture) } @@ -1508,10 +1588,15 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { addGestureRecognizer(horizontalPanGesture) addGestureRecognizer(tapGesture) addGestureRecognizer(longPressGesture) + addGestureRecognizer(rangeHandlePanGesture) tapGesture.require(toFail: longPressGesture) } override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if gestureRecognizer === rangeHandlePanGesture { + return enableLineRangeSelection && rangeHandle(at: gestureRecognizer.location(in: self)) != nil + } + if isRangeDragging { return false } guard gestureRecognizer === horizontalPanGesture else { return true } @@ -1550,12 +1635,23 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { } let row = rows[rowIndex] + if row.kind == "hunk", row.hunkAction != nil { + let rect = CGRect(x: 0, y: rowOffsets[rowIndex] - verticalOffset, width: viewportWidth, height: height(for: row)) + handleHunkTap(row: row, rect: rect, point: point) + return + } if row.kind == "comment" { onToggleComment?(row.id) return } if row.kind == "line" { + if enableLineRangeSelection, let start = rangeStartIndex, + resolvedFileId(for: rows[start]) == resolvedFileId(for: row) { + updateNativeRange(start: start, end: rowIndex) + return + } + clearNativeRange() onPressLine?(linePressPayload(for: row, gesture: "tap")) return } @@ -1569,8 +1665,17 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { handleFileHeaderTap(row: row, rect: rect, point: point) } + private func handleHunkTap(row: ReviewDiffNativeRow, rect: CGRect, point: CGPoint) { + if row.hunkRevertAction == true, hunkRevertRect(in: rect).contains(point) { + if row.hunkRevertActionDisabled != true { onPressHunk?(row.id, "revert") } + } else if hunkActionRect(in: rect).contains(point), row.hunkActionDisabled != true { + onPressHunk?(row.id, "stage") + } + } + @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) { - guard gesture.state == .began else { + if gesture.state == .ended || gesture.state == .cancelled || gesture.state == .failed { + finishRangeDrag() return } @@ -1585,11 +1690,100 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { } let row = rows[rowIndex] - guard row.kind == "line" else { - return + if gesture.state == .began { + guard row.kind == "line" else { return } + if !enableLineRangeSelection { + onPressLine?(linePressPayload(for: row, gesture: "longPress")) + return + } + beginNativeRange(at: rowIndex) + } else if gesture.state == .changed, isRangeDragging, let start = rangeStartIndex { + updateNativeRange(start: start, end: nearestRangeLine(to: rowIndex)) } + } - onPressLine?(linePressPayload(for: row, gesture: "longPress")) + private func beginNativeRange(at index: Int) { + let fileId = resolvedFileId(for: rows[index]) + rangeLineIndices = rows.indices.filter { + rows[$0].kind == "line" && resolvedFileId(for: rows[$0]) == fileId && height(at: $0) > 0 + } + isRangeDragging = true + parentScrollView?.panGestureRecognizer.isEnabled = false + updateNativeRange(start: index, end: index) + } + + private func updateNativeRange(start: Int, end: Int) { + guard rows.indices.contains(start), rows.indices.contains(end), rows[end].kind == "line", + resolvedFileId(for: rows[start]) == resolvedFileId(for: rows[end]) else { return } + if rangeStartIndex == start && rangeEndIndex == end { return } + rangeStartIndex = start + rangeEndIndex = end + let first = min(start, end), last = max(start, end) + nativeSelectedRowIds = Set(rangeLineIndices.filter { $0 >= first && $0 <= last }.map { rows[$0].id }) + onSelectLineRange?(["startRowId": rows[first].id, "endRowId": rows[last].id]) + setNeedsDisplayForVisibleBounds() + } + + private func nearestRangeLine(to index: Int) -> Int { + guard let first = rangeLineIndices.first, let last = rangeLineIndices.last else { return index } + let target = min(max(index, first), last) + var low = 0, high = rangeLineIndices.count + while low < high { + let middle = (low + high) / 2 + if rangeLineIndices[middle] < target { low = middle + 1 } else { high = middle } + } + return rangeLineIndices[min(low, rangeLineIndices.count - 1)] + } + + private func finishRangeDrag() { + isRangeDragging = false + parentScrollView?.panGestureRecognizer.isEnabled = true + } + + private func clearNativeRange() { + finishRangeDrag() + rangeStartIndex = nil + rangeEndIndex = nil + rangeLineIndices = [] + nativeSelectedRowIds = [] + } + + private func rangeHandleCenters() -> (CGPoint, CGPoint)? { + guard let start = rangeStartIndex, let end = rangeEndIndex, + rows.indices.contains(start), rows.indices.contains(end) else { return nil } + let first = min(start, end), last = max(start, end) + guard height(at: first) > 0, height(at: last) > 0 else { return nil } + let x = max(12, stickyWidth - 6) + return ( + CGPoint(x: x, y: rowOffsets[first] - verticalOffset), + CGPoint(x: x, y: rowOffsets[last] + height(at: last) - verticalOffset) + ) + } + + private func rangeHandle(at point: CGPoint) -> Bool? { + guard let (start, end) = rangeHandleCenters() else { return nil } + if hypot(point.x - start.x, point.y - start.y) <= 22 { return true } + if hypot(point.x - end.x, point.y - end.y) <= 22 { return false } + return nil + } + + @objc private func handleRangeHandlePan(_ gesture: UIPanGestureRecognizer) { + if gesture.state == .ended || gesture.state == .cancelled || gesture.state == .failed { + finishRangeDrag() + return + } + let point = gesture.location(in: self) + if gesture.state == .began, let movingStart = rangeHandle(at: point), + let start = rangeStartIndex, let end = rangeEndIndex { + rangeStartIndex = movingStart ? max(start, end) : min(start, end) + rangeEndIndex = movingStart ? min(start, end) : max(start, end) + isRangeDragging = true + parentScrollView?.panGestureRecognizer.isEnabled = false + } + if isRangeDragging, let start = rangeStartIndex, + let index = rowIndex(at: verticalOffset + point.y) { + updateNativeRange(start: start, end: nearestRangeLine(to: index)) + } } private func linePressPayload(for row: ReviewDiffNativeRow, gesture: String) -> [String: Any] { @@ -1616,8 +1810,15 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { let interactiveRects = fileHeaderInteractiveRects(for: row, cardRect: rect) let fileId = resolvedFileId(for: row) - if interactiveRects.checkbox.contains(point) { - onToggleViewedFile?(fileId) + if row.fileAction != nil, fileActionRect(in: rect).contains(point) { + if row.fileActionDisabled != true { onPressFileAction?(fileId, row.id) } + return + } + + // Keep the drawn mark small while giving it a full touch target in the file header. + let checkboxTouchRect = interactiveRects.checkbox.insetBy(dx: -12, dy: -12).intersection(rect) + if showViewedCheckbox && checkboxTouchRect.contains(point) { + if viewedCheckboxEnabled { onToggleViewedFile?(fileId) } return } @@ -2066,6 +2267,12 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { drawnRowCount += 1 } drawStickyFileHeader(context: context) + if let (start, end) = rangeHandleCenters() { + UIColor.systemBlue.setFill() + for point in [start, end] { + UIBezierPath(ovalIn: CGRect(x: point.x - 5, y: point.y - 5, width: 10, height: 10)).fill() + } + } maybeEmitDrawMetrics( drawnRowCount: drawnRowCount, @@ -2159,7 +2366,12 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { drawFileIcon(rect: interactiveRects.icon, changeType: row.changeType) - drawViewedCheckbox(rect: interactiveRects.checkbox, checked: viewedFileIds.contains(fileId)) + if showViewedCheckbox { + context.saveGState() + if !viewedCheckboxEnabled { context.setAlpha(0.4) } + drawViewedCheckbox(rect: interactiveRects.checkbox, checked: viewedFileIds.contains(fileId)) + context.restoreGState() + } let deletions = row.deletions ?? 0 let additions = row.additions ?? 0 @@ -2169,7 +2381,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { let addWidth = textWidth(addText, font: fileHeaderMetaFont) let countsGap = min(style.fileHeaderCountGap, 4) let countsWidth = deleteWidth + countsGap + addWidth - let countsX = interactiveRects.checkbox.minX - 10 - countsWidth + let countsRight = row.fileAction == nil ? interactiveRects.checkbox.minX : fileActionRect(in: cardRect).minX + let countsX = countsRight - 10 - countsWidth drawSingleLineText( deleteText, rect: CGRect(x: countsX, y: centerY - 9, width: deleteWidth, height: 18), @@ -2185,14 +2398,34 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { let pathLayout = fileHeaderPathLayout(for: row, cardRect: cardRect) let pathOffset = horizontalOffset(for: resolvedFileId(for: row), kind: .fileHeaderPath) - drawSingleLineText( - pathLayout.displayPath, - rect: pathLayout.rect, - color: theme.text, - font: fileHeaderFont, - horizontalOffset: pathOffset - ) + if let segments = row.renameSegments, !segments.isEmpty { + drawRenamePath(segments, rowId: row.id, rect: pathLayout.rect, horizontalOffset: pathOffset, context: context) + } else { + drawSingleLineText( + pathLayout.displayPath, + rect: pathLayout.rect, + color: theme.text, + font: fileHeaderFont, + horizontalOffset: pathOffset + ) + } drawFileHeaderPathScrollFade(row, pathRect: pathLayout.rect, horizontalOffset: pathOffset, context: context) + if let action = row.fileAction { + let button = fileActionRect(in: cardRect).insetBy(dx: 4, dy: 7) + context.setStrokeColor(theme.border.cgColor) + context.setLineWidth(1 / traitCollection.displayScale) + UIBezierPath(roundedRect: button, cornerRadius: 6).stroke() + drawCenteredText( + action, + rect: button, + color: row.fileActionDisabled == true ? theme.mutedText : theme.hunkText, + font: .systemFont(ofSize: 12, weight: .semibold) + ) + } + } + + private func fileActionRect(in rect: CGRect) -> CGRect { + CGRect(x: rect.maxX - 100, y: rect.minY, width: 94, height: rect.height) } private func drawNoticeRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) { @@ -2237,7 +2470,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { theme.background.setFill() context.fill(rect) - let isCollapsed = collapsedCommentIds.contains(row.id) + let isCollapsed = collapsedCommentIds.contains(row.id) || row.commentResolvedSummary != nil let cardInset = CGFloat(8) let cardRect = rect.insetBy(dx: cardInset, dy: 5) let cardPath = UIBezierPath(roundedRect: cardRect, cornerRadius: 10) @@ -2250,13 +2483,28 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { let chevronRect = CGRect(x: cardRect.minX + 10, y: cardRect.minY + 11, width: 16, height: 16) drawDisclosureChevron(rect: chevronRect, color: theme.mutedText, collapsed: isCollapsed) - let title = "Comment on \(row.commentRangeLabel ?? "line")" + let pending = row.commentPending == true + if pending { + let badge = CGRect(x: cardRect.maxX - 88, y: cardRect.minY + 7, width: 78, height: 24) + UIColor.systemOrange.withAlphaComponent(0.14).setFill() + UIBezierPath(roundedRect: badge, cornerRadius: 5).fill() + UIColor.systemOrange.setStroke() + let clock = CGRect(x: badge.minX + 6, y: badge.minY + 6, width: 12, height: 12) + let path = UIBezierPath(ovalIn: clock) + path.move(to: CGPoint(x: clock.midX, y: clock.minY + 3)) + path.addLine(to: CGPoint(x: clock.midX, y: clock.midY)) + path.addLine(to: CGPoint(x: clock.midX + 3, y: clock.midY + 1)) + path.lineWidth = 1 + path.stroke() + drawSingleLineText("Pending", rect: CGRect(x: badge.minX + 23, y: badge.minY + 3, width: 52, height: 18), color: UIColor.systemOrange, font: fileHeaderSubtextFont) + } + let title = row.commentResolvedSummary ?? "Comment on \(row.commentRangeLabel ?? "line")" drawSingleLineText( title, rect: CGRect( x: chevronRect.maxX + 10, y: cardRect.minY + 8, - width: max(24, cardRect.width - chevronRect.maxX - 28), + width: max(24, cardRect.maxX - chevronRect.maxX - (pending ? 98 : 28)), height: fileHeaderSubtextFont.lineHeight + 4 ), color: theme.mutedText, @@ -2295,12 +2543,13 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { let addWidth = textWidth("+\(additions)", font: fileHeaderMetaFont) let countsGap = min(style.fileHeaderCountGap, 4) let countsWidth = deleteWidth + countsGap + addWidth - let countsX = interactiveRects.checkbox.minX - 10 - countsWidth + let countsRight = row.fileAction == nil ? interactiveRects.checkbox.minX : fileActionRect(in: cardRect).minX + let countsX = countsRight - 10 - countsWidth let pathX = interactiveRects.icon.maxX + 10 let pathWidth = max(CGFloat(24), countsX - pathX - 12) let displayPath: String - if let previousPath = row.previousPath, previousPath != row.filePath, let filePath = row.filePath { - displayPath = "\(previousPath) -> \(filePath)" + if let segments = row.renameSegments, !segments.isEmpty { + displayPath = segments.map(\.text).joined() } else { displayPath = row.filePath ?? "" } @@ -2319,7 +2568,9 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { let horizontalPadding = style.fileHeaderHorizontalPadding let chevronRect = CGRect(x: cardRect.minX + horizontalPadding, y: centerY - 10, width: 20, height: 20) let iconRect = CGRect(x: chevronRect.maxX + 8, y: centerY - 10, width: 20, height: 20) - let checkboxRect = CGRect(x: cardRect.maxX - horizontalPadding - 20, y: centerY - 10, width: 20, height: 20) + let checkboxRect = showViewedCheckbox + ? CGRect(x: cardRect.maxX - horizontalPadding - 20, y: centerY - 10, width: 20, height: 20) + : CGRect(x: cardRect.maxX - horizontalPadding + 10, y: centerY, width: 0, height: 0) return ReviewDiffFileHeaderInteractiveRects( chevron: chevronRect, icon: iconRect, @@ -2467,7 +2718,13 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { .foregroundColor: color, .paragraphStyle: paragraphStyle, ] - (text as NSString).draw(in: rect, withAttributes: attributes) + (text as NSString).draw( + in: CGRect( + x: rect.minX, y: centeredTextY(in: rect, font: font), + width: rect.width, height: font.lineHeight + ), + withAttributes: attributes + ) } private func drawHunkRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) { @@ -2478,7 +2735,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { context.fill(rect) context.saveGState() - context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: style.rowHeight)) + let actionWidth: CGFloat = row.hunkAction == nil ? 0 : (row.hunkRevertAction == true ? 224 : 112) + context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth - actionWidth), height: rect.height)) drawText( row.text ?? "", rect: CGRect( @@ -2491,6 +2749,31 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { font: hunkFont ) context.restoreGState() + if let action = row.hunkAction { + let button = hunkActionRect(in: rect).insetBy(dx: 6, dy: 6) + theme.headerBackground.setFill() + UIBezierPath(roundedRect: button, cornerRadius: 6).fill() + drawCenteredText(action, rect: button, color: row.hunkActionDisabled == true ? theme.mutedText : theme.hunkText, font: .systemFont(ofSize: 13, weight: .semibold)) + } + if row.hunkRevertAction == true { + let button = hunkRevertRect(in: rect).insetBy(dx: 6, dy: 6) + theme.headerBackground.setFill() + UIBezierPath(roundedRect: button, cornerRadius: 6).fill() + drawCenteredText( + "Revert hunk", + rect: button, + color: row.hunkRevertActionDisabled == true ? theme.mutedText : theme.deleteText, + font: .systemFont(ofSize: 13, weight: .semibold) + ) + } + } + + private func hunkActionRect(in rect: CGRect) -> CGRect { + CGRect(x: viewportWidth - 112, y: rect.minY, width: 112, height: rect.height) + } + + private func hunkRevertRect(in rect: CGRect) -> CGRect { + CGRect(x: viewportWidth - 224, y: rect.minY, width: 112, height: rect.height) } private func drawCodeRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) { @@ -2621,10 +2904,12 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { rect: CGRect, context: CGContext ) { - guard selectedRowIds.contains(row.id) else { + guard selectedRowIds.contains(row.id) || nativeSelectedRowIds.contains(row.id) else { return } + UIColor.systemBlue.withAlphaComponent(0.1).setFill() + context.fill(rect) theme.hunkText.withAlphaComponent(0.95).setFill() context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height)) } @@ -2744,6 +3029,33 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { rect.midY - font.lineHeight / 2 } + private func drawRenamePath( + _ segments: [ReviewDiffRenameSegment], rowId: String, rect: CGRect, + horizontalOffset: CGFloat, context: CGContext + ) { + let text: NSAttributedString + if let cached = renameAttributedStringsByRowId[rowId] { + text = cached + } else { + let result = NSMutableAttributedString(string: "") + for segment in segments { + var attributes: [NSAttributedString.Key: Any] = [ + .font: fileHeaderFont, .ligature: 0, + .foregroundColor: segment.kind == "context" ? theme.mutedText : theme.text + ] + if segment.kind == "removed" { attributes[.backgroundColor] = theme.deleteBackground } + if segment.kind == "added" { attributes[.backgroundColor] = theme.addBackground } + result.append(NSAttributedString(string: segment.text, attributes: attributes)) + } + renameAttributedStringsByRowId[rowId] = result + text = result + } + context.saveGState() + context.clip(to: rect) + text.draw(at: CGPoint(x: rect.minX - horizontalOffset, y: rect.midY - fileHeaderFont.lineHeight / 2)) + context.restoreGState() + } + private func drawSingleLineText( _ text: String, rect: CGRect, diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index b2ad65cf05374..60fdac028d5af 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -1,6 +1,6 @@ import { isDraftThreadRoute } from "./features/threads/thread-route-state"; import { ComponentCatalogScreen } from "./features/development/ComponentCatalogScreen"; -import { PullRequestGuideStopScreen } from "./features/pull-requests/guide/PullRequestGuideScreen"; +import { PullRequestGuideStopScreen } from "./features/pull-requests/guide/PullRequestGuideChapterScreen"; import { PullRequestInlineCommentScreen } from "./features/pull-requests/PullRequestInlineCommentScreen"; import { PullRequestDiscussionScreen } from "./features/pull-requests/PullRequestDiscussionScreen"; import { createWorkspaceStackNavigator } from "./features/layout/createWorkspaceStackNavigator"; @@ -46,13 +46,17 @@ import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRo import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen"; import { AttachmentFileScreen } from "./features/files/AttachmentFileScreen"; import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen"; +import { + ThreadFilesWorkspaceScreen, + ThreadGitWorkspaceScreen, + ThreadFileWorkspaceScreen, +} from "./features/layout/workspace/ThreadWorkspaceScreen"; import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout"; import { HardwareKeyboardCommandOverlay, HardwareKeyboardCommandProvider, } from "./features/keyboard/HardwareKeyboardCommandProvider"; import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentComposerSheet"; -import { ReviewSheet } from "./features/review/ReviewSheet"; import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen"; import { DevicePreviewRouteScreen } from "./features/devices/DevicePreviewRouteScreen"; import { BrowserPreviewRouteScreen } from "./features/browser/BrowserPreviewRouteScreen"; @@ -638,22 +642,27 @@ const RootStackConfig = createWorkspaceStackNavigator({ options: { ...modalRoute({ header: "solid" }), gestureEnabled: false }, }), ThreadReview: createNativeStackScreen({ - screen: ReviewSheet, + screen: ThreadGitWorkspaceScreen, linking: `${THREAD_LINKING_PREFIX}/review`, options: pageRoute(), }), + ThreadReviewFile: createNativeStackScreen({ + screen: ThreadGitWorkspaceScreen, + linking: `${THREAD_LINKING_PREFIX}/review/file`, + options: pageRoute(), + }), ThreadReviewComment: createNativeStackScreen({ screen: ReviewCommentComposerSheet, linking: `${THREAD_LINKING_PREFIX}/review-comment`, options: sheetRoute({ detent: 0.55, android: "fullScreen" }), }), ThreadFiles: createNativeStackScreen({ - screen: ThreadFilesTreeScreen, + screen: ThreadFilesWorkspaceScreen, linking: `${THREAD_LINKING_PREFIX}/files`, options: pageRoute({ title: "Files" }), }), ThreadFile: createNativeStackScreen({ - screen: ThreadFileScreen, + screen: ThreadFileWorkspaceScreen, linking: `${THREAD_LINKING_PREFIX}/files/:path*`, options: pageRoute(), }), diff --git a/apps/mobile/src/components/DiffDisclosureIcons.tsx b/apps/mobile/src/components/DiffDisclosureIcons.tsx new file mode 100644 index 0000000000000..918d0ba1ca378 --- /dev/null +++ b/apps/mobile/src/components/DiffDisclosureIcons.tsx @@ -0,0 +1,50 @@ +import type { IconProps } from "@tabler/icons-react-native/types"; +import { forwardRef } from "react"; +import Svg, { Path } from "react-native-svg"; + +// Match the paired chevrons used by the desktop diff controls. +export const DiffCollapseIcon = forwardRef(function DiffCollapseIcon( + { size = 24, color = "currentColor", strokeWidth = 2, ...props }, + ref, +) { + return ( + + + + + ); +}); + +export const DiffExpandIcon = forwardRef(function DiffExpandIcon( + { size = 24, color = "currentColor", strokeWidth = 2, ...props }, + ref, +) { + return ( + + + + + ); +}); diff --git a/apps/mobile/src/components/ui/AppSymbol.android.tsx b/apps/mobile/src/components/ui/AppSymbol.android.tsx index 7aaeac554417a..6bbf15faef116 100644 --- a/apps/mobile/src/components/ui/AppSymbol.android.tsx +++ b/apps/mobile/src/components/ui/AppSymbol.android.tsx @@ -1,3 +1,7 @@ +import IconPaperclip from "@tabler/icons-react-native/IconPaperclip"; +import IconSquare from "@tabler/icons-react-native/IconSquare"; +import IconSquareCheckFilled from "@tabler/icons-react-native/IconSquareCheckFilled"; +import { DiffCollapseIcon, DiffExpandIcon } from "../DiffDisclosureIcons"; import type { Icon } from "@tabler/icons-react-native/types"; /* Package-root imports eagerly register the entire Tabler icon set in Metro. */ import IconAdjustmentsHorizontal from "@tabler/icons-react-native/IconAdjustmentsHorizontal"; @@ -204,6 +208,11 @@ const ANDROID_ICON_BY_SF_SYMBOL = { "doc.on.doc": IconCopy, "doc.text": IconFileText, "file.diff": IconFileDiff, + "diff.collapse": DiffCollapseIcon, + "diff.expand": DiffExpandIcon, + paperclip: IconPaperclip, + square: IconSquare, + "checkmark.square.fill": IconSquareCheckFilled, ellipsis: IconDots, moon: IconMoon, "ellipsis.circle": IconDotsCircleHorizontal, @@ -299,6 +308,8 @@ const ANDROID_ICON_BY_SF_SYMBOL = { | "git.pull-request.closed" | "git.pull-request.draft" | "git.commit" + | "diff.collapse" + | "diff.expand" | "file.diff" | "robot", Icon @@ -310,6 +321,8 @@ const SF_ICON_LOOKUP: Partial< | "git.pull-request.closed" | "git.pull-request.draft" | "git.commit" + | "diff.collapse" + | "diff.expand" | "file.diff" | "robot", Icon diff --git a/apps/mobile/src/components/ui/AppSymbol.tsx b/apps/mobile/src/components/ui/AppSymbol.tsx index 146a9ccb305e7..dac358ca67d41 100644 --- a/apps/mobile/src/components/ui/AppSymbol.tsx +++ b/apps/mobile/src/components/ui/AppSymbol.tsx @@ -1,3 +1,4 @@ +import { DiffCollapseIcon, DiffExpandIcon } from "../DiffDisclosureIcons"; import IconGitBranch from "@tabler/icons-react-native/IconGitBranch"; import IconGitMerge from "@tabler/icons-react-native/IconGitMerge"; import IconGitCommit from "@tabler/icons-react-native/IconGitCommit"; @@ -24,6 +25,8 @@ const CUSTOM_ICONS = { "git.pull-request.draft": IconGitPullRequestDraft, "git.commit": IconGitCommit, robot: IconRobot, + "diff.collapse": DiffCollapseIcon, + "diff.expand": DiffExpandIcon, }; function AppSymbolView(props: AppSymbolViewProps) { diff --git a/apps/mobile/src/components/ui/SegmentedControl.types.ts b/apps/mobile/src/components/ui/SegmentedControl.types.ts index 472ed46b0e2f2..538643568b65b 100644 --- a/apps/mobile/src/components/ui/SegmentedControl.types.ts +++ b/apps/mobile/src/components/ui/SegmentedControl.types.ts @@ -1,12 +1,16 @@ +import type { AppSymbolName } from "./AppSymbol"; + export interface SegmentedControlProps { readonly options: readonly { readonly value: Value; readonly label: string; + readonly badge?: number; readonly accessibilityLabel?: string; + readonly icon?: AppSymbolName; }[]; readonly selected: Value; readonly onSelect: (value: Value) => void; - /** Compact uses a 32-point capsule; default uses 36 points on iOS and 40 on Android. */ + /** Sizing applies to the non-Material control. */ readonly size?: "default" | "compact"; readonly variant?: "standalone" | "card"; readonly role?: "tab" | "button"; diff --git a/apps/mobile/src/components/ui/Tabs.tsx b/apps/mobile/src/components/ui/Tabs.tsx new file mode 100644 index 0000000000000..931769180c909 --- /dev/null +++ b/apps/mobile/src/components/ui/Tabs.tsx @@ -0,0 +1,102 @@ +import { GlassView } from "expo-glass-effect"; +import { useEffect, useRef, useState, type ComponentRef, type ReactNode } from "react"; +import { Pressable, ScrollView, View } from "react-native"; +import Animated, { useAnimatedStyle, useReducedMotion, withTiming } from "react-native-reanimated"; +import { withUniwind } from "uniwind"; + +import { cn } from "../../lib/cn"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; +import { useAppearancePreferences } from "../../features/settings/appearance/AppearancePreferencesProvider"; +import { AppText } from "./AppText"; + +const TabGlass = withUniwind(GlassView, { style: { fromClassName: "className" } }); + +export interface TabsProps { + readonly options: readonly { + readonly value: Value; + readonly label: string; + /** Rendered after the label, such as diff stats or a count. */ + readonly trailing?: ReactNode; + }[]; + readonly selected: Value; + readonly onSelect: (value: Value) => void; + readonly className?: string; +} + +/** Section navigation. Value pickers use SegmentedControl. */ +export function Tabs(props: TabsProps) { + const { themeAppearance } = useAppearancePreferences(); + const reduceMotion = useReducedMotion(); + const scrollView = useRef>(null); + const [frames, setFrames] = useState>>({}); + const frame = frames[props.selected]; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- keeps the selected tab visible in the native scroll viewport + useEffect(() => { + if (frame) scrollView.current?.scrollTo({ x: frame.x, animated: false }); + }, [frame]); + const duration = reduceMotion ? 0 : 220; + const indicatorStyle = useAnimatedStyle(() => ({ + width: withTiming(frame?.width ?? 0, { duration }), + transform: [{ translateX: withTiming(frame?.x ?? 0, { duration }) }], + })); + + return ( + + + {frame ? ( + + {NATIVE_LIQUID_GLASS_SUPPORTED ? ( + + ) : ( + + )} + + ) : null} + {props.options.map((option) => { + const selected = option.value === props.selected; + return ( + props.onSelect(option.value)} + onLayout={({ nativeEvent: { layout } }) => + setFrames((current) => + current[option.value]?.x === layout.x && + current[option.value]?.width === layout.width + ? current + : { ...current, [option.value]: { x: layout.x, width: layout.width } }, + ) + } + className="min-h-11 shrink-0 flex-row items-center gap-1.5 rounded-full px-3.5 active:opacity-70" + > + + {option.label} + + {option.trailing} + + ); + })} + + + ); +} diff --git a/apps/mobile/src/components/ui/segmentedControlColors.ts b/apps/mobile/src/components/ui/segmentedControlColors.ts index 6b58675726e4f..b612e9b1f006d 100644 --- a/apps/mobile/src/components/ui/segmentedControlColors.ts +++ b/apps/mobile/src/components/ui/segmentedControlColors.ts @@ -28,10 +28,11 @@ export function segmentedControlColors( colors["--color-grouped-card"], ) : flattenThemeColor(themeColorWithAlpha(accent, dark ? 0.1 : 0.07), colors["--color-screen"]); - let selectedColor = colors["--color-secondary"]; + // The thumb must lift off the track: --color-secondary nearly matches it in light themes. + let selectedColor = dark && card ? colors["--color-secondary"] : colors["--color-card"]; if (card && !dark) selectedColor = colors["--color-screen"]; if (dark && (!card || themeId === "t3-code")) { - selectedColor = flattenThemeColor(themeColorWithAlpha(accent, 0.32), colors["--color-screen"]); + selectedColor = flattenThemeColor(themeColorWithAlpha(accent, 0.2), colors["--color-screen"]); } let selectedTextColor = colors[!card && dark ? "--color-foreground" : "--color-secondary-foreground"]; diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts index bf8c6e8e311ca..0662ddd7e2cd9 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts @@ -29,6 +29,10 @@ export interface NativeReviewDiffRow { readonly fileId?: string; readonly filePath?: string; readonly previousPath?: string | null; + readonly renameSegments?: ReadonlyArray<{ + readonly text: string; + readonly kind: "unchanged" | "removed" | "added" | "context"; + }>; readonly changeType?: | "modified" | "new" @@ -46,7 +50,15 @@ export interface NativeReviewDiffRow { readonly wordDiffRanges?: ReadonlyArray; readonly commentText?: string; readonly commentRangeLabel?: string; + readonly commentPending?: boolean; + readonly commentResolvedSummary?: string; readonly commentSectionTitle?: string; + readonly hunkAction?: "Stage hunk" | "Unstage hunk"; + readonly hunkActionDisabled?: boolean; + readonly hunkRevertAction?: boolean; + readonly hunkRevertActionDisabled?: boolean; + readonly fileAction?: "Stage file" | "Unstage file"; + readonly fileActionDisabled?: boolean; } export interface NativeReviewDiffWordDiffRange { @@ -120,6 +132,9 @@ export interface NativeReviewDiffViewProps extends ViewProps { readonly collapsedFileIdsJson?: string; readonly viewedFileIdsJson?: string; readonly selectedRowIdsJson?: string; + readonly enableLineRangeSelection?: boolean; + readonly showViewedCheckbox?: boolean; + readonly viewedCheckboxEnabled?: boolean; readonly collapsedCommentIdsJson?: string; readonly appearanceScheme: "light" | "dark"; readonly themeJson: string; @@ -140,6 +155,12 @@ export interface NativeReviewDiffViewProps extends ViewProps { ) => void; readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void; readonly onToggleViewedFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void; + readonly onPressHunk?: ( + event: NativeSyntheticEvent<{ readonly rowId?: string; readonly action?: "stage" | "revert" }>, + ) => void; + readonly onPressFileAction?: ( + event: NativeSyntheticEvent<{ readonly fileId?: string; readonly rowId?: string }>, + ) => void; readonly onPressLine?: ( event: NativeSyntheticEvent<{ readonly rowId?: string; @@ -150,6 +171,9 @@ export interface NativeReviewDiffViewProps extends ViewProps { readonly change?: "context" | "add" | "delete"; }>, ) => void; + readonly onSelectLineRange?: ( + event: NativeSyntheticEvent<{ readonly startRowId: string; readonly endRowId: string }>, + ) => void; readonly onToggleComment?: (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => void; } diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index 279269b8a13c3..e8d73f2b0251e 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -4,7 +4,7 @@ import { LegendList } from "@legendapp/list/react-native"; import type { ProjectEntry } from "@t3tools/contracts"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { SymbolView } from "../../components/ui/AppSymbol"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { Pressable, RefreshControl, View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; @@ -115,6 +115,8 @@ export function FileTreeBrowser(props: { readonly onPreviewFile?: (path: string) => void; readonly onRefresh: () => void; readonly onSelectFile: (path: string) => void; + readonly toolbar?: ReactNode; + readonly pinnedToolbar?: boolean; }) { const { expandedPaths, setExpandedPaths } = props; const [pendingSelection, setPendingSelection] = useState<{ @@ -212,11 +214,11 @@ export function FileTreeBrowser(props: { const extraData = { expandedPaths, loadingDirectories, selectedPath }; // Keep the list as direct screen content so UIKit can apply header insets. - return ( + const list = ( } renderItem={renderItem} ListHeaderComponent={ <> + {props.pinnedToolbar ? null : props.toolbar} {props.error && props.entries.length > 0 ? ( {props.error} @@ -284,4 +291,12 @@ export function FileTreeBrowser(props: { } /> ); + return props.pinnedToolbar ? ( + + {props.toolbar} + {list} + + ) : ( + list + ); } diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 11fa0a0c7af46..7d0ad5c90980b 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -20,6 +20,7 @@ import { } from "@t3tools/shared/filePreview"; import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; +import { AppTextInput } from "../../components/ui/AppText"; import { ScreenContent } from "../../components/ui/ScreenContent"; import { filePreviewDelimiter, parseDelimitedPreview } from "@t3tools/shared/delimitedPreview"; import { DelimitedFilePreview } from "./DelimitedFilePreview"; @@ -250,6 +251,7 @@ function FileContent(props: { readonly threadId: ThreadId | null; readonly initialLine: number | null; readonly truncated: boolean; + readonly headerReserved?: boolean; readonly onRefresh?: () => Promise | void; }) { // Reopening a mutable host file must not reuse a poster from an earlier visit. @@ -350,7 +352,7 @@ function FileContent(props: { contents={props.fileContents} path={props.relativePath} initialLine={props.initialLine} - headerInsetTop={props.truncated ? 0 : undefined} + headerInsetTop={props.truncated || props.headerReserved ? 0 : undefined} onRefresh={props.onRefresh} /> )} @@ -456,7 +458,12 @@ function FilesToolbarBottomFade() { ); } -export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { +export function ThreadFilesTreeScreen( + props: ThreadFilesRouteScreenProps & { + readonly workspaceTabs?: ReactNode; + readonly workspaceHeader?: ReactNode; + }, +) { const navigation = useNavigation(); const { fileInspector } = useWorkspacePaneState(); const [searchQuery, setSearchQuery] = useState(""); @@ -523,6 +530,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { projectName={projectName} selectedPath={null} onSelectFile={handleSelectFile} + toolbar={props.workspaceTabs} /> ) : null; const handlePreviewFile = (relativePath: string) => { @@ -574,15 +582,17 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { ); } - return ( - - + const content = ( + <> + {props.workspaceHeader ?? ( + + )} + {props.workspaceTabs} + + + + + ) : ( + props.workspaceTabs + ) + } /> - + ); + return {content}; } -export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { +export function ThreadFileScreen( + props: ThreadFileRouteScreenProps & { readonly workspaceTabs?: ReactNode }, +) { const navigation = useNavigation(); const { fileInspector } = useWorkspacePaneState(); const { appearance, setCodeWordBreak } = useAppearancePreferences(); @@ -1029,6 +1063,15 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { actions={fileMenuActions} /> + {props.workspaceTabs ? ( + + {props.workspaceTabs} + + ) : null} fileQuery.refresh()} /> diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index dd95959b30899..9b625accbce0a 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -3,7 +3,7 @@ import { environmentSession } from "../../state/session"; import type { EnvironmentId } from "@t3tools/contracts"; import { AppBar } from "../../components/ui/AppBar"; import { ScreenContent } from "../../components/ui/ScreenContent"; -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { View } from "react-native"; import { useEnvironmentQuery } from "../../state/query"; @@ -19,6 +19,7 @@ export function ThreadFileNavigatorPane(props: { readonly projectName: string; readonly selectedPath: string | null; readonly onSelectFile: (path: string) => void; + readonly toolbar?: ReactNode; }) { const [searchQuery, setSearchQuery] = useState(""); const { themeAppearance: highlightTheme } = useAppearancePreferences(); @@ -74,6 +75,7 @@ export function ThreadFileNavigatorPane(props: { entriesQuery.selectPath(path); props.onSelectFile(path); }} + toolbar={props.toolbar} /> ); diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 19ffe072fb58e..285f70b19aaea 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -82,6 +82,10 @@ import { } from "./workspace/workspace-detail-selection"; import { startOpeningDetail } from "./workspace/useSelectWorkspaceDetail"; import { useWorkspaceTabs } from "./workspace/WorkspaceTabsContext"; +import { + rememberedThreadWorkspaceNavigationState, + useRememberThreadWorkspaceLocation, +} from "./workspace/threadWorkspaceNavigationMemory"; import { HomeListOptionsProvider } from "../home/home-list-options"; import { newTaskDestination } from "../threads/new-task-project-selection"; import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; @@ -269,6 +273,7 @@ function AdaptiveWorkspaceLayoutContent( const workspacePage = Platform.OS === "android" && (androidSettingsOpen || workspace?.state.selectedTab === "usage"); const navigation = useNavigation(); + useRememberThreadWorkspaceLocation(); const [primarySidebarPreferredVisible, setPrimarySidebarPreferredVisible] = useState(true); const [fileInspectorPreferredVisible, setFileInspectorPreferredVisible] = useState(true); const [nativeInspectorWidthFraction, setNativeInspectorWidthFraction] = useState(0.4); @@ -575,7 +580,20 @@ function AdaptiveWorkspaceLayoutContent( tab: detail.name === "Thread" ? "threads" : "pullRequests", }); startOpeningDetail(detail); - navigation.dispatch(selectWorkspaceDetail(detail, sheet)); + const state = rootStackState(navigation); + const remembered = + detail.name === "Thread" && !sheet && detail.params?.environmentId && detail.params.threadId + ? rememberedThreadWorkspaceNavigationState( + state, + { + environmentId: detail.params.environmentId, + threadId: detail.params.threadId, + }, + true, + ) + : null; + if (state && remembered) dispatchRootStackReset(navigation, state, remembered); + else navigation.dispatch(selectWorkspaceDetail(detail, sheet)); }); } const handleSelectThreadRoute = (params: ReactNavigation.RootParamList["Thread"]) => diff --git a/apps/mobile/src/features/layout/workspace/ThreadWorkspaceScreen.tsx b/apps/mobile/src/features/layout/workspace/ThreadWorkspaceScreen.tsx new file mode 100644 index 0000000000000..7a40d3b7b9c52 --- /dev/null +++ b/apps/mobile/src/features/layout/workspace/ThreadWorkspaceScreen.tsx @@ -0,0 +1,244 @@ +import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { useState } from "react"; +import { View } from "react-native"; +import { AppActivityIndicator } from "../../../components/ui/AppActivityIndicator"; +import { + readWorkspacePane, + rememberWorkspacePane, + resolveWorkspacePane, + rememberThreadWorkspaceLocation, + readThreadReviewSelection, + workspaceThreadMemoryKey, + type WorkspacePane, +} from "./workspacePaneMemory"; +import { Tabs } from "../../../components/ui/Tabs"; +import { useEnvironmentQuery } from "../../../state/query"; +import { useEnvironmentPresentation } from "../../../state/presentation"; +import { vcsEnvironment } from "../../../state/vcs"; +import { environmentSession } from "../../../state/session"; +import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; +import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; +import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; +import { useThreadSelection } from "../../../state/use-thread-selection"; +import { ThreadFilesTreeScreen, ThreadFileScreen } from "../../files/ThreadFilesRouteScreen"; +import { PullRequestDiffStats } from "../../pull-requests/PullRequestPresentation"; +import { ThreadGitSplitButton } from "../../threads/threadGitActions"; +import { ReviewHeader, ReviewSheet } from "../../review/ReviewSheet"; + +type Props = StaticScreenProps<{ + readonly environmentId: string; + readonly threadId: string; + readonly path?: string[]; + readonly line?: string; + readonly cwd?: string; + readonly projectName?: string; + readonly reviewSectionId?: string; + readonly reviewFilePath?: string; + readonly reviewWorkingTreeScope?: "staged" | "unstaged"; +}>; + +function ThreadWorkspaceScreen(props: Props & { readonly defaultPane?: WorkspacePane }) { + const navigation = useNavigation(); + const paneKey = workspaceThreadMemoryKey( + props.route.params.environmentId, + props.route.params.threadId, + ); + const [tab, setTab] = useState(() => + props.route.params.path + ? "files" + : props.route.params.reviewSectionId + ? "git" + : (props.defaultPane ?? readWorkspacePane(paneKey)), + ); + const environmentId = EnvironmentId.make(props.route.params.environmentId); + const threadId = ThreadId.make(props.route.params.threadId); + const { selectedThreadCwd: cwd } = useSelectedThreadWorktree(); + const session = useEnvironmentQuery(environmentSession.sessionStateAtom(environmentId)); + const environment = useEnvironmentPresentation(environmentId); + const access = resolveFilesystemReadAccess({ + isCatalogReady: environment.isReady, + connection: environment.presentation?.connection ?? null, + session: session.data, + sessionError: session.error, + }); + const status = useEnvironmentQuery( + access.canReadFiles && cwd !== null + ? vcsEnvironment.status({ environmentId, input: { cwd } }) + : null, + ); + // Status counts each changed path once, including edits that cancel between + // the index and working tree, and updates through the existing Git subscription. + const count = status.data?.workingTree?.files.length; + const statusUnavailable = + !access.isPending && (!access.canReadFiles || cwd === null || status.error !== null); + const selectedTab = resolveWorkspacePane(tab, count ?? (statusUnavailable ? 0 : undefined)); + if (selectedTab !== null && selectedTab !== tab) { + setTab(selectedTab); + rememberWorkspacePane(paneKey, selectedTab); + } + const selectTab = (pane: WorkspacePane) => { + setTab(pane); + rememberWorkspacePane(paneKey, pane); + const params = props.route.params; + if (pane === "files") { + rememberThreadWorkspaceLocation({ + name: params.path ? "ThreadFile" : "ThreadFiles", + params: { + environmentId: params.environmentId, + threadId: params.threadId, + ...(params.path ? { path: params.path } : {}), + ...(params.line ? { line: params.line } : {}), + ...(params.cwd ? { cwd: params.cwd } : {}), + ...(params.projectName ? { projectName: params.projectName } : {}), + }, + }); + } else { + const sectionId = params.reviewSectionId ?? readThreadReviewSelection(paneKey)?.sectionId; + rememberThreadWorkspaceLocation({ + name: + params.reviewFilePath || params.reviewWorkingTreeScope + ? "ThreadReviewFile" + : "ThreadReview", + params: { + environmentId: params.environmentId, + threadId: params.threadId, + ...(sectionId ? { reviewSectionId: sectionId } : {}), + ...(params.reviewFilePath ? { reviewFilePath: params.reviewFilePath } : {}), + ...(params.reviewWorkingTreeScope + ? { reviewWorkingTreeScope: params.reviewWorkingTreeScope } + : {}), + }, + }); + } + }; + const workingTree = status.data?.workingTree; + const { selectedThread } = useThreadSelection(); + const gitState = useSelectedThreadGitState(); + const gitActions = useSelectedThreadGitActions(); + // The selection-based git hooks only apply when this workspace belongs to the selected thread. + const gitActionsAvailable = + selectedThread !== null && String(selectedThread.id) === String(threadId); + const tabs = ( + + 0 || workingTree.deletions > 0) ? ( + + ) : undefined, + }, + { value: "files" as const, label: "Files" }, + ]} + selected={selectedTab ?? "files"} + onSelect={selectTab} + /> + + {gitActionsAvailable ? ( + + ) : null} + + ); + const workspaceHeader = ( + status.refresh()} + onReturnToThread={() => navigation.goBack()} + /> + ); + if (selectedTab === null) + return ( + + {workspaceHeader} + {tabs} + + + + + ); + return selectedTab === "files" ? ( + props.route.params.path ? ( + + ) : ( + + ) + ) : ( + + ); +} + +export function ThreadFilesWorkspaceScreen(props: Props) { + return ( + + ); +} + +export function ThreadGitWorkspaceScreen(props: Props) { + return ( + + ); +} + +export function ThreadFileWorkspaceScreen(props: Parameters[0]) { + const { threadId } = props.route.params; + if (!threadId) return ; + return ( + + ); +} diff --git a/apps/mobile/src/features/layout/workspace/threadWorkspaceNavigationMemory.test.ts b/apps/mobile/src/features/layout/workspace/threadWorkspaceNavigationMemory.test.ts new file mode 100644 index 0000000000000..4f88072f4ca92 --- /dev/null +++ b/apps/mobile/src/features/layout/workspace/threadWorkspaceNavigationMemory.test.ts @@ -0,0 +1,169 @@ +import * as NodeModule from "node:module"; +import { describe, expect, it, vi } from "vite-plus/test"; +import type { + StackNavigationState, + StackRouter as StackRouterType, +} from "@react-navigation/native"; + +vi.mock("@react-navigation/native", () => ({ useNavigation: vi.fn() })); + +import { + isFocusedThreadWorkspace, + rememberFocusedThreadWorkspace, + rememberedThreadWorkspaceNavigationState, +} from "./threadWorkspaceNavigationMemory"; +import { readThreadWorkspaceLocation } from "./workspacePaneMemory"; + +function loadRouters() { + const require = NodeModule.createRequire(import.meta.url); + const nativePackage = require.resolve("@react-navigation/native/package.json"); + const requireFromNative = NodeModule.createRequire(nativePackage); + const corePackage = requireFromNative.resolve("@react-navigation/core/package.json"); + const requireFromCore = NodeModule.createRequire(corePackage); + return requireFromCore("@react-navigation/routers") as { + readonly CommonActions: typeof import("@react-navigation/native").CommonActions; + readonly StackActions: typeof import("@react-navigation/native").StackActions; + readonly StackRouter: typeof StackRouterType; + }; +} + +const { CommonActions, StackActions, StackRouter } = loadRouters(); +const router = StackRouter({}); +const routeNames = [ + "Home", + "Thread", + "ThreadReview", + "ThreadReviewFile", + "ThreadFiles", + "ThreadFile", + "ThreadReviewComment", +]; +const options = { + routeNames, + routeParamList: Object.fromEntries(routeNames.map((name) => [name, undefined])), + routeGetIdList: {}, +}; +type State = StackNavigationState>; +const initial = () => router.getInitialState(options); +function apply(state: State, action: Parameters[1]) { + const next = router.getStateForAction(state, action, options); + if (!next) throw new Error("Router rejected the navigation action"); + return next as State; +} +const identity = (environmentId: string, threadId: string) => ({ environmentId, threadId }); + +function openReviewFile( + state: State, + params: { environmentId: string; threadId: string }, + scope: "staged" | "unstaged" = "unstaged", +) { + let next = apply(state, StackActions.push("Thread", params)); + next = apply(next, StackActions.push("ThreadReview", params)); + return apply( + next, + StackActions.push("ThreadReviewFile", { + ...params, + reviewSectionId: "git:working-tree", + reviewFilePath: "src/rates.ts", + reviewWorkingTreeScope: scope, + }), + ); +} + +describe("native per-thread workspace restoration", () => { + it("restores a diff above its own chat/tree and remembers explicit Back to tree then chat", () => { + const first = identity("restore-env", "thread-a"); + const second = identity("restore-env", "thread-b"); + const opened = openReviewFile(initial(), first, "staged"); + rememberFocusedThreadWorkspace(opened); + let switched = apply(initial(), StackActions.push("Thread", second)); + rememberFocusedThreadWorkspace(switched); + const restored = rememberedThreadWorkspaceNavigationState(switched, first); + expect(restored).not.toBeNull(); + switched = apply(switched, CommonActions.reset(restored!)); + expect(switched.routes.map((route) => route.name)).toEqual([ + "Home", + "Thread", + "ThreadReview", + "ThreadReviewFile", + ]); + expect(switched.routes[1]?.params).toEqual(first); + expect(switched.routes.at(-1)?.params).toMatchObject({ + ...first, + reviewSectionId: "git:working-tree", + reviewFilePath: "src/rates.ts", + reviewWorkingTreeScope: "staged", + }); + + const backToTree = apply(switched, StackActions.pop()); + rememberFocusedThreadWorkspace(backToTree); + const treeRestored = apply( + backToTree, + CommonActions.reset(rememberedThreadWorkspaceNavigationState(backToTree, first)!), + ); + expect(treeRestored.routes.at(-1)?.name).toBe("ThreadReview"); + expect(treeRestored.routes.at(-1)?.params).not.toHaveProperty("reviewFilePath"); + + const backToChat = apply(backToTree, StackActions.pop()); + rememberFocusedThreadWorkspace(backToChat); + expect(backToChat.routes.at(-1)?.name).toBe("Thread"); + expect(rememberedThreadWorkspaceNavigationState(backToChat, first)).toBeNull(); + }); + + it("keeps environments isolated and does not let the comment sheet overwrite its underlying diff", () => { + const first = identity("isolated-env-one", "same-thread-id"); + const second = identity("isolated-env-two", "same-thread-id"); + const opened = openReviewFile(initial(), first); + rememberFocusedThreadWorkspace(opened); + const comment = apply(opened, StackActions.push("ThreadReviewComment", first)); + rememberFocusedThreadWorkspace(comment); + expect(readThreadWorkspaceLocation(first.environmentId, first.threadId)?.name).toBe( + "ThreadReviewFile", + ); + const secondChat = apply(initial(), StackActions.push("Thread", second)); + rememberFocusedThreadWorkspace(secondChat); + expect(rememberedThreadWorkspaceNavigationState(secondChat, second)).toBeNull(); + expect(rememberedThreadWorkspaceNavigationState(secondChat, first)?.routes.at(-1)?.name).toBe( + "ThreadReviewFile", + ); + }); + + it("recognizes the current thread while its viewer is open and reconstructs an Android peer above Home", () => { + const current = identity("android-restore-env", "active-thread"); + const opened = openReviewFile(initial(), current); + rememberFocusedThreadWorkspace(opened); + expect(isFocusedThreadWorkspace(opened, current)).toBe(true); + expect(isFocusedThreadWorkspace(opened, identity("android-restore-env", "other-thread"))).toBe( + false, + ); + const peer = rememberedThreadWorkspaceNavigationState(opened, current, true); + expect(peer?.routes.map((route) => route.name)).toEqual([ + "Home", + "Thread", + "ThreadReview", + "ThreadReviewFile", + ]); + }); + + it("restores all-files scope without turning it into a single-file view", () => { + const params = identity("scope-restore-env", "all-files-thread"); + const opened = apply( + initial(), + StackActions.push("ThreadReviewFile", { + ...params, + reviewSectionId: "git:working-tree", + reviewWorkingTreeScope: "unstaged", + }), + ); + rememberFocusedThreadWorkspace(opened); + const restored = apply( + initial(), + CommonActions.reset(rememberedThreadWorkspaceNavigationState(initial(), params)!), + ); + expect(restored.routes.at(-1)?.params).toMatchObject({ + ...params, + reviewWorkingTreeScope: "unstaged", + }); + expect(restored.routes.at(-1)?.params).not.toHaveProperty("reviewFilePath"); + }); +}); diff --git a/apps/mobile/src/features/layout/workspace/threadWorkspaceNavigationMemory.ts b/apps/mobile/src/features/layout/workspace/threadWorkspaceNavigationMemory.ts new file mode 100644 index 0000000000000..3d713630d328b --- /dev/null +++ b/apps/mobile/src/features/layout/workspace/threadWorkspaceNavigationMemory.ts @@ -0,0 +1,108 @@ +import { useEffect } from "react"; +import { useNavigation, type NavigationState } from "@react-navigation/native"; +import { + readThreadWorkspaceLocation, + rememberThreadWorkspaceLocation, + type ThreadWorkspaceLocation, +} from "./workspacePaneMemory"; + +const locationNames = new Set([ + "Thread", + "ThreadReview", + "ThreadReviewFile", + "ThreadFiles", + "ThreadFile", +]); + +/** Only the focused destination writes memory; a hidden chat cannot overwrite its diff. */ +export function rememberFocusedThreadWorkspace( + state: Pick | undefined, +) { + if (!state) return; + const route = state.routes[state.index]; + if (!route || !locationNames.has(route.name)) return; + const params = route.params as Readonly> | undefined; + if (typeof params?.environmentId !== "string" || typeof params.threadId !== "string") return; + const stringParam = (key: string) => (typeof params[key] === "string" ? params[key] : undefined); + const scope = params.reviewWorkingTreeScope; + const path = params.path; + rememberThreadWorkspaceLocation({ + name: route.name as ThreadWorkspaceLocation["name"], + params: { + environmentId: params.environmentId, + threadId: params.threadId, + ...(typeof params.reviewSectionId === "string" + ? { reviewSectionId: params.reviewSectionId } + : {}), + ...(typeof params.reviewFilePath === "string" + ? { reviewFilePath: params.reviewFilePath } + : {}), + ...(scope === "staged" || scope === "unstaged" ? { reviewWorkingTreeScope: scope } : {}), + ...(Array.isArray(path) && path.every((part) => typeof part === "string") ? { path } : {}), + ...(stringParam("line") ? { line: String(params.line) } : {}), + ...(stringParam("cwd") ? { cwd: String(params.cwd) } : {}), + ...(stringParam("projectName") ? { projectName: String(params.projectName) } : {}), + }, + }); +} + +export function useRememberThreadWorkspaceLocation() { + const navigation = useNavigation(); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Subscribe to native navigation changes. + useEffect(() => { + // Route state also observes explicit Back: returning to chat remembers chat. + const remember = () => rememberFocusedThreadWorkspace(navigation.getState()); + remember(); + return navigation.addListener("state", remember); + }, [navigation]); +} + +/** Restore an inspector above its own chat/tree, keeping ordinary Back navigation intact. */ +export function rememberedThreadWorkspaceNavigationState( + state: NavigationState | undefined, + params: { readonly environmentId: string; readonly threadId: string }, + peer = false, +) { + if (!state) return null; + const location = readThreadWorkspaceLocation(params.environmentId, params.threadId); + if (!location || location.name === "Thread") return null; + const firstThread = state.routes.findIndex((route) => locationNames.has(route.name)); + const home = state.routes.find((route) => route.name === "Home"); + const prefix = peer + ? home + ? [home] + : [{ name: "Home" }] + : firstThread >= 0 + ? state.routes.slice(0, firstThread) + : state.routes.slice(0, state.index + 1); + const rememberedParams = { ...location.params, ...params }; + const tree = + location.name === "ThreadReviewFile" + ? [{ name: "ThreadReview", params }] + : location.name === "ThreadFile" + ? [{ name: "ThreadFiles", params }] + : []; + const routes = [ + ...prefix, + { name: "Thread", params }, + ...tree, + { name: location.name, params: rememberedParams }, + ]; + return { ...state, index: routes.length - 1, routes }; +} + +/** Selecting the current sidebar thread should leave its nested viewer alone. */ +export function isFocusedThreadWorkspace( + state: Pick | undefined, + params: { readonly environmentId: string; readonly threadId: string }, +) { + if (!state) return false; + const route = state.routes[state.index]; + const current = route?.params as Readonly> | undefined; + return Boolean( + route && + locationNames.has(route.name) && + current?.environmentId === params.environmentId && + current.threadId === params.threadId, + ); +} diff --git a/apps/mobile/src/features/layout/workspace/workspacePaneMemory.test.ts b/apps/mobile/src/features/layout/workspace/workspacePaneMemory.test.ts new file mode 100644 index 0000000000000..d2c0b0142295b --- /dev/null +++ b/apps/mobile/src/features/layout/workspace/workspacePaneMemory.test.ts @@ -0,0 +1,26 @@ +import { expect, it } from "vite-plus/test"; +import { + readWorkspacePane, + rememberWorkspacePane, + resolveWorkspacePane, +} from "./workspacePaneMemory"; + +it("waits for the initial status and chooses Git for changed files or Files for a clean tree", () => { + expect(resolveWorkspacePane(null, undefined)).toBeNull(); + expect(resolveWorkspacePane(null, 4)).toBe("git"); + expect(resolveWorkspacePane(null, 0)).toBe("files"); +}); + +it("keeps the selected pane across staging updates and thread changes", () => { + const first = "mobile-pane-thread-one"; + const second = "mobile-pane-thread-two"; + expect(readWorkspacePane(first)).toBeNull(); + const initial = resolveWorkspacePane(null, 3)!; + rememberWorkspacePane(first, initial); + expect(resolveWorkspacePane(readWorkspacePane(first), 0)).toBe("git"); + rememberWorkspacePane(first, "files"); + rememberWorkspacePane(second, "git"); + expect(resolveWorkspacePane(readWorkspacePane(first), 7)).toBe("files"); + expect(readWorkspacePane(second)).toBe("git"); + expect(readWorkspacePane(first)).toBe("files"); +}); diff --git a/apps/mobile/src/features/layout/workspace/workspacePaneMemory.ts b/apps/mobile/src/features/layout/workspace/workspacePaneMemory.ts new file mode 100644 index 0000000000000..176ed127b1962 --- /dev/null +++ b/apps/mobile/src/features/layout/workspace/workspacePaneMemory.ts @@ -0,0 +1,85 @@ +import { Atom } from "effect/reactivity"; +import { appAtomRegistry } from "../../../state/atom-registry"; + +export type WorkspacePane = "git" | "files"; +const rememberedPane = Atom.family((key: string) => + Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel(`mobile:workspace:pane:${key}`), + ), +); + +export function readWorkspacePane(key: string) { + return appAtomRegistry.get(rememberedPane(key)); +} +export function rememberWorkspacePane(key: string, pane: WorkspacePane) { + appAtomRegistry.set(rememberedPane(key), pane); +} + +/** The first ready status chooses a pane once; later Git updates preserve that choice. */ +export function resolveWorkspacePane( + current: WorkspacePane | null, + changedFiles: number | undefined, +): WorkspacePane | null { + if (current !== null) return current; + return changedFiles === undefined ? null : changedFiles > 0 ? "git" : "files"; +} + +export interface ThreadReviewSelection { + readonly sectionId: string; + readonly path?: string; + readonly scope?: "staged" | "unstaged"; +} + +export interface ThreadWorkspaceLocation { + readonly name: "Thread" | "ThreadReview" | "ThreadReviewFile" | "ThreadFiles" | "ThreadFile"; + readonly params: { + readonly environmentId: string; + readonly threadId: string; + readonly reviewSectionId?: string; + readonly reviewFilePath?: string; + readonly reviewWorkingTreeScope?: "staged" | "unstaged"; + readonly path?: string[]; + readonly line?: string; + readonly cwd?: string; + readonly projectName?: string; + }; +} + +const rememberedLocation = Atom.family((key: string) => + Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel(`mobile:workspace:location:${key}`), + ), +); +const rememberedReviewSelection = Atom.family((key: string) => + Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel(`mobile:workspace:review-selection:${key}`), + ), +); + +export function workspaceThreadMemoryKey(environmentId: string, threadId: string) { + return JSON.stringify([environmentId, threadId]); +} + +export function readThreadWorkspaceLocation(environmentId: string, threadId: string) { + return appAtomRegistry.get(rememberedLocation(workspaceThreadMemoryKey(environmentId, threadId))); +} + +export function rememberThreadWorkspaceLocation(location: ThreadWorkspaceLocation) { + const key = workspaceThreadMemoryKey(location.params.environmentId, location.params.threadId); + const atom = rememberedLocation(key); + if (JSON.stringify(appAtomRegistry.get(atom)) !== JSON.stringify(location)) + appAtomRegistry.set(atom, location); +} + +export function readThreadReviewSelection(key: string) { + return appAtomRegistry.get(rememberedReviewSelection(key)); +} + +export function rememberThreadReviewSelection(key: string, selection: ThreadReviewSelection) { + const atom = rememberedReviewSelection(key); + if (JSON.stringify(appAtomRegistry.get(atom)) !== JSON.stringify(selection)) + appAtomRegistry.set(atom, selection); +} diff --git a/apps/mobile/src/features/pull-requests/PullRequestActivityFilters.tsx b/apps/mobile/src/features/pull-requests/PullRequestActivityFilters.tsx index e1e9383f44e8e..7a734299b50c3 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestActivityFilters.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestActivityFilters.tsx @@ -2,7 +2,7 @@ import { Pressable, View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView } from "../../components/ui/AppSymbol"; import { Menu } from "../../components/ui/Menu"; -import type { ActivityFilter } from "./pullRequestActivityFilter"; +import type { ActivityFilter } from "@t3tools/client-runtime/review-activity"; export function PullRequestActivityFilters({ value, diff --git a/apps/mobile/src/features/pull-requests/PullRequestAttachments.tsx b/apps/mobile/src/features/pull-requests/PullRequestAttachments.tsx new file mode 100644 index 0000000000000..c66be837e5c6f --- /dev/null +++ b/apps/mobile/src/features/pull-requests/PullRequestAttachments.tsx @@ -0,0 +1,164 @@ +import { useAtomValue } from "@effect/atom-react"; +import { publishReviewAttachment } from "@t3tools/client-runtime/review-attachments"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import * as Option from "effect/Option"; +import * as DocumentPicker from "expo-document-picker"; +import { File, UploadType } from "expo-file-system"; +import { useEffect, useRef, useState } from "react"; +import { ActivityIndicator, Alert, Pressable } from "react-native"; +import { SymbolView } from "../../components/ui/AppSymbol"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { attachmentEnvironment } from "../../state/attachments"; +import { composerPullRequests } from "../../state/pull-requests"; +import { environmentSession } from "../../state/session"; +import { useEnvironmentQuery } from "../../state/query"; +import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; + +export function PullRequestAttachments({ + params, + disabled, + onAppend, + onBusyChange, +}: { + params: PullRequestParams; + disabled?: boolean; + onAppend: (markdown: string) => void; + onBusyChange?: (busy: boolean) => void; +}) { + const target = pullRequestTarget(params); + const detail = useEnvironmentQuery(composerPullRequests.detail(target)); + const allowed = useAtomValue( + composerPullRequests.uploadAttachment.permissionAtom(target.environmentId), + ); + const [busy, setBusy] = useState(false); + const lock = useRef(false); + const scopeKey = JSON.stringify([ + params.environmentId, + params.projectId, + params.host, + params.repository, + params.number, + ]); + const transfer = useRef<{ + scopeKey: string; + cancelled: boolean; + abort?: (() => void) | undefined; + } | null>(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Abort in-flight uploads when their destination changes or unmounts. + useEffect( + () => () => { + if (transfer.current?.scopeKey === scopeKey) { + transfer.current.cancelled = true; + transfer.current.abort?.(); + } + }, + [scopeKey], + ); + const upload = async () => { + if (lock.current || disabled) return; + lock.current = true; + const session = { scopeKey, cancelled: false, abort: undefined as (() => void) | undefined }; + transfer.current = session; + setBusy(true); + onBusyChange?.(true); + return await (async () => { + const selection = await DocumentPicker.getDocumentAsync({ + type: [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "video/mp4", + "video/quicktime", + "video/webm", + ], + multiple: true, + copyToCacheDirectory: true, + }); + if (selection.canceled) return; + for (const asset of selection.assets) { + const file = new File(asset.uri); + await (async () => { + if (session.cancelled) return; + const mimeType = asset.mimeType || file.type || "application/octet-stream"; + const markdown = await publishReviewAttachment({ + registry: appAtomRegistry, + createUploadUrl: attachmentEnvironment.createUploadUrl, + remove: attachmentEnvironment.remove, + publish: composerPullRequests.uploadAttachment, + environmentId: target.environmentId, + reference: target.input, + isCancelled: () => session.cancelled, + onMinted: () => (session.cancelled ? "cancel" : "continue"), + onTransferStart: (abort) => { + session.abort = abort; + if (session.cancelled) abort(); + }, + upload: { type: "file", name: asset.name, mimeType, sizeBytes: file.size }, + resolveUploadUrl: (relativeUrl) => { + const connection = appAtomRegistry.get( + environmentSession.preparedConnectionValueAtom(target.environmentId), + ); + return Option.isSome(connection) + ? resolveAssetUrl(connection.value.httpBaseUrl, relativeUrl) + : null; + }, + transport: (url) => { + const abort = new AbortController(); + return { + abort: () => abort.abort(), + done: file + .upload(url, { + httpMethod: "POST", + uploadType: UploadType.BINARY_CONTENT, + headers: { "Content-Type": mimeType }, + signal: abort.signal, + }) + .then((result) => { + if (result.status < 200 || result.status >= 300) + throw new Error(`Upload failed (${result.status}).`); + }), + }; + }, + }); + if (!session.cancelled) onAppend(markdown); + })().finally(async () => { + if (file.exists) file.delete(); + }); + } + })() + .catch(async (error: unknown) => { + if (!session.cancelled) + Alert.alert( + "Could not attach the file", + error instanceof Error ? error.message : "Try uploading again.", + ); + }) + .finally(async () => { + lock.current = false; + setBusy(false); + onBusyChange?.(false); + }); + }; + if ( + !allowed || + !detail.data?.capabilities.attachments || + detail.data.viewerPermissions.attachments !== true + ) + return null; + return ( + void upload()} + > + {busy ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/mobile/src/features/pull-requests/PullRequestCommentCard.tsx b/apps/mobile/src/features/pull-requests/PullRequestCommentCard.tsx new file mode 100644 index 0000000000000..af93d4b61cfd2 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/PullRequestCommentCard.tsx @@ -0,0 +1,513 @@ +import { usePendingReviewCommentActions } from "./usePendingReviewCommentActions"; +import { useRetiredReviewDraftCommentIds } from "./usePullRequestReviewDraft"; +import { + reviewThreadReplyTarget, + pendingReviewCommentsFromThreads, +} from "@t3tools/client-runtime/review-editing"; +import { useAtomRefresh } from "@effect/atom-react"; +import { useNavigation } from "@react-navigation/native"; +import { reviewLineRangeLabel } from "@t3tools/client-runtime/review-selection"; +import { + mergeReviewThreadComments, + reviewThreadSummary, +} from "@t3tools/client-runtime/review-threads"; +import type { + PullRequestActor, + PullRequestReaction, + PullRequestReviewThread, + PullRequestThreadComment, +} from "@t3tools/contracts"; +import { useMemo, useRef, useState, type ReactNode } from "react"; +import { Alert, Pressable, StyleSheet, View } from "react-native"; +import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; +import { SymbolView } from "../../components/ui/AppSymbol"; +import { AppText as Text } from "../../components/ui/AppText"; +import { relativeTime } from "../../lib/time"; +import { composerPullRequests } from "../../state/pull-requests"; +import { useEnvironmentQuery } from "../../state/query"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { PullRequestCommentComposer } from "./PullRequestCommentComposer"; +import { PullRequestCommentControls } from "./PullRequestCommentControls"; +import { PullRequestMarkdown } from "./PullRequestMarkdown"; +import { PullRequestPendingBadge } from "./PullRequestPendingComment"; +import { PullRequestAvatar } from "./PullRequestPresentation"; +import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; +import { usePullRequestPermissions } from "./usePullRequestPermissions"; + +const LONG_BODY_CHARACTERS = 450; +const LONG_BODY_LINES = 8; + +/** A comment body that folds very long remarks behind "Show more" so lists stay scannable. */ +export function PullRequestCommentBody({ body }: { body: string }) { + const [expanded, setExpanded] = useState(false); + const { themeVariables: colors } = useAppearancePreferences(); + const long = body.length > LONG_BODY_CHARACTERS || body.split("\n").length > LONG_BODY_LINES; + const folded = long && !expanded; + const fill = colors["--color-grouped-card"]; + return ( + + + + + {folded ? ( + + + + + + + + + + + + + ) : null} + {long ? ( + setExpanded(!expanded)} + hitSlop={8} + className="flex-row items-center justify-center gap-1 pt-1 active:opacity-70" + > + + {expanded ? "Show less" : "Show more"} + + + + ) : null} + + ); +} + +/** + * One dense comment: author, time and actions on a single row, the body directly beneath. The + * action slot keeps 44pt targets but pulls them into the row so they add no vertical gap. + */ +export function PullRequestCommentItem({ + params, + comment, + title, + badges, + meta, + leadingAction, + onDiscard, + disabled, + onResolve, + onUnresolve, +}: { + params: PullRequestParams; + comment: { + id: string; + body: string; + url: string | null; + createdAt: string; + author: PullRequestActor | null; + reactions?: ReadonlyArray | undefined; + kind?: string; + canEdit?: boolean; + }; + /** Muted verb after the author, such as "commented" or "approved". */ + title?: string | undefined; + badges?: ReactNode; + /** A second muted line, such as the file a remark was left on. */ + meta?: ReactNode; + /** A direct action shown before the reaction and overflow controls. */ + leadingAction?: ReactNode; + onDiscard?: (() => void) | undefined; + disabled?: boolean; + onResolve?: (() => void) | undefined; + onUnresolve?: (() => void) | undefined; +}) { + return ( + ( + + + + + + {comment.author?.name ?? comment.author?.login ?? "Unknown author"} + + {title ? {title} : null} + + + {relativeTime(comment.createdAt)} + + {badges} + + {leadingAction} + {actions} + + + {meta} + {comment.body.trim() ? : null} + {reactions} + + )} + /> + ); +} + +/** Icon-only action that matches the reaction and overflow controls beside it. */ +function InlineIconAction({ + icon, + label, + onPress, + disabled, +}: { + icon: "checkmark" | "arrow.uturn.backward"; + label: string; + onPress: () => void; + disabled?: boolean; +}) { + return ( + + + + ); +} + +/** + * Host operations for one review conversation. Resolution goes through the shared optimistic + * command, so every card, count, and tab reflects it before the host answers and rolls back on + * failure; only the error needs reporting here. + */ +export function usePullRequestThreadActions( + params: PullRequestParams, + thread: PullRequestReviewThread, +) { + const target = pullRequestTarget(params); + const detail = useEnvironmentQuery(composerPullRequests.detail(target)); + const permissions = usePullRequestPermissions(params, detail.data); + const refresh = useAtomRefresh(composerPullRequests.activity(target)); + const replyCommand = useAtomCommand(composerPullRequests.replyToThread, { + reportFailure: false, + }); + const resolveCommand = useAtomCommand(composerPullRequests.setThreadResolution, { + reportFailure: false, + }); + const loadCommand = useAtomCommand(composerPullRequests.threadComments, { + reportFailure: false, + }); + const hasPending = thread.comments.some((comment) => comment.pendingReviewId); + return { + canReply: permissions.canReply, + canResolve: permissions.canResolve && thread.canResolve !== false && !hasPending, + toggleResolved: () => { + const resolved = !thread.isResolved; + void resolveCommand({ + ...target, + input: { ...target.input, threadId: thread.id, resolved }, + }).then((result) => { + if (result._tag === "Failure") + Alert.alert( + resolved ? "Could not resolve the conversation" : "Could not reopen the conversation", + "Its previous state has been restored. Try again.", + ); + }); + }, + /** Resolves to whether the host took it, so a failed reply keeps its words. */ + reply: async (body: string) => { + const result = await replyCommand({ + ...target, + input: { ...target.input, ...reviewThreadReplyTarget(thread), body }, + }); + if (result._tag === "Failure") { + Alert.alert("Reply could not be posted", "Your reply has been kept. Try again."); + return false; + } + refresh(); + return true; + }, + loadMore: async (cursor: string) => { + const result = await loadCommand({ + ...target, + input: { ...target.input, threadId: thread.id, cursor }, + }); + if (result._tag === "Failure") { + Alert.alert("More comments could not be loaded"); + return null; + } + return result.value; + }, + }; +} + +/** + * A review conversation as one card, shared by the Comments sheet, the Overview timeline, and the + * discussion screen. It follows desktop's floating panel: a summary header that folds the thread, + * dense comments, a direct Resolve on the first comment, and the reply field inside the card. + * Resolved conversations open folded and fold again whenever they become resolved. + */ +export function PullRequestThreadCard({ + params, + thread, + showPath = true, + defaultExpanded, + autoFocusReply = false, + onBeforeNavigate, +}: { + params: PullRequestParams; + thread: PullRequestReviewThread; + showPath?: boolean; + defaultExpanded?: boolean; + autoFocusReply?: boolean; + /** Lets a modal host dismiss itself before the file diff is pushed beneath it. */ + onBeforeNavigate?: () => void; +}) { + const navigation = useNavigation(); + const actions = usePullRequestThreadActions(params, thread); + const [expanded, setExpanded] = useState(defaultExpanded ?? !thread.isResolved); + const [lastResolved, setLastResolved] = useState(thread.isResolved); + if (lastResolved !== thread.isResolved) { + setLastResolved(thread.isResolved); + setExpanded(!thread.isResolved); + } + const [loadedPage, setLoadedPage] = useState<{ + readonly threadId: string; + readonly comments: ReadonlyArray; + readonly nextCursor: string | null; + } | null>(null); + const [loadingMore, setLoadingMore] = useState(false); + const [reply, setReply] = useState(""); + const [sending, setSending] = useState(false); + const [uploading, setUploading] = useState(false); + const sendingRef = useRef(false); + const page = loadedPage?.threadId === thread.id ? loadedPage : null; + const pendingActions = usePendingReviewCommentActions(params); + const retiredIds = useRetiredReviewDraftCommentIds(params); + const comments = useMemo(() => { + const retired = new Set(retiredIds); + return mergeReviewThreadComments(thread.comments, page?.comments ?? []).filter( + (comment) => !comment.pendingReviewId || !retired.has(comment.id), + ); + }, [thread.comments, page?.comments, retiredIds]); + const nextCursor = page === null ? (thread.nextCommentsCursor ?? null) : page.nextCursor; + const summary = reviewThreadSummary(thread); + const pendingById = new Map( + pendingReviewCommentsFromThreads([{ ...thread, comments }]).map((comment) => [ + comment.id, + { ...comment, headSha: "", code: "" }, + ]), + ); + + const send = async () => { + const body = reply.trim(); + if (!body || sendingRef.current || uploading) return; + sendingRef.current = true; + setSending(true); + return await (async () => { + if (await actions.reply(body)) { + // The mutation returns no comment; reopen the cursor so the reply stays reachable. + setLoadedPage((previous) => + previous?.threadId === thread.id + ? { ...previous, nextCursor: previous.nextCursor ?? thread.nextCommentsCursor ?? null } + : previous, + ); + setReply((current) => (current.trim() === body ? "" : current)); + } + })().finally(async () => { + sendingRef.current = false; + setSending(false); + }); + }; + const loadMore = async () => { + if (nextCursor === null || loadingMore) return; + setLoadingMore(true); + return await (async () => { + const result = await actions.loadMore(nextCursor); + if (result === null) return; + setLoadedPage((previous) => ({ + threadId: thread.id, + comments: mergeReviewThreadComments( + previous?.threadId === thread.id ? previous.comments : [], + result.comments, + ), + nextCursor: result.nextCursor, + })); + })().finally(async () => { + setLoadingMore(false); + }); + }; + + return ( + + {showPath ? ( + { + onBeforeNavigate?.(); + navigation.navigate("PullRequestFile", { ...params, path: thread.path }); + }} + className="flex-row items-center gap-1.5 border-b border-border-subtle px-3 py-1.5 active:opacity-70" + > + + + {thread.path} + {thread.line === null ? "" : `:${thread.line}`} + + {thread.isOutdated ? ( + Outdated + ) : null} + + ) : null} + + setExpanded(!expanded)} + className="min-h-10 min-w-0 flex-1 flex-row items-center gap-2 pl-3 active:opacity-70" + > + + + {thread.isResolved && expanded ? "Collapse" : summary} + + {thread.line !== null ? ( + + {reviewLineRangeLabel(thread)} + + ) : null} + + + + {thread.isResolved && actions.canResolve ? ( + + Unresolve + + ) : ( + + )} + + {expanded ? ( + <> + {comments.map((comment, index) => ( + + : null} + disabled={pendingActions.busy} + onDiscard={ + comment.pendingReviewId && + pendingById.has(comment.id) && + pendingActions.canDiscard + ? () => + Alert.alert( + "Discard this comment?", + "Your other review comments will be kept.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Discard", + style: "destructive", + onPress: () => + void pendingActions.discard(pendingById.get(comment.id)!), + }, + ], + ) + : undefined + } + leadingAction={ + index === 0 && actions.canResolve && !thread.isResolved ? ( + + ) : null + } + onResolve={ + actions.canResolve && !thread.isResolved ? actions.toggleResolved : undefined + } + onUnresolve={ + actions.canResolve && thread.isResolved ? actions.toggleResolved : undefined + } + /> + + ))} + {nextCursor !== null ? ( + void loadMore()} + className="min-h-10 justify-center border-t border-border-subtle px-3 active:opacity-70" + > + + {loadingMore ? "Loading…" : "Load more comments"} + + + ) : null} + {actions.canReply ? ( + void send()} + onBusyChange={setUploading} + autoFocus={autoFocusReply} + minimumHeight={44} + /> + ) : null} + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/pull-requests/PullRequestCommentComposer.tsx b/apps/mobile/src/features/pull-requests/PullRequestCommentComposer.tsx new file mode 100644 index 0000000000000..7c01c9d7ad448 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/PullRequestCommentComposer.tsx @@ -0,0 +1,130 @@ +import { composerPullRequests } from "../../state/pull-requests"; +import { useEnvironmentQuery } from "../../state/query"; +import { pullRequestTarget } from "./usePullRequestDiff"; +import { usePullRequestCompletions } from "./usePullRequestCompletions"; +import { PullRequestCompletionSuggestions } from "./PullRequestCompletionSuggestions"; +import { useState } from "react"; +import { View } from "react-native"; +import { AppTextInput as TextInput } from "../../components/ui/AppText"; +import { ControlPill } from "../../components/ui/ControlPill"; +import { PullRequestAttachments } from "./PullRequestAttachments"; +import type { PullRequestParams } from "./usePullRequestDiff"; +import { useReviewAttachmentInsertion } from "./useReviewAttachmentInsertion"; + +/** + * One compact field and attached upload/send toolbar for every native review composer. `inline` + * drops the outer card so a reply can sit inside a comment card, as on desktop. + */ +export function PullRequestCommentComposer({ + params, + value, + onChangeText, + label, + placeholder, + editable = true, + busy = false, + canSubmit, + submitLabel, + showSubmitLabel = false, + onSubmit, + onBusyChange, + autoFocus = false, + minimumHeight = 80, + variant = "card", +}: { + params: PullRequestParams; + value: string; + onChangeText: (value: string) => void; + label: string; + placeholder: string; + editable?: boolean; + busy?: boolean; + canSubmit: boolean; + submitLabel: string; + showSubmitLabel?: boolean; + onSubmit: () => void; + onBusyChange?: (busy: boolean) => void; + autoFocus?: boolean; + minimumHeight?: number; + variant?: "card" | "inline"; +}) { + const detail = useEnvironmentQuery(composerPullRequests.detail(pullRequestTarget(params))); + const completions = usePullRequestCompletions({ + environmentId: params.environmentId, + projectId: params.projectId, + value, + onChangeValue: onChangeText, + support: detail.data?.capabilities ?? null, + }); + const insertion = useReviewAttachmentInsertion(value, onChangeText); + const [uploading, setUploading] = useState(false); + const [height, setHeight] = useState(minimumHeight); + return ( + + + { + insertion.inputProps.onSelectionChange(event); + completions.onSelectionChange(event); + }} + onBlur={completions.onBlur} + accessibilityLabel={label} + placeholder={placeholder} + placeholderTextColorClassName="accent-foreground-muted" + multiline + maxLength={65536} + value={value} + onChangeText={onChangeText} + editable={editable && !busy && !uploading} + autoFocus={autoFocus} + onContentSizeChange={(event) => { + const next = Math.min( + 200, + Math.max(minimumHeight, Math.ceil(event.nativeEvent.contentSize.height)), + ); + setHeight((current) => (current === next ? current : next)); + }} + className={ + variant === "inline" + ? "min-h-0 flex-1 rounded-none border-0 bg-transparent px-3 py-2.5 text-sm text-foreground" + : "min-h-0 rounded-none border-0 bg-transparent px-3 py-3 text-base text-foreground" + } + style={{ height, textAlignVertical: "top" }} + /> + + { + setUploading(busy); + onBusyChange?.(busy); + }} + onAppend={insertion.insert} + /> + {variant === "inline" ? null : } + + + + ); +} diff --git a/apps/mobile/src/features/pull-requests/PullRequestCommentControls.tsx b/apps/mobile/src/features/pull-requests/PullRequestCommentControls.tsx new file mode 100644 index 0000000000000..15e8da683aade --- /dev/null +++ b/apps/mobile/src/features/pull-requests/PullRequestCommentControls.tsx @@ -0,0 +1,336 @@ +import { usePullRequestThreads } from "./usePullRequestThreads"; +import { canEditPullRequestComment } from "@t3tools/client-runtime/review-editing"; +import { PullRequestCommentComposer } from "./PullRequestCommentComposer"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useAtomValue } from "@effect/atom-react"; +import { + PULL_REQUEST_REACTION_ORDER, + applyPendingPullRequestReactions, + pullRequestReactionEmoji, + pullRequestReactionName, +} from "@t3tools/client-runtime/review-reactions"; +import type { + PullRequestActor, + PullRequestReaction, + PullRequestReactionContent, +} from "@t3tools/contracts"; +import { useState, type ReactNode } from "react"; +import { Alert, Linking, Modal, Pressable, View } from "react-native"; +import * as Clipboard from "expo-clipboard"; +import { Menu } from "../../components/ui/Menu"; +import { SymbolView } from "../../components/ui/AppSymbol"; +import { AppText as Text } from "../../components/ui/AppText"; +import { composerPullRequests } from "../../state/pull-requests"; +import { useEnvironmentQuery } from "../../state/query"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; +import { ControlPill } from "../../components/ui/ControlPill"; +import { KeyboardAvoidingView } from "react-native-keyboard-controller"; + +/** Native controls share reaction vocabulary and optimistic counts with desktop. */ +export function PullRequestCommentControls({ + params, + comment, + onEdit, + onUnresolve, + onResolve, + onDiscard, + disabled, + render, +}: { + params: PullRequestParams; + comment: { + id: string; + body: string; + url: string | null; + reactions?: ReadonlyArray | undefined; + kind?: string; + author?: PullRequestActor | null; + canEdit?: boolean; + }; + onEdit?: (() => void) | undefined; + onUnresolve?: (() => void) | undefined; + onResolve?: (() => void) | undefined; + disabled?: boolean; + onDiscard?: (() => void) | undefined; + render?: (slots: { actions: ReactNode; reactions: ReactNode }) => ReactNode; +}) { + const target = pullRequestTarget(params); + const detail = useEnvironmentQuery(composerPullRequests.detail(target)); + const activity = useEnvironmentQuery(composerPullRequests.activity(target)); + const agent = usePullRequestThreads(params, detail.data); + const markdown = `${comment.author?.login ? `**${comment.author.login}**\n\n` : ""}${comment.body}${comment.url ? `\n\n[View comment](${comment.url})` : ""}`; + const allowed = useAtomValue( + composerPullRequests.setReaction.permissionAtom(target.environmentId), + ); + const write = useAtomCommand(composerPullRequests.setReaction, { reportFailure: false }); + const update = useAtomCommand(composerPullRequests.updateComment, { reportFailure: false }); + const canWriteComment = useAtomValue( + composerPullRequests.updateComment.permissionAtom(target.environmentId), + ); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(comment.body); + const [saving, setSaving] = useState(false); + const [uploading, setUploading] = useState(false); + const kind = + comment.kind === undefined + ? "review-comment" + : comment.kind === "issue-comment" + ? "issue-comment" + : comment.kind === "review-comment" + ? "review-comment" + : "review"; + const canEdit = + canWriteComment && + detail.data !== null && + canEditPullRequestComment(detail.data, { + kind, + author: comment.author ?? null, + ...(comment.canEdit === undefined ? {} : { canEdit: comment.canEdit }), + }); + const edit = () => { + if (onEdit) onEdit(); + else { + setDraft(comment.body); + setEditing(true); + } + }; + const save = async () => { + if (!canEdit || saving || uploading || !draft.trim() || kind === "review") return; + setSaving(true); + const result = await update({ + ...target, + input: { ...target.input, commentId: comment.id, kind, body: draft }, + }); + setSaving(false); + if (result._tag === "Failure") { + Alert.alert("Could not edit the comment", "Your draft has been kept. Try again."); + return; + } + setEditing(false); + activity.refresh(); + }; + const reactions = comment.reactions ?? []; + const signature = JSON.stringify(reactions); + const [state, setState] = useState({ + signature, + overlay: new Map(), + }); + const [busy, setBusy] = useState(false); + if (signature !== state.signature) setState({ signature, overlay: new Map() }); + const shown = applyPendingPullRequestReactions( + reactions, + state.signature === signature ? state.overlay : new Map(), + ); + const canReact = allowed && detail.data?.capabilities.reactions === true; + const react = async (content: PullRequestReactionContent) => { + if (!canReact || busy) return; + const reacted = !shown.find((reaction) => reaction.content === content)?.viewerHasReacted; + setState((current) => ({ signature, overlay: new Map(current.overlay).set(content, reacted) })); + setBusy(true); + const result = await write({ + ...target, + input: { ...target.input, subjectId: comment.id, content, reacted }, + }); + setBusy(false); + if (result._tag === "Failure") { + setState({ signature, overlay: new Map() }); + Alert.alert("Could not save the reaction"); + } else activity.refresh(); + }; + const reactionChips = shown.map((reaction) => ( + void react(reaction.content)} + > + + {pullRequestReactionEmoji(reaction.content)} {reaction.count} + + + )); + const reactionPicker = canReact ? ( + ({ + id: content, + title: `${pullRequestReactionEmoji(content)} ${pullRequestReactionName(content)}`, + state: shown.some((reaction) => reaction.content === content && reaction.viewerHasReacted) + ? ("on" as const) + : ("off" as const), + attributes: { disabled: busy || Boolean(disabled) }, + }))} + onPressAction={({ nativeEvent }) => { + const content = PULL_REQUEST_REACTION_ORDER.find( + (content) => content === nativeEvent.event, + ); + if (content) void react(content); + }} + > + + + + + ) : null; + const commentMenu = ( + { + if (nativeEvent.event === "resolve") onResolve?.(); + else if (nativeEvent.event === "composer") { + if (agent.canAddToComposer) void agent.addToComposer({ markdown }); + else + void agent.ask( + agent.threads.length === 1 ? agent.threads[0] : undefined, + "ask", + markdown, + ); + } else if (nativeEvent.event === "unresolve") onUnresolve?.(); + else if (nativeEvent.event === "discard") onDiscard?.(); + else if (nativeEvent.event === "edit") edit(); + else if (nativeEvent.event === "copy") void Clipboard.setStringAsync(markdown); + else if (nativeEvent.event === "copy-link" && comment.url) + void Clipboard.setStringAsync(comment.url); + else if (nativeEvent.event === "open" && comment.url) void Linking.openURL(comment.url); + }} + > + + + + + ); + const actions = ( + + {reactionPicker} + {commentMenu} + + ); + return ( + <> + {render ? ( + render({ + actions, + reactions: shown.length ? ( + {reactionChips} + ) : null, + }) + ) : ( + + {reactionChips} + {actions} + + )} + { + if (!saving && !uploading) setEditing(false); + }} + > + + { + if (!saving && !uploading) setEditing(false); + }} + /> + + + Edit comment + setEditing(false)} + /> + + void save()} + onBusyChange={setUploading} + autoFocus + /> + + + + + ); +} diff --git a/apps/mobile/src/features/pull-requests/PullRequestCommentForm.tsx b/apps/mobile/src/features/pull-requests/PullRequestCommentForm.tsx new file mode 100644 index 0000000000000..f4982a0799797 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/PullRequestCommentForm.tsx @@ -0,0 +1,91 @@ +import { composerPullRequests } from "../../state/pull-requests"; +import { useEnvironmentQuery } from "../../state/query"; +import { pullRequestTarget } from "./usePullRequestDiff"; +import { usePullRequestCompletions } from "./usePullRequestCompletions"; +import { PullRequestCompletionSuggestions } from "./PullRequestCompletionSuggestions"; +import { useState } from "react"; +import { View } from "react-native"; +import { AppTextInput as TextInput } from "../../components/ui/AppText"; +import { PullRequestAttachments } from "./PullRequestAttachments"; +import type { PullRequestParams } from "./usePullRequestDiff"; +import { useReviewAttachmentInsertion } from "./useReviewAttachmentInsertion"; + +/** Desktop's comment form: a plain field with the attachment control beneath; buttons live in the sheet footer. */ +export function PullRequestCommentForm({ + params, + value, + onChangeText, + label, + placeholder, + editable = true, + busy = false, + onBusyChange, + autoFocus = false, + minimumHeight = 88, +}: { + params: PullRequestParams; + value: string; + onChangeText: (value: string) => void; + label: string; + placeholder: string; + editable?: boolean; + busy?: boolean; + onBusyChange?: (busy: boolean) => void; + autoFocus?: boolean; + minimumHeight?: number; +}) { + const detail = useEnvironmentQuery(composerPullRequests.detail(pullRequestTarget(params))); + const completions = usePullRequestCompletions({ + environmentId: params.environmentId, + projectId: params.projectId, + value, + onChangeValue: onChangeText, + support: detail.data?.capabilities ?? null, + }); + const insertion = useReviewAttachmentInsertion(value, onChangeText); + const [uploading, setUploading] = useState(false); + const [height, setHeight] = useState(minimumHeight); + return ( + + + { + insertion.inputProps.onSelectionChange(event); + completions.onSelectionChange(event); + }} + onBlur={completions.onBlur} + accessibilityLabel={label} + placeholder={placeholder} + placeholderTextColorClassName="accent-foreground-muted" + multiline + maxLength={65536} + value={value} + onChangeText={onChangeText} + editable={editable && !busy && !uploading} + autoFocus={autoFocus} + onContentSizeChange={(event) => { + const next = Math.min( + 220, + Math.max(minimumHeight, Math.ceil(event.nativeEvent.contentSize.height)), + ); + setHeight((current) => (current === next ? current : next)); + }} + className="rounded-xl border border-border-subtle bg-grouped-card px-3 py-3 text-base text-foreground" + style={{ height, textAlignVertical: "top" }} + /> + + { + setUploading(busy); + onBusyChange?.(busy); + }} + onAppend={insertion.insert} + /> + + + ); +} diff --git a/apps/mobile/src/features/pull-requests/PullRequestCommentsSheet.tsx b/apps/mobile/src/features/pull-requests/PullRequestCommentsSheet.tsx new file mode 100644 index 0000000000000..58bb9e85b3c5e --- /dev/null +++ b/apps/mobile/src/features/pull-requests/PullRequestCommentsSheet.tsx @@ -0,0 +1,191 @@ +import { useNavigation } from "@react-navigation/native"; +import { countReviewThreads } from "@t3tools/client-runtime/review-threads"; +import { useState } from "react"; +import { Modal, Pressable, ScrollView, View } from "react-native"; +import { KeyboardAvoidingView } from "react-native-keyboard-controller"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { SymbolView } from "../../components/ui/AppSymbol"; +import { AppText as Text } from "../../components/ui/AppText"; +import { SegmentedControl } from "../../components/ui/SegmentedControl"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { PullRequestThreadCard } from "./PullRequestCommentCard"; +import { PullRequestMarkdown } from "./PullRequestMarkdown"; +import { + PullRequestPendingBadge, + PullRequestPendingCommentActions, +} from "./PullRequestPendingComment"; +import { usePullRequestReviewDraft } from "./usePullRequestReviewDraft"; +import type { PullRequestParams } from "./usePullRequestDiff"; + +/** One PR-wide checklist remains accessible while switching review tabs. */ +export function PullRequestCommentsSheet({ + params, + visible, + onClose, +}: { + params: PullRequestParams; + visible: boolean; + onClose: () => void; +}) { + const navigation = useNavigation(); + const { themeVariables: colors } = useAppearancePreferences(); + const draft = usePullRequestReviewDraft(params); + const [status, setStatus] = useState<"resolved" | "unresolved" | "pending">("unresolved"); + // Threads come from the optimistic activity projection, so a resolve moves its card between + // tabs and updates every count before the host answers. + const published = draft.threads.filter((thread) => + thread.comments.some((comment) => !comment.pendingReviewId), + ); + const pendingThreads = draft.threads.filter((thread) => + thread.comments.some((comment) => comment.pendingReviewId), + ); + const resolved = published.filter((thread) => thread.isResolved); + const unresolved = published.filter((thread) => !thread.isResolved); + const counts = countReviewThreads(published); + // Removing the last draft also removes its tab; stay on an available status. + const selectedStatus = + status === "pending" && draft.comments.length === 0 ? "unresolved" : status; + if (selectedStatus !== status) setStatus(selectedStatus); + const threads = + selectedStatus === "resolved" + ? resolved + : selectedStatus === "unresolved" + ? unresolved + : pendingThreads; + const pending = selectedStatus === "pending" ? draft.ungroupedComments : []; + const options = [ + { + value: "unresolved" as const, + label: "Unresolved", + ...(draft.activityLoaded ? { badge: unresolved.length } : {}), + }, + { + value: "resolved" as const, + label: "Resolved", + ...(draft.activityLoaded ? { badge: resolved.length } : {}), + }, + ...(draft.comments.length + ? [{ value: "pending" as const, label: "Pending", badge: draft.comments.length }] + : []), + ]; + return ( + + {/* SafeAreaView forwards className to a native view without Uniwind styling. */} + + + + Comments + + {draft.activityLoaded + ? `${counts.open} open · ${counts.resolved} resolved` + : draft.activityError + ? "Could not load comments" + : "Loading comments…"} + {draft.comments.length ? ` · ${draft.comments.length} pending` : ""} + + + + + + + + + + {/* Replies are written inside the cards, so the list must clear the keyboard. */} + + + {pending.map((comment) => ( + + + + + + + + {comment.path} + + + { + onClose(); + navigation.navigate("PullRequestInlineComment", { + ...params, + draftId: comment.id, + path: comment.path, + position: comment.position, + ...(comment.startPosition ? { startPosition: comment.startPosition } : {}), + headSha: comment.headSha, + code: comment.code, + ...(comment.oldPath ? { oldPath: comment.oldPath } : {}), + }); + }} + > + Open pending comment + + + ))} + {threads.map((thread) => ( + + ))} + {draft.activityError ? ( + + + {draft.activityError} + + + Try again + + + ) : !draft.activityLoaded && selectedStatus !== "pending" ? ( + + Loading comments… + + ) : null} + {draft.activityLoaded && !draft.activityError && !threads.length && !pending.length ? ( + + {selectedStatus === "resolved" + ? "No resolved comments." + : "No unresolved comments."} + + ) : null} + + + + + ); +} diff --git a/apps/mobile/src/features/pull-requests/PullRequestCommitPicker.tsx b/apps/mobile/src/features/pull-requests/PullRequestCommitPicker.tsx new file mode 100644 index 0000000000000..cbcd2df01a755 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/PullRequestCommitPicker.tsx @@ -0,0 +1,177 @@ +import type { PullRequestCommit } from "@t3tools/contracts"; +import { + reviewCommitScope, + reviewCommitSelection, + toggleReviewCommit, +} from "@t3tools/client-runtime/review-workspace"; +import { useState } from "react"; +import { Modal, Pressable, ScrollView, View, useWindowDimensions } from "react-native"; +import { AppText as Text } from "../../components/ui/AppText"; +import { ControlPill } from "../../components/ui/ControlPill"; +import { SymbolView } from "../../components/ui/AppSymbol"; +import { relativeTime } from "../../lib/time"; +import { REVIEW_MONO_FONT_FAMILY } from "../review/reviewDiffRendering"; + +/** A compact range picker keeps native menus from wrapping every commit into a tall card. */ +export function PullRequestCommitPicker({ + open, + onClose, + commits, + scope, + lastReviewed, + canSelectRange, + onChange, +}: { + readonly open: boolean; + readonly onClose: () => void; + /** Oldest first, matching the shared contiguous-range selection helpers. */ + readonly commits: ReadonlyArray; + readonly scope: string | null; + readonly lastReviewed: string | null; + readonly canSelectRange: boolean; + readonly onChange: (scope: string | null) => void; +}) { + const { height } = useWindowDimensions(); + const [shown, setShown] = useState(10); + const oids = commits.map((commit) => commit.oid); + const selected = reviewCommitSelection(oids, scope); + const first = selected[0]; + const last = selected.at(-1); + const reviewedIndex = lastReviewed === null ? -1 : oids.indexOf(lastReviewed); + const sinceDisabled = reviewedIndex < 0 || reviewedIndex === commits.length - 1; + const recentCommits = [...commits].reverse().slice(0, shown); + const chooseQuick = (next: string | null) => { + onChange(next); + onClose(); + }; + return ( + + + + + + Commit range + + + + + {scope === null + ? "All" + : `${selected.length} ${selected.length === 1 ? "commit" : "commits"} selected`} + + {scope !== null && first ? ( + + {first.slice(0, 7)} + {last && first !== last ? ` → ${last.slice(0, 7)}` : ""} + + ) : null} + + chooseQuick(null)} + > + + {scope === null ? ( + + ) : null} + + All + + chooseQuick(reviewCommitScope(oids.slice(reviewedIndex + 1)))} + > + + Since last reviewed + + + {recentCommits.map((entry) => { + const checked = selected.includes(entry.oid); + const authors = entry.authors + ?.map((author) => author.name ?? author.login) + .join(", "); + return ( + { + onChange( + canSelectRange + ? reviewCommitScope( + toggleReviewCommit(oids, scope === null ? [] : selected, entry.oid), + ) + : entry.oid, + ); + if (!canSelectRange) onClose(); + }} + > + + {checked ? ( + + ) : null} + + + + {entry.messageHeadline || "Untitled commit"} + + + {authors ? `${authors} · ` : ""} + {relativeTime(entry.committedDate)} + + + + {entry.oid.slice(0, 7)} + + + ); + })} + {commits.length > shown ? ( + setShown((current) => current + 10)} + > + + Show more ({commits.length - shown} left) + + + ) : null} + + + + + ); +} diff --git a/apps/mobile/src/features/pull-requests/PullRequestComposerNotice.tsx b/apps/mobile/src/features/pull-requests/PullRequestComposerNotice.tsx new file mode 100644 index 0000000000000..e16865ee8368a --- /dev/null +++ b/apps/mobile/src/features/pull-requests/PullRequestComposerNotice.tsx @@ -0,0 +1,38 @@ +import { View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Toast } from "../../components/ui/Toast"; +import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspaceDetail"; +import { dismissComposerInsertNotice, useComposerInsertNotice } from "./composer-insert-notice"; + +/** Confirms a comment reached the thread's composer without leaving the pull request. */ +export function PullRequestComposerNotice(props: { readonly bottomInset?: number }) { + const notice = useComposerInsertNotice(); + const selectDetail = useSelectWorkspaceDetail(); + const insets = useSafeAreaInsets(); + if (notice === null) return null; + return ( + + { + dismissComposerInsertNotice(); + selectDetail({ + name: "Thread", + params: { environmentId: notice.environmentId, threadId: notice.threadId }, + }); + }, + }} + dismissMode="swipe" + onDismiss={dismissComposerInsertNotice} + /> + + ); +} diff --git a/apps/mobile/src/features/pull-requests/PullRequestControls.tsx b/apps/mobile/src/features/pull-requests/PullRequestControls.tsx index ef8f59c74b079..5bd2098085e01 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestControls.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestControls.tsx @@ -20,6 +20,7 @@ import { usePullRequestPermissions } from "./usePullRequestPermissions"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { usePullRequestReviewDraft } from "./usePullRequestReviewDraft"; import { usePullRequestAgentActions } from "./usePullRequestAgentActions"; +import type { PullRequestAgentTask } from "./pullRequestAgentTasks"; export function PullRequestCardGroup(props: { readonly children: ReactNode }) { return ( @@ -105,30 +106,195 @@ export function PullRequestCardRow(props: { ); } -export function PullRequestActionToolbar(props: { +type PullRequestActionToolbarProps = { readonly pr: PullRequestDetail | null; readonly params: PullRequestParams; readonly filePath?: string; readonly onOpenFiles?: () => void; + readonly showFiles?: boolean; readonly onHeightChange?: (height: number) => void; -}) { +}; + +function useVerticalPullRequestRail() { + const metrics = useNativeLayoutMetrics(); + return Platform.OS === "ios" && metrics !== null && metrics.verticalBarEdge !== "none"; +} + +function pullRequestThreadMenuItems( + agent: ReturnType, + task: PullRequestAgentTask, +) { + return renderThreadMenuItems(agent.threadActions(task), agent.select, agent.disabled); +} + +function pullRequestAgentMenuItems(agent: ReturnType) { + return agent.actions + .filter((action) => action.task !== "ask") + .map((action) => + agent.threads.length > 1 && !action.disabled ? ( + + {pullRequestThreadMenuItems(agent, action.task)} + + ) : ( + agent.start(action.task)} + > + {action.title} + + ), + ); +} + +type PullRequestHeaderToolbarProps = { + readonly pr: PullRequestDetail | null; + readonly params: PullRequestParams; + readonly filePath?: string; + readonly title: string; + readonly accessibilityLabel?: string; + readonly onOpenComments: () => void; + readonly commentsInHorizontalBar?: boolean; + readonly controls?: ReadonlyArray; + readonly railMenuItems?: ReactNode; + readonly children?: ReactNode; +}; + +function pullRequestHeaderMenu(props: PullRequestHeaderToolbarProps, actions?: ReactNode) { + return ( + + {actions} + {props.children} + + ); +} + +export function PullRequestHeaderToolbar(props: PullRequestHeaderToolbarProps) { + const vertical = useVerticalPullRequestRail(); + return vertical ? ( + + ) : ( + + {props.controls} + {props.commentsInHorizontalBar !== false ? ( + + ) : null} + {pullRequestHeaderMenu(props)} + + ); +} + +function PullRequestVerticalHeaderToolbar(props: PullRequestHeaderToolbarProps) { + const navigation = useNavigation(); + const draft = usePullRequestReviewDraft(props.params); + const permissions = usePullRequestPermissions(props.params, props.pr); + const agent = usePullRequestAgentActions(props.params, props.pr, props.filePath); + const { themeVariables: colors } = useAppearancePreferences(); + const pendingReview = + props.pr !== null && + permissions.verdicts.length > 0 && + (draft.comments.length > 0 || Boolean(draft.pendingReviewId)); + const showAgentControls = props.pr !== null && (agent.canAsk || !permissions.connected); + const canOpenThread = props.pr !== null && (agent.threads.length > 0 || showAgentControls); + const { shortLabel } = sourceControlClients.get( + (props.pr ?? props.params.initialSummary)?.provider, + ).changeRequest; + const threadLabel = agent.threads.length ? "Continue thread" : `Work on ${shortLabel}`; + const submitLabel = `Submit review${draft.comments.length ? ` · ${draft.comments.length}` : ""}`; + const actions = ( + <> + {canOpenThread ? ( + agent.threads.length > 1 ? ( + + {pullRequestThreadMenuItems(agent, "ask")} + + ) : ( + agent.start("ask")} + > + {threadLabel} + + ) + ) : null} + {showAgentControls ? ( + + {pullRequestAgentMenuItems(agent)} + + ) : null} + {props.railMenuItems} + + ); + return ( + + {/* UIKit places trailing items from the edge inward, reversing the vertical order. */} + {pendingReview ? ( + + navigation.navigate("PullRequestReview", { ...props.params, mode: "review" }) + } + separateBackground + visibilityPriority="high" + /> + ) : null} + {pullRequestHeaderMenu(props, actions)} + + + ); +} + +export function PullRequestActionToolbar(props: PullRequestActionToolbarProps) { + const vertical = useVerticalPullRequestRail(); + return vertical ? null : ; +} + +function PullRequestHorizontalActionToolbar(props: PullRequestActionToolbarProps) { const navigation = useNavigation(); - const { draft } = usePullRequestReviewDraft(props.params); + const { comments, pendingReviewId, busy: draftBusy } = usePullRequestReviewDraft(props.params); const { themeVariables: colors } = useAppearancePreferences(); const insets = useSafeAreaInsets(); - const nativeMetrics = useNativeLayoutMetrics(); - const verticalControls = nativeMetrics !== null && nativeMetrics.verticalBarEdge !== "none"; const permissions = usePullRequestPermissions(props.params, props.pr); const agent = usePullRequestAgentActions(props.params, props.pr, props.filePath); const canReview = permissions.verdicts.length > 0; - const pendingReview = canReview && draft.comments.length > 0; + const pendingReview = canReview && (comments.length > 0 || Boolean(pendingReviewId)); const showAgentControls = agent.canAsk || !permissions.connected; const canOpenThread = agent.threads.length > 0 || showAgentControls; const { shortLabel } = sourceControlClients.get( (props.pr ?? props.params.initialSummary)?.provider, ).changeRequest; const threadLabel = agent.threads.length ? "Continue thread" : `Work on ${shortLabel}`; - const primaryLabel = pendingReview ? `Submit review · ${draft.comments.length}` : threadLabel; + const primaryLabel = pendingReview + ? `Submit review${comments.length ? ` · ${comments.length}` : ""}` + : threadLabel; const chooseThread = agent.chooseThread; const agentActions = agent.actions.filter((action) => action.task !== "ask"); const open = (mode: "review" | "comment") => @@ -138,7 +304,7 @@ export function PullRequestActionToolbar(props: { const openFiles = props.onOpenFiles ?? (() => navigation.navigate("PullRequestFiles", props.params)); if (!props.pr) return null; - const primaryDisabled = pendingReview ? false : agent.disabled; + const primaryDisabled = pendingReview ? draftBusy : agent.disabled; const showPrimary = pendingReview || canOpenThread; if (Platform.OS === "android") { const primary = ( @@ -188,7 +354,9 @@ export function PullRequestActionToolbar(props: { )} ) : null} - + {props.showFiles !== false ? ( + + ) : null} @@ -201,13 +369,6 @@ export function PullRequestActionToolbar(props: { title={primaryLabel} accessibilityLabel={primaryLabel} variant="prominent" - icon={ - verticalControls - ? pendingReview - ? "text.bubble" - : "bubble.left.and.bubble.right" - : undefined - } tintColor={colors["--color-prominent-action"]} disabled={primaryDisabled} separateBackground @@ -220,13 +381,6 @@ export function PullRequestActionToolbar(props: { label={primaryLabel} accessibilityLabel={primaryLabel} variant="prominent" - icon={ - verticalControls - ? pendingReview - ? "text.bubble" - : "bubble.left.and.bubble.right" - : undefined - } tintColor={colors["--color-prominent-action"]} disabled={primaryDisabled} onPress={primaryPress} @@ -243,41 +397,20 @@ export function PullRequestActionToolbar(props: { disabled={agent.disabled} separateBackground > - {agentActions.map((action) => - chooseThread && !action.disabled ? ( - - {renderThreadMenuItems(agent.threadActions(action.task), agent.select)} - - ) : ( - agent.start(action.task)} - > - {action.title} - - ), - )} + {pullRequestAgentMenuItems(agent)} ) : null; - const filesControl = ( - - ); - if (verticalControls) - return ( - - {filesControl} - {agentControl} - {primaryControl} - - ); + // Tabler file-diff geometry, rasterized at 1x/2x/3x for UIKit template images. + const filesControl = + props.showFiles !== false ? ( + + ) : null; return ( {agentControl} @@ -289,18 +422,22 @@ export function PullRequestActionToolbar(props: { ); } -function renderThreadMenuItems(items: MenuAction[], select: (id: string) => void): ReactNode { +function renderThreadMenuItems( + items: MenuAction[], + select: (id: string) => void, + disabled = false, +): ReactNode { return items.map((item) => item.subactions ? ( - - {renderThreadMenuItems(item.subactions, select)} + + {renderThreadMenuItems(item.subactions, select, disabled)} ) : ( item.id && select(item.id)} > {item.title} diff --git a/apps/mobile/src/features/pull-requests/PullRequestFilesSheet.tsx b/apps/mobile/src/features/pull-requests/PullRequestFilesSheet.tsx new file mode 100644 index 0000000000000..565d71f5e17d6 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/PullRequestFilesSheet.tsx @@ -0,0 +1,73 @@ +import { Modal, Pressable, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { AppText as Text } from "../../components/ui/AppText"; +import { SymbolView } from "../../components/ui/AppSymbol"; +import { ReviewFileTree, type ReviewFileTreeItem } from "../review/ReviewFileTree"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; + +/** Shows exactly the file set in the reader, including its chapter and filters. */ +export function PullRequestFilesSheet({ + open, + onClose, + files, + title, + onSelectFile, + onSetViewed, + onLoadMore, +}: { + readonly open: boolean; + readonly onClose: () => void; + readonly files: ReadonlyArray; + readonly title: string; + readonly onSelectFile: (path: string) => void; + readonly onSetViewed?: (paths: ReadonlyArray, viewed: boolean) => void; + readonly onLoadMore?: () => void; +}) { + const { themeVariables: colors } = useAppearancePreferences(); + return ( + + + + + {title} + {files.length} + + + + + {files.length ? ( + { + onSelectFile(path); + onClose(); + }} + onSetViewed={onSetViewed} + /> + ) : ( + No files match this view. + )} + {onLoadMore ? ( + + Load more files + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/pull-requests/PullRequestInlineCommentScreen.test.tsx b/apps/mobile/src/features/pull-requests/PullRequestInlineCommentScreen.test.tsx new file mode 100644 index 0000000000000..e765d98cacfb8 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/PullRequestInlineCommentScreen.test.tsx @@ -0,0 +1,279 @@ +import { act, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import type { PendingReviewComment, ReviewDraft } from "./pullRequestReviewDrafts"; +import { expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + viewer: "alice", + ready: true, + flush: vi.fn().mockResolvedValue(true), + goBack: vi.fn(), + alert: vi.fn(), + update: vi.fn(), + discard: vi.fn(), + comments: null as readonly PendingReviewComment[] | null, + save: vi.fn().mockResolvedValue({ _tag: "Success", value: {} }), + buttons: new Map void }>(), + form: null as { value: string; onChangeText: (text: string) => void } | null, +})); +vi.mock("../../lib/uuid", () => ({ uuidv4: () => "generated" })); +vi.mock("./usePullRequestDiff", () => ({ + pullRequestTarget: (params: object) => ({ environmentId: "env", input: params }), +})); +vi.mock("./pullRequestDiffComments", () => ({ reviewPositionLabel: () => "Line 1" })); +vi.mock("react-native", () => ({ + Alert: { alert: mocks.alert }, + View: ({ children }: { children: ReactNode }) =>
{children}
, + ScrollView: ({ children }: { children: ReactNode }) =>
{children}
, +})); +vi.mock("@react-navigation/native", () => ({ + useFocusEffect: () => {}, + useNavigation: () => ({ goBack: mocks.goBack }), +})); +vi.mock("../../components/ui/AppText", () => ({ + AppText: ({ children }: { children: ReactNode }) => {children}, +})); +vi.mock("../../components/ui/Button", () => ({ + Button: (props: { label: string; disabled: boolean; onPress: () => void }) => { + mocks.buttons.set(props.label, props); + return null; + }, +})); +vi.mock("../../components/ui/AppSymbol", () => ({ SymbolView: () => null })); +vi.mock("./PullRequestSheetContent", () => ({ + PullRequestSheetContent: ({ children, footer }: { children: ReactNode; footer: ReactNode }) => ( +
+ {children} + {footer} +
+ ), +})); +vi.mock("./PullRequestCommentForm", () => ({ + PullRequestCommentForm: (props: { value: string; onChangeText: (text: string) => void }) => { + mocks.form = props; + return null; + }, +})); +vi.mock("./PullRequestCommentCard", () => ({ PullRequestThreadCard: () => null })); +vi.mock("./PullRequestPendingComment", () => ({ + PullRequestPendingBadge: () => null, + PullRequestPendingCommentActions: () => null, +})); +vi.mock("./usePendingReviewCommentActions", () => ({ + usePendingReviewCommentActions: () => ({ discard: mocks.discard }), +})); +vi.mock("./usePullRequestThreads", () => ({ + pendingCommentComposerContext: () => null, + usePullRequestThreads: () => ({ canAddToComposer: false }), +})); +vi.mock("./usePullRequestPermissions", () => ({ + usePullRequestPermissions: () => ({ canInlineComment: true }), +})); +vi.mock("../../state/pull-requests", () => ({ + composerPullRequests: { + detail: () => null, + activity: () => null, + updateComment: null, + saveDraftComment: null, + }, +})); +vi.mock("../../state/query", () => ({ + useEnvironmentQuery: () => ({ + data: { + viewer: mocks.viewer, + headSha: "head", + reviewThreads: [], + capabilities: { review: { draftComment: true } }, + }, + isPending: false, + error: null, + refresh: vi.fn(), + }), +})); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => mocks.save })); +vi.mock("./usePullRequestReviewDraft", () => ({ + setReviewDraftBusy: () => true, + usePullRequestReviewDraft: (params: object) => ({ + scope: { ...params, viewer: mocks.viewer }, + ready: mocks.ready, + isReady: () => mocks.ready, + busy: false, + error: null, + update: mocks.update, + complete: mocks.update, + flush: mocks.flush, + comments: + mocks.comments ?? + (mocks.viewer === "alice" ? [{ id: "draft", body: "Alice's private feedback" }] : []), + }), +})); +import { PullRequestInlineCommentScreen } from "./PullRequestInlineCommentScreen"; + +function setup() { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + mocks.viewer = "alice"; + mocks.ready = true; + mocks.update.mockReset(); + mocks.discard.mockReset(); + mocks.comments = null; + mocks.save.mockClear(); + mocks.flush.mockReset().mockResolvedValue(true); + mocks.goBack.mockClear(); + mocks.alert.mockClear(); + mocks.buttons.clear(); + mocks.form = null; +} + +it("blocks an open editor's text from being saved by a different account", async () => { + setup(); + const root = createRoot(document.createElement("div")); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + const route = { + params: { + environmentId: "env", + projectId: "project", + repository: "owner/repo", + number: 1, + draftId: "draft", + path: "a.ts", + position: { kind: "added" as const, newLine: 1 }, + headSha: "head", + code: "code", + body: "Alice's private feedback", + }, + }; + act(() => root.render()); + act(() => mocks.form!.onChangeText("Alice's unsaved edit")); + mocks.viewer = "bob"; + act(() => root.render()); + const save = mocks.buttons.get("Add to review")!; + expect(mocks.form!.value).toBe(""); + await act(async () => save.onPress()); + expect(mocks.update).not.toHaveBeenCalled(); + expect(mocks.save).not.toHaveBeenCalled(); + expect(save.disabled).toBe(true); +}); + +it.each([1, 2])("keeps the editor open when draft flush %i fails", async (failedFlush) => { + setup(); + mocks.flush.mockResolvedValueOnce(failedFlush === 2).mockResolvedValueOnce(false); + const root = createRoot(document.createElement("div")); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + const route = { + params: { + environmentId: "env", + projectId: "project", + repository: "owner/repo", + number: 1, + path: "a.ts", + position: { kind: "added" as const, newLine: 1 }, + headSha: "head", + code: "code", + body: "Feedback", + }, + }; + act(() => root.render()); + act(() => mocks.form!.onChangeText("Feedback")); + await act(async () => mocks.buttons.get("Add to review")!.onPress()); + expect(mocks.goBack).not.toHaveBeenCalled(); + expect(mocks.alert).toHaveBeenCalledWith( + "Could not save the review comment", + expect.stringContaining("Could not save the draft on this device."), + ); + expect(mocks.save).toHaveBeenCalledTimes(failedFlush - 1); +}); + +it("waits for settled ownership before showing or editing text and capturing the editor account", async () => { + setup(); + mocks.ready = false; + const root = createRoot(document.createElement("div")); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + const route = { + params: { + environmentId: "env", + projectId: "project", + repository: "owner/repo", + number: 1, + path: "a.ts", + position: { kind: "added" as const, newLine: 1 }, + headSha: "head", + code: "code", + body: "Unowned route text", + }, + }; + act(() => root.render()); + expect(mocks.form!.value).toBe(""); + act(() => mocks.form!.onChangeText("Unowned edit")); + mocks.viewer = "bob"; + mocks.ready = true; + act(() => root.render()); + expect(mocks.form!.value).toBe(""); + act(() => mocks.form!.onChangeText("Bob's feedback")); + await act(async () => mocks.buttons.get("Add to review")!.onPress()); + expect(mocks.save).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + comment: expect.objectContaining({ body: "Bob's feedback" }), + }), + }), + ); +}); + +it("keeps a moved hosted draft locally while identity refreshes and does not start another hosted save", async () => { + setup(); + mocks.comments = [ + { + id: "draft", + path: "a.ts", + headSha: "old", + code: "old line", + body: "Alice's feedback", + position: { kind: "added", newLine: 1 }, + hostedComment: { reviewId: "review", commentId: "hosted", url: "https://host/comment" }, + }, + ]; + mocks.update.mockImplementation((transform: (draft: ReviewDraft) => ReviewDraft) => { + mocks.comments = transform({ + summary: "", + comment: "", + comments: mocks.comments ?? [], + }).comments; + }); + mocks.discard.mockImplementation(async () => { + mocks.ready = false; + mocks.comments = []; + return true; + }); + const root = createRoot(document.createElement("div")); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + const route = { + params: { + environmentId: "env", + projectId: "project", + repository: "owner/repo", + number: 1, + draftId: "draft", + path: "a.ts", + position: { kind: "added" as const, newLine: 2 }, + headSha: "head", + code: "new line", + }, + }; + act(() => root.render()); + await act(async () => mocks.buttons.get("Save")!.onPress()); + expect(mocks.comments).toMatchObject([ + { headSha: "head", position: { kind: "added", newLine: 2 }, body: "Alice's feedback" }, + ]); + expect(mocks.comments[0]?.hostedComment).toBeUndefined(); + expect(mocks.save).not.toHaveBeenCalled(); + expect(mocks.goBack).not.toHaveBeenCalled(); + expect(mocks.alert).toHaveBeenCalledWith( + "Could not save the review comment", + expect.stringContaining("save again"), + ); + mocks.viewer = "bob"; + mocks.ready = true; + act(() => root.render()); + await act(async () => mocks.buttons.get("Save")!.onPress()); + expect(mocks.save).not.toHaveBeenCalled(); +}); diff --git a/apps/mobile/src/features/pull-requests/PullRequestInlineCommentScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestInlineCommentScreen.tsx index d199fe3990ca4..85d6cb7d6dc1c 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestInlineCommentScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestInlineCommentScreen.tsx @@ -1,28 +1,39 @@ -import { settlePromise } from "@t3tools/client-runtime/state/runtime"; -import { AsyncResult } from "effect/reactivity"; -import * as Dialog from "../../components/ui/Dialog"; +import { PullRequestThreadCard } from "./PullRequestCommentCard"; +import { Alert } from "react-native"; +import { PullRequestCommentForm } from "./PullRequestCommentForm"; import { useFocusEffect, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useRef, useState } from "react"; -import { View } from "react-native"; +import { ScrollView, View } from "react-native"; import type { PullRequestReviewPosition } from "@t3tools/contracts"; -import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/AppText"; +import { AppText as Text } from "../../components/ui/AppText"; import { Button } from "../../components/ui/Button"; +import { SymbolView } from "../../components/ui/AppSymbol"; import { uuidv4 } from "../../lib/uuid"; import { composerPullRequests } from "../../state/pull-requests"; import { useEnvironmentQuery } from "../../state/query"; import { PullRequestSheetContent } from "./PullRequestSheetContent"; -import { usePullRequestReviewDraft } from "./usePullRequestReviewDraft"; +import { setReviewDraftBusy, usePullRequestReviewDraft } from "./usePullRequestReviewDraft"; +import { pullRequestReviewKey } from "./pullRequestReviewDrafts"; import { reviewPositionLabel } from "./pullRequestDiffComments"; import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; import { usePullRequestPermissions } from "./usePullRequestPermissions"; -import { PullRequestCompletionSuggestions } from "./PullRequestCompletionSuggestions"; -import { usePullRequestCompletions } from "./usePullRequestCompletions"; + +import { useAtomCommand } from "../../state/use-atom-command"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { usePendingReviewCommentActions } from "./usePendingReviewCommentActions"; +import { pendingCommentComposerContext, usePullRequestThreads } from "./usePullRequestThreads"; +import { + PullRequestPendingBadge, + PullRequestPendingCommentActions, +} from "./PullRequestPendingComment"; export type InlineCommentParams = PullRequestParams & { readonly draftId?: string; readonly path: string; readonly oldPath?: string; readonly position: PullRequestReviewPosition; + /** The first line of a range comment, which ends at `position`. */ + readonly startPosition?: PullRequestReviewPosition; readonly headSha: string; readonly code: string; }; @@ -30,27 +41,45 @@ export function PullRequestInlineCommentScreen({ route }: StaticScreenProps draft.id === params.draftId); + const ownerKey = pullRequestReviewKey(store.scope, store.scope.viewer); + const [draftOwner, setDraftOwner] = useState(null); + if (draftOwner === null && store.ready) setDraftOwner(ownerKey); + const accountChanged = draftOwner !== null && draftOwner !== ownerKey; + const [commentId] = useState(() => params.draftId ?? uuidv4()); + const saved = store.comments.find((draft) => draft.id === commentId); const [editedBody, setBody] = useState(null); - const body = editedBody ?? saved?.body ?? ""; - const detail = useEnvironmentQuery(composerPullRequests.detail(pullRequestTarget(params))); - const { refresh: refreshDetail } = detail; - const completions = usePullRequestCompletions({ - environmentId: params.environmentId, - projectId: params.projectId, - value: body, - onChangeValue: setBody, - support: detail.data?.capabilities ?? null, + const body = store.ready && !accountChanged ? (editedBody ?? saved?.body ?? "") : ""; + const pendingActions = usePendingReviewCommentActions(params); + const updateComment = useAtomCommand(composerPullRequests.updateComment, { + reportFailure: false, + }); + const saveDraftComment = useAtomCommand(composerPullRequests.saveDraftComment, { + reportFailure: false, }); + const activity = useEnvironmentQuery(composerPullRequests.activity(pullRequestTarget(params))); + const pendingThread = saved?.hostedComment + ? activity.data?.reviewThreads.find((thread) => + thread.comments.some((comment) => comment.id === saved.hostedComment?.commentId), + ) + : undefined; + const target = pullRequestTarget(params); + const detail = useEnvironmentQuery( + composerPullRequests.detail({ ...target, input: { ...target.input, allowStale: false } }), + ); + const { refresh: refreshDetail } = detail; useFocusEffect(refreshDetail); const permissions = usePullRequestPermissions(params, detail.data); const stale = detail.data && detail.data.headSha !== params.headSha; const pending = useRef(false); + const [uploading, setUploading] = useState(false); const [busy, setBusy] = useState(false); const save = async () => { if ( pending.current || - !store.ready || + uploading || + store.busy || + !store.isReady() || + accountChanged || !body.trim() || detail.isPending || detail.error || @@ -60,76 +89,194 @@ export function PullRequestInlineCommentScreen({ route }: StaticScreenProps ({ - ...draft, - comments: [ - ...draft.comments.filter((comment) => comment.id !== params.draftId), - { - id: params.draftId ?? uuidv4(), - path: params.path, - ...(params.oldPath ? { oldPath: params.oldPath } : {}), - position: params.position, - headSha: params.headSha, - code: params.code, - body, - }, - ], - })); - // flush reports write failures through the draft store's error field. - const result = await settlePromise(() => store.flush()); - pending.current = false; - setBusy(false); - if (AsyncResult.isSuccess(result)) navigation.goBack(); + let locked = false; + return await (async () => { + let hostedComment = saved?.hostedComment; + if (hostedComment && saved) { + const moved = + saved.headSha !== params.headSha || + saved.path !== params.path || + JSON.stringify(saved.position) !== JSON.stringify(params.position) || + JSON.stringify(saved.startPosition) !== JSON.stringify(params.startPosition); + if (moved) { + if (!(await pendingActions.discard(saved))) return; + hostedComment = undefined; + } + } + if (!setReviewDraftBusy(store.scope, true)) + throw new Error("Wait for the pending comment to finish saving."); + locked = true; + if (hostedComment && saved && body !== saved.body) { + const result = await updateComment({ + ...target, + input: { + ...target.input, + kind: "review-comment", + commentId: hostedComment.commentId, + body, + }, + }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + } + const comment = { + id: commentId, + path: params.path, + ...(params.oldPath ? { oldPath: params.oldPath } : {}), + position: params.position, + ...(params.startPosition ? { startPosition: params.startPosition } : {}), + headSha: params.headSha, + code: params.code, + body, + ...(hostedComment ? { hostedComment } : {}), + }; + store.complete((draft) => ({ + ...draft, + comments: [...draft.comments.filter((comment) => comment.id !== commentId), comment], + })); + if (!(await store.flush())) throw new Error("Could not save the draft on this device."); + if (!hostedComment && detail.data?.capabilities.review.draftComment === true) { + if (!store.isReady()) + throw new Error( + "Your comment is saved on this device. Wait for the account refresh, then save again.", + ); + const result = await saveDraftComment({ + ...target, + input: { ...target.input, comment }, + }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + store.complete((draft) => ({ + ...draft, + comments: draft.comments.map((entry) => + entry.id === comment.id ? { ...entry, hostedComment: result.value } : entry, + ), + })); + if (!(await store.flush())) throw new Error("Could not save the draft on this device."); + } + detail.refresh(); + navigation.goBack(); + })() + .catch(async (cause: unknown) => { + Alert.alert( + "Could not save the review comment", + `${cause instanceof Error ? cause.message : "Saving failed."}\nYour draft is kept in T3. Try saving again.`, + ); + }) + .finally(async () => { + if (locked) setReviewDraftBusy(store.scope, false); + pending.current = false; + setBusy(false); + }); + }; + const agent = usePullRequestThreads(params, detail.data, params.path); + const addToComposer = async () => { + if (!store.isReady() || accountChanged || !detail.data || !body.trim()) return; + const added = await agent.addToComposer({ + context: pendingCommentComposerContext(detail.data, { + path: params.path, + body, + code: params.code, + position: params.position, + ...(params.startPosition ? { startPosition: params.startPosition } : {}), + }), + filePath: params.path, + }); + if (added) navigation.goBack(); }; + const canSave = + !uploading && + !store.busy && + store.ready && + !accountChanged && + Boolean(body.trim()) && + !detail.isPending && + detail.error === null && + !stale && + permissions.canInlineComment; return ( void save()} - /> + + @@ -166,7 +193,7 @@ export function DiffCommentAnnotation({ variant="outline" disabled={ speech?.preparing || - pending || + blocked || (!speech?.blocksSubmission && !secondaryAction.allowEmpty && !trimmedText) } onClick={() => submit(speech, secondaryAction.onAction, secondaryAction.allowEmpty)} @@ -177,7 +204,7 @@ export function DiffCommentAnnotation({ ) : null} + + ); + }; + const viewTabs = ( + { + const next = values[0]; + if (next === "files" || next === "git") { + if (next === "files" && selection.path) setWorkspacePath(selection.path); + setView(next); + } + }} + aria-label="Workspace navigation" + > + + + Git + + {changedCount} + + + + + Files + + + ); + return ( +
+
+
+ + {view === "git" + ? scope === "staged" + ? "Staged" + : "Unstaged" + : (workspacePath ?? "Workspace files")} + + {!fileTreeOpen ? viewTabs : null} +
+ {fileTreeOpen ? ( +
+ {viewTabs} +
+ ) : null} +
+
+
+ {view === "files" ? ( + + ) : ( + <> + {data.query.error ? ( +
+

{data.query.error}

+ +
+ ) : null} + {files.length === 0 ? ( +

+ {!optimistic && data.query.isPending ? "Loading changes…" : "No changes."} +

+ ) : ( + + !props.ignoreWhitespace && + canStage && + !pending && + Boolean( + reviewSelectedLinesPatch( + data.hunksByPath.get(resolveFileDiffPath(file)) ?? [], + range, + scope === "staged", + props.split ? "split" : "unified", + ), + ) + } + onStageLines={ + view === "git" && !props.ignoreWhitespace + ? (file, range) => { + const path = resolveFileDiffPath(file); + const patch = reviewSelectedLinesPatch( + data.hunksByPath.get(path) ?? [], + range, + scope === "staged", + props.split ? "split" : "unified", + ); + if (patch) void updateStaging(scope, stagingPaths(scope, path), patch); + } + : undefined + } + canRevertLines={(file, range) => + !props.ignoreWhitespace && + canRevert && + !pending && + Boolean( + reviewSelectedLinesPatch( + data.hunksByPath.get(resolveFileDiffPath(file)) ?? [], + range, + true, + props.split ? "split" : "unified", + ), + ) + } + onRevertLines={ + view === "git" && scope === "unstaged" && !props.ignoreWhitespace + ? (file, range) => { + const path = resolveFileDiffPath(file); + const patch = reviewSelectedLinesPatch( + data.hunksByPath.get(path) ?? [], + range, + true, + props.split ? "split" : "unified", + ); + if (patch) + setReverting({ + paths: stagingPaths(scope, path), + patch, + label: `the selected ${range.start === range.end ? "line" : "lines"} in ${path}`, + }); + } + : undefined + } + renderHunkAction={ + view === "git" + ? (file, index) => { + const path = resolveFileDiffPath(file); + const patch = reviewHunkPatch( + data.hunksByPath.get(path) ?? [], + file.hunks[index]?.hunkSpecs ?? "", + ); + return patch ? ( +
+ {scope === "unstaged" ? ( + + ) : null} + +
+ ) : null; + } + : undefined + } + renderHeaderFilenameSuffix={renderActions} + renderHeaderMetadata={(file) => { + const stat = file.cacheKey?.endsWith(":pending") + ? data.source?.files?.find((stat) => stat.path === resolveFileDiffPath(file)) + : null; + return ; + }} + renderHeaderPrefix={(file) => ( + + )} + onRevealSearchMatch={(key) => { + const file = files.find((file) => buildFileDiffIdentityKey(file) === key); + if (file) + setFolded( + (current) => + new Set( + [...current].filter((path) => path !== resolveFileDiffPath(file)), + ), + ); + }} + options={{ + diffStyle: props.split ? "split" : "unified", + overflow: props.wordWrap ? "wrap" : "scroll", + theme: resolveDiffThemeName(resolvedTheme), + themeType: resolvedTheme, + ...(optimistic ? {} : { loadDiffFiles: data.loadDiffFiles }), + }} + /> + )} + {data.patches.settledFileCount < data.files.length && !selectedPath ? ( + + ) : null} + + )} +
+ {fileTreeOpen ? ( + + ) : null} +
+ { + if (!open && !pending) setReverting(null); + }} + > + + + Revert unstaged changes? + + This reverts {reverting?.patch ? "only " : "unstaged edits in "} + {reverting?.label}. Staged changes are kept. + {!reverting?.patch ? " Selected new, untracked files will be deleted." : null} + + + + + + + + +
+ ); +} diff --git a/apps/web/src/components/diffs/WorkingChangesTree.tsx b/apps/web/src/components/diffs/WorkingChangesTree.tsx new file mode 100644 index 0000000000000..759deb4aa69cb --- /dev/null +++ b/apps/web/src/components/diffs/WorkingChangesTree.tsx @@ -0,0 +1,340 @@ +import { LegendList, type LegendListRef } from "@legendapp/list/react"; +import { reviewFileTreeRows, reviewFolderContains } from "@t3tools/client-runtime/review-workspace"; +import { + reviewTreeRowKey, + selectReviewTreeRows, + reviewTreeActionFiles, + type ReviewTreeRow, + type ReviewTreeSelection, +} from "@t3tools/client-runtime/review-tree-selection"; +import { ChevronDown, ChevronRight, Minus, Plus, Undo2, PanelsTopLeft } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { useTheme } from "~/hooks/useTheme"; +import { PierreEntryIcon } from "../chat/PierreEntryIcon"; +import { ReviewIcon as MorphIcon } from "~/components/ReviewIcon"; +import { Button } from "../ui/button"; +import { Badge } from "../ui/badge"; +import { Menu, MenuItem, MenuPopup } from "../ui/menu"; +import { ReviewPath } from "./ReviewPath"; +import type { DiffFileTreeEntry } from "./diffFileTree.logic"; + +type Scope = "staged" | "unstaged"; +type Row = + | { kind: "section"; scope: Scope; path: string } + | (ReturnType>[number] & { scope: Scope }); + +/** One virtualized list keeps Git sections adjacent and exposes actions beside every row. */ +export function WorkingChangesTree({ + staged, + unstaged, + selected, + disabled, + revertDisabled, + onSelect, + onStage, + onRevert, + onViewAll, +}: { + staged: ReadonlyArray; + unstaged: ReadonlyArray; + selected: { scope: Scope; path: string } | null; + disabled: boolean; + revertDisabled: boolean; + onSelect: (scope: Scope, path: string) => void; + onStage: (scope: Scope, paths: ReadonlyArray | null) => void; + onRevert: (paths: ReadonlyArray | null) => void; + onViewAll: (scope: Scope) => void; +}) { + const { resolvedTheme } = useTheme(); + const list = useRef(null); + const [open, setOpen] = useState({ staged: true, unstaged: true }); + const [collapsed, setCollapsed] = useState>(() => new Set()); + const [selection, setSelection] = useState(() => ({ + rows: selected ? [{ ...selected, kind: "file" }] : [], + anchor: selected ? { ...selected, kind: "file" } : null, + })); + const [contextMenu, setContextMenu] = useState<{ + row: ReviewTreeRow; + x: number; + y: number; + } | null>(null); + const menuAnchor = contextMenu + ? { getBoundingClientRect: () => new DOMRect(contextMenu.x, contextMenu.y, 0, 0) } + : undefined; + const lastRevealed = useRef(null); + const sections = { staged, unstaged }; + const sectionPaths = { + staged: staged.map((file) => file.path), + unstaged: unstaged.map((file) => file.path), + }; + const rows = (["staged", "unstaged"] as const).flatMap((scope) => [ + { kind: "section" as const, scope, path: scope }, + ...(open[scope] + ? reviewFileTreeRows( + scope === "staged" ? staged : unstaged, + (entry) => entry.path, + new Set( + [...collapsed] + .filter((key) => key.startsWith(`${scope}:`)) + .map((key) => key.slice(scope.length + 1)), + ), + ).map((row) => ({ ...row, scope })) + : []), + ]) satisfies Row[]; + const selectableRows = rows.filter((row) => row.kind !== "section"); + const selectedKeys = new Set(selection.rows.map(reviewTreeRowKey)); + const selectedFolders = selection.rows.filter((row) => row.kind === "folder"); + const isInSelection = (row: ReviewTreeRow) => + selectedKeys.has(reviewTreeRowKey(row)) || + selectedFolders.some( + (folder) => folder.scope === row.scope && reviewFolderContains(folder.path, row.path), + ); + const contextPaths = contextMenu + ? reviewTreeActionFiles(sectionPaths[contextMenu.row.scope], selection.rows, contextMenu.row) + : []; + const revealScope = selected?.scope; + const revealPath = selected?.path; + const revealKey = selected ? reviewTreeRowKey(selected) : null; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Scroll the virtualized tree to an externally selected file. + useEffect(() => { + if (revealKey === lastRevealed.current) return; + const index = rows.findIndex( + (row) => row.kind === "file" && row.scope === revealScope && row.path === revealPath, + ); + if (index >= 0) { + lastRevealed.current = revealKey; + list.current?.scrollToIndex({ index, animated: false, viewPosition: 0.5 }); + } + if (revealKey === null) lastRevealed.current = null; + }, [rows, revealScope, revealPath, revealKey]); + const toggleFolder = (row: ReviewTreeRow) => + setCollapsed((current) => { + const next = new Set(current); + const key = `${row.scope}:${row.path}`; + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + const actions = (scope: Scope, row: ReviewTreeRow | null) => { + const path = row?.path ?? null; + const groupTarget = row && isInSelection(row); + const label = + groupTarget && (selection.rows.length > 1 || row.kind === "folder") + ? "selected files" + : (path ?? "all files"); + const targets = () => reviewTreeActionFiles(sectionPaths[scope], selection.rows, row); + return ( +
+ {scope === "unstaged" ? ( + + ) : null} + +
+ ); + }; + return ( +
+ `${row.scope}:${row.kind}:${row.path}`} + estimatedItemSize={24} + drawDistance={240} + className="min-h-0 flex-1 overflow-x-hidden px-4" + extraData={{ selected, selectedKeys, disabled, revertDisabled, resolvedTheme }} + renderItem={({ item: row }) => { + if (row.kind === "section") + return ( +
+ + + {sections[row.scope].length} + + + {actions(row.scope, null)} +
+ ); + const folder = row.kind === "folder"; + const batchSelected = selectedKeys.has(reviewTreeRowKey(row)); + const current = selected?.scope === row.scope && selected.path === row.path; + const status = folder ? "modified" : row.file.status; + const statusClass = + status === "added" || status === "untracked" + ? "text-success-foreground" + : status === "deleted" + ? "text-destructive-foreground" + : status === "renamed" + ? "text-warning-foreground" + : "text-info-foreground"; + return ( +
{ + event.preventDefault(); + event.stopPropagation(); + if (!isInSelection(row)) setSelection({ rows: [row], anchor: row }); + setContextMenu({ row, x: event.clientX, y: event.clientY }); + }} + > + {folder ? ( + + ) : null} + +
+ {actions(row.scope, row)} +
+ + {folder + ? "•" + : row.file.status === "added" + ? "A" + : row.file.status === "deleted" + ? "D" + : row.file.status === "renamed" + ? "R" + : "M"} + +
+ ); + }} + /> + { + if (!open) setContextMenu(null); + }} + > + + {contextMenu ? ( + <> + { + onStage(contextMenu.row.scope, contextPaths); + setContextMenu(null); + }} + > + + {contextMenu.row.scope === "staged" ? "Unstage changes" : "Stage changes"} + + {contextMenu.row.scope === "unstaged" ? ( + { + onRevert(contextPaths); + setContextMenu(null); + }} + > + + Revert changes + + ) : null} + + ) : null} + + +
+ ); +} diff --git a/apps/web/src/components/diffs/diffFileTree.logic.ts b/apps/web/src/components/diffs/diffFileTree.logic.ts index 41d2a5e676e70..612b38c88f2a0 100644 --- a/apps/web/src/components/diffs/diffFileTree.logic.ts +++ b/apps/web/src/components/diffs/diffFileTree.logic.ts @@ -12,6 +12,26 @@ import { basenameOfPath, resolvePierreIconForEntry, T3_PIERRE_ICONS } from "~/pi export interface DiffFileTreeEntry { readonly path: string; readonly status: GitStatus; + readonly reviewed?: boolean; + readonly unresolvedComments?: number; +} + +/** Pierre folder ids end with a slash; a selected folder includes its hidden descendants. */ +export function diffFileTreeReviewEntries( + entries: ReadonlyArray, + selectedPaths: ReadonlyArray, + clickedPath: string, +): ReadonlyArray { + const contains = (target: string, path: string) => + target.endsWith("/") ? path.startsWith(target) : path === target; + const targets = selectedPaths.some((path) => contains(path, clickedPath)) + ? selectedPaths + : [clickedPath]; + const exact = new Set(targets); + const folders = targets.filter((path) => path.endsWith("/")); + return entries.filter( + (entry) => exact.has(entry.path) || folders.some((path) => entry.path.startsWith(path)), + ); } function toGitStatus(file: FileDiffMetadata): GitStatus { diff --git a/apps/web/src/components/diffs/useReviewFilePatches.ts b/apps/web/src/components/diffs/useReviewFilePatches.ts index 728f43433b010..dc2aefa0f6943 100644 --- a/apps/web/src/components/diffs/useReviewFilePatches.ts +++ b/apps/web/src/components/diffs/useReviewFilePatches.ts @@ -1,3 +1,4 @@ +import { orderReviewFiles } from "@t3tools/client-runtime/review-workspace"; import { RegistryContext, useAtomValue } from "@effect/atom-react"; import type { FileDiffMetadata } from "@pierre/diffs"; import type { @@ -13,10 +14,6 @@ import { useStableCallback } from "~/hooks/useStableCallback"; import { getRenderablePatch, resolveFileDiffPath, type RenderablePatch } from "~/lib/diffRendering"; import { reviewEnvironment } from "~/state/review"; -const filePathCollator = new Intl.Collator(undefined, { - numeric: true, - sensitivity: "base", -}); const INITIAL_FILE_COUNT = 4; export function useReviewFilePatches({ @@ -28,6 +25,7 @@ export function useReviewFilePatches({ theme, previewInput, preview, + workingTreeScope, }: { environmentId: EnvironmentId | undefined; cwd: string | undefined; @@ -38,6 +36,7 @@ export function useReviewFilePatches({ /** The preview the file list came from; a newer revision of it refreshes loaded patches. */ previewInput: ReviewDiffPreviewInput | undefined; preview: RenderablePatch | null; + workingTreeScope?: "staged" | "unstaged" | undefined; }) { const registry = useContext(RegistryContext); const scope = JSON.stringify([ @@ -47,7 +46,7 @@ export function useReviewFilePatches({ source?.baseRef, source?.headRef, baseRef, - ignoreWhitespace, + workingTreeScope, ]); const files = sortedReviewFiles(source); const automaticPaths = files.slice(0, INITIAL_FILE_COUNT).map((file) => file.path); @@ -66,9 +65,10 @@ export function useReviewFilePatches({ previewInput, scope, requested, + workingTreeScope, }); const queriedPatchesAtom = reviewPatchesAtom( - JSON.stringify([theme, queries.map(({ index, target }) => [index, target])]), + JSON.stringify([theme, ignoreWhitespace, queries.map(({ index, target }) => [index, target])]), ); const patches = useAtomValue(queriedPatchesAtom); const retry = useStableCallback((path: string) => { @@ -117,7 +117,14 @@ export function useReviewFilePatches({ { name: file.path, ...(file.previousPath ? { prevName: file.previousPath } : {}), - type: file.previousPath ? "rename-changed" : "change", + type: + file.status === "added" + ? "new" + : file.status === "deleted" + ? "deleted" + : file.previousPath || file.status === "renamed" + ? "rename-changed" + : "change", hunks: [], additionLines: [], deletionLines: [], @@ -130,9 +137,7 @@ export function useReviewFilePatches({ }, ]; }) - : (preview?.kind === "files" ? preview.files : []).toSorted((a, b) => - filePathCollator.compare(resolveFileDiffPath(a), resolveFileDiffPath(b)), - ); + : orderReviewFiles(preview?.kind === "files" ? preview.files : [], resolveFileDiffPath); const fileStates = new Map( files.map((file, index) => { const patch = patches.get(index); @@ -151,7 +156,22 @@ export function useReviewFilePatches({ ] as const; }), ); + const readyFilePaths = new Set( + files + .filter((_, index) => { + const patch = patches.get(index); + return patch && patch._tag !== "Initial"; + }) + .map((file) => file.path), + ); + const rawPatches = [...patches.values()].flatMap((result) => + result._tag === "Success" ? [result.value.source.diff] : [], + ); return { + rawPatches, + scope, + settledFileCount, + readyFilePaths, fileStates, isPending: [...patches.values()].some((patch) => patch._tag === "Initial" || patch.waiting), retry, @@ -167,10 +187,10 @@ function reviewFileQueries({ cwd, source, baseRef, - ignoreWhitespace, previewInput, scope, requested, + workingTreeScope, }: { environmentId: EnvironmentId | undefined; cwd: string | undefined; @@ -178,6 +198,7 @@ function reviewFileQueries({ baseRef: string | null; ignoreWhitespace: boolean; previewInput: ReviewDiffPreviewInput | undefined; + workingTreeScope?: "staged" | "unstaged" | undefined; scope: string; requested: { scope: string; paths: string[] }; }) { @@ -201,7 +222,8 @@ function reviewFileQueries({ cwd, ...(baseRef ? { baseRef } : {}), ...(source.kind === "commit" ? { commitSha: source.id } : {}), - ignoreWhitespace, + ignoreWhitespace: false, + ...(workingTreeScope ? { workingTreeScope } : {}), file: { path: file.path, previousPath: file.previousPath, @@ -216,17 +238,22 @@ function reviewFileQueries({ } function sortedReviewFiles(source: ReviewDiffPreviewSource | null) { - return source?.files?.toSorted((a, b) => filePathCollator.compare(a.path, b.path)) ?? []; + return orderReviewFiles(source?.files ?? [], (file) => file.path); } type ReviewPatchTarget = Parameters[0]; const parsedPatchAtom = Atom.family((key: string) => { - const [theme, target] = JSON.parse(key) as ["light" | "dark", ReviewPatchTarget]; + const [theme, ignoreWhitespace, target] = JSON.parse(key) as [ + "light" | "dark", + boolean, + ReviewPatchTarget, + ]; return Atom.map(reviewEnvironment.diffFilePatch(target), (result) => AsyncResult.map(result, (source) => { let patch = getRenderablePatch(source.diff, `diff-panel:${theme}`, { compactPartialHunkOffsets: true, + ignoreWhitespace, }); if (patch?.kind === "files" && patch.files.length === 1 && source.files?.length === 1) { const stat = source.files[0]!; @@ -241,12 +268,14 @@ const parsedPatchAtom = Atom.family((key: string) => { }); const reviewPatchesAtom = Atom.family((key: string) => { - const [theme, entries] = JSON.parse(key) as [ + const [theme, ignoreWhitespace, entries] = JSON.parse(key) as [ "light" | "dark", + boolean, ReadonlyArray, ]; const parsed = entries.map( - ([index, target]) => [index, parsedPatchAtom(JSON.stringify([theme, target]))] as const, + ([index, target]) => + [index, parsedPatchAtom(JSON.stringify([theme, ignoreWhitespace, target]))] as const, ); return Atom.make((get) => new Map(parsed.map(([index, atom]) => [index, get(atom)]))); }); diff --git a/apps/web/src/components/diffs/workingChangesState.test.ts b/apps/web/src/components/diffs/workingChangesState.test.ts new file mode 100644 index 0000000000000..d1666732d3fe9 --- /dev/null +++ b/apps/web/src/components/diffs/workingChangesState.test.ts @@ -0,0 +1,440 @@ +import { describe, expect, it } from "vite-plus/test"; +import { hydratePartialDiff } from "@pierre/diffs"; +import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; +import { + reviewPatchHunks, + reviewSelectedLinesPatch, +} from "@t3tools/client-runtime/review-workspace"; +import { reviewDiffEdits } from "@t3tools/client-runtime/review-diff-context"; +import { getRenderablePatch } from "~/lib/diffRendering"; +import { + applyWorkingChangesMutation, + type WorkingChangesSnapshot, + type WorkingChangesState, +} from "./workingChangesState"; + +const header = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n"; +function snapshot(body: string): WorkingChangesSnapshot { + if (!body) return { files: [], hunksByPath: new Map() }; + const patch = header + body; + const parsed = getRenderablePatch(patch, patch); + if (parsed?.kind !== "files") throw new Error("Invalid fixture"); + return { + files: parsed.files, + hunksByPath: new Map([["file.txt", reviewPatchHunks(patch)[0]!.hunks]]), + }; +} +function edits(snapshot: WorkingChangesSnapshot) { + return snapshot.files.flatMap((file) => + file.hunks.flatMap((hunk) => + hunk.hunkContent.flatMap((content) => + content.type === "change" + ? [ + ...file.deletionLines + .slice(content.deletionLineIndex, content.deletionLineIndex + content.deletions) + .map((line) => `-${line.trimEnd()}`), + ...file.additionLines + .slice(content.additionLineIndex, content.additionLineIndex + content.additions) + .map((line) => `+${line.trimEnd()}`), + ] + : [], + ), + ), + ); +} +const stagedAlpha = snapshot("@@ -1,3 +1,3 @@\n-alpha\n+ALPHA\n beta\n gamma\n"); +const unstagedGamma = snapshot("@@ -1,3 +1,3 @@\n ALPHA\n beta\n-gamma\n+GAMMA\n"); +const combined = snapshot("@@ -1,3 +1,3 @@\n-alpha\n+ALPHA\n beta\n-gamma\n+GAMMA\n"); + +describe("optimistic working changes", () => { + it("reverts one selected added line while preserving staged and neighboring unstaged changes", () => { + const unstaged = snapshot("@@ -1,3 +1,4 @@\n ALPHA\n-beta\n+BETA\n+extra\n gamma\n"); + const patch = reviewSelectedLinesPatch( + unstaged.hunksByPath.get("file.txt")!, + { start: 3, end: 3, side: "additions" }, + true, + )!; + const next = applyWorkingChangesMutation({ staged: stagedAlpha, unstaged }, unstaged, { + scope: "unstaged", + paths: ["file.txt"], + patch, + revert: true, + }); + expect(edits(next.unstaged)).toEqual(["-beta", "+BETA"]); + expect(edits(next.staged)).toEqual(["-alpha", "+ALPHA"]); + expect(next.staged.hunksByPath.get("file.txt")).toEqual( + stagedAlpha.hunksByPath.get("file.txt"), + ); + }); + + it("removes a completed hunk from the all-files render but retains it as context in a selected file", () => { + const unstaged = snapshot( + "@@ -1,3 +1,3 @@\n-old\n+new\n context\n end\n@@ -20,1 +20,1 @@\n-other old\n+other new\n", + ); + const patch = unstaged.hunksByPath.get("file.txt")![0]!; + const mutation = { scope: "unstaged" as const, paths: ["file.txt"], patch, revert: true }; + const allFiles = applyWorkingChangesMutation({ staged: snapshot(""), unstaged }, unstaged, { + ...mutation, + retainContext: false, + }); + const singleFile = applyWorkingChangesMutation({ staged: snapshot(""), unstaged }, unstaged, { + ...mutation, + retainContext: true, + }); + expect(edits(allFiles.unstaged)).toEqual(["-other old", "+other new"]); + expect(allFiles.unstaged.files[0]!.hunks).toHaveLength(1); + expect(allFiles.unstaged.files[0]!.hunks[0]!.additionStart).toBe(20); + expect(singleFile.unstaged.files[0]!.hunks).toHaveLength(2); + const final = applyWorkingChangesMutation(singleFile, unstaged, { + ...mutation, + patch: singleFile.unstaged.hunksByPath.get("file.txt")![0]!, + retainContext: true, + }); + expect(final.unstaged.files).toEqual([]); + expect(final.unstaged.retainedFiles).toHaveLength(1); + expect(reviewDiffEdits(final.unstaged.retainedFiles![0]!)).toBe("[]"); + expect(final.staged.files).toEqual([]); + }); + + it("retains a fully reverted file as context for the single-file reader", () => { + const next = applyWorkingChangesMutation( + { staged: stagedAlpha, unstaged: unstagedGamma }, + combined, + { scope: "unstaged", paths: ["file.txt"], revert: true }, + ); + expect(next.unstaged.files).toEqual([]); + expect(next.unstaged.retainedFiles).toHaveLength(1); + expect(reviewDiffEdits(next.unstaged.retainedFiles![0]!)).toBe("[]"); + expect(edits(next.staged)).toEqual(["-alpha", "+ALPHA"]); + }); + + it("keeps a restored line visible after reverting an addition and then its remaining deletion", () => { + const unstaged = snapshot("@@ -1,3 +1,3 @@\n ALPHA\n-beta\n+BETA\n gamma\n"); + const patch = reviewSelectedLinesPatch( + unstaged.hunksByPath.get("file.txt")!, + { start: 2, end: 2, side: "additions" }, + true, + )!; + const partial = applyWorkingChangesMutation({ staged: stagedAlpha, unstaged }, unstaged, { + scope: "unstaged", + paths: ["file.txt"], + patch, + revert: true, + }); + const cleared = applyWorkingChangesMutation(partial, unstaged, { + scope: "unstaged", + paths: ["file.txt"], + patch: partial.unstaged.hunksByPath.get("file.txt")![0]!, + revert: true, + }); + expect(cleared.unstaged.files).toEqual([]); + expect(cleared.unstaged.retainedFiles![0]!.additionLines).toContain("beta\n"); + }); + + it.each(["staged", "unstaged"] as const)( + "drops cleared hunks from the %s all-files view", + (scope) => { + const changes = snapshot( + "@@ -1,1 +1,1 @@\n-old\n+new\n@@ -20,1 +20,1 @@\n-other old\n+other new\n", + ); + const empty = snapshot(""); + const current = { + staged: scope === "staged" ? changes : empty, + unstaged: scope === "unstaged" ? changes : empty, + }; + const next = applyWorkingChangesMutation(current, changes, { + scope, + paths: ["file.txt"], + patch: changes.hunksByPath.get("file.txt")![0]!, + retainContext: false, + }); + expect(next[scope].files[0]!.hunks).toHaveLength(1); + expect(edits(next[scope])).toEqual(["-other old", "+other new"]); + }, + ); + + it("composes overlapping context when a hunk moves into an already-staged file", () => { + const current = { staged: stagedAlpha, unstaged: unstagedGamma }; + const next = applyWorkingChangesMutation(current, combined, { + scope: "unstaged", + paths: ["file.txt"], + patch: unstagedGamma.hunksByPath.get("file.txt")![0]!, + }); + expect(next.unstaged.files).toEqual([]); + expect(edits(next.staged)).toEqual(["-alpha", "+ALPHA", "-gamma", "+GAMMA"]); + expect( + next.staged.files[0]?.hunks.map((hunk) => [hunk.deletionStart, hunk.additionStart]), + ).toEqual([[1, 1]]); + // The retained authoritative snapshot is a complete rollback, not a mutated cache. + expect(current.staged).toBe(stagedAlpha); + expect(current.unstaged).toBe(unstagedGamma); + expect(edits(current.unstaged)).toEqual(["-gamma", "+GAMMA"]); + }); + + it("unstages a hunk without losing another staged edit in the same file", () => { + const next = applyWorkingChangesMutation( + { staged: combined, unstaged: snapshot("") }, + combined, + { + scope: "staged", + paths: ["file.txt"], + patch: unstagedGamma.hunksByPath.get("file.txt")![0]!, + }, + ); + expect(edits(next.staged)).toEqual(["-alpha", "+ALPHA"]); + expect(edits(next.unstaged)).toEqual(["-gamma", "+GAMMA"]); + }); + + it("keeps correct destination coordinates when unstaging an insertion before unstaged edits", () => { + const staged = snapshot("@@ -1,3 +1,4 @@\n alpha\n+inserted\n beta\n gamma\n"); + const unstaged = snapshot("@@ -1,4 +1,4 @@\n alpha\n inserted\n beta\n-gamma\n+GAMMA\n"); + const next = applyWorkingChangesMutation({ staged, unstaged }, snapshot(""), { + scope: "staged", + paths: ["file.txt"], + patch: staged.hunksByPath.get("file.txt")![0]!, + }); + expect(next.staged.files).toEqual([]); + expect(edits(next.unstaged)).toEqual(["+inserted", "-gamma", "+GAMMA"]); + expect(JSON.parse(reviewDiffEdits(next.unstaged.files[0]!))).toContainEqual([ + "+", + 4, + "GAMMA\n", + ]); + expect(JSON.parse(reviewDiffEdits(next.unstaged.files[0]!))).toContainEqual([ + "-", + 3, + "gamma\n", + ]); + }); + + it("stages selected added lines while keeping the remaining replacement unstaged", () => { + const current: WorkingChangesState = { + staged: snapshot(""), + unstaged: snapshot("@@ -1,3 +1,4 @@\n alpha\n-beta\n+BETA\n+extra\n gamma\n"), + }; + const selected = header + "@@ -1,3 +1,4 @@\n alpha\n beta\n+extra\n gamma\n"; + const next = applyWorkingChangesMutation(current, snapshot(""), { + scope: "unstaged", + paths: ["file.txt"], + patch: selected, + }); + expect(edits(next.staged)).toEqual(["+extra"]); + expect(edits(next.unstaged)).toEqual(["-beta", "+BETA"]); + for (const scope of [next.staged, next.unstaged]) { + for (const patch of scope.hunksByPath.get("file.txt")!) { + expect(parsePatchFiles(patch, { throwOnError: true })[0]?.files).toHaveLength(1); + } + } + expect(next.unstaged.files[0]?.additionLines).toEqual([ + "alpha\n", + "BETA\n", + "extra\n", + "gamma\n", + ]); + }); + + it("whole-file stage and unstage merge both scopes without dropping existing edits", () => { + const staged = applyWorkingChangesMutation( + { staged: stagedAlpha, unstaged: unstagedGamma }, + combined, + { scope: "unstaged", paths: ["file.txt"] }, + ); + expect(staged.unstaged.files).toEqual([]); + expect(edits(staged.staged)).toEqual(["-alpha", "+ALPHA", "-gamma", "+GAMMA"]); + const unstaged = applyWorkingChangesMutation( + { staged: stagedAlpha, unstaged: unstagedGamma }, + combined, + { scope: "staged", paths: ["file.txt"] }, + ); + expect(unstaged.staged.files).toEqual([]); + expect(edits(unstaged.unstaged)).toEqual(["-alpha", "+ALPHA", "-gamma", "+GAMMA"]); + }); + + it("revert removes only the unstaged entry and retains the staged snapshot", () => { + const next = applyWorkingChangesMutation( + { staged: stagedAlpha, unstaged: unstagedGamma }, + combined, + { scope: "unstaged", paths: ["file.txt"], revert: true }, + ); + expect(next.unstaged.files).toEqual([]); + expect(edits(next.staged)).toEqual(["-alpha", "+ALPHA"]); + }); + + it("keeps a staged hunk's open code region as context, alongside remaining edits", () => { + const unstaged = snapshot( + "@@ -10,3 +10,3 @@\n before\n-old\n+new\n after\n@@ -30,3 +30,3 @@\n other before\n-other old\n+other new\n other after\n", + ); + const next = applyWorkingChangesMutation({ staged: snapshot(""), unstaged }, unstaged, { + scope: "unstaged", + paths: ["file.txt"], + patch: unstaged.hunksByPath.get("file.txt")![0]!, + }); + const file = next.unstaged.files[0]!; + expect(file.hunks.map((hunk) => [hunk.additionStart, hunk.additionCount])).toEqual([ + [10, 3], + [30, 3], + ]); + expect(file.hunks[0]!.hunkContent).toEqual([ + { type: "context", lines: 3, additionLineIndex: 0, deletionLineIndex: 0 }, + ]); + expect(file.additionLines.slice(0, 3)).toEqual(["before\n", "new\n", "after\n"]); + expect(edits(next.unstaged)).toEqual(["-other old", "+other new"]); + expect(file.additionLines.some((line) => line.includes("\u0000unknown:"))).toBe(false); + }); + + it("keeps the open file after staging its final edit without leaving it in the unstaged tree", () => { + const next = applyWorkingChangesMutation( + { staged: snapshot(""), unstaged: unstagedGamma }, + combined, + { + scope: "unstaged", + paths: ["file.txt"], + }, + ); + expect(next.unstaged.files).toEqual([]); + expect(next.unstaged.retainedFiles).toHaveLength(1); + const file = next.unstaged.retainedFiles![0]!; + expect(reviewDiffEdits(file)).toBe("[]"); + expect(file.additionLines).toEqual(["ALPHA\n", "beta\n", "GAMMA\n"]); + expect(file.hunks[0]!.additionStart).toBe(1); + }); + + it("retains the same context and line mapping when staging selected lines inside a hunk", () => { + const unstaged = snapshot("@@ -10,4 +10,5 @@\n before\n-old\n+new\n+extra\n after\n end\n"); + const next = applyWorkingChangesMutation({ staged: snapshot(""), unstaged }, unstaged, { + scope: "unstaged", + paths: ["file.txt"], + patch: header + "@@ -10,4 +10,5 @@\n before\n old\n+extra\n after\n end\n", + }); + expect(edits(next.unstaged)).toEqual(["-old", "+new"]); + expect(next.unstaged.files[0]!.hunks[0]!.additionStart).toBe(10); + expect(next.unstaged.files[0]!.additionLines).toEqual([ + "before\n", + "new\n", + "extra\n", + "after\n", + "end\n", + ]); + const authoritative = snapshot( + "@@ -10,5 +10,5 @@\n before\n-old\n+new\n extra\n after\n end\n", + ); + expect(reviewDiffEdits(next.unstaged.files[0]!)).toBe(reviewDiffEdits(authoritative.files[0]!)); + }); + + it("preserves both open regions across consecutive writes after Git regroups the remaining patch", () => { + const unstaged = snapshot( + "@@ -10,3 +10,3 @@\n before\n-old\n+new\n after\n@@ -30,3 +30,3 @@\n other before\n-other old\n+other new\n other after\n", + ); + const first = applyWorkingChangesMutation({ staged: snapshot(""), unstaged }, unstaged, { + scope: "unstaged", + paths: ["file.txt"], + patch: unstaged.hunksByPath.get("file.txt")![0]!, + }); + const refreshed = snapshot( + "@@ -30,3 +30,3 @@\n other before\n-other old\n+other new\n other after\n", + ); + const second = applyWorkingChangesMutation( + { + staged: first.staged, + unstaged: { ...refreshed, files: first.unstaged.files }, + }, + unstaged, + { + scope: "unstaged", + paths: ["file.txt"], + patch: refreshed.hunksByPath.get("file.txt")![0]!, + }, + ); + expect(second.unstaged.files).toEqual([]); + const file = second.unstaged.retainedFiles![0]!; + expect(reviewDiffEdits(file)).toBe("[]"); + expect(file.hunks.map((hunk) => [hunk.additionStart, hunk.additionCount])).toEqual([ + [10, 3], + [30, 3], + ]); + expect(file.additionLines).toEqual([ + "before\n", + "new\n", + "after\n", + "other before\n", + "other new\n", + "other after\n", + ]); + }); + + it("retains hydrated full contents and region indices so expanded context stays available", () => { + const oldLines = Array.from({ length: 40 }, (_, index) => `line ${index + 1}\n`); + const newLines = [...oldLines]; + newLines[10] = "new first\n"; + newLines[30] = "new second\n"; + const unstaged = snapshot( + "@@ -10,3 +10,3 @@\n line 10\n-line 11\n+new first\n line 12\n@@ -30,3 +30,3 @@\n line 30\n-line 31\n+new second\n line 32\n", + ); + hydratePartialDiff("merge", unstaged.files[0]!, { + oldFile: { name: "file.txt", contents: oldLines.join("") }, + newFile: { name: "file.txt", contents: newLines.join("") }, + }); + const next = applyWorkingChangesMutation({ staged: snapshot(""), unstaged }, unstaged, { + scope: "unstaged", + paths: ["file.txt"], + patch: unstaged.hunksByPath.get("file.txt")![0]!, + }); + const file = next.unstaged.files[0]!; + expect(file.isPartial).toBe(false); + expect(file.additionLines).toEqual(newLines); + expect(file.deletionLines[10]).toBe("new first\n"); + expect(file.deletionLines[30]).toBe("line 31\n"); + expect(file.hunks.map((hunk) => [hunk.additionStart, hunk.additionLineIndex])).toEqual([ + [10, 9], + [30, 29], + ]); + expect(file.hunks.map((hunk) => hunk.splitLineStart)).toEqual([9, 29]); + expect(file.splitLineCount).toBe(40); + expect(file.unifiedLineCount).toBe(41); + expect(edits(next.unstaged)).toEqual(["-line 31", "+new second"]); + }); + + it("keeps nearby and far hydrated windows while selected insertion lines become context", () => { + const original = Array.from({ length: 150 }, (_, index) => `line ${index + 1}\n`); + const working = [...original]; + working[82] = "changed near\n"; + working[123] = "changed far\n"; + working.splice(83, 0, "extra\n"); + const unstaged = snapshot( + "@@ -80,7 +80,8 @@\n line 80\n line 81\n line 82\n-line 83\n+changed near\n+extra\n line 84\n line 85\n line 86\n@@ -121,7 +122,7 @@\n line 121\n line 122\n line 123\n-line 124\n+changed far\n line 125\n line 126\n line 127\n", + ); + hydratePartialDiff("merge", unstaged.files[0]!, { + oldFile: { name: "file.txt", contents: original.join("") }, + newFile: { name: "file.txt", contents: working.join("") }, + }); + const selected = + header + + "@@ -80,7 +80,8 @@\n line 80\n line 81\n line 82\n line 83\n+extra\n line 84\n line 85\n line 86\n"; + const next = applyWorkingChangesMutation({ staged: snapshot(""), unstaged }, unstaged, { + scope: "unstaged", + paths: ["file.txt"], + patch: selected, + }); + const file = next.unstaged.files[0]!; + expect(file.isPartial).toBe(false); + expect(file.additionLines).toEqual(working); + expect(file.deletionLines[83]).toBe("extra\n"); + expect(file.hunks.map((hunk) => [hunk.additionStart, hunk.additionCount])).toEqual([ + [80, 8], + [122, 7], + ]); + expect(file.hunks.map((hunk) => hunk.splitLineStart)).toEqual([79, 121]); + expect(file.splitLineCount).toBe(151); + expect(edits(next.unstaged)).toEqual([ + "-line 83", + "+changed near", + "-line 124", + "+changed far", + ]); + expect(edits(next.staged)).toEqual(["+extra"]); + const refreshed = snapshot( + "@@ -83 +83 @@\n-line 83\n+changed near\n@@ -125 +125 @@\n-line 124\n+changed far\n", + ); + expect(reviewDiffEdits(file)).toBe(reviewDiffEdits(refreshed.files[0]!)); + }); +}); diff --git a/apps/web/src/components/diffs/workingChangesState.ts b/apps/web/src/components/diffs/workingChangesState.ts new file mode 100644 index 0000000000000..a5c4442b4f885 --- /dev/null +++ b/apps/web/src/components/diffs/workingChangesState.ts @@ -0,0 +1,5 @@ +export { + applyWorkingChangesMutation, + type WorkingChangesSnapshot, + type WorkingChangesState, +} from "@t3tools/client-runtime/review-working-changes-state"; diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 8729e74d27e0c..1f218cac1db63 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -2,6 +2,7 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; import type { ContextMenuItem as TreeContextMenuItem, ContextMenuOpenContext as TreeContextMenuOpenContext, + GitStatusEntry, } from "@pierre/trees"; import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react"; @@ -55,8 +56,12 @@ interface FileBrowserPanelProps { onRefreshSelectedFile?: () => void; workspaceMutationId: string | null; answersPanelFind?: boolean; + /** Existing Git status data decorates loaded rows without loading more directories. */ + gitChanges?: ReadonlyArray; } +const NO_GIT_CHANGES: ReadonlyArray = []; + function treePath(entry: ProjectEntry): string { return entry.kind === "directory" ? `${entry.path}/` : entry.path; } @@ -157,10 +162,27 @@ export default function FileBrowserPanel({ workspaceMutationId, hostFolder = false, answersPanelFind = false, + gitChanges = NO_GIT_CHANGES, }: FileBrowserPanelProps) { const panelPath = (relativePath: string) => hostFolder ? joinHostPath(cwd, relativePath) : relativePath; const { resolvedTheme } = useTheme(); + const selectionCSS = (() => { + if (!selectedPath) return PIERRE_TREE_UNSAFE_CSS; + const path = selectedPath.replace(/\/$/, ""); + return `${PIERRE_TREE_UNSAFE_CSS} + button[data-type='item']:is([data-item-path=${CSS.escape(path)}], [data-item-path=${CSS.escape(`${path}/`)}])::before { + content: ''; + position: absolute; + inset: 0; + display: block; + pointer-events: none; + outline: 1px solid var(--ring); + outline-offset: -1px; + border-radius: var(--radius-md); + } + `; + })(); const composerRef = useComposerHandleContext(); const fileContextMenu = useFileContextMenu(environmentId); const { @@ -316,6 +338,16 @@ export default function FileBrowserPanel({ loadingDirectoriesRef.current = loadingDirectories; model.setComposition(model.getComposition()); }, [loadingDirectories, model]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- installs selection styling into the imperative tree shadow DOM + useEffect(() => { + const shadow = model.getFileTreeContainer()?.shadowRoot; + if (!shadow) return; + const style = document.createElement("style"); + style.dataset.reviewSelection = "true"; + style.textContent = selectionCSS; + shadow.append(style); + return () => style.remove(); + }, [model, selectionCSS]); const search = useFileTreeSearch(model); // Pierre restores the unfiltered tree when a search has no matches. const hasNoSearchMatches = search.value.trim().length > 0 && search.matchingPaths.length === 0; @@ -391,16 +423,19 @@ export default function FileBrowserPanel({ }, [directoryPaths, expansionRoot, load, model, pendingExpandedPaths]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pushes ignored-file status into the imperative Pierre tree model useEffect(() => { - model.setGitStatus( - entries - .filter((entry) => entry.ignored) - .map((entry) => ({ - path: treePath(entry), - status: "ignored", - })), - ); - }, [entries, model]); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- loads the selected file's ancestor directories and aborts on cleanup + const statuses = new Map(); + for (const entry of entries) { + if (entry.ignored) { + const path = treePath(entry); + statuses.set(path, { path, status: "ignored" }); + } + } + // Pierre colors filenames, adds the status letter, and marks ancestor folders. + // Supplying unloaded paths also marks their visible ancestors without scanning them. + for (const change of gitChanges) statuses.set(change.path, change); + model.setGitStatus([...statuses.values()]); + }, [entries, gitChanges, model]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- loads the selected file ancestors into the imperative tree useEffect(() => { if (!selectedPath) return; const controller = new AbortController(); @@ -517,7 +552,7 @@ export default function FileBrowserPanel({ return (
{ diff --git a/apps/web/src/components/files/WorkspaceSourcePreview.tsx b/apps/web/src/components/files/WorkspaceSourcePreview.tsx new file mode 100644 index 0000000000000..040ae377c2ce9 --- /dev/null +++ b/apps/web/src/components/files/WorkspaceSourcePreview.tsx @@ -0,0 +1,53 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Hash from "effect/Hash"; +import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; +import { Button } from "../ui/button"; +import { useProjectFileQuery } from "./projectFilesQueryState"; +import ReadOnlySourcePreview from "./ReadOnlySourcePreview"; + +/** The explorer opens ordinary source without changing the active Git comparison. */ +export function WorkspaceSourcePreview(props: { + environmentId: EnvironmentId; + cwd: string; + path: string | null; + revision: string | null; + onOpenFile: (path: string) => void; +}) { + const file = useProjectFileQuery(props.environmentId, props.cwd, props.path); + useWorkspaceMutationRefresh({ + mutationId: props.revision, + resourceKey: `${props.environmentId}:${props.cwd}:${props.path}`, + refresh: file.refresh, + }); + if (!props.path) + return ( +

Select a file to view its contents.

+ ); + if (file.error) + return ( +
+

{file.error}

+ + +
+ ); + if (!file.data) return

Loading file…

; + return ( + <> + {file.data.truncated ? ( +

+ Showing the first 1 MB of this file. +

+ ) : null} + + + ); +} diff --git a/apps/web/src/components/preview/RightPanelResizeHandle.tsx b/apps/web/src/components/preview/RightPanelResizeHandle.tsx index affd8ee729388..2c46277adb07d 100644 --- a/apps/web/src/components/preview/RightPanelResizeHandle.tsx +++ b/apps/web/src/components/preview/RightPanelResizeHandle.tsx @@ -8,10 +8,17 @@ interface Props extends Pick< > { handlers: ResizableWidthHandlers; className?: string; + edge?: "left" | "right"; label?: string; } -export function RightPanelResizeHandle({ handlers, className, label, ...separatorProps }: Props) { +export function RightPanelResizeHandle({ + handlers, + className, + edge = "left", + label, + ...separatorProps +}: Props) { return (
{}} - explainPullRequest={() => {}} canFixFindings={false} startFixFindings={() => {}} handoffLabels={{ fixFindings: "Fix review findings" }} - canWriteGuide={false} - guideRunning={false} - guideWritten={false} - writeGuide={() => {}} pickableEnvironments={[]} actingEnvironmentId={environmentId} onActingEnvironmentChange={() => {}} can={() => false} autoMergeArmed={false} showsDraftToggle={false} - showsMergeNow={false} showsAutoMerge={false} showsMergeMethods={false} allowedMergeMethods={[]} diff --git a/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx b/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx index 3c075a937d181..091cd4d0d364c 100644 --- a/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx +++ b/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx @@ -3,7 +3,7 @@ import { canEditPullRequestMaintainerEdits, maintainerEditsChoiceInForce, type MaintainerEditsChoice, -} from "@t3tools/client-runtime/pull-request-editing"; +} from "@t3tools/client-runtime/review-editing"; import type { ChangeRequestTerminology } from "@t3tools/client-runtime/source-control-clients"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { @@ -16,14 +16,12 @@ import type { } from "@t3tools/contracts"; import { ArrowUpRightIcon, - BookOpenIcon, CopyIcon, HammerIcon, LinkIcon, MessageCircleQuestionIcon, MoreHorizontalIcon, RotateCcwIcon, - ScrollTextIcon, Trash2Icon, } from "lucide-react"; import { useId, useState } from "react"; @@ -73,21 +71,15 @@ export function PullRequestActionsMenu({ handoff, attachesToComposer, askAboutPullRequest, - explainPullRequest, canFixFindings, startFixFindings, handoffLabels, - canWriteGuide, - guideRunning, - guideWritten, - writeGuide, pickableEnvironments, actingEnvironmentId, onActingEnvironmentChange, can, autoMergeArmed, showsDraftToggle, - showsMergeNow, showsAutoMerge, showsMergeMethods, allowedMergeMethods, @@ -125,21 +117,15 @@ export function PullRequestActionsMenu({ handoff: string | null; attachesToComposer: boolean; askAboutPullRequest: () => void; - explainPullRequest: () => void; canFixFindings: boolean; startFixFindings: () => void; handoffLabels: { readonly fixFindings: string }; - canWriteGuide: boolean; - guideRunning: boolean; - guideWritten: boolean; - writeGuide: () => void; pickableEnvironments: ReadonlyArray; actingEnvironmentId: EnvironmentId; onActingEnvironmentChange: (environmentId: EnvironmentId) => void; can: (action: PullRequestAction) => boolean; autoMergeArmed: boolean; showsDraftToggle: boolean; - showsMergeNow: boolean; showsAutoMerge: boolean; showsMergeMethods: boolean; allowedMergeMethods: ReadonlyArray; @@ -255,27 +241,10 @@ export function PullRequestActionsMenu({ - - - - - {handoff === "explain" ? "Opening..." : `Explain this ${terminology.shortLabel}`} - - - A walk through the diff and what to read closely. - - - {handoff === "findings" ? "Preparing..." : handoffLabels.fixFindings} - {canWriteGuide ? ( - - - {guideRunning ? "Writing guide..." : !guideWritten ? "Write guide" : "Rewrite guide"} - - ) : null} {pickableEnvironments.length > 0 ? ( ) : null} - {showsMergeNow ? ( - void perform("merge", selectedMergeMethod))} - > - - {inlineConfirm.isArmed("merge") ? "Confirm merge?" : "Merge now"} - - ) : null} + {/* The same merge, left with the host to carry out once its requirements + pass. A conflicting branch cannot be armed because nothing the host + waits for will clear the conflict. */} {autoMergeArmed && can("disable-auto-merge") ? ( void perform("disable-auto-merge")}> @@ -365,7 +326,10 @@ export function PullRequestActionsMenu({ ) : null} {showsMergeMethods ? ( <> - {showsDraftToggle || showsMergeNow || showsAutoMerge ? : null} + {/* Only below the draft control. A host with no draft of its own, or + a draft whose control is already the header button, would leave + this against the separator that opened the group. */} + {showsDraftToggle || showsAutoMerge ? : null} onSelectMergeMethod(method as PullRequestMergeMethod)} @@ -386,11 +350,7 @@ export function PullRequestActionsMenu({ ) : null} - {pullRequestActionMenuHasGroup( - showsDraftToggle, - showsAutoMerge || showsMergeNow, - showsMergeMethods, - ) ? ( + {pullRequestActionMenuHasGroup(showsDraftToggle, showsAutoMerge, showsMergeMethods) ? ( ) : null} diff --git a/apps/web/src/components/pullRequest/PullRequestAdminMergeOption.tsx b/apps/web/src/components/pullRequest/PullRequestAdminMergeOption.tsx new file mode 100644 index 0000000000000..c31bfd247e425 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestAdminMergeOption.tsx @@ -0,0 +1,20 @@ +import { useId } from "react"; +import { Checkbox } from "../ui/checkbox"; + +export function PullRequestAdminMergeOption({ + checked, + disabled, + onCheckedChange, +}: { + checked: boolean; + disabled: boolean; + onCheckedChange: (checked: boolean) => void; +}) { + const id = useId(); + return ( + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestAttachments.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestAttachments.browser.test.tsx new file mode 100644 index 0000000000000..5fd6761c75d00 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestAttachments.browser.test.tsx @@ -0,0 +1,71 @@ +import { Atom } from "effect/reactivity"; +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { useRef, useState } from "react"; +import { expect, it, vi } from "vite-plus/test"; +import { act, render } from "../../../test/render"; +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { attachmentEnvironment } from "~/state/attachments"; + +const state = vi.hoisted(() => ({ publish: vi.fn() })); +vi.mock(import("@t3tools/client-runtime/review-attachments"), () => ({ + publishReviewAttachment: state.publish, +})); +vi.mock(import("~/state/query"), async (original) => ({ + ...(await original()), + useEnvironmentQuery: (() => ({ + data: { capabilities: { attachments: true }, viewerPermissions: { attachments: true } }, + })) as never, +})); +import { PullRequestAttachments } from "./PullRequestAttachments"; + +function setup() { + let finish!: (markdown: string) => void; + state.publish.mockReset().mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + return { finish }; +} + +function Composer() { + const textareaRef = useRef(null); + const [text, setText] = useState("Original"); + return ( +
+