1package soy.iko.opencode.ui.components
3import android.util.Base64
4import androidx.compose.foundation.background
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.gestures.detectTapGestures
7import androidx.compose.foundation.gestures.detectTransformGestures
8import androidx.compose.foundation.gestures.detectVerticalDragGestures
9import androidx.compose.foundation.layout.Box
10import androidx.compose.foundation.layout.Column
11import androidx.compose.foundation.layout.WindowInsets
12import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.heightIn
15import androidx.compose.foundation.layout.navigationBars
16import androidx.compose.foundation.layout.padding
17import androidx.compose.foundation.layout.statusBars
18import androidx.compose.foundation.layout.windowInsetsPadding
19import androidx.compose.material3.CircularProgressIndicator
20import androidx.compose.material3.Icon
21import androidx.compose.material3.MaterialTheme
22import androidx.compose.material3.Text
23import androidx.compose.runtime.Composable
24import androidx.compose.runtime.Immutable
25import androidx.compose.runtime.getValue
26import androidx.compose.runtime.mutableFloatStateOf
27import androidx.compose.runtime.mutableIntStateOf
28import androidx.compose.runtime.mutableStateOf
29import androidx.compose.runtime.produceState
30import androidx.compose.runtime.remember
31import androidx.compose.runtime.rememberCoroutineScope
32import androidx.compose.runtime.setValue
33import androidx.compose.ui.Alignment
34import androidx.compose.ui.Modifier
35import androidx.compose.ui.draw.clip
36import androidx.compose.ui.graphics.graphicsLayer
37import androidx.compose.ui.input.pointer.pointerInput
38import androidx.compose.ui.layout.ContentScale
39import androidx.compose.ui.platform.LocalContext
40import androidx.compose.ui.res.stringResource
41import androidx.compose.ui.semantics.Role
42import androidx.compose.ui.semantics.contentDescription
43import androidx.compose.ui.semantics.invisibleToUser
44import androidx.compose.ui.semantics.semantics
45import androidx.compose.ui.text.style.TextOverflow
46import androidx.compose.ui.unit.dp
47import androidx.compose.ui.window.Dialog
48import androidx.compose.ui.window.DialogProperties
49import coil3.compose.SubcomposeAsyncImage
50import coil3.network.NetworkHeaders
51import coil3.network.httpHeaders
52import coil3.request.ImageRequest
53import coil3.request.crossfade
55import kotlinx.coroutines.Dispatchers
56import kotlinx.coroutines.launch
57import kotlin.math.absoluteValue
58import androidx.compose.animation.core.Animatable
59import kotlinx.coroutines.withContext
60import androidx.compose.material.icons.Icons
61import androidx.compose.material.icons.filled.BrokenImage
62import androidx.compose.material.icons.filled.Close
63import androidx.compose.material.icons.filled.MoreVert
64import soy.iko.opencode.R
65import soy.iko.opencode.data.model.FilePart
66import soy.iko.opencode.data.model.ServerProfile
67import soy.iko.opencode.data.model.sourcePath
68import soy.iko.opencode.data.network.CertPinUtil
69import soy.iko.opencode.data.network.HttpClientFactory
70import soy.iko.opencode.data.network.NetworkConfig
71import soy.iko.opencode.util.runCatchingCancellable
74 * Carries the bits needed to load an image off the opencode server: the base URL (for
75 * resolving relative `url`s) and an optional HTTP Basic header for protected servers.
78data class ImageLoadContext(
80 val basicAuthHeader: String?,
83/** Build an [ImageLoadContext] from a resolved connection profile (password included). */
84fun ServerProfile.toImageContext(): ImageLoadContext {
85 // Mirror HttpClientFactory's auth condition (hasAuth, i.e. a non-blank username): a
86 // profile configured with a username but an empty password is still authenticated by the
87 // REST/SSE channel (password defaults to the empty string there), so image loads must
88 // attach the same Basic header or they'd 401 while every other request succeeds.
89 val auth = if (hasAuth) {
90 val raw = "$username:${password.orEmpty()}".toByteArray()
91 "Basic " + Base64.encodeToString(raw, Base64.NO_WRAP)
95 // Resolve image URLs against the HTTPS-upgraded base URL, not the raw stored one, so a
96 // requireHttps (or certificate-pinned) profile never fetches an image — with the Basic auth
97 // header attached — over cleartext http even when the stored URL is http://. This mirrors
98 // how HttpClientFactory computes the effective base URL for the REST/SSE channel.
99 return ImageLoadContext(baseUrl = CertPinUtil.effectiveBaseUrl(this), basicAuthHeader = auth)
102/** True if the part references a raster/vector image we can render. */
103val FilePart.isImage: Boolean
104 get() = mime?.startsWith("image/") == true
106private fun decodeDataUri(data: String): ByteArray? {
107 val comma = data.indexOf(',')
108 if (comma < 0) return null
109 val header = data.substring(0, comma)
110 val payload = data.substring(comma + 1)
111 // Only ";base64" payloads are Base64; a plain/percent-encoded data URI (e.g.
112 // "data:image/svg+xml,%3Csvg…") must be percent-decoded to bytes instead of Base64-decoded,
113 // which would throw and render the broken-image icon.
115 if (header.contains(";base64", ignoreCase = true)) {
116 Base64.decode(payload, Base64.DEFAULT)
118 android.net.Uri.decode(payload).toByteArray()
124 * Resolve a [FilePart] to a Coil-loadable model: a decoded [ByteArray] for inline
125 * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
127private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
129 if (!src.isNullOrBlank() && src.startsWith("data:")) return decodeDataUri(src)
130 val url = url ?: return null
131 // Attachments are persisted as a data URL in `part.url`, so a data URI can arrive here too.
132 if (url.startsWith("data:")) return decodeDataUri(url)
133 // Resolve the URL (absolute, relative, or server-absolute like "/media/x.png") against
134 // the base, collapsing any ../ segments. Both absolute and relative forms must clear the
135 // same-origin check below — an absolute foreign URL (e.g. "https://evil.com/x.png") would
136 // otherwise get the server's Basic auth attached and leak the credentials to that host.
137 val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
138 val baseUri = runCatching { java.net.URI(base) }.getOrElse { return null }
139 val resolved = runCatching {
140 java.net.URI(base).resolve(url).normalize()
141 }.getOrElse { return null }
142 // Guard on same origin (scheme + host + port), not base-path prefix: the real risk is
143 // sending the request (with its Basic auth) to another host. A server-absolute path
144 // resolves to a different base *path* but the same origin, so it must still be allowed.
145 // Compare *effective* ports (substituting the scheme default when unspecified) so an
146 // absolute image URL that spells out the default port — "https://host:443/x.png" while
147 // the base is "https://host/" — isn't wrongly rejected as cross-origin (URI.port is -1
148 // when omitted, so a raw `==` would treat 443 and -1 as different).
149 val sameOrigin = resolved.host != null &&
150 resolved.scheme.equals(baseUri.scheme, ignoreCase = true) &&
151 resolved.host.equals(baseUri.host, ignoreCase = true) &&
152 effectivePort(resolved) == effectivePort(baseUri)
153 return if (sameOrigin) resolved.toString() else null
156/** The URI's port, or the scheme's default (80/443) when unspecified (`port == -1`). */
157private fun effectivePort(uri: java.net.URI): Int {
158 if (uri.port != -1) return uri.port
159 return when (uri.scheme?.lowercase()) {
167 * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network)
168 * and server-relative URLs (fetched with Basic auth when the server requires it).
169 * Falls back to nothing when the part has no loadable source.
172fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
173 val context = LocalContext.current
174 // Distinguish "still resolving off-thread" from "resolved to nothing loadable": produceState
175 // seeds a sentinel so the first (pre-resolve) frame renders nothing (as before), but once
176 // resolveModel returns null — a cross-origin URL (blocked so the server's Basic auth can't
177 // leak off-origin), a malformed data URI, or a source with no path — we show the broken-image
178 // fallback instead of an empty gap (PartView already committed to RemoteImage, so there's no
179 // FileChip to fall through to).
181 // Bumping retryKey re-runs resolveModel (and re-builds the ImageRequest below) so a failed
182 // load can be re-issued by tapping the error state. Without retryKey in these keys, a null
183 // model (e.g. a cross-origin URL blocked at resolve time) would stay null forever and the
184 // retry button would be a no-op that re-renders the broken-image icon — see the ImageRetry
185 // callback at the SubcomposeAsyncImage error slot.
186 var retryKey by remember(part.source, part.url, ctx.baseUrl) { mutableIntStateOf(0) }
187 val model = produceState<Any?>(initialValue = ImageResolving, part.source, part.url, ctx.baseUrl, retryKey) {
188 value = withContext(Dispatchers.Default) { part.resolveModel(ctx) }
190 if (model === ImageResolving) return
193 BrokenImageIcon(onRetry = { retryKey++ })
197 // Fullscreen zoomable viewer state: opened by tapping the inline image.
198 var showFullscreen by remember { mutableStateOf(false) }
199 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model, retryKey) {
200 ImageRequest.Builder(context)
203 // resolveModel already verified same-origin, so a String model can only point at
204 // the user's own server — attaching Basic auth over http (not just https) is safe.
205 // A data-URI model is a ByteArray, so gate on the model being a URL String.
206 if (model is String) ctx.basicAuthHeader?.let {
207 httpHeaders(NetworkHeaders.Builder().add("Authorization", it).build())
213 SubcomposeAsyncImage(
215 contentDescription = part.filename ?: stringResource(R.string.image),
216 contentScale = ContentScale.FillWidth,
218 .heightIn(max = NetworkConfig.inlineImageMaxHeightDp.dp)
219 .clip(MaterialTheme.shapes.small)
220 // Tap to open a fullscreen zoomable viewer so the user can inspect details
221 // without the inline 320dp height cap.
222 .clickable(role = Role.Button) { showFullscreen = true },
225 modifier = Modifier.fillMaxWidth().heightIn(min = NetworkConfig.inlineImageMinHeightDp.dp),
226 contentAlignment = Alignment.Center,
228 val loadingLabel = stringResource(R.string.loading)
229 CircularProgressIndicator(
230 modifier = Modifier.semantics { contentDescription = loadingLabel },
234 error = { ImageRetry(onRetry = { retryKey++ }) },
236 if (showFullscreen) {
237 FullscreenImageViewer(request = request, contentDescription = part.filename ?: stringResource(R.string.image)) {
238 showFullscreen = false
244 * Fullscreen zoomable image viewer opened by tapping an inline [RemoteImage]. Supports
245 * pinch-to-zoom and pan via [detectTransformGestures], double-tap to toggle zoom, and
246 * re-centers when zoom returns to 1×. Uses a non-interactive Dialog window so it overlays
247 * the whole screen. Offers Save-to-gallery and Share actions via the top bar.
250internal fun FullscreenImageViewer(
251 request: ImageRequest,
252 contentDescription: String,
253 onDismiss: () -> Unit,
255 // Scale is an Animatable so a double-tap smoothly tweens between 1× and the zoom target,
256 // while pinch-to-zoom drives it via snapTo (no animation) for immediate 1:1 finger tracking.
257 val scale = remember { Animatable(1f) }
258 var offsetX by remember { mutableFloatStateOf(0f) }
259 var offsetY by remember { mutableFloatStateOf(0f) }
260 val scope = rememberCoroutineScope()
261 val context = LocalContext.current
262 // Double-tap zoom tween, collapsed to an instant snap under reduced motion. Resolved in
263 // the composable body (not inside the pointerInput coroutine) since it reads the
264 // composition's LocalReducedMotion.
265 val zoomSpec = rememberMotionTween<Float>(NetworkConfig.imageViewerZoomAnimMs)
266 // Swipe-to-dismiss: track the vertical drag offset. When the image is at 1× (not zoomed
267 // or panned) and the user drags down beyond a threshold, dismiss the viewer — a common
268 // gesture for photo viewers. The drag also fades the background so the dismiss reads as
269 // a continuous motion rather than a snap.
270 var dragOffsetY by remember { mutableFloatStateOf(0f) }
271 val dismissThreshold = NetworkConfig.imageViewerSwipeDismissThreshold
272 var showOverflow by remember { mutableStateOf(false) }
273 val saveLabel = stringResource(R.string.save_image)
274 val shareLabel = stringResource(R.string.share_image)
275 val savedLabel = stringResource(R.string.image_saved)
276 val saveFailedLabel = stringResource(R.string.image_save_failed)
278 onDismissRequest = onDismiss,
279 properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
284 .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = (1f - (dragOffsetY.absoluteValue / dismissThreshold)).coerceIn(0f, 1f)))
285 .pointerInput(Unit) {
286 detectTransformGestures { _, pan, zoom, _ ->
287 // Clamp the zoom so the image can't be scaled down below 1× (which would
288 // leave empty letterbox) or blown up so far it becomes a handful of pixels.
289 val newScale = (scale.value * zoom).coerceIn(1f, NetworkConfig.imageViewerMaxZoom)
290 scope.launch { scale.snapTo(newScale) }
291 if (newScale <= 1f) {
292 // Returning to 1× recenters so a prior pan doesn't leave the image
293 // stuck off-center at its rest size.
302 // Swipe-to-dismiss: only when the image is at 1× (not zoomed/panned), a
303 // vertical drag accumulates an offset; crossing the threshold dismisses.
304 .pointerInput(scale.value) {
305 if (scale.value <= 1.001f) {
306 detectVerticalDragGestures(
308 if (dragOffsetY.absoluteValue >= dismissThreshold) {
311 // Animate the snap-back to rest. A simple reset; the user's
312 // finger is already lifting so a full spring isn't needed.
316 onVerticalDrag = { _, dragAmount ->
317 // Only accumulate downward drags (positive Y) for dismiss, so an
318 // upward swipe doesn't accidentally trigger it.
319 if (dragAmount > 0) dragOffsetY += dragAmount
324 // Tap handling on the same surface: a double-tap toggles zoom (animated, with a
325 // recenter on the way back to 1×); a single tap dismisses the viewer. detectTapGestures
326 // defers the single-tap callback until it knows the touch isn't the first of a pair.
327 .pointerInput(Unit) {
331 if (scale.value > 1.001f) {
334 scale.animateTo(1f, zoomSpec)
336 scale.animateTo(NetworkConfig.imageViewerDoubleTapZoom, zoomSpec)
340 onTap = { onDismiss() },
343 contentAlignment = Alignment.Center,
345 SubcomposeAsyncImage(
347 contentDescription = contentDescription,
348 contentScale = ContentScale.Fit,
352 scaleX = scale.value,
353 scaleY = scale.value,
354 translationX = offsetX,
355 // Apply the swipe-drag offset on top of any pan offset so the image
356 // follows the finger during a dismiss swipe.
357 translationY = offsetY + dragOffsetY,
360 // Close button in the top corner, inset below the status bar so it doesn't sit
361 // under the notch / status bar overlay on edge-to-edge devices.
362 androidx.compose.material3.IconButton(
365 .align(Alignment.TopEnd)
366 .windowInsetsPadding(WindowInsets.statusBars)
367 .padding(end = 8.dp),
371 contentDescription = stringResource(R.string.close),
372 tint = androidx.compose.ui.graphics.Color.White,
375 // Overflow with Save-to-gallery and Share. Save decodes the request's bytes via
376 // Coil and writes them to MediaStore (Pictures/opencode); Share fires ACTION_SEND
377 // with a content Uri. Both run off the main thread on the coroutine scope.
380 .align(Alignment.TopStart)
381 .windowInsetsPadding(WindowInsets.statusBars)
382 .padding(start = 8.dp),
384 androidx.compose.material3.IconButton(onClick = { showOverflow = true }) {
386 Icons.Filled.MoreVert,
387 contentDescription = stringResource(R.string.more_options),
388 tint = androidx.compose.ui.graphics.Color.White,
391 androidx.compose.material3.DropdownMenu(
392 expanded = showOverflow,
393 onDismissRequest = { showOverflow = false },
395 androidx.compose.material3.DropdownMenuItem(
396 text = { Text(saveLabel) },
400 val ok = runCatchingCancellable { saveImageToGallery(context, request, contentDescription) }.isSuccess
401 showToast(context, if (ok) savedLabel else saveFailedLabel)
405 androidx.compose.material3.DropdownMenuItem(
406 text = { Text(shareLabel) },
410 runCatchingCancellable { shareImage(context, request, contentDescription) }
411 .onFailure { showToast(context, saveFailedLabel) }
417 // Show the alt text / filename as a caption at the bottom of the viewer so a user
418 // can tell what the image is (e.g. an agent-named diagram) without leaving fullscreen.
419 // The contentDescription is already used by the image itself for TalkBack; the caption
420 // is hidden from a11y here to avoid double-announcing.
421 if (contentDescription.isNotBlank() && contentDescription != stringResource(R.string.image)) {
424 .align(Alignment.BottomCenter)
426 .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.5f))
427 .windowInsetsPadding(WindowInsets.navigationBars)
428 .padding(horizontal = 16.dp, vertical = 8.dp),
432 color = androidx.compose.ui.graphics.Color.White,
433 style = MaterialTheme.typography.bodySmall,
435 overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
436 modifier = Modifier.semantics { invisibleToUser() },
440 // Zoom-level indicator: a small chip at the top-start showing the current zoom
441 // factor (e.g. "2.5×") whenever the image is zoomed beyond 1×, with a tap action
442 // to reset to 1×. Aids orientation when deep-zoomed and gives a one-tap reset
443 // (otherwise only a double-tap toggles between 1× and the zoom target).
444 if (scale.value > 1.01f) {
445 val zoomText = remember(scale.value) {
446 String.format(java.util.Locale.US, "%.1f×", scale.value)
448 val resetLabel = stringResource(R.string.reset_zoom)
449 androidx.compose.material3.Surface(
450 color = androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.6f),
451 shape = MaterialTheme.shapes.small,
453 .align(Alignment.TopStart)
455 .windowInsetsPadding(WindowInsets.statusBars)
456 .clickable(role = Role.Button) {
457 scope.launch { scale.animateTo(1f, zoomSpec) }
461 .semantics { this.contentDescription = "$zoomText, $resetLabel" },
465 color = androidx.compose.ui.graphics.Color.White,
466 style = MaterialTheme.typography.labelSmall,
467 modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
475/** Error slot for [RemoteImage]: a broken-image icon plus a "Tap to retry" caption, the whole box
476 * clickable so a failed load can be re-issued instead of stranding a permanent broken icon. */
478private fun ImageRetry(onRetry: () -> Unit) {
479 val label = stringResource(R.string.tap_to_retry)
483 .heightIn(min = NetworkConfig.inlineImageMinHeightDp.dp)
484 .clickable(role = Role.Button, onClick = onRetry),
485 contentAlignment = Alignment.Center,
487 Column(horizontalAlignment = Alignment.CenterHorizontally) {
491 style = MaterialTheme.typography.labelSmall,
492 color = MaterialTheme.colorScheme.onSurfaceVariant,
493 modifier = Modifier.padding(top = 4.dp),
499/** Sentinel for [RemoteImage]'s produceState while resolveModel runs off-thread, so the initial
500 * (still-resolving) frame is distinguishable from a resolved-to-null (unloadable) result. */
501private val ImageResolving = Any()
503/** A centered, fixed-min-height box matching the loading/error slots so an image placeholder
504 * reserves the same space whether it's loading, failed, or unresolvable. */
506private fun ImageStatusBox(content: @Composable () -> Unit) {
508 modifier = Modifier.fillMaxWidth().heightIn(min = NetworkConfig.inlineImageMinHeightDp.dp),
509 contentAlignment = Alignment.Center,
514private fun BrokenImageIcon(onRetry: (() -> Unit)? = null) {
515 if (onRetry == null) {
517 Icons.Filled.BrokenImage,
518 contentDescription = stringResource(R.string.image_failed),
519 tint = MaterialTheme.colorScheme.onSurfaceVariant,
522 // Reuse the retry affordance so a null-model (e.g. a cross-origin URL blocked at resolve
523 // time) is retryable just like a failed network fetch — tapping re-runs resolveModel
524 // (retryKey is a produceState key) which may succeed if the block was transient.
525 ImageRetry(onRetry = onRetry)
529/** Decode the image behind [request] via Coil and write it to the system MediaStore
530 * (Pictures/opencode) so it appears in the gallery. Returns true on success. Runs on a
531 * background dispatcher; callers should use runCatchingCancellable. */
532private suspend fun saveImageToGallery(
533 context: android.content.Context,
534 request: ImageRequest,
537 val bitmap = decodeRequestBitmap(context, request) ?: return false
538 val resolver = context.contentResolver
539 val name = (displayName.ifBlank { "opencode-image" }).take(100)
540 val timestamp = System.currentTimeMillis() / 1000
541 val values = android.content.ContentValues().apply {
542 put(android.provider.MediaStore.Images.Media.DISPLAY_NAME, "$name-${timestamp}.png")
543 put(android.provider.MediaStore.Images.Media.MIME_TYPE, "image/png")
544 if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
545 put(android.provider.MediaStore.Images.Media.RELATIVE_PATH, "Pictures/opencode")
546 put(android.provider.MediaStore.Images.Media.IS_PENDING, 1)
549 val uri = resolver.insert(android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
551 // Write the bitmap, then finalize the pending row on success or DELETE it on failure. A null
552 // stream or a compress throw previously left the row stranded with IS_PENDING=1 — invisible to
553 // other apps and never cleaned up by this code, accumulating one orphaned entry per failed save.
556 resolver.openOutputStream(uri)?.use { out ->
557 bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, out)
558 } ?: return@runCatching
561 if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
564 values.put(android.provider.MediaStore.Images.Media.IS_PENDING, 0)
565 runCatching { resolver.update(uri, values, null, null) }
567 // Delete the orphaned pending row so a failed save doesn't leak an invisible
568 // MediaStore entry. Best-effort — a delete failure leaves a stray row, but the OS
569 // may eventually garbage-collect it.
570 runCatching { resolver.delete(uri, null, null) }
576/** Decode the image behind [request] to a Bitmap via Coil's ImageLoader. Returns null on
577 * failure. Suspending so callers run it on a background dispatcher. */
578private suspend fun decodeRequestBitmap(context: android.content.Context, request: ImageRequest): android.graphics.Bitmap? {
579 return soy.iko.opencode.util.runCatchingCancellableSuspend {
580 val loader = coil3.SingletonImageLoader.get(context)
581 val result = loader.execute(request)
582 result.image?.toBitmap()
586/** Share the image behind [request] via ACTION_SEND. Writes a temp file to the cache dir and
587 * grants the chooser read access via FileProvider. */
588private suspend fun shareImage(
589 context: android.content.Context,
590 request: ImageRequest,
593 val bitmap = decodeRequestBitmap(context, request) ?: error("decode failed")
594 val name = (displayName.ifBlank { "opencode-image" }).take(80)
595 val file = java.io.File(context.cacheDir, "share/$name-${System.currentTimeMillis()}.png")
596 file.parentFile?.mkdirs()
597 file.outputStream().use { bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) }
598 val authority = "${context.packageName}.fileprovider"
599 val uri = androidx.core.content.FileProvider.getUriForFile(context, authority, file)
600 val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
602 putExtra(android.content.Intent.EXTRA_STREAM, uri)
603 addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
605 context.startActivity(android.content.Intent.createChooser(intent, displayName))
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list