1package soy.iko.opencode.ui.components
3import android.util.Base64
4import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.heightIn
7import androidx.compose.foundation.shape.RoundedCornerShape
8import androidx.compose.material3.MaterialTheme
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.Immutable
11import androidx.compose.runtime.remember
12import androidx.compose.ui.Alignment
13import androidx.compose.ui.Modifier
14import androidx.compose.ui.draw.clip
15import androidx.compose.ui.layout.ContentScale
16import androidx.compose.ui.platform.LocalContext
17import androidx.compose.ui.res.stringResource
18import androidx.compose.ui.semantics.contentDescription
19import androidx.compose.ui.semantics.semantics
20import androidx.compose.ui.unit.dp
21import coil.compose.SubcomposeAsyncImage
22import coil.request.ImageRequest
23import androidx.compose.material3.CircularProgressIndicator
24import androidx.compose.material3.Icon
25import androidx.compose.material.icons.Icons
26import androidx.compose.material.icons.filled.BrokenImage
27import soy.iko.opencode.R
28import soy.iko.opencode.data.model.FilePart
29import soy.iko.opencode.data.model.ServerProfile
30import soy.iko.opencode.data.network.HttpClientFactory
33 * Carries the bits needed to load an image off the opencode server: the base URL (for
34 * resolving relative `url`s) and an optional HTTP Basic header for protected servers.
37data class ImageLoadContext(
39 val basicAuthHeader: String?,
42/** Build an [ImageLoadContext] from a resolved connection profile (password included). */
43fun ServerProfile.toImageContext(): ImageLoadContext {
44 val auth = if (hasAuth && !password.isNullOrEmpty()) {
45 val raw = "$username:$password".toByteArray()
46 "Basic " + Base64.encodeToString(raw, Base64.NO_WRAP)
50 return ImageLoadContext(baseUrl = baseUrl, basicAuthHeader = auth)
53/** True if the part references a raster/vector image we can render. */
54val FilePart.isImage: Boolean
55 get() = mime?.startsWith("image/") == true
58 * Resolve a [FilePart] to a Coil-loadable model: a decoded [ByteArray] for inline
59 * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
61private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
63 if (!src.isNullOrBlank() && src.startsWith("data:")) {
64 val comma = src.indexOf(',')
65 if (comma < 0) return null
66 val payload = src.substring(comma + 1)
67 return runCatching { Base64.decode(payload, Base64.DEFAULT) }.getOrNull()
69 val url = url ?: return null
70 return if (url.startsWith("http://") || url.startsWith("https://")) {
73 // Resolve relative URL against the base. Collapse any ../ segments to
74 // prevent path traversal from escaping the server's base path.
75 val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
76 val resolved = runCatching {
77 java.net.URI(base).resolve(url).normalize().toString()
78 }.getOrElse { return null }
79 // Guard: if normalization escaped the base path, return null (don't
80 // attempt to load the base URL itself as an image — it's not one).
81 if (!resolved.startsWith(base)) null else resolved
86 * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network)
87 * and server-relative URLs (fetched with Basic auth when the server requires it).
88 * Falls back to nothing when the part has no loadable source.
91fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
92 val context = LocalContext.current
93 val model = remember(part.source, part.url, ctx.baseUrl) { part.resolveModel(ctx) } ?: return
94 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader) {
95 ImageRequest.Builder(context)
98 // Only send Basic auth over HTTPS; sending credentials over
99 // cleartext HTTP would expose them on the network.
100 val isHttps = (model as? String)?.startsWith("https://") == true
101 if (isHttps) ctx.basicAuthHeader?.let { addHeader("Authorization", it) }
106 SubcomposeAsyncImage(
108 contentDescription = part.filename ?: stringResource(R.string.image),
109 contentScale = ContentScale.FillWidth,
111 .heightIn(max = 320.dp)
112 .clip(RoundedCornerShape(12.dp)),
115 modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
116 contentAlignment = Alignment.Center,
118 val loadingLabel = stringResource(R.string.loading)
119 CircularProgressIndicator(
120 modifier = Modifier.semantics { contentDescription = loadingLabel },
126 modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
127 contentAlignment = Alignment.Center,
130 Icons.Filled.BrokenImage,
131 contentDescription = stringResource(R.string.image_failed),
132 tint = MaterialTheme.colorScheme.onSurfaceVariant,
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