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 val auth = if (hasAuth && !password.isNullOrEmpty()) {
86 val raw = "$username:$password".toByteArray()
87 "Basic " + Base64.encodeToString(raw, Base64.NO_WRAP)
91 // Resolve image URLs against the HTTPS-upgraded base URL, not the raw stored one, so a
92 // requireHttps (or certificate-pinned) profile never fetches an image — with the Basic auth
93 // header attached — over cleartext http even when the stored URL is http://. This mirrors
94 // how HttpClientFactory computes the effective base URL for the REST/SSE channel.
95 return ImageLoadContext(baseUrl = CertPinUtil.effectiveBaseUrl(this), basicAuthHeader = auth)
98/** True if the part references a raster/vector image we can render. */
99val FilePart.isImage: Boolean
100 get() = mime?.startsWith("image/") == true
102private fun decodeDataUri(data: String): ByteArray? {
103 val comma = data.indexOf(',')
104 if (comma < 0) return null
105 val header = data.substring(0, comma)
106 val payload = data.substring(comma + 1)
107 // Only ";base64" payloads are Base64; a plain/percent-encoded data URI (e.g.
108 // "data:image/svg+xml,%3Csvg…") must be percent-decoded to bytes instead of Base64-decoded,
109 // which would throw and render the broken-image icon.
111 if (header.contains(";base64", ignoreCase = true)) {
112 Base64.decode(payload, Base64.DEFAULT)
114 android.net.Uri.decode(payload).toByteArray()
120 * Resolve a [FilePart] to a Coil-loadable model: a decoded [ByteArray] for inline
121 * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
123private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
125 if (!src.isNullOrBlank() && src.startsWith("data:")) return decodeDataUri(src)
126 val url = url ?: return null
127 // Attachments are persisted as a data URL in `part.url`, so a data URI can arrive here too.
128 if (url.startsWith("data:")) return decodeDataUri(url)
129 // Resolve the URL (absolute, relative, or server-absolute like "/media/x.png") against
130 // the base, collapsing any ../ segments. Both absolute and relative forms must clear the
131 // same-origin check below — an absolute foreign URL (e.g. "https://evil.com/x.png") would
132 // otherwise get the server's Basic auth attached and leak the credentials to that host.
133 val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
134 val baseUri = runCatching { java.net.URI(base) }.getOrElse { return null }
135 val resolved = runCatching {
136 java.net.URI(base).resolve(url).normalize()
137 }.getOrElse { return null }
138 // Guard on same origin (scheme + host + port), not base-path prefix: the real risk is
139 // sending the request (with its Basic auth) to another host. A server-absolute path
140 // resolves to a different base *path* but the same origin, so it must still be allowed.
141 // Compare *effective* ports (substituting the scheme default when unspecified) so an
142 // absolute image URL that spells out the default port — "https://host:443/x.png" while
143 // the base is "https://host/" — isn't wrongly rejected as cross-origin (URI.port is -1
144 // when omitted, so a raw `==` would treat 443 and -1 as different).
145 val sameOrigin = resolved.host != null &&
146 resolved.scheme.equals(baseUri.scheme, ignoreCase = true) &&
147 resolved.host.equals(baseUri.host, ignoreCase = true) &&
148 effectivePort(resolved) == effectivePort(baseUri)
149 return if (sameOrigin) resolved.toString() else null
152/** The URI's port, or the scheme's default (80/443) when unspecified (`port == -1`). */
153private fun effectivePort(uri: java.net.URI): Int {
154 if (uri.port != -1) return uri.port
155 return when (uri.scheme?.lowercase()) {
163 * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network)
164 * and server-relative URLs (fetched with Basic auth when the server requires it).
165 * Falls back to nothing when the part has no loadable source.
168fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
169 val context = LocalContext.current
170 // Distinguish "still resolving off-thread" from "resolved to nothing loadable": produceState
171 // seeds a sentinel so the first (pre-resolve) frame renders nothing (as before), but once
172 // resolveModel returns null — a cross-origin URL (blocked so the server's Basic auth can't
173 // leak off-origin), a malformed data URI, or a source with no path — we show the broken-image
174 // fallback instead of an empty gap (PartView already committed to RemoteImage, so there's no
175 // FileChip to fall through to).
177 // Bumping retryKey re-runs resolveModel (and re-builds the ImageRequest below) so a failed
178 // load can be re-issued by tapping the error state. Without retryKey in these keys, a null
179 // model (e.g. a cross-origin URL blocked at resolve time) would stay null forever and the
180 // retry button would be a no-op that re-renders the broken-image icon — see the ImageRetry
181 // callback at the SubcomposeAsyncImage error slot.
182 var retryKey by remember(part.source, part.url, ctx.baseUrl) { mutableIntStateOf(0) }
183 val model = produceState<Any?>(initialValue = ImageResolving, part.source, part.url, ctx.baseUrl, retryKey) {
184 value = withContext(Dispatchers.Default) { part.resolveModel(ctx) }
186 if (model === ImageResolving) return
189 BrokenImageIcon(onRetry = { retryKey++ })
193 // Fullscreen zoomable viewer state: opened by tapping the inline image.
194 var showFullscreen by remember { mutableStateOf(false) }
195 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model, retryKey) {
196 ImageRequest.Builder(context)
199 // resolveModel already verified same-origin, so a String model can only point at
200 // the user's own server — attaching Basic auth over http (not just https) is safe.
201 // A data-URI model is a ByteArray, so gate on the model being a URL String.
202 if (model is String) ctx.basicAuthHeader?.let {
203 httpHeaders(NetworkHeaders.Builder().add("Authorization", it).build())
209 SubcomposeAsyncImage(
211 contentDescription = part.filename ?: stringResource(R.string.image),
212 contentScale = ContentScale.FillWidth,
214 .heightIn(max = NetworkConfig.inlineImageMaxHeightDp.dp)
215 .clip(MaterialTheme.shapes.small)
216 // Tap to open a fullscreen zoomable viewer so the user can inspect details
217 // without the inline 320dp height cap.
218 .clickable(role = Role.Button) { showFullscreen = true },
221 modifier = Modifier.fillMaxWidth().heightIn(min = NetworkConfig.inlineImageMinHeightDp.dp),
222 contentAlignment = Alignment.Center,
224 val loadingLabel = stringResource(R.string.loading)
225 CircularProgressIndicator(
226 modifier = Modifier.semantics { contentDescription = loadingLabel },
230 error = { ImageRetry(onRetry = { retryKey++ }) },
232 if (showFullscreen) {
233 FullscreenImageViewer(request = request, contentDescription = part.filename ?: stringResource(R.string.image)) {
234 showFullscreen = false
240 * Fullscreen zoomable image viewer opened by tapping an inline [RemoteImage]. Supports
241 * pinch-to-zoom and pan via [detectTransformGestures], double-tap to toggle zoom, and
242 * re-centers when zoom returns to 1×. Uses a non-interactive Dialog window so it overlays
243 * the whole screen. Offers Save-to-gallery and Share actions via the top bar.
246internal fun FullscreenImageViewer(
247 request: ImageRequest,
248 contentDescription: String,
249 onDismiss: () -> Unit,
251 // Scale is an Animatable so a double-tap smoothly tweens between 1× and the zoom target,
252 // while pinch-to-zoom drives it via snapTo (no animation) for immediate 1:1 finger tracking.
253 val scale = remember { Animatable(1f) }
254 var offsetX by remember { mutableFloatStateOf(0f) }
255 var offsetY by remember { mutableFloatStateOf(0f) }
256 val scope = rememberCoroutineScope()
257 val context = LocalContext.current
258 // Double-tap zoom tween, collapsed to an instant snap under reduced motion. Resolved in
259 // the composable body (not inside the pointerInput coroutine) since it reads the
260 // composition's LocalReducedMotion.
261 val zoomSpec = rememberMotionTween<Float>(NetworkConfig.imageViewerZoomAnimMs)
262 // Swipe-to-dismiss: track the vertical drag offset. When the image is at 1× (not zoomed
263 // or panned) and the user drags down beyond a threshold, dismiss the viewer — a common
264 // gesture for photo viewers. The drag also fades the background so the dismiss reads as
265 // a continuous motion rather than a snap.
266 var dragOffsetY by remember { mutableFloatStateOf(0f) }
267 val dismissThreshold = NetworkConfig.imageViewerSwipeDismissThreshold
268 var showOverflow by remember { mutableStateOf(false) }
269 val saveLabel = stringResource(R.string.save_image)
270 val shareLabel = stringResource(R.string.share_image)
271 val savedLabel = stringResource(R.string.image_saved)
272 val saveFailedLabel = stringResource(R.string.image_save_failed)
274 onDismissRequest = onDismiss,
275 properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
280 .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = (1f - (dragOffsetY.absoluteValue / dismissThreshold)).coerceIn(0f, 1f)))
281 .pointerInput(Unit) {
282 detectTransformGestures { _, pan, zoom, _ ->
283 // Clamp the zoom so the image can't be scaled down below 1× (which would
284 // leave empty letterbox) or blown up so far it becomes a handful of pixels.
285 val newScale = (scale.value * zoom).coerceIn(1f, NetworkConfig.imageViewerMaxZoom)
286 scope.launch { scale.snapTo(newScale) }
287 if (newScale <= 1f) {
288 // Returning to 1× recenters so a prior pan doesn't leave the image
289 // stuck off-center at its rest size.
298 // Swipe-to-dismiss: only when the image is at 1× (not zoomed/panned), a
299 // vertical drag accumulates an offset; crossing the threshold dismisses.
300 .pointerInput(scale.value) {
301 if (scale.value <= 1.001f) {
302 detectVerticalDragGestures(
304 if (dragOffsetY.absoluteValue >= dismissThreshold) {
307 // Animate the snap-back to rest. A simple reset; the user's
308 // finger is already lifting so a full spring isn't needed.
312 onVerticalDrag = { _, dragAmount ->
313 // Only accumulate downward drags (positive Y) for dismiss, so an
314 // upward swipe doesn't accidentally trigger it.
315 if (dragAmount > 0) dragOffsetY += dragAmount
320 // Tap handling on the same surface: a double-tap toggles zoom (animated, with a
321 // recenter on the way back to 1×); a single tap dismisses the viewer. detectTapGestures
322 // defers the single-tap callback until it knows the touch isn't the first of a pair.
323 .pointerInput(Unit) {
327 if (scale.value > 1.001f) {
330 scale.animateTo(1f, zoomSpec)
332 scale.animateTo(NetworkConfig.imageViewerDoubleTapZoom, zoomSpec)
336 onTap = { onDismiss() },
339 contentAlignment = Alignment.Center,
341 SubcomposeAsyncImage(
343 contentDescription = contentDescription,
344 contentScale = ContentScale.Fit,
348 scaleX = scale.value,
349 scaleY = scale.value,
350 translationX = offsetX,
351 // Apply the swipe-drag offset on top of any pan offset so the image
352 // follows the finger during a dismiss swipe.
353 translationY = offsetY + dragOffsetY,
356 // Close button in the top corner, inset below the status bar so it doesn't sit
357 // under the notch / status bar overlay on edge-to-edge devices.
358 androidx.compose.material3.IconButton(
361 .align(Alignment.TopEnd)
362 .windowInsetsPadding(WindowInsets.statusBars)
363 .padding(end = 8.dp),
367 contentDescription = stringResource(R.string.close),
368 tint = androidx.compose.ui.graphics.Color.White,
371 // Overflow with Save-to-gallery and Share. Save decodes the request's bytes via
372 // Coil and writes them to MediaStore (Pictures/opencode); Share fires ACTION_SEND
373 // with a content Uri. Both run off the main thread on the coroutine scope.
376 .align(Alignment.TopStart)
377 .windowInsetsPadding(WindowInsets.statusBars)
378 .padding(start = 8.dp),
380 androidx.compose.material3.IconButton(onClick = { showOverflow = true }) {
382 Icons.Filled.MoreVert,
383 contentDescription = stringResource(R.string.more_options),
384 tint = androidx.compose.ui.graphics.Color.White,
387 androidx.compose.material3.DropdownMenu(
388 expanded = showOverflow,
389 onDismissRequest = { showOverflow = false },
391 androidx.compose.material3.DropdownMenuItem(
392 text = { Text(saveLabel) },
396 val ok = runCatchingCancellable { saveImageToGallery(context, request, contentDescription) }.isSuccess
397 showToast(context, if (ok) savedLabel else saveFailedLabel)
401 androidx.compose.material3.DropdownMenuItem(
402 text = { Text(shareLabel) },
406 runCatchingCancellable { shareImage(context, request, contentDescription) }
407 .onFailure { showToast(context, saveFailedLabel) }
413 // Show the alt text / filename as a caption at the bottom of the viewer so a user
414 // can tell what the image is (e.g. an agent-named diagram) without leaving fullscreen.
415 // The contentDescription is already used by the image itself for TalkBack; the caption
416 // is hidden from a11y here to avoid double-announcing.
417 if (contentDescription.isNotBlank() && contentDescription != stringResource(R.string.image)) {
420 .align(Alignment.BottomCenter)
422 .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.5f))
423 .windowInsetsPadding(WindowInsets.navigationBars)
424 .padding(horizontal = 16.dp, vertical = 8.dp),
428 color = androidx.compose.ui.graphics.Color.White,
429 style = MaterialTheme.typography.bodySmall,
431 overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
432 modifier = Modifier.semantics { invisibleToUser() },
436 // Zoom-level indicator: a small chip at the top-start showing the current zoom
437 // factor (e.g. "2.5×") whenever the image is zoomed beyond 1×, with a tap action
438 // to reset to 1×. Aids orientation when deep-zoomed and gives a one-tap reset
439 // (otherwise only a double-tap toggles between 1× and the zoom target).
440 if (scale.value > 1.01f) {
441 val zoomText = remember(scale.value) {
442 String.format(java.util.Locale.US, "%.1f×", scale.value)
444 val resetLabel = stringResource(R.string.reset_zoom)
445 androidx.compose.material3.Surface(
446 color = androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.6f),
447 shape = MaterialTheme.shapes.small,
449 .align(Alignment.TopStart)
451 .windowInsetsPadding(WindowInsets.statusBars)
452 .clickable(role = Role.Button) {
453 scope.launch { scale.animateTo(1f, zoomSpec) }
457 .semantics { this.contentDescription = "$zoomText, $resetLabel" },
461 color = androidx.compose.ui.graphics.Color.White,
462 style = MaterialTheme.typography.labelSmall,
463 modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
471/** Error slot for [RemoteImage]: a broken-image icon plus a "Tap to retry" caption, the whole box
472 * clickable so a failed load can be re-issued instead of stranding a permanent broken icon. */
474private fun ImageRetry(onRetry: () -> Unit) {
475 val label = stringResource(R.string.tap_to_retry)
479 .heightIn(min = NetworkConfig.inlineImageMinHeightDp.dp)
480 .clickable(role = Role.Button, onClick = onRetry),
481 contentAlignment = Alignment.Center,
483 Column(horizontalAlignment = Alignment.CenterHorizontally) {
487 style = MaterialTheme.typography.labelSmall,
488 color = MaterialTheme.colorScheme.onSurfaceVariant,
489 modifier = Modifier.padding(top = 4.dp),
495/** Sentinel for [RemoteImage]'s produceState while resolveModel runs off-thread, so the initial
496 * (still-resolving) frame is distinguishable from a resolved-to-null (unloadable) result. */
497private val ImageResolving = Any()
499/** A centered, fixed-min-height box matching the loading/error slots so an image placeholder
500 * reserves the same space whether it's loading, failed, or unresolvable. */
502private fun ImageStatusBox(content: @Composable () -> Unit) {
504 modifier = Modifier.fillMaxWidth().heightIn(min = NetworkConfig.inlineImageMinHeightDp.dp),
505 contentAlignment = Alignment.Center,
510private fun BrokenImageIcon(onRetry: (() -> Unit)? = null) {
511 if (onRetry == null) {
513 Icons.Filled.BrokenImage,
514 contentDescription = stringResource(R.string.image_failed),
515 tint = MaterialTheme.colorScheme.onSurfaceVariant,
518 // Reuse the retry affordance so a null-model (e.g. a cross-origin URL blocked at resolve
519 // time) is retryable just like a failed network fetch — tapping re-runs resolveModel
520 // (retryKey is a produceState key) which may succeed if the block was transient.
521 ImageRetry(onRetry = onRetry)
525/** Decode the image behind [request] via Coil and write it to the system MediaStore
526 * (Pictures/opencode) so it appears in the gallery. Returns true on success. Runs on a
527 * background dispatcher; callers should use runCatchingCancellable. */
528private suspend fun saveImageToGallery(
529 context: android.content.Context,
530 request: ImageRequest,
533 val bitmap = decodeRequestBitmap(context, request) ?: return false
534 val resolver = context.contentResolver
535 val name = (displayName.ifBlank { "opencode-image" }).take(100)
536 val timestamp = System.currentTimeMillis() / 1000
537 val values = android.content.ContentValues().apply {
538 put(android.provider.MediaStore.Images.Media.DISPLAY_NAME, "$name-${timestamp}.png")
539 put(android.provider.MediaStore.Images.Media.MIME_TYPE, "image/png")
540 if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
541 put(android.provider.MediaStore.Images.Media.RELATIVE_PATH, "Pictures/opencode")
542 put(android.provider.MediaStore.Images.Media.IS_PENDING, 1)
545 val uri = resolver.insert(android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
547 // Write the bitmap, then finalize the pending row on success or DELETE it on failure. A null
548 // stream or a compress throw previously left the row stranded with IS_PENDING=1 — invisible to
549 // other apps and never cleaned up by this code, accumulating one orphaned entry per failed save.
552 resolver.openOutputStream(uri)?.use { out ->
553 bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, out)
554 } ?: return@runCatching
557 if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
560 values.put(android.provider.MediaStore.Images.Media.IS_PENDING, 0)
561 runCatching { resolver.update(uri, values, null, null) }
563 // Delete the orphaned pending row so a failed save doesn't leak an invisible
564 // MediaStore entry. Best-effort — a delete failure leaves a stray row, but the OS
565 // may eventually garbage-collect it.
566 runCatching { resolver.delete(uri, null, null) }
572/** Decode the image behind [request] to a Bitmap via Coil's ImageLoader. Returns null on
573 * failure. Suspending so callers run it on a background dispatcher. */
574private suspend fun decodeRequestBitmap(context: android.content.Context, request: ImageRequest): android.graphics.Bitmap? {
575 return soy.iko.opencode.util.runCatchingCancellableSuspend {
576 val loader = coil3.SingletonImageLoader.get(context)
577 val result = loader.execute(request)
578 result.image?.toBitmap()
582/** Share the image behind [request] via ACTION_SEND. Writes a temp file to the cache dir and
583 * grants the chooser read access via FileProvider. */
584private suspend fun shareImage(
585 context: android.content.Context,
586 request: ImageRequest,
589 val bitmap = decodeRequestBitmap(context, request) ?: error("decode failed")
590 val name = (displayName.ifBlank { "opencode-image" }).take(80)
591 val file = java.io.File(context.cacheDir, "share/$name-${System.currentTimeMillis()}.png")
592 file.parentFile?.mkdirs()
593 file.outputStream().use { bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) }
594 val authority = "${context.packageName}.fileprovider"
595 val uri = androidx.core.content.FileProvider.getUriForFile(context, authority, file)
596 val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
598 putExtra(android.content.Intent.EXTRA_STREAM, uri)
599 addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
601 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