Skip to content

Commit 382f8c4

Browse files
committed
add a floating copy button, next to a literal value under the mouse cursor and in the response body JSON, for copying the literal value
1 parent 31c7545 commit 382f8c4

File tree

11 files changed

+149
-11
lines changed

11 files changed

+149
-11
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ _Changes since 1.7.3_
1212
- Cookie support (Default disabled. Enable in the Subproject Configuration Dialog)
1313
- On pre-flight, update environment variables according to request headers, query parameters and bodies
1414
- A "Save Raw" button at the response viewer to save raw response body to a file
15+
- A floating copy button, next to a literal value under the mouse cursor and in the response body JSON, for copying the literal value
1516

1617
### Changed
1718
- The "Copy All" button near the response body viewer has been relocated to the upper level, as it copies the whole Request and Response rather than only response body

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ kotlin {
9494
implementation("org.bouncycastle:bcpkix-jdk18on:1.79")
9595

9696
// text field
97-
api("io.github.sunny-chung:bigtext-ui-composable:2.2.0")
97+
api("io.github.sunny-chung:bigtext-ui-composable:2.3.0")
9898

9999
// for proguard to understand the code
100100
// implementation("com.github.luben:zstd-jni:1.5.5-11")

src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/RangeExtension.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,6 @@ fun IntRange.toNonEmptyRange(): IntRange {
3535

3636
val IntRange.length: Int
3737
get() = this.endInclusive - this.start + 1
38+
39+
val IntRange.isNotEmpty: Boolean
40+
get() = length > 0

src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/PrettifyResult.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,9 @@ data class PrettifyResult(
44
val prettyString: String,
55
val collapsableLineRange: List<IntRange> = emptyList(),
66
val collapsableCharRange: List<IntRange> = emptyList(),
7+
8+
/**
9+
* For String, quotes are included into the ranges. Guaranteed to be sorted.
10+
*/
11+
val literalRange: List<IntRange> = emptyList(),
712
)

src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/parser/JsonParser.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.dslplatform.json.DslJson
44
import com.dslplatform.json.ObjectConverter
55
import com.dslplatform.json.StringConverter
66
import com.sunnychung.application.multiplatform.hellohttp.model.PrettifyResult
7+
import com.sunnychung.application.multiplatform.hellohttp.util.log
78

89
private val WHITESPACE_BYTES: Set<Byte> = listOf(' ', '\n', '\r', '\t').map { it.code.toByte() }.toSet()
910

@@ -35,6 +36,7 @@ class JsonParser(jsonBytes: ByteArray) {
3536
val startLineStack = mutableListOf<Int>()
3637
val charGroups = mutableListOf<IntRange>()
3738
val startCharStack = mutableListOf<Int>()
39+
val literalRange = mutableListOf<IntRange>()
3840

3941
return PrettifyResult(
4042
prettyString = buildString {
@@ -107,11 +109,23 @@ class JsonParser(jsonBytes: ByteArray) {
107109
// parser.serialize(node, baos)
108110
// append(baos.toByteArray().decodeToString())
109111

112+
val start = lastIndex + 1
113+
110114
StringConverter.serialize(node, writer)
111115
append(writer.toString())
112116
writer.reset()
117+
118+
val end = lastIndex
119+
log.v { "literalRange += $start .. $end" }
120+
121+
literalRange += start .. end
113122
} else {
123+
val start = lastIndex + 1
124+
114125
append(node.toString())
126+
127+
literalRange += start .. lastIndex
128+
115129
// } else {
116130
// throw RuntimeException("what is this? -- $node")
117131
}
@@ -121,6 +135,7 @@ class JsonParser(jsonBytes: ByteArray) {
121135
},
122136
collapsableLineRange = lineGroups,
123137
collapsableCharRange = charGroups,
138+
literalRange = literalRange,
124139
)
125140
}
126141

src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/Any.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,11 @@ inline fun <T1, T2, T3, R> let(a1: T1?, a2: T2?, a3: T3?, compute: (T1, T2, T3)
1515
null
1616
}
1717
}
18+
19+
inline fun <T> letIf(condition: Boolean, block: () -> T) : T? {
20+
return if (condition) {
21+
block()
22+
} else {
23+
null
24+
}
25+
}

src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/AppTextField.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ fun AppTextField(
7070
),
7171
contentPadding: PaddingValues = PaddingValues(6.dp),
7272
hasIndicatorLine: Boolean = false,
73-
onPointerEvent: ((event: PointerEvent, tag: String?) -> Unit)? = null,
73+
onPointerEvent: ((event: PointerEvent, charIndex: Int, tag: String?) -> Unit)? = null,
7474
onFinishInit: BigTextFieldStateScope.() -> Unit = {},
7575
) {
7676
val textState by rememberConcurrentLargeAnnotatedBigTextFieldState(value, key)
@@ -229,7 +229,7 @@ fun AppTextField(
229229
),
230230
contentPadding: PaddingValues = PaddingValues(6.dp),
231231
hasIndicatorLine: Boolean = false,
232-
onPointerEvent: ((event: PointerEvent, tag: String?) -> Unit)? = null,
232+
onPointerEvent: ((event: PointerEvent, charIndex: Int, tag: String?) -> Unit)? = null,
233233
isAsynchronous: Boolean = false,
234234

235235
/**
@@ -486,7 +486,7 @@ fun AppTextFieldWithPlaceholder(
486486
),
487487
contentPadding: PaddingValues = PaddingValues(6.dp),
488488
hasIndicatorLine: Boolean = false,
489-
onPointerEvent: ((event: PointerEvent, tag: String?) -> Unit)? = null,
489+
onPointerEvent: ((event: PointerEvent, charIndex: Int, tag: String?) -> Unit)? = null,
490490
onFinishInit: () -> Unit = {},
491491
) {
492492
return AppTextField(

src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/AppTextFieldWithVariables.kt

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import androidx.compose.ui.graphics.Color
1717
import androidx.compose.ui.semantics.semantics
1818
import androidx.compose.ui.text.TextStyle
1919
import androidx.compose.ui.unit.dp
20+
import com.sunnychung.application.multiplatform.hellohttp.util.letIf
2021
import com.sunnychung.application.multiplatform.hellohttp.util.log
2122
import com.sunnychung.application.multiplatform.hellohttp.ux.AppUX.ENV_VAR_VALUE_MAX_DISPLAY_LENGTH
2223
import com.sunnychung.application.multiplatform.hellohttp.ux.local.LocalColor
@@ -124,8 +125,8 @@ fun AppTextFieldWithVariables(
124125
singleLine = singleLine,
125126
colors = colors,
126127
contentPadding = contentPadding,
127-
onPointerEvent = if (isSupportVariables) {
128-
{ event, tag ->
128+
onPointerEvent = letIf(isSupportVariables) {
129+
{ event, _, tag ->
129130
log.v { "onPointerEventOnAnnotatedTag $tag $event" }
130131
mouseHoverVariable =
131132
if (tag?.startsWith(EnvironmentVariableIncrementalTransformation.TAG_PREFIX) == true) {
@@ -134,8 +135,6 @@ fun AppTextFieldWithVariables(
134135
null
135136
}
136137
}
137-
} else {
138-
null
139138
},
140139
interactionSource = interactionSource,
141140
onFinishInit = onFinishInit,

src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import androidx.compose.ui.input.key.isShiftPressed
4444
import androidx.compose.ui.input.key.key
4545
import androidx.compose.ui.input.key.onPreviewKeyEvent
4646
import androidx.compose.ui.input.key.type
47+
import androidx.compose.ui.input.pointer.PointerEvent
4748
import androidx.compose.ui.layout.onGloballyPositioned
4849
import androidx.compose.ui.layout.positionInParent
4950
import androidx.compose.ui.platform.LocalDensity
@@ -54,15 +55,20 @@ import androidx.compose.ui.text.rememberTextMeasurer
5455
import androidx.compose.ui.unit.Dp
5556
import androidx.compose.ui.unit.IntSize
5657
import androidx.compose.ui.unit.dp
58+
import com.dslplatform.json.DslJson
59+
import com.dslplatform.json.StringConverter
5760
import com.google.common.collect.TreeRangeMap
5861
import com.sunnychung.application.multiplatform.hellohttp.extension.contains
5962
import com.sunnychung.application.multiplatform.hellohttp.extension.intersect
63+
import com.sunnychung.application.multiplatform.hellohttp.extension.isNotEmpty
6064
import com.sunnychung.application.multiplatform.hellohttp.extension.length
6165
import com.sunnychung.application.multiplatform.hellohttp.model.SyntaxHighlight
6266
import com.sunnychung.application.multiplatform.hellohttp.util.TreeRangeMaps
6367
import com.sunnychung.application.multiplatform.hellohttp.util.chunkedLatest
6468
import com.sunnychung.application.multiplatform.hellohttp.util.let
69+
import com.sunnychung.application.multiplatform.hellohttp.util.letIf
6570
import com.sunnychung.application.multiplatform.hellohttp.util.log
71+
import com.sunnychung.application.multiplatform.hellohttp.util.string
6672
import com.sunnychung.application.multiplatform.hellohttp.ux.AppUX.ENV_VAR_VALUE_MAX_DISPLAY_LENGTH
6773
import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.abbr
6874
import com.sunnychung.application.multiplatform.hellohttp.ux.compose.rememberLast
@@ -122,10 +128,13 @@ fun CodeEditorView(
122128
onTextChange: ((String) -> Unit)? = null,
123129
collapsableLines: List<IntRange> = emptyList(),
124130
collapsableChars: List<IntRange> = emptyList(),
131+
isShowCopyLiteralButton: Boolean = false,
132+
literalChars: List<IntRange> = emptyList(),
125133
textColor: Color = LocalColor.current.text,
126134
syntaxHighlight: SyntaxHighlight,
127135
isEnableVariables: Boolean = false,
128136
knownVariables: Map<String, String> = mutableMapOf(),
137+
onPointerEvent: ((event: PointerEvent, charIndex: Int) -> Unit)? = null,
129138
onMeasured: ((textFieldPositionTop: Float) -> Unit)? = null,
130139
onTextManipulatorReady: ((BigTextFieldState) -> Unit)? = null,
131140
testTag: String? = null,
@@ -134,6 +143,7 @@ fun CodeEditorView(
134143

135144
val themeColours = LocalColor.current
136145
val fonts = LocalFont.current
146+
val density = LocalDensity.current
137147
val coroutineScope = rememberCoroutineScope()
138148

139149
val inputFilter = BigTextInputFilter { it.replace("\r\n".toRegex(), "\n") }
@@ -252,6 +262,45 @@ fun CodeEditorView(
252262

253263
var isSyntaxHighlightDisabled = false
254264

265+
var lineNumberColumnWidth by remember(density) { mutableStateOf(0) }
266+
267+
var mouseHoverCharIndex by remember { mutableStateOf(-1) }
268+
val textLiteralRangeForCopy: IntRange? = remember(mouseHoverCharIndex, bigTextFieldState.viewState.isComponentReady) {
269+
if (!isShowCopyLiteralButton) return@remember null
270+
if (mouseHoverCharIndex !in 0 ..< bigTextValue.length) return@remember null
271+
272+
literalChars.binarySearch {
273+
if (it.last < mouseHoverCharIndex) {
274+
-1
275+
} else if (mouseHoverCharIndex < it.first) {
276+
1
277+
} else {
278+
0
279+
}
280+
}.let {
281+
if (it >= 0) {
282+
literalChars[it]
283+
} else {
284+
null
285+
}
286+
}.also {
287+
log.d { "mouseHoverCharIndex = $mouseHoverCharIndex, lit = $it" }
288+
}
289+
}
290+
val showCopyPositionAt: Pair<Float, Float>? = remember(textLiteralRangeForCopy, density, bigTextFieldState.viewState.isComponentReady) {
291+
val startOfValue = textLiteralRangeForCopy?.start ?: return@remember null
292+
val xyOfStartOfValue =
293+
bigTextFieldState.viewState.findRelativeXYOfOriginalCharIndex(startOfValue, scrollState.value, 0)
294+
?: return@remember null
295+
296+
// put at the left of the string literal, overlapping for 2.dp
297+
val copyButtonX = xyOfStartOfValue.first - with(density) { (16.dp - 2.dp).toPx() } // 16.dp is the size of the copy button
298+
copyButtonX to xyOfStartOfValue.second
299+
}
300+
if (isShowCopyLiteralButton) {
301+
log.v { "showCopyPositionAt = $showCopyPositionAt" }
302+
}
303+
255304
remember(searchOptions) {
256305
searchTrigger.trySend(Unit)
257306
}
@@ -465,7 +514,7 @@ fun CodeEditorView(
465514
)
466515
}
467516

468-
Box(modifier = Modifier.weight(1f).onGloballyPositioned {
517+
Box(modifier = Modifier.weight(1f).clipToBounds().onGloballyPositioned {
469518
textFieldSize = it.size
470519
log.v { "text field pos = ${it.positionInParent().y}" }
471520
onMeasured?.invoke(it.positionInParent().y)
@@ -497,6 +546,9 @@ fun CodeEditorView(
497546
bigTextFieldState.viewState.isLayoutDisabled = false
498547
},
499548
modifier = Modifier.fillMaxHeight()
549+
.onGloballyPositioned {
550+
lineNumberColumnWidth = it.size.width
551+
}
500552
)
501553

502554
if (isReadOnly) {
@@ -529,6 +581,13 @@ fun CodeEditorView(
529581
isSelectable = true,
530582
scrollState = scrollState,
531583
viewState = bigTextFieldState.viewState,
584+
onPointerEvent = letIf(onPointerEvent != null || isShowCopyLiteralButton) {
585+
{ event: PointerEvent, charIndex: Int, _ ->
586+
log.v { "CEV onPointerEvent $event" }
587+
mouseHoverCharIndex = charIndex
588+
onPointerEvent?.invoke(event, charIndex)
589+
}
590+
},
532591
onTextLayout = { layoutResult = it },
533592
onTransformInit = { transformedText = it },
534593
contextMenu = AppBigTextFieldContextMenu,
@@ -635,13 +694,14 @@ fun CodeEditorView(
635694
}
636695
}
637696
},
638-
onPointerEvent = { event, tag ->
697+
onPointerEvent = { event, charIndex, tag ->
639698
log.v { "onPointerEventOnAnnotatedTag $tag $event" }
640699
mouseHoverVariable = if (tag?.startsWith(EnvironmentVariableIncrementalTransformation.TAG_PREFIX) == true) {
641700
tag.replaceFirst(EnvironmentVariableIncrementalTransformation.TAG_PREFIX, "")
642701
} else {
643702
null
644703
}
704+
onPointerEvent?.invoke(event, charIndex)
645705
},
646706
modifier = Modifier.fillMaxSize()
647707
.focusRequester(textFieldFocusRequester)
@@ -656,6 +716,42 @@ fun CodeEditorView(
656716
}
657717
}
658718
}
719+
if (showCopyPositionAt != null
720+
&& textLiteralRangeForCopy?.isNotEmpty == true
721+
&& !(
722+
textLiteralRangeForCopy.length == 2 && "\"\"" == bigTextValue.substring(textLiteralRangeForCopy).string()
723+
) // has something to copy
724+
) {
725+
with(density) {
726+
FloatingCopyButton(
727+
textProvider = {
728+
val literal = bigTextValue.substring(textLiteralRangeForCopy).string()
729+
if (syntaxHighlight == SyntaxHighlight.Json && literal.startsWith('"')) {
730+
try {
731+
// deserialize quoted string
732+
// TODO refactor this function not to couple with DslJson directly
733+
val bytes = literal.toByteArray()
734+
val reader = DslJson<Any?>().newReader().process(bytes, bytes.size)
735+
reader.nextToken
736+
StringConverter.deserialize(reader)
737+
} catch (_: Throwable) {
738+
literal
739+
}
740+
} else {
741+
literal
742+
}
743+
},
744+
size = 16.dp,
745+
innerPadding = 2.dp,
746+
color = themeColours.syntaxColor.stringLiteral,
747+
modifier = Modifier
748+
.offset(
749+
x = (lineNumberColumnWidth + showCopyPositionAt.first).toDp(), // this button is floating above the `Box` containing line number view and the BigTextLabel
750+
y = showCopyPositionAt.second.toDp() + 2.dp
751+
)
752+
)
753+
}
754+
}
659755
VerticalScrollbar(
660756
modifier = Modifier.align(Alignment.CenterEnd),
661757
adapter = rememberScrollbarAdapter(scrollState),

src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/FloatingCopyButton.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.PaddingValues
55
import androidx.compose.foundation.shape.RoundedCornerShape
66
import androidx.compose.runtime.Composable
77
import androidx.compose.ui.Modifier
8+
import androidx.compose.ui.graphics.Color
89
import androidx.compose.ui.platform.LocalClipboardManager
910
import androidx.compose.ui.text.AnnotatedString
1011
import androidx.compose.ui.unit.Dp
@@ -15,14 +16,20 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.local.LocalColor
1516

1617
@Composable
1718
fun FloatingCopyButton(textToCopy: String, size: Dp = 20.dp, innerPadding: Dp = 4.dp, modifier: Modifier = Modifier) {
19+
FloatingCopyButton(textProvider = { textToCopy }, size = size, innerPadding = innerPadding, modifier = modifier)
20+
}
21+
22+
@Composable
23+
fun FloatingCopyButton(textProvider: () -> String, size: Dp = 20.dp, innerPadding: Dp = 4.dp, color: Color = LocalColor.current.copyButton, modifier: Modifier = Modifier) {
1824
val colours = LocalColor.current
1925
val clipboardManager = LocalClipboardManager.current
2026
AppImageButton(
2127
resource = "copy-to-clipboard.svg",
2228
size = size + innerPadding * 2,
2329
innerPadding = PaddingValues(innerPadding),
24-
color = colours.copyButton,
30+
color = color,
2531
onClick = {
32+
val textToCopy = textProvider()
2633
log.d { "Copied $textToCopy" }
2734
clipboardManager.setText(AnnotatedString(textToCopy))
2835
AppContext.ErrorMessagePromptViewModel.showSuccessMessage("Copied text")

0 commit comments

Comments
 (0)