git.iko.soy/opencode-android
on 2794fda help sign in
1229 lines 61 kB history raw
1package soy.iko.opencode.ui.file
3import android.content.Intent
4import androidx.compose.animation.AnimatedContent
5import androidx.compose.animation.core.RepeatMode
6import androidx.compose.animation.core.animateFloat
7import androidx.compose.animation.core.infiniteRepeatable
8import androidx.compose.animation.core.rememberInfiniteTransition
9import androidx.compose.animation.core.tween
10import androidx.compose.animation.fadeIn
11import androidx.compose.animation.fadeOut
12import androidx.compose.animation.togetherWith
13import androidx.compose.foundation.background
14import androidx.compose.foundation.clickable
15import androidx.compose.foundation.horizontalScroll
16import androidx.compose.foundation.interaction.DragInteraction
17import androidx.compose.foundation.layout.Box
18import androidx.compose.foundation.layout.BoxScope
19import androidx.compose.foundation.layout.heightIn
20import androidx.compose.foundation.layout.Column
21import androidx.compose.foundation.layout.Arrangement
22import androidx.compose.foundation.layout.Row
23import androidx.compose.foundation.layout.Spacer
24import androidx.compose.foundation.layout.fillMaxSize
25import androidx.compose.foundation.layout.fillMaxWidth
26import androidx.compose.foundation.layout.height
27import androidx.compose.foundation.layout.imePadding
28import androidx.compose.foundation.layout.padding
29import androidx.compose.foundation.layout.size
30import androidx.compose.foundation.layout.width
31import androidx.compose.foundation.lazy.LazyColumn
32import androidx.compose.foundation.lazy.LazyListState
33import androidx.compose.foundation.lazy.itemsIndexed
34import androidx.compose.foundation.lazy.rememberLazyListState
35import androidx.compose.foundation.rememberScrollState
36import androidx.compose.foundation.text.KeyboardActions
37import androidx.compose.foundation.text.KeyboardOptions
38import androidx.compose.foundation.text.selection.SelectionContainer
39import androidx.compose.material.icons.Icons
40import androidx.compose.material.icons.automirrored.filled.ArrowBack
41import androidx.compose.material.icons.automirrored.filled.Chat
42import androidx.compose.material.icons.automirrored.filled.WrapText
43import androidx.compose.material.icons.filled.Close
44import androidx.compose.material.icons.filled.ContentCopy
45import androidx.compose.material.icons.filled.ErrorOutline
46import androidx.compose.material.icons.filled.KeyboardArrowDown
47import androidx.compose.material.icons.filled.KeyboardArrowUp
48import androidx.compose.material.icons.filled.Link
49import androidx.compose.material.icons.filled.MoreVert
50import androidx.compose.material.icons.filled.Refresh
51import androidx.compose.material.icons.filled.Search
52import androidx.compose.material.icons.filled.OpenInNew
53import androidx.compose.material.icons.filled.Share
54import androidx.compose.material3.AlertDialog
55import androidx.compose.material3.CircularProgressIndicator
56import androidx.compose.material3.DropdownMenu
57import androidx.compose.material3.DropdownMenuItem
58import androidx.compose.material3.ExperimentalMaterial3Api
59import androidx.compose.material3.FilterChip
60import androidx.compose.material3.Icon
61import androidx.compose.material3.IconButton
62import androidx.compose.material3.MaterialTheme
63import androidx.compose.material3.OutlinedTextField
64import androidx.compose.material3.Scaffold
65import androidx.compose.material3.SnackbarHost
66import androidx.compose.material3.SnackbarHostState
67import androidx.compose.material3.Text
68import androidx.compose.material3.TextButton
69import androidx.compose.material3.TopAppBar
70import androidx.compose.runtime.Composable
71import androidx.compose.runtime.LaunchedEffect
72import androidx.compose.runtime.MutableState
73import androidx.compose.runtime.getValue
74import androidx.compose.runtime.mutableIntStateOf
75import androidx.compose.runtime.mutableStateOf
76import androidx.compose.runtime.produceState
77import androidx.compose.runtime.remember
78import androidx.compose.runtime.rememberCoroutineScope
79import androidx.compose.runtime.saveable.rememberSaveable
80import androidx.compose.runtime.setValue
81import androidx.compose.ui.Alignment
82import androidx.compose.ui.Modifier
83import androidx.compose.ui.draw.alpha
84import androidx.compose.ui.draw.clip
85import androidx.compose.ui.layout.onSizeChanged
86import androidx.compose.ui.platform.LocalContext
87import androidx.compose.ui.platform.LocalDensity
88import androidx.compose.ui.platform.LocalSoftwareKeyboardController
89import androidx.compose.ui.semantics.Role
90import androidx.compose.ui.semantics.contentDescription
91import androidx.compose.ui.semantics.semantics
92import androidx.compose.ui.text.font.FontFamily
93import androidx.compose.ui.text.input.ImeAction
94import androidx.compose.ui.text.input.KeyboardType
95import androidx.compose.ui.text.style.TextOverflow
96import androidx.compose.ui.unit.dp
97import androidx.compose.ui.res.stringResource
98import androidx.lifecycle.compose.collectAsStateWithLifecycle
99import androidx.lifecycle.viewmodel.compose.viewModel
100import coil3.compose.SubcomposeAsyncImage
101import coil3.request.ImageRequest
102import coil3.request.crossfade
103import soy.iko.opencode.di.AppContainer
104import soy.iko.opencode.R
105import soy.iko.opencode.ui.components.ConnectionBannerFor
106import soy.iko.opencode.ui.components.DiffView
107import soy.iko.opencode.ui.components.FullscreenImageViewer
108import soy.iko.opencode.ui.components.LocalChatTextScale
109import soy.iko.opencode.ui.components.LocalCodeWrap
110import soy.iko.opencode.ui.components.copyToClipboard
111import soy.iko.opencode.ui.components.highlightLine
112import soy.iko.opencode.ui.components.relativeTime
113import soy.iko.opencode.ui.components.syntaxFor
114import soy.iko.opencode.ui.components.rememberHighlightPalette
115import soy.iko.opencode.ui.components.scaledBy
116import soy.iko.opencode.ui.vmFactory
117import soy.iko.opencode.util.runCatchingCancellable
118import kotlinx.coroutines.Dispatchers
119import kotlinx.coroutines.delay
120import kotlinx.coroutines.flow.collect
121import kotlinx.coroutines.launch
122import kotlinx.coroutines.withContext
124import soy.iko.opencode.data.network.NetworkConfig
126/** Cap on how many lines the raw viewer renders and searches. See [NetworkConfig.maxRenderedFileLines]. */
127private const val MAX_RENDERED_LINES = NetworkConfig.maxRenderedFileLines
129@OptIn(ExperimentalMaterial3Api::class)
130@Composable
131fun FileViewScreen(
132 container: AppContainer,
133 path: String,
134 onBack: () -> Unit,
135 initialLine: Int? = null,
136) {
137 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
138 val state by vm.state.collectAsStateWithLifecycle()
139 val context = LocalContext.current
140 val scope = rememberCoroutineScope()
141 val snackbar = remember { SnackbarHostState() }
142 val filename = remember(path) {
143 val trimmed = path.trimEnd('/')
144 trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } }
145 }
146 val shareLabel = stringResource(R.string.share)
147 // Diff vs raw view toggle. Defaults to showing the diff when one exists; the user can
148 // switch to the raw new content. Persisted via rememberSaveable across rotation. When the
149 // viewer is opened at a specific line (from a search hit), start on the raw view so the
150 // targeted line is actually rendered (the diff view has no line list to scroll).
151 val hasDiff = state.content?.diff != null && state.content?.diff?.isNotBlank() == true
152 var showDiff by rememberSaveable(path) { mutableStateOf(initialLine == null) }
153 val showToggle = hasDiff && state.content?.content.orEmpty().isNotEmpty()
154 // Find-in-file and line-wrapping state, persisted so a rotation or reload keeps the
155 // user's query/mode.
156 var findActive by rememberSaveable(path) { mutableStateOf(false) }
157 var findQuery by rememberSaveable(path) { mutableStateOf("") }
158 var matchPos by rememberSaveable(path) { mutableIntStateOf(0) }
159 // Seed the per-file wrap toggle from the user's global code-wrap preference so the viewer
160 // respects it by default; they can still flip it per-file (persisted across rotation).
161 val codeWrap = LocalCodeWrap.current
162 var wrap by rememberSaveable(path) { mutableStateOf(codeWrap) }
163 val listState = rememberLazyListState()
164 val rawText = state.content?.content.orEmpty()
165 // Directory portion of the path, shown under the filename in the top bar so a user who
166 // has drilled into a deep workspace keeps context of where this file lives.
167 val pathSubtitle = remember(path) {
168 val trimmed = path.trimEnd('/')
169 val parent = trimmed.substringBeforeLast('/', "")
170 parent.ifBlank { path }
171 }
172 // Compact "12.3 KB · 2d ago" metadata line for the top bar. Built from the size/mtime the
173 // ViewModel resolved via the parent directory listing (null when the server doesn't emit
174 // them). Uses the same formatFileSize/relativeTime helpers as the file browser's FileRow so
175 // the formatting is identical across screens. Built here (not in the top bar) because it
176 // needs the Context and the state, and the top bar is a stateless composable.
177 val metadataLine = remember(state.size, state.mtime) {
178 buildMetadataLine(context, state.size, state.mtime)
179 }
180 // Split the raw text exactly once here (the line viewer and find-in-file both consume this
181 // list) and cap it so a huge file doesn't get split twice or overwhelm the LazyColumn.
182 // `take()` (not `subList()`) so the cap actually releases the tail: subList returns a view
183 // backed by the full list, which would keep every line (and the full rawText) in memory.
184 val allLines = remember(rawText) { rawText.split("\n") }
185 // The viewer renders the file in chunks of MAX_RENDERED_LINES so a huge file doesn't lay
186 // out thousands of lines at once. The user can load the next chunk via the truncation
187 // banner's "Load more" button instead of being stranded at the first cap (or forced to
188 // leave the app via Share full file).
189 var renderLimit by rememberSaveable(path) { mutableStateOf(MAX_RENDERED_LINES) }
190 val lines = remember(rawText, renderLimit) { allLines.take(renderLimit) }
191 val truncated = allLines.size > lines.size
192 val moreLines = (allLines.size - lines.size).coerceAtMost(MAX_RENDERED_LINES)
193 // The line the viewer was opened at (from a search hit), highlighted until the user scrolls
194 // or starts an in-file find so the landed line stands out. Null once cleared.
195 val highlightLine = rememberJumpToLineHighlight(
196 path = path,
197 initialLine = initialLine,
198 content = state.content,
199 isBinary = state.content?.isBinary == true,
200 lineCount = lines.size,
201 listState = listState,
202 )
203 // Find-in-file match-case toggle (default case-insensitive). Persisted so a rotation keeps
204 // the choice, and applied to the off-thread match scan below.
205 var caseSensitive by rememberSaveable(path) { mutableStateOf(false) }
206 // Regex mode: when on, the query is interpreted as a regular expression (Java Pattern)
207 // instead of a plain substring. A power-user affordance mirroring editor find bars.
208 var regex by rememberSaveable(path) { mutableStateOf(false) }
209 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
210 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
211 var debouncedFind by remember { mutableStateOf("") }
212 LaunchedEffect(findQuery) {
213 val q = findQuery.trim()
214 if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q }
215 }
216 val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind, caseSensitive, regex) {
217 val q = debouncedFind
218 value = if (q.isEmpty()) emptyList()
219 else withContext(Dispatchers.Default) {
220 // Compile the pattern once per scan when regex mode is on; a malformed pattern
221 // yields no matches (rather than throwing) and the FindBar surfaces the error
222 // state via the zero match count. Case-sensitivity maps to Pattern flags.
223 val pattern = if (regex) {
224 runCatching {
225 val flags = if (caseSensitive) 0 else java.util.regex.Pattern.CASE_INSENSITIVE
226 java.util.regex.Pattern.compile(q, flags)
227 }.getOrNull()
228 } else {
229 null
230 }
231 lines.mapIndexedNotNull { index, line ->
232 val matched = if (regex) {
233 pattern?.matcher(line)?.find() == true
234 } else {
235 line.contains(q, ignoreCase = !caseSensitive)
236 }
237 if (matched) index else null
238 }
239 }
240 }
241 // Keep the current match index in range when the match set shrinks (e.g. the file
242 // reloaded with fewer matches), so the counter never shows a stale "10 / 3".
243 LaunchedEffect(matchIndices.size) {
244 matchPos = matchPos.coerceIn(0, (matchIndices.size - 1).coerceAtLeast(0))
245 }
246 // Scroll to the current match whenever it (or the match set) changes.
247 LaunchedEffect(matchPos, matchIndices) {
248 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
249 }
250 // "Go to line" dialog state. Clears the jump highlight and scrolls the LazyColumn to the
251 // requested line (clamped to the rendered range) when the user confirms.
252 var showGoToLine by rememberSaveable(path) { mutableStateOf(false) }
253 fun goToLine(n: Int) {
254 val target = (n - 1).coerceIn(0, (lines.size - 1).coerceAtLeast(0))
255 highlightLine.value = null
256 scope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
257 }
258 Scaffold(
259 topBar = {
260 FileViewTopBar(
261 filename = filename,
262 subtitle = pathSubtitle,
263 metadata = metadataLine,
264 loading = state.loading,
265 content = rawText,
266 wrap = wrap,
267 diffShown = showToggle && showDiff,
268 onBack = onBack,
269 onReload = { vm.reload() },
270 // Find searches the raw line list, which isn't on screen under a diff. Rather
271 // than silently greying the icon out, keep it tappable and explain where find
272 // lives when the user is in diff mode.
273 onToggleFind = {
274 if (showToggle && showDiff) {
275 scope.launch { snackbar.showSnackbar(context.getString(R.string.switch_to_raw_to_search)) }
276 } else {
277 findActive = !findActive
278 if (!findActive) findQuery = ""
279 }
280 },
281 onToggleWrap = { wrap = !wrap },
282 onGoToLine = { showGoToLine = true },
283 onCopy = { copyToClipboard(context, filename, rawText) },
284 onCopyPath = { copyToClipboard(context, context.getString(R.string.clip_label_path), path) },
285 onShare = {
286 val send = Intent(Intent.ACTION_SEND).apply {
287 type = "text/plain"
288 putExtra(Intent.EXTRA_SUBJECT, filename)
289 putExtra(Intent.EXTRA_TEXT, rawText)
290 }
291 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
292 .onFailure { scope.launch { snackbar.showSnackbar(context.getString(R.string.no_share_app)) } }
293 },
294 onOpenExternally = {
295 // Write the viewed content to a cache file and hand a content:// URI to an
296 // external app via ACTION_VIEW, so a file the in-app viewer can only show as
297 // text (or a large file beyond the render cap) can be opened in a real editor.
298 scope.launch {
299 val ok = runCatchingCancellable {
300 withContext(Dispatchers.IO) {
301 val dir = java.io.File(context.cacheDir, "external").apply { mkdirs() }
302 // Evict stale cache files handed to external apps so repeated
303 // opens of large files don't accumulate forever. A TTL (vs.
304 // wiping the dir) preserves files an external app may still be
305 // reading from a recent open. Pruned before writing the new one.
306 val cutoff = System.currentTimeMillis() - NetworkConfig.externalCacheTtlMs
307 dir.listFiles()?.forEach { f ->
308 runCatching { if (f.lastModified() < cutoff) f.delete() }
309 }
310 val file = java.io.File(dir, filename.ifBlank { "file.txt" })
311 file.writeText(rawText)
312 val authority = context.packageName + ".fileprovider"
313 val uri = androidx.core.content.FileProvider.getUriForFile(context, authority, file)
314 val view = Intent(Intent.ACTION_VIEW).apply {
315 setDataAndType(uri, android.webkit.MimeTypeMap.getSingleton()
316 .getMimeTypeFromExtension(filename.substringAfterLast('.', ""))
317 ?: "text/plain")
318 addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
319 }
320 context.startActivity(view)
321 }
322 }.isSuccess
323 if (!ok) scope.launch { snackbar.showSnackbar(context.getString(R.string.open_externally_failed)) }
324 }
325 },
326 onOpenInChat = {
327 // Start a new session with this file attached as an @path reference,
328 // mirroring the file browser row's "Open in chat" action — the viewer is
329 // the more natural place for it since the user is already looking at the file.
330 container.setPendingShare("@$path")
331 container.requestNewSession()
332 onBack()
333 },
334 )
335 },
336 snackbarHost = { SnackbarHost(snackbar) },
337 ) { padding ->
338 // Pull-to-refresh wraps the content so a user who pull-to-refreshes out of habit
339 // can reload the file, mirroring the file browser and session list gestures. The
340 // top-bar reload icon remains for a targeted tap.
341 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
342 isRefreshing = state.loading && state.content != null,
343 onRefresh = { vm.reload() },
344 modifier = Modifier.fillMaxSize().imePadding().padding(padding),
345 ) {
346 Box(modifier = Modifier.fillMaxSize()) {
347 // Surface SSE connection state so a dropped stream is visible while viewing a file.
348 ConnectionBannerFor(container)
349 // AnimatedContent cross-fades between loading / error / content states so the
350 // viewer doesn't snap abruptly when a reload finishes or fails.
351 AnimatedContent(
352 targetState = state,
353 transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
354 label = "fileViewState",
355 ) { s ->
356 // Single source of truth for matches: the off-thread `matchIndices` produceState
357 // (keyed on the file content + debounced query). Both navigation (onPrev/onNext +
358 // scroll) and display (counter + highlight) read from it, so the counter can't show
359 // a new query while next/prev act on a stale set. The earlier in-composition
360 // per-layer scan re-ran the whole-file O(n) match on the UI thread each keystroke
361 // (the exact work produceState was moved off-thread to avoid); it's acceptable for
362 // the crossfade's exiting layer to briefly use this unified set until it settles.
363 FileViewStateContent(
364 state = s,
365 path = path,
366 filename = filename,
367 showToggle = showToggle,
368 showDiff = showDiff,
369 onSetShowDiff = {
370 showDiff = it
371 // Close any open find bar when entering diff mode: find searches
372 // the raw content and scrolls the line LazyColumn, neither of
373 // which is visible in diff mode, so leaving it open would be a
374 // confusing no-op.
375 if (it) { findActive = false; findQuery = "" }
376 },
377 findActive = findActive,
378 findQuery = findQuery,
379 onQueryChange = { findQuery = it; matchPos = 0; if (it.isNotEmpty()) highlightLine.value = null },
380 matchIndices = matchIndices,
381 matchPos = matchPos,
382 onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
383 onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
384 onCloseFind = { findActive = false; findQuery = "" },
385 wrap = wrap,
386 lines = lines,
387 truncated = truncated,
388 moreLines = moreLines,
389 onLoadMore = { renderLimit += MAX_RENDERED_LINES },
390 highlightLineIndex = highlightLine.value,
391 listState = listState,
392 onRetry = { vm.reload() },
393 caseSensitive = caseSensitive,
394 onToggleCaseSensitive = { caseSensitive = !caseSensitive },
395 regex = regex,
396 onToggleRegex = { regex = !regex },
397 onShareFull = {
398 val send = Intent(Intent.ACTION_SEND).apply {
399 type = "text/plain"
400 putExtra(Intent.EXTRA_SUBJECT, filename)
401 putExtra(Intent.EXTRA_TEXT, rawText)
402 }
403 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
404 .onFailure {
405 // A large file can blow past the Binder transaction limit
406 // (~1MB); distinguish that from a missing share target so the
407 // message is honest instead of blaming a nonexistent app.
408 val msg = if (it is android.os.DeadObjectException ||
409 it is android.os.TransactionTooLargeException
410 ) {
411 context.getString(R.string.file_share_too_large)
412 } else {
413 context.getString(R.string.no_share_app)
414 }
415 scope.launch { snackbar.showSnackbar(msg) }
416 }
417 },
418 )
419 }
420 }
421 }
422 }
424 GoToLineLauncher(
425 visible = showGoToLine,
426 maxLine = lines.size,
427 onConfirm = { n -> showGoToLine = false; goToLine(n) },
428 onDismiss = { showGoToLine = false },
429 )
432// Wraps the go-to-line dialog's visibility gate so the branch stays out of FileViewScreen
433// (keeping that composable under the cyclomatic-complexity threshold).
434@Composable
435private fun GoToLineLauncher(
436 visible: Boolean,
437 maxLine: Int,
438 onConfirm: (Int) -> Unit,
439 onDismiss: () -> Unit,
440) {
441 if (visible) {
442 GoToLineDialog(maxLine = maxLine, onConfirm = onConfirm, onDismiss = onDismiss)
443 }
446@Composable
447private fun GoToLineDialog(maxLine: Int, onConfirm: (Int) -> Unit, onDismiss: () -> Unit) {
448 var text by rememberSaveable { mutableStateOf("") }
449 val parsed = text.trim().toIntOrNull()
450 val valid = parsed != null && parsed in 1..maxLine
451 AlertDialog(
452 onDismissRequest = onDismiss,
453 title = { Text(stringResource(R.string.go_to_line)) },
454 text = {
455 OutlinedTextField(
456 value = text,
457 onValueChange = { v -> text = v.filter { it.isDigit() } },
458 singleLine = true,
459 isError = text.isNotEmpty() && !valid,
460 supportingText = {
461 Text(
462 stringResource(R.string.go_to_line_hint, maxLine),
463 style = MaterialTheme.typography.bodySmall,
464 )
465 },
466 keyboardOptions = KeyboardOptions(
467 keyboardType = KeyboardType.Number,
468 imeAction = ImeAction.Done,
469 ),
470 keyboardActions = KeyboardActions(onDone = { if (valid) parsed?.let { onConfirm(it) } }),
471 )
472 },
473 confirmButton = {
474 TextButton(onClick = { parsed?.let { onConfirm(it) } }, enabled = valid) {
475 Text(stringResource(R.string.go_to_line))
476 }
477 },
478 dismissButton = {
479 TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
480 },
481 )
484// Owns the "jump to a line from a search hit" behaviour: scroll to the target once the raw
485// content loads (one-shot), and clear the highlight the moment the user drags the list by hand.
486// Extracted so FileViewScreen stays under the cyclomatic-complexity threshold.
487@Composable
488private fun rememberJumpToLineHighlight(
489 path: String,
490 initialLine: Int?,
491 content: Any?,
492 isBinary: Boolean,
493 lineCount: Int,
494 listState: LazyListState,
495): MutableState<Int?> {
496 val highlight = rememberSaveable(path) { mutableStateOf<Int?>(null) }
497 var didInitialScroll by rememberSaveable(path) { mutableStateOf(false) }
498 LaunchedEffect(content, initialLine) {
499 val line = initialLine ?: return@LaunchedEffect
500 if (didInitialScroll || content == null || isBinary) return@LaunchedEffect
501 val target = (line - 1).coerceIn(0, (lineCount - 1).coerceAtLeast(0))
502 runCatchingCancellable { listState.scrollToItem(target) }
503 highlight.value = target
504 didInitialScroll = true
505 }
506 LaunchedEffect(listState) {
507 if (initialLine == null) return@LaunchedEffect
508 listState.interactionSource.interactions.collect { interaction ->
509 if (interaction is DragInteraction.Start) highlight.value = null
510 }
511 }
512 return highlight
515/** Build the compact "12.3 KB · 2d ago" metadata line for the file viewer's top bar from the
516 * size/mtime the ViewModel resolved. Returns an empty string when neither is present so the
517 * caller can drop the line in unconditionally. Uses the same helpers as the file browser's
518 * FileRow so the formatting is identical across screens. */
519private fun buildMetadataLine(context: android.content.Context, size: Long?, mtime: Long?): String {
520 val parts = buildList {
521 size?.let { add(formatFileSize(it)) }
522 mtime?.let { add(relativeTime(context, it)) }
523 }
524 return parts.joinToString(" · ")
527@OptIn(ExperimentalMaterial3Api::class)
528@Composable
529private fun FileViewTopBar(
530 filename: String,
531 subtitle: String,
532 metadata: String,
533 loading: Boolean,
534 content: String,
535 wrap: Boolean,
536 diffShown: Boolean,
537 onBack: () -> Unit,
538 onReload: () -> Unit,
539 onToggleFind: () -> Unit,
540 onToggleWrap: () -> Unit,
541 onGoToLine: () -> Unit,
542 onCopy: () -> Unit,
543 onCopyPath: () -> Unit,
544 onShare: () -> Unit,
545 onOpenExternally: () -> Unit,
546 onOpenInChat: () -> Unit,
547) {
548 TopAppBar(
549 title = {
550 // Two- or three-line title: filename (primary), the directory portion of the path
551 // (secondary), and an optional muted metadata line (size · mtime) so a user who
552 // has drilled into a deep workspace keeps context of where this file lives and how
553 // stale it is without an extra breadcrumb row.
554 Column {
555 Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis)
556 if (subtitle.isNotBlank()) {
557 Text(
558 subtitle,
559 maxLines = 1,
560 overflow = TextOverflow.Ellipsis,
561 style = MaterialTheme.typography.bodySmall,
562 color = MaterialTheme.colorScheme.onSurfaceVariant,
563 )
564 }
565 if (metadata.isNotEmpty()) {
566 Text(
567 metadata,
568 maxLines = 1,
569 overflow = TextOverflow.Ellipsis,
570 style = MaterialTheme.typography.labelSmall,
571 color = MaterialTheme.colorScheme.onSurfaceVariant,
572 )
573 }
574 }
575 },
576 navigationIcon = {
577 IconButton(onClick = onBack) {
578 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
579 }
580 },
581 actions = {
582 // In-place refresh: a file's content can change on the server (the agent may
583 // be editing it), so reload without requiring a back-out. Shows a spinner while
584 // loading since the content now stays on screen during a reload (the spinner is
585 // the only progress signal).
586 val loadingLabel = stringResource(R.string.loading)
587 IconButton(onClick = onReload, enabled = !loading) {
588 if (loading) {
589 CircularProgressIndicator(
590 Modifier.size(20.dp).semantics { contentDescription = loadingLabel },
591 strokeWidth = 2.dp,
592 )
593 } else {
594 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
595 }
596 }
597 if (content.isNotEmpty()) {
598 // Find-in-file: toggles a search bar over the raw content. Stays enabled even in
599 // diff mode — the find bar searches the raw text and scrolls the line LazyColumn,
600 // neither of which is visible under a diff, so tapping it there surfaces a hint to
601 // switch to Raw rather than silently no-opping behind a greyed-out icon.
602 IconButton(onClick = onToggleFind) {
603 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
604 }
605 // Overflow the less-frequent actions (wrap / copy / share) so a long filename
606 // title isn't crowded off screen by five inline icons on a phone.
607 var menuExpanded by remember { mutableStateOf(false) }
608 IconButton(onClick = { menuExpanded = true }) {
609 Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options_for, filename))
610 }
611 DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
612 // Wrap long lines instead of horizontal-scrolling; useful for prose. Disabled
613 // in diff mode because DiffView ignores wrap, so an active-looking toggle there
614 // would be a silent no-op.
615 DropdownMenuItem(
616 text = { Text(stringResource(R.string.go_to_line)) },
617 onClick = { onGoToLine(); menuExpanded = false },
618 leadingIcon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
619 )
620 DropdownMenuItem(
621 text = { Text(stringResource(R.string.wrap_lines)) },
622 onClick = { onToggleWrap(); menuExpanded = false },
623 enabled = !diffShown,
624 leadingIcon = {
625 Icon(
626 Icons.AutoMirrored.Filled.WrapText,
627 contentDescription = null,
628 tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
629 )
630 },
631 )
632 DropdownMenuItem(
633 text = { Text(stringResource(R.string.copy)) },
634 onClick = { onCopy(); menuExpanded = false },
635 leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) },
636 )
637 DropdownMenuItem(
638 text = { Text(stringResource(R.string.copy_path)) },
639 onClick = { onCopyPath(); menuExpanded = false },
640 leadingIcon = { Icon(Icons.Filled.Link, contentDescription = null) },
641 )
642 DropdownMenuItem(
643 text = { Text(stringResource(R.string.open_in_chat)) },
644 onClick = { onOpenInChat(); menuExpanded = false },
645 leadingIcon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) },
646 )
647 DropdownMenuItem(
648 text = { Text(stringResource(R.string.share)) },
649 onClick = { onShare(); menuExpanded = false },
650 leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) },
651 )
652 DropdownMenuItem(
653 text = { Text(stringResource(R.string.open_externally)) },
654 onClick = { onOpenExternally(); menuExpanded = false },
655 leadingIcon = { Icon(Icons.Filled.OpenInNew, contentDescription = null) },
656 )
657 }
658 }
659 },
660 )
663@Composable
664private fun BoxScope.FileViewStateContent(
665 state: FileViewState,
666 path: String,
667 filename: String,
668 showToggle: Boolean,
669 showDiff: Boolean,
670 onSetShowDiff: (Boolean) -> Unit,
671 findActive: Boolean,
672 findQuery: String,
673 onQueryChange: (String) -> Unit,
674 matchIndices: List<Int>,
675 matchPos: Int,
676 onPrev: () -> Unit,
677 onNext: () -> Unit,
678 onCloseFind: () -> Unit,
679 wrap: Boolean,
680 lines: List<String>,
681 truncated: Boolean,
682 moreLines: Int,
683 onLoadMore: () -> Unit,
684 highlightLineIndex: Int?,
685 listState: LazyListState,
686 onRetry: () -> Unit,
687 onShareFull: () -> Unit,
688 caseSensitive: Boolean,
689 onToggleCaseSensitive: () -> Unit,
690 regex: Boolean,
691 onToggleRegex: () -> Unit,
692) {
693 when {
694 // Full-screen skeleton only on the initial load (no content yet). A reload keeps the
695 // existing content on screen; the top-bar reload button shows the in-flight progress.
696 // Skeleton "code line" rows pre-structure the layout so a large file's load feels
697 // faster than a bare spinner and the real content cross-fades into an already-shaped
698 // gutter+text area.
699 state.loading && state.content == null -> {
700 val loadingLabel = stringResource(R.string.loading)
701 val reducedMotion = soy.iko.opencode.ui.components.LocalReducedMotion.current
702 val transition = rememberInfiniteTransition(label = "file_skeleton")
703 val pulse by transition.animateFloat(
704 initialValue = 0.35f,
705 targetValue = 0.7f,
706 animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
707 label = "fileSkeletonAlpha",
708 )
709 val skeletonAlpha = if (reducedMotion) 0.5f else pulse
710 val skeletonColor = MaterialTheme.colorScheme.surfaceVariant
711 Column(
712 modifier = Modifier
713 .align(Alignment.TopCenter)
714 .padding(top = 24.dp)
715 .padding(horizontal = 16.dp)
716 .fillMaxWidth()
717 .semantics { contentDescription = loadingLabel },
718 verticalArrangement = Arrangement.spacedBy(8.dp),
719 ) {
720 repeat(10) { i ->
721 Row(
722 modifier = Modifier.fillMaxWidth(),
723 horizontalArrangement = Arrangement.spacedBy(8.dp),
724 ) {
725 Box(
726 Modifier
727 .width(36.dp)
728 .height(14.dp)
729 .clip(MaterialTheme.shapes.small)
730 .background(skeletonColor)
731 .alpha(skeletonAlpha),
732 )
733 Box(
734 Modifier
735 .fillMaxWidth(if (i % 3 == 0) 0.9f else 0.7f)
736 .height(14.dp)
737 .clip(MaterialTheme.shapes.small)
738 .background(skeletonColor)
739 .alpha(skeletonAlpha),
740 )
741 }
742 }
743 }
744 }
745 state.error != null -> Column(
746 modifier = Modifier.align(Alignment.Center).padding(24.dp),
747 horizontalAlignment = Alignment.CenterHorizontally,
748 ) {
749 Icon(
750 Icons.Filled.ErrorOutline,
751 contentDescription = null,
752 modifier = Modifier.size(56.dp),
753 tint = MaterialTheme.colorScheme.error,
754 )
755 Spacer(Modifier.size(12.dp))
756 Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
757 Spacer(Modifier.size(12.dp))
758 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
759 }
760 // Image preview: if the file has an image extension, render it inline instead of
761 // showing "Binary file". The server returns binary content as base64; decode and
762 // display. Falls back to the binary-file label if decoding fails.
763 state.content != null && state.content.isBinary && run {
764 val ext = path.substringAfterLast('.', "").lowercase()
765 ext in setOf("png", "jpg", "jpeg", "gif", "webp", "bmp", "svg")
766 } -> {
767 ImageFilePreview(
768 content = state.content,
769 modifier = Modifier.align(Alignment.Center).padding(16.dp),
770 )
771 }
772 state.content?.isBinary == true -> Text(
773 stringResource(R.string.binary_file),
774 modifier = Modifier.align(Alignment.Center).padding(24.dp),
775 color = MaterialTheme.colorScheme.onSurfaceVariant,
776 )
777 else -> FileViewContentBody(
778 content = state.content,
779 filename = filename,
780 showToggle = showToggle,
781 showDiff = showDiff,
782 onSetShowDiff = onSetShowDiff,
783 findActive = findActive,
784 findQuery = findQuery,
785 onQueryChange = onQueryChange,
786 matchIndices = matchIndices,
787 matchPos = matchPos,
788 onPrev = onPrev,
789 onNext = onNext,
790 onCloseFind = onCloseFind,
791 wrap = wrap,
792 lines = lines,
793 truncated = truncated,
794 moreLines = moreLines,
795 onLoadMore = onLoadMore,
796 highlightLineIndex = highlightLineIndex,
797 listState = listState,
798 onShareFull = onShareFull,
799 caseSensitive = caseSensitive,
800 onToggleCaseSensitive = onToggleCaseSensitive,
801 regex = regex,
802 onToggleRegex = onToggleRegex,
803 )
804 }
807@Composable
808private fun BoxScope.FileViewContentBody(
809 content: soy.iko.opencode.data.model.FileContent?,
810 filename: String,
811 showToggle: Boolean,
812 showDiff: Boolean,
813 onSetShowDiff: (Boolean) -> Unit,
814 findActive: Boolean,
815 findQuery: String,
816 onQueryChange: (String) -> Unit,
817 matchIndices: List<Int>,
818 matchPos: Int,
819 onPrev: () -> Unit,
820 onNext: () -> Unit,
821 onCloseFind: () -> Unit,
822 wrap: Boolean,
823 lines: List<String>,
824 truncated: Boolean,
825 moreLines: Int,
826 onLoadMore: () -> Unit,
827 highlightLineIndex: Int?,
828 listState: LazyListState,
829 onShareFull: () -> Unit,
830 caseSensitive: Boolean,
831 onToggleCaseSensitive: () -> Unit,
832 regex: Boolean,
833 onToggleRegex: () -> Unit,
834) {
835 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
836 // reachable while scrolling. Each contributes a top inset so the content below
837 // isn't hidden behind the overlay. The inset is measured from the actual overlay
838 // height (not a hardcoded 52dp/row) so accessibility font scaling — which can
839 // make the FilterChip labels and FindBar text field taller than 52dp — doesn't
840 // cause the first lines of content to hide behind the overlay.
841 // The raw text is on screen whenever we're NOT rendering the diff — i.e. either the
842 // file has no diff toggle at all (plain file: showToggle == false) or the toggle is
843 // set to Raw. Gate the find bar on this rather than on !showDiff: for a plain file
844 // showDiff stays at its `true` default forever (nothing ever flips it), so !showDiff
845 // would keep the find bar — and therefore find-in-file — permanently hidden.
846 val rawOnScreen = !(showToggle && showDiff)
847 val overlayRows = (if (showToggle) 1 else 0) + (if (findActive && rawOnScreen) 1 else 0)
848 var overlayHeightPx by remember { mutableIntStateOf(0) }
849 // Only apply the measured inset while an overlay is actually composed. When overlayRows
850 // drops to 0 the overlay Column is removed and onSizeChanged never fires to zero the
851 // height, so keying the inset off overlayRows (rather than the stale measurement) avoids
852 // a phantom top gap on the content below — e.g. after closing find on a plain file.
853 val topInset = if (overlayRows > 0) {
854 with(androidx.compose.ui.platform.LocalDensity.current) { overlayHeightPx.toDp() }
855 } else 0.dp
856 if (overlayRows > 0) {
857 Column(
858 modifier = Modifier
859 .align(Alignment.TopCenter)
860 .fillMaxWidth()
861 .onSizeChanged { overlayHeightPx = it.height }
862 .padding(top = 4.dp),
863 verticalArrangement = Arrangement.spacedBy(4.dp),
864 ) {
865 if (showToggle) {
866 Row(
867 modifier = Modifier.fillMaxWidth(),
868 horizontalArrangement = Arrangement.Center,
869 ) {
870 FilterChip(
871 selected = showDiff,
872 onClick = { onSetShowDiff(true) },
873 label = { Text(stringResource(R.string.show_diff)) },
874 )
875 Spacer(Modifier.size(8.dp))
876 FilterChip(
877 selected = !showDiff,
878 onClick = { onSetShowDiff(false) },
879 label = { Text(stringResource(R.string.show_raw)) },
880 )
881 }
882 }
883 if (findActive && rawOnScreen) {
884 FindBar(
885 query = findQuery,
886 onQueryChange = onQueryChange,
887 matchCount = matchIndices.size,
888 matchPos = matchPos,
889 onPrev = onPrev,
890 onNext = onNext,
891 onClose = onCloseFind,
892 caseSensitive = caseSensitive,
893 onToggleCaseSensitive = onToggleCaseSensitive,
894 regex = regex,
895 onToggleRegex = onToggleRegex,
896 )
897 }
898 }
899 }
900 if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
901 // Use LazyDiffView so a 5000-line diff composes only the visible rows. The previous
902 // DiffView + verticalScroll composed every row eagerly, which OOM'd on large diffs.
903 soy.iko.opencode.ui.components.LazyDiffView(
904 diff = content.diff,
905 modifier = Modifier
906 .fillMaxSize()
907 .padding(top = topInset)
908 .padding(8.dp),
909 )
910 } else {
911 val text = content?.content.orEmpty()
912 if (text.isEmpty()) {
913 Text(
914 stringResource(R.string.empty_file),
915 modifier = Modifier.align(Alignment.Center).padding(24.dp),
916 color = MaterialTheme.colorScheme.onSurfaceVariant,
917 )
918 } else {
919 FileTextContent(
920 lines = lines,
921 filename = filename,
922 topInset = topInset,
923 truncated = truncated,
924 moreLines = moreLines,
925 onLoadMore = onLoadMore,
926 wrap = wrap,
927 findQuery = findQuery,
928 matchIndices = matchIndices,
929 highlightLineIndex = highlightLineIndex,
930 listState = listState,
931 onShareFull = onShareFull,
932 )
933 }
934 }
937@Composable
938private fun FindBar(
939 query: String,
940 onQueryChange: (String) -> Unit,
941 matchCount: Int,
942 matchPos: Int,
943 onPrev: () -> Unit,
944 onNext: () -> Unit,
945 onClose: () -> Unit,
946 caseSensitive: Boolean,
947 onToggleCaseSensitive: () -> Unit,
948 regex: Boolean,
949 onToggleRegex: () -> Unit,
950) {
951 val keyboardController = LocalSoftwareKeyboardController.current
952 val matchCaseLabel = stringResource(R.string.match_case)
953 val regexLabel = stringResource(R.string.regex_search)
954 Row(
955 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
956 verticalAlignment = Alignment.CenterVertically,
957 ) {
958 OutlinedTextField(
959 value = query,
960 onValueChange = onQueryChange,
961 modifier = Modifier.weight(1f),
962 placeholder = { Text(stringResource(R.string.find_hint)) },
963 singleLine = true,
964 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
965 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
966 trailingIcon = {
967 Row(verticalAlignment = Alignment.CenterVertically) {
968 // Case-sensitivity toggle. A small "Aa" chip keeps the bar compact while
969 // surfacing a previously substring-only, always-case-insensitive search.
970 FilterChip(
971 selected = caseSensitive,
972 onClick = onToggleCaseSensitive,
973 label = { Text("Aa", style = MaterialTheme.typography.labelSmall) },
974 modifier = Modifier.semantics { contentDescription = matchCaseLabel },
975 )
976 Spacer(Modifier.size(8.dp))
977 // Regex toggle (".*"): when on, the query is a Java Pattern. Mirrors
978 // editor find bars; the label stays compact to match the "Aa" chip.
979 FilterChip(
980 selected = regex,
981 onClick = onToggleRegex,
982 label = { Text(".*", style = MaterialTheme.typography.labelSmall) },
983 modifier = Modifier.semantics { contentDescription = regexLabel },
984 )
985 Spacer(Modifier.size(8.dp))
986 val countText = if (query.isBlank()) ""
987 else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
988 else stringResource(R.string.match_count, matchPos + 1, matchCount)
989 if (countText.isNotEmpty()) {
990 // Use error color for "no matches" so the zero-result state is
991 // distinguishable from a positive match count at a glance.
992 val color = if (query.isNotBlank() && matchCount == 0) MaterialTheme.colorScheme.error
993 else MaterialTheme.colorScheme.onSurfaceVariant
994 Text(
995 countText,
996 // labelMedium (not labelSmall) so the counter is legible for low-vision
997 // users at large font scales; the error color still distinguishes zero
998 // matches at a glance.
999 style = MaterialTheme.typography.labelMedium,
1000 color = color,
1004 },
1006 IconButton(onClick = onPrev, enabled = matchCount > 0) {
1007 Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_previous))
1009 IconButton(onClick = onNext, enabled = matchCount > 0) {
1010 Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_next))
1012 IconButton(onClick = onClose) {
1013 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
1018@Composable
1019private fun FileTextContent(
1020 lines: List<String>,
1021 filename: String,
1022 topInset: androidx.compose.ui.unit.Dp,
1023 truncated: Boolean,
1024 moreLines: Int,
1025 onLoadMore: () -> Unit,
1026 wrap: Boolean,
1027 findQuery: String,
1028 matchIndices: List<Int>,
1029 highlightLineIndex: Int?,
1030 listState: LazyListState,
1031 onShareFull: () -> Unit,
1032) {
1033 // Honor the user's chat text-size preference (the same one the chat markdown honors) so
1034 // a user who bumped the scale for readability gets the same scale in the code viewer.
1035 // Applied by nesting a MaterialTheme with a scaled typography, exactly how MarkdownText
1036 // does it — the gutter/code Text below read MaterialTheme.typography.bodySmall, so this
1037 // reaches both without per-Text plumbing.
1038 val scale = LocalChatTextScale.current
1039 val base = MaterialTheme.typography
1040 val scaled = remember(scale, base) { base.scaledBy(scale) }
1041 // Gutter width scales with the digit count and the font scale so accessibility
1042 // text scaling (e.g. 1.3x) and the app's chat-text scale don't make line numbers overflow
1043 // the gutter and collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
1044 // proportionally for larger font/app scales.
1045 val fontScale = androidx.compose.ui.platform.LocalDensity.current.fontScale
1046 val gutterWidth = remember(lines.size, fontScale, scale) {
1047 ((lines.size.toString().length.coerceAtLeast(3) * 10) *
1048 fontScale.coerceAtLeast(1f) *
1049 scale.coerceAtLeast(1f)).dp
1051 val hScrollState = rememberScrollState()
1052 val palette = rememberHighlightPalette()
1053 // Resolve the file's language once instead of re-parsing the extension for every line.
1054 val syntax = remember(filename) { syntaxFor(filename) }
1055 // Pre-compute per-line block-comment/triple-string state snapshots so a LazyColumn row
1056 // can highlight its line correctly without re-walking every preceding line on each
1057 // recomposition. Each snapshot is two booleans; for a 5000-line file that's ~10KB,
1058 // trivial next to the line strings themselves. highlightLine(line, syntax, palette, state)
1059 // mutates the passed state in place, so we give each line its own snapshot derived from
1060 // the prior line's end state — this is what makes a 50-line block comment color correctly
1061 // on every line, not just the opener.
1062 val lineStates = remember(lines, syntax) {
1063 if (syntax.lang == soy.iko.opencode.ui.components.Language.NONE) {
1064 emptyList()
1065 } else {
1066 val states = ArrayList<soy.iko.opencode.ui.components.BlockCommentState>(lines.size)
1067 var current = soy.iko.opencode.ui.components.BlockCommentState()
1068 for (line in lines) {
1069 val snapshot = soy.iko.opencode.ui.components.BlockCommentState().also {
1070 it.inBlockComment = current.inBlockComment
1071 it.inTripleString = current.inTripleString
1073 states.add(snapshot)
1074 // Advance the running state by highlighting into a throwaway builder — the
1075 // side effect on `current` is what we need. We discard the AnnotatedString.
1076 soy.iko.opencode.ui.components.highlightLine(line, syntax, palette, current)
1078 states
1081 val q = findQuery.trim()
1082 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
1083 MaterialTheme(typography = scaled) {
1084 Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
1085 if (truncated) {
1086 androidx.compose.foundation.layout.Row(
1087 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
1088 verticalAlignment = Alignment.CenterVertically,
1089 horizontalArrangement = Arrangement.SpaceBetween,
1090 ) {
1091 Text(
1092 stringResource(R.string.file_truncated, lines.size),
1093 style = MaterialTheme.typography.labelSmall,
1094 color = MaterialTheme.colorScheme.onSurfaceVariant,
1095 modifier = Modifier.weight(1f),
1097 // Load the next chunk in-place so the user can keep reading the file without
1098 // leaving the app. Falls back to Share full file for the whole content.
1099 TextButton(onClick = onLoadMore) {
1100 Text(stringResource(R.string.load_more_lines, moreLines))
1102 TextButton(onClick = onShareFull) {
1103 Text(stringResource(R.string.share_full_file))
1107 // SelectionContainer lets the user select/copy a portion of the rendered code
1108 // (not just the whole-file Copy in the overflow). Selection across recycled
1109 // LazyColumn items can drop spans that have scrolled away, but partial selection
1110 // of the visible region — the common case — works.
1111 SelectionContainer {
1112 LazyColumn(
1113 state = listState,
1114 modifier = Modifier
1115 .weight(1f)
1116 .fillMaxWidth()
1117 .padding(8.dp),
1118 ) {
1119 itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
1120 val isMatch = q.isNotEmpty() && index in matchSet
1121 // The jump-to-line target keeps the same emphasis as a find match so the line
1122 // the viewer landed on is obvious until the user scrolls or starts a find.
1123 val isTarget = index == highlightLineIndex
1124 Row(
1125 modifier = Modifier
1126 .fillMaxWidth()
1127 .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
1128 .then(if (isMatch || isTarget) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
1129 ) {
1130 Text(
1131 "${index + 1}",
1132 modifier = Modifier.width(gutterWidth),
1133 style = MaterialTheme.typography.bodySmall,
1134 fontFamily = FontFamily.Monospace,
1135 color = MaterialTheme.colorScheme.onSurfaceVariant,
1137 // Heuristic syntax highlighting per line. Falls back to plain text for
1138 // unknown extensions, so non-code files render unchanged. Memoized so the
1139 // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
1140 // file, or palette actually changes — not on every recomposition (e.g. every
1141 // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
1142 // Uses the stateful highlightLine so multi-line block comments and triple-quoted
1143 // strings color correctly on every line they span (the per-line snapshot in
1144 // lineStates carries the in-block/in-trString flag for this line's position).
1145 val highlighted = remember(line, syntax, palette, index) {
1146 val state = lineStates.getOrNull(index)
1147 if (state != null) {
1148 val s = soy.iko.opencode.ui.components.BlockCommentState().also {
1149 it.inBlockComment = state.inBlockComment
1150 it.inTripleString = state.inTripleString
1152 soy.iko.opencode.ui.components.highlightLine(line, syntax, palette, s)
1153 } else {
1154 soy.iko.opencode.ui.components.highlightLine(line, syntax, palette)
1157 Text(
1158 highlighted,
1159 modifier = Modifier.padding(start = 8.dp),
1160 style = MaterialTheme.typography.bodySmall,
1170/**
1171 * Renders an image file (PNG/JPG/GIF/WebP/BMP/SVG) inline in the file viewer instead of
1172 * the generic "Binary file" label. Decodes the base64 content from [FileContent.content]
1173 * into bytes and hands them to Coil, which renders the image and powers the fullscreen
1174 * zoomable viewer (pinch-zoom, save, share) on tap — the same [FullscreenImageViewer] the
1175 * chat attachments use. Falls back to the binary-file label if the base64 payload can't be
1176 * decoded (corrupt content or an unsupported encoding).
1177 */
1178@Composable
1179private fun ImageFilePreview(
1180 content: soy.iko.opencode.data.model.FileContent,
1181 modifier: Modifier = Modifier,
1182) {
1183 val context = LocalContext.current
1184 val desc = stringResource(R.string.image_file_preview)
1185 // Decode the base64 payload to bytes once (off-thread via produceState so a large image
1186 // doesn't block the first frame). Coil takes the ByteArray as its model and decodes it to
1187 // a Bitmap internally — the same request drives both the inline preview and the fullscreen
1188 // viewer, so a tap reuses the already-cached decode for the zoomed view.
1189 val bytesState by produceState<ByteArray?>(initialValue = null, content.content) {
1190 value = withContext(Dispatchers.Default) {
1191 runCatching {
1192 android.util.Base64.decode(content.content, android.util.Base64.DEFAULT)
1193 }.getOrNull()
1196 val bytes = bytesState
1197 if (bytes == null) {
1198 Text(
1199 stringResource(R.string.binary_file),
1200 modifier = modifier,
1201 color = MaterialTheme.colorScheme.onSurfaceVariant,
1203 return
1205 var showFullscreen by remember { mutableStateOf(false) }
1206 val request = remember(bytes) {
1207 ImageRequest.Builder(context)
1208 .data(bytes)
1209 .crossfade(true)
1210 .build()
1212 Column(
1213 modifier = modifier.fillMaxWidth(),
1214 horizontalAlignment = Alignment.CenterHorizontally,
1215 ) {
1216 SubcomposeAsyncImage(
1217 model = request,
1218 contentDescription = desc,
1219 modifier = Modifier
1220 .fillMaxWidth()
1221 .heightIn(max = 400.dp)
1222 .clip(MaterialTheme.shapes.medium)
1223 .clickable(role = Role.Button) { showFullscreen = true },
1226 if (showFullscreen) {
1227 FullscreenImageViewer(request = request, contentDescription = desc) { showFullscreen = false }