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.Image
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.WrapText
42import androidx.compose.material.icons.filled.Close
43import androidx.compose.material.icons.filled.ContentCopy
44import androidx.compose.material.icons.filled.KeyboardArrowDown
45import androidx.compose.material.icons.filled.KeyboardArrowUp
46import androidx.compose.material.icons.filled.Link
47import androidx.compose.material.icons.filled.MoreVert
48import androidx.compose.material.icons.filled.Refresh
49import androidx.compose.material.icons.filled.Search
50import androidx.compose.material.icons.filled.OpenInNew
51import androidx.compose.material.icons.filled.Share
52import androidx.compose.material3.AlertDialog
53import androidx.compose.material3.CircularProgressIndicator
54import androidx.compose.material3.DropdownMenu
55import androidx.compose.material3.DropdownMenuItem
56import androidx.compose.material3.ExperimentalMaterial3Api
57import androidx.compose.material3.FilterChip
58import androidx.compose.material3.Icon
59import androidx.compose.material3.IconButton
60import androidx.compose.material3.MaterialTheme
61import androidx.compose.material3.OutlinedTextField
62import androidx.compose.material3.Scaffold
63import androidx.compose.material3.SnackbarHost
64import androidx.compose.material3.SnackbarHostState
65import androidx.compose.material3.Text
66import androidx.compose.material3.TextButton
67import androidx.compose.material3.TopAppBar
68import androidx.compose.runtime.Composable
69import androidx.compose.runtime.LaunchedEffect
70import androidx.compose.runtime.MutableState
71import androidx.compose.runtime.getValue
72import androidx.compose.runtime.mutableIntStateOf
73import androidx.compose.runtime.mutableStateOf
74import androidx.compose.runtime.produceState
75import androidx.compose.runtime.remember
76import androidx.compose.runtime.rememberCoroutineScope
77import androidx.compose.runtime.saveable.rememberSaveable
78import androidx.compose.runtime.setValue
79import androidx.compose.ui.Alignment
80import androidx.compose.ui.graphics.asImageBitmap
81import androidx.compose.ui.Modifier
82import androidx.compose.ui.draw.alpha
83import androidx.compose.ui.draw.clip
84import androidx.compose.ui.layout.onSizeChanged
85import androidx.compose.ui.platform.LocalContext
86import androidx.compose.ui.platform.LocalDensity
87import androidx.compose.ui.platform.LocalSoftwareKeyboardController
88import androidx.compose.ui.semantics.contentDescription
89import androidx.compose.ui.semantics.semantics
90import androidx.compose.ui.text.font.FontFamily
91import androidx.compose.ui.text.input.ImeAction
92import androidx.compose.ui.text.input.KeyboardType
93import androidx.compose.ui.text.style.TextOverflow
94import androidx.compose.ui.unit.dp
95import androidx.compose.ui.res.stringResource
96import androidx.lifecycle.compose.collectAsStateWithLifecycle
97import androidx.lifecycle.viewmodel.compose.viewModel
98import soy.iko.opencode.di.AppContainer
99import soy.iko.opencode.R
100import soy.iko.opencode.ui.components.ConnectionBannerFor
101import soy.iko.opencode.ui.components.DiffView
102import soy.iko.opencode.ui.components.LocalChatTextScale
103import soy.iko.opencode.ui.components.LocalCodeWrap
104import soy.iko.opencode.ui.components.copyToClipboard
105import soy.iko.opencode.ui.components.highlightLine
106import soy.iko.opencode.ui.components.syntaxFor
107import soy.iko.opencode.ui.components.rememberHighlightPalette
108import soy.iko.opencode.ui.components.scaledBy
109import soy.iko.opencode.ui.vmFactory
110import soy.iko.opencode.util.runCatchingCancellable
111import kotlinx.coroutines.Dispatchers
112import kotlinx.coroutines.delay
113import kotlinx.coroutines.flow.collect
114import kotlinx.coroutines.launch
115import kotlinx.coroutines.withContext
117import soy.iko.opencode.data.network.NetworkConfig
119/** Cap on how many lines the raw viewer renders and searches. See [NetworkConfig.maxRenderedFileLines]. */
120private const val MAX_RENDERED_LINES = NetworkConfig.maxRenderedFileLines
122@OptIn(ExperimentalMaterial3Api::class)
125 container: AppContainer,
128 initialLine: Int? = null,
130 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
131 val state by vm.state.collectAsStateWithLifecycle()
132 val context = LocalContext.current
133 val scope = rememberCoroutineScope()
134 val snackbar = remember { SnackbarHostState() }
135 val filename = remember(path) {
136 val trimmed = path.trimEnd('/')
137 trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } }
139 val shareLabel = stringResource(R.string.share)
140 // Diff vs raw view toggle. Defaults to showing the diff when one exists; the user can
141 // switch to the raw new content. Persisted via rememberSaveable across rotation. When the
142 // viewer is opened at a specific line (from a search hit), start on the raw view so the
143 // targeted line is actually rendered (the diff view has no line list to scroll).
144 val hasDiff = state.content?.diff != null && state.content?.diff?.isNotBlank() == true
145 var showDiff by rememberSaveable(path) { mutableStateOf(initialLine == null) }
146 val showToggle = hasDiff && state.content?.content.orEmpty().isNotEmpty()
147 // Find-in-file and line-wrapping state, persisted so a rotation or reload keeps the
148 // user's query/mode.
149 var findActive by rememberSaveable(path) { mutableStateOf(false) }
150 var findQuery by rememberSaveable(path) { mutableStateOf("") }
151 var matchPos by rememberSaveable(path) { mutableIntStateOf(0) }
152 // Seed the per-file wrap toggle from the user's global code-wrap preference so the viewer
153 // respects it by default; they can still flip it per-file (persisted across rotation).
154 val codeWrap = LocalCodeWrap.current
155 var wrap by rememberSaveable(path) { mutableStateOf(codeWrap) }
156 val listState = rememberLazyListState()
157 val rawText = state.content?.content.orEmpty()
158 // Directory portion of the path, shown under the filename in the top bar so a user who
159 // has drilled into a deep workspace keeps context of where this file lives.
160 val pathSubtitle = remember(path) {
161 val trimmed = path.trimEnd('/')
162 val parent = trimmed.substringBeforeLast('/', "")
163 parent.ifBlank { path }
165 // Split the raw text exactly once here (the line viewer and find-in-file both consume this
166 // list) and cap it so a huge file doesn't get split twice or overwhelm the LazyColumn.
167 // `take()` (not `subList()`) so the cap actually releases the tail: subList returns a view
168 // backed by the full list, which would keep every line (and the full rawText) in memory.
169 val allLines = remember(rawText) { rawText.split("\n") }
170 // The viewer renders the file in chunks of MAX_RENDERED_LINES so a huge file doesn't lay
171 // out thousands of lines at once. The user can load the next chunk via the truncation
172 // banner's "Load more" button instead of being stranded at the first cap (or forced to
173 // leave the app via Share full file).
174 var renderLimit by rememberSaveable(path) { mutableStateOf(MAX_RENDERED_LINES) }
175 val lines = remember(rawText, renderLimit) { allLines.take(renderLimit) }
176 val truncated = allLines.size > lines.size
177 val moreLines = (allLines.size - lines.size).coerceAtMost(MAX_RENDERED_LINES)
178 // The line the viewer was opened at (from a search hit), highlighted until the user scrolls
179 // or starts an in-file find so the landed line stands out. Null once cleared.
180 val highlightLine = rememberJumpToLineHighlight(
182 initialLine = initialLine,
183 content = state.content,
184 isBinary = state.content?.isBinary == true,
185 lineCount = lines.size,
186 listState = listState,
188 // Find-in-file match-case toggle (default case-insensitive). Persisted so a rotation keeps
189 // the choice, and applied to the off-thread match scan below.
190 var caseSensitive by rememberSaveable(path) { mutableStateOf(false) }
191 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
192 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
193 var debouncedFind by remember { mutableStateOf("") }
194 LaunchedEffect(findQuery) {
195 val q = findQuery.trim()
196 if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q }
198 val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind, caseSensitive) {
199 val q = debouncedFind
200 value = if (q.isEmpty()) emptyList()
201 else withContext(Dispatchers.Default) {
202 lines.mapIndexedNotNull { index, line ->
203 index.takeIf { line.contains(q, ignoreCase = !caseSensitive) }
207 // Keep the current match index in range when the match set shrinks (e.g. the file
208 // reloaded with fewer matches), so the counter never shows a stale "10 / 3".
209 LaunchedEffect(matchIndices.size) {
210 matchPos = matchPos.coerceIn(0, (matchIndices.size - 1).coerceAtLeast(0))
212 // Scroll to the current match whenever it (or the match set) changes.
213 LaunchedEffect(matchPos, matchIndices) {
214 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
216 // "Go to line" dialog state. Clears the jump highlight and scrolls the LazyColumn to the
217 // requested line (clamped to the rendered range) when the user confirms.
218 var showGoToLine by rememberSaveable(path) { mutableStateOf(false) }
219 fun goToLine(n: Int) {
220 val target = (n - 1).coerceIn(0, (lines.size - 1).coerceAtLeast(0))
221 highlightLine.value = null
222 scope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
228 subtitle = pathSubtitle,
229 loading = state.loading,
232 diffShown = showToggle && showDiff,
234 onReload = { vm.reload() },
235 // Find searches the raw line list, which isn't on screen under a diff. Rather
236 // than silently greying the icon out, keep it tappable and explain where find
237 // lives when the user is in diff mode.
239 if (showToggle && showDiff) {
240 scope.launch { snackbar.showSnackbar(context.getString(R.string.switch_to_raw_to_search)) }
242 findActive = !findActive
243 if (!findActive) findQuery = ""
246 onToggleWrap = { wrap = !wrap },
247 onGoToLine = { showGoToLine = true },
248 onCopy = { copyToClipboard(context, filename, rawText) },
249 onCopyPath = { copyToClipboard(context, context.getString(R.string.clip_label_path), path) },
251 val send = Intent(Intent.ACTION_SEND).apply {
253 putExtra(Intent.EXTRA_SUBJECT, filename)
254 putExtra(Intent.EXTRA_TEXT, rawText)
256 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
257 .onFailure { scope.launch { snackbar.showSnackbar(context.getString(R.string.no_share_app)) } }
260 // Write the viewed content to a cache file and hand a content:// URI to an
261 // external app via ACTION_VIEW, so a file the in-app viewer can only show as
262 // text (or a large file beyond the render cap) can be opened in a real editor.
264 val ok = runCatchingCancellable {
265 withContext(Dispatchers.IO) {
266 val dir = java.io.File(context.cacheDir, "external").apply { mkdirs() }
267 val file = java.io.File(dir, filename.ifBlank { "file.txt" })
268 file.writeText(rawText)
269 val authority = context.packageName + ".fileprovider"
270 val uri = androidx.core.content.FileProvider.getUriForFile(context, authority, file)
271 val view = Intent(Intent.ACTION_VIEW).apply {
272 setDataAndType(uri, android.webkit.MimeTypeMap.getSingleton()
273 .getMimeTypeFromExtension(filename.substringAfterLast('.', ""))
275 addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
277 context.startActivity(view)
280 if (!ok) scope.launch { snackbar.showSnackbar(context.getString(R.string.open_externally_failed)) }
285 snackbarHost = { SnackbarHost(snackbar) },
287 // Pull-to-refresh wraps the content so a user who pull-to-refreshes out of habit
288 // can reload the file, mirroring the file browser and session list gestures. The
289 // top-bar reload icon remains for a targeted tap.
290 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
291 isRefreshing = state.loading && state.content != null,
292 onRefresh = { vm.reload() },
293 modifier = Modifier.fillMaxSize().imePadding().padding(padding),
295 Box(modifier = Modifier.fillMaxSize()) {
296 // Surface SSE connection state so a dropped stream is visible while viewing a file.
297 ConnectionBannerFor(container)
298 // AnimatedContent cross-fades between loading / error / content states so the
299 // viewer doesn't snap abruptly when a reload finishes or fails.
302 transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
303 label = "fileViewState",
305 // Single source of truth for matches: the off-thread `matchIndices` produceState
306 // (keyed on the file content + debounced query). Both navigation (onPrev/onNext +
307 // scroll) and display (counter + highlight) read from it, so the counter can't show
308 // a new query while next/prev act on a stale set. The earlier in-composition
309 // per-layer scan re-ran the whole-file O(n) match on the UI thread each keystroke
310 // (the exact work produceState was moved off-thread to avoid); it's acceptable for
311 // the crossfade's exiting layer to briefly use this unified set until it settles.
312 FileViewStateContent(
316 showToggle = showToggle,
320 // Close any open find bar when entering diff mode: find searches
321 // the raw content and scrolls the line LazyColumn, neither of
322 // which is visible in diff mode, so leaving it open would be a
324 if (it) { findActive = false; findQuery = "" }
326 findActive = findActive,
327 findQuery = findQuery,
328 onQueryChange = { findQuery = it; matchPos = 0; if (it.isNotEmpty()) highlightLine.value = null },
329 matchIndices = matchIndices,
331 onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
332 onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
333 onCloseFind = { findActive = false; findQuery = "" },
336 truncated = truncated,
337 moreLines = moreLines,
338 onLoadMore = { renderLimit += MAX_RENDERED_LINES },
339 highlightLineIndex = highlightLine.value,
340 listState = listState,
341 onRetry = { vm.reload() },
342 caseSensitive = caseSensitive,
343 onToggleCaseSensitive = { caseSensitive = !caseSensitive },
345 val send = Intent(Intent.ACTION_SEND).apply {
347 putExtra(Intent.EXTRA_SUBJECT, filename)
348 putExtra(Intent.EXTRA_TEXT, rawText)
350 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
352 // A large file can blow past the Binder transaction limit
353 // (~1MB); distinguish that from a missing share target so the
354 // message is honest instead of blaming a nonexistent app.
355 val msg = if (it is android.os.DeadObjectException ||
356 it is android.os.TransactionTooLargeException
358 context.getString(R.string.file_share_too_large)
360 context.getString(R.string.no_share_app)
362 scope.launch { snackbar.showSnackbar(msg) }
372 visible = showGoToLine,
373 maxLine = lines.size,
374 onConfirm = { n -> showGoToLine = false; goToLine(n) },
375 onDismiss = { showGoToLine = false },
379// Wraps the go-to-line dialog's visibility gate so the branch stays out of FileViewScreen
380// (keeping that composable under the cyclomatic-complexity threshold).
382private fun GoToLineLauncher(
385 onConfirm: (Int) -> Unit,
386 onDismiss: () -> Unit,
389 GoToLineDialog(maxLine = maxLine, onConfirm = onConfirm, onDismiss = onDismiss)
394private fun GoToLineDialog(maxLine: Int, onConfirm: (Int) -> Unit, onDismiss: () -> Unit) {
395 var text by rememberSaveable { mutableStateOf("") }
396 val parsed = text.trim().toIntOrNull()
397 val valid = parsed != null && parsed in 1..maxLine
399 onDismissRequest = onDismiss,
400 title = { Text(stringResource(R.string.go_to_line)) },
404 onValueChange = { v -> text = v.filter { it.isDigit() } },
406 isError = text.isNotEmpty() && !valid,
409 stringResource(R.string.go_to_line_hint, maxLine),
410 style = MaterialTheme.typography.bodySmall,
413 keyboardOptions = KeyboardOptions(
414 keyboardType = KeyboardType.Number,
415 imeAction = ImeAction.Done,
417 keyboardActions = KeyboardActions(onDone = { if (valid) parsed?.let { onConfirm(it) } }),
421 TextButton(onClick = { parsed?.let { onConfirm(it) } }, enabled = valid) {
422 Text(stringResource(R.string.go_to_line))
426 TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
431// Owns the "jump to a line from a search hit" behaviour: scroll to the target once the raw
432// content loads (one-shot), and clear the highlight the moment the user drags the list by hand.
433// Extracted so FileViewScreen stays under the cyclomatic-complexity threshold.
435private fun rememberJumpToLineHighlight(
441 listState: LazyListState,
442): MutableState<Int?> {
443 val highlight = rememberSaveable(path) { mutableStateOf<Int?>(null) }
444 var didInitialScroll by rememberSaveable(path) { mutableStateOf(false) }
445 LaunchedEffect(content, initialLine) {
446 val line = initialLine ?: return@LaunchedEffect
447 if (didInitialScroll || content == null || isBinary) return@LaunchedEffect
448 val target = (line - 1).coerceIn(0, (lineCount - 1).coerceAtLeast(0))
449 runCatchingCancellable { listState.scrollToItem(target) }
450 highlight.value = target
451 didInitialScroll = true
453 LaunchedEffect(listState) {
454 if (initialLine == null) return@LaunchedEffect
455 listState.interactionSource.interactions.collect { interaction ->
456 if (interaction is DragInteraction.Start) highlight.value = null
462@OptIn(ExperimentalMaterial3Api::class)
464private fun FileViewTopBar(
472 onReload: () -> Unit,
473 onToggleFind: () -> Unit,
474 onToggleWrap: () -> Unit,
475 onGoToLine: () -> Unit,
477 onCopyPath: () -> Unit,
479 onOpenExternally: () -> Unit,
483 // Two-line title: filename (primary) + the directory portion of the path
484 // (secondary), so a file opened from deep in a workspace keeps its location
485 // visible without an extra breadcrumb row.
487 Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis)
488 if (subtitle.isNotBlank()) {
492 overflow = TextOverflow.Ellipsis,
493 style = MaterialTheme.typography.bodySmall,
494 color = MaterialTheme.colorScheme.onSurfaceVariant,
500 IconButton(onClick = onBack) {
501 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
505 // In-place refresh: a file's content can change on the server (the agent may
506 // be editing it), so reload without requiring a back-out. Shows a spinner while
507 // loading since the content now stays on screen during a reload (the spinner is
508 // the only progress signal).
509 val loadingLabel = stringResource(R.string.loading)
510 IconButton(onClick = onReload, enabled = !loading) {
512 CircularProgressIndicator(
513 Modifier.size(20.dp).semantics { contentDescription = loadingLabel },
517 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
520 if (content.isNotEmpty()) {
521 // Find-in-file: toggles a search bar over the raw content. Stays enabled even in
522 // diff mode — the find bar searches the raw text and scrolls the line LazyColumn,
523 // neither of which is visible under a diff, so tapping it there surfaces a hint to
524 // switch to Raw rather than silently no-opping behind a greyed-out icon.
525 IconButton(onClick = onToggleFind) {
526 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
528 // Overflow the less-frequent actions (wrap / copy / share) so a long filename
529 // title isn't crowded off screen by five inline icons on a phone.
530 var menuExpanded by remember { mutableStateOf(false) }
531 IconButton(onClick = { menuExpanded = true }) {
532 Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options))
534 DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
535 // Wrap long lines instead of horizontal-scrolling; useful for prose. Disabled
536 // in diff mode because DiffView ignores wrap, so an active-looking toggle there
537 // would be a silent no-op.
539 text = { Text(stringResource(R.string.go_to_line)) },
540 onClick = { onGoToLine(); menuExpanded = false },
541 leadingIcon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
544 text = { Text(stringResource(R.string.wrap_lines)) },
545 onClick = { onToggleWrap(); menuExpanded = false },
546 enabled = !diffShown,
549 Icons.AutoMirrored.Filled.WrapText,
550 contentDescription = null,
551 tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
556 text = { Text(stringResource(R.string.copy)) },
557 onClick = { onCopy(); menuExpanded = false },
558 leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) },
561 text = { Text(stringResource(R.string.copy_path)) },
562 onClick = { onCopyPath(); menuExpanded = false },
563 leadingIcon = { Icon(Icons.Filled.Link, contentDescription = null) },
566 text = { Text(stringResource(R.string.share)) },
567 onClick = { onShare(); menuExpanded = false },
568 leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) },
571 text = { Text(stringResource(R.string.open_externally)) },
572 onClick = { onOpenExternally(); menuExpanded = false },
573 leadingIcon = { Icon(Icons.Filled.OpenInNew, contentDescription = null) },
582private fun BoxScope.FileViewStateContent(
583 state: FileViewState,
588 onSetShowDiff: (Boolean) -> Unit,
591 onQueryChange: (String) -> Unit,
592 matchIndices: List<Int>,
596 onCloseFind: () -> Unit,
601 onLoadMore: () -> Unit,
602 highlightLineIndex: Int?,
603 listState: LazyListState,
605 onShareFull: () -> Unit,
606 caseSensitive: Boolean,
607 onToggleCaseSensitive: () -> Unit,
610 // Full-screen skeleton only on the initial load (no content yet). A reload keeps the
611 // existing content on screen; the top-bar reload button shows the in-flight progress.
612 // Skeleton "code line" rows pre-structure the layout so a large file's load feels
613 // faster than a bare spinner and the real content cross-fades into an already-shaped
615 state.loading && state.content == null -> {
616 val loadingLabel = stringResource(R.string.loading)
617 val reducedMotion = soy.iko.opencode.ui.components.LocalReducedMotion.current
618 val transition = rememberInfiniteTransition(label = "file_skeleton")
619 val pulse by transition.animateFloat(
620 initialValue = 0.35f,
622 animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
623 label = "fileSkeletonAlpha",
625 val skeletonAlpha = if (reducedMotion) 0.5f else pulse
626 val skeletonColor = MaterialTheme.colorScheme.surfaceVariant
629 .align(Alignment.TopCenter)
630 .padding(top = 24.dp)
631 .padding(horizontal = 16.dp)
633 .semantics { contentDescription = loadingLabel },
634 verticalArrangement = Arrangement.spacedBy(8.dp),
638 modifier = Modifier.fillMaxWidth(),
639 horizontalArrangement = Arrangement.spacedBy(8.dp),
645 .clip(MaterialTheme.shapes.small)
646 .background(skeletonColor)
647 .alpha(skeletonAlpha),
651 .fillMaxWidth(if (i % 3 == 0) 0.9f else 0.7f)
653 .clip(MaterialTheme.shapes.small)
654 .background(skeletonColor)
655 .alpha(skeletonAlpha),
661 state.error != null -> Column(
662 modifier = Modifier.align(Alignment.Center).padding(24.dp),
663 horizontalAlignment = Alignment.CenterHorizontally,
665 Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
666 Spacer(Modifier.size(12.dp))
667 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
669 // Image preview: if the file has an image extension, render it inline instead of
670 // showing "Binary file". The server returns binary content as base64; decode and
671 // display. Falls back to the binary-file label if decoding fails.
672 state.content != null && state.content.isBinary && run {
673 val ext = path.substringAfterLast('.', "").lowercase()
674 ext in setOf("png", "jpg", "jpeg", "gif", "webp", "bmp", "svg")
677 content = state.content,
678 modifier = Modifier.align(Alignment.Center).padding(16.dp),
681 state.content?.isBinary == true -> Text(
682 stringResource(R.string.binary_file),
683 modifier = Modifier.align(Alignment.Center).padding(24.dp),
684 color = MaterialTheme.colorScheme.onSurfaceVariant,
686 else -> FileViewContentBody(
687 content = state.content,
689 showToggle = showToggle,
691 onSetShowDiff = onSetShowDiff,
692 findActive = findActive,
693 findQuery = findQuery,
694 onQueryChange = onQueryChange,
695 matchIndices = matchIndices,
699 onCloseFind = onCloseFind,
702 truncated = truncated,
703 moreLines = moreLines,
704 onLoadMore = onLoadMore,
705 highlightLineIndex = highlightLineIndex,
706 listState = listState,
707 onShareFull = onShareFull,
708 caseSensitive = caseSensitive,
709 onToggleCaseSensitive = onToggleCaseSensitive,
715private fun BoxScope.FileViewContentBody(
716 content: soy.iko.opencode.data.model.FileContent?,
720 onSetShowDiff: (Boolean) -> Unit,
723 onQueryChange: (String) -> Unit,
724 matchIndices: List<Int>,
728 onCloseFind: () -> Unit,
733 onLoadMore: () -> Unit,
734 highlightLineIndex: Int?,
735 listState: LazyListState,
736 onShareFull: () -> Unit,
737 caseSensitive: Boolean,
738 onToggleCaseSensitive: () -> Unit,
740 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
741 // reachable while scrolling. Each contributes a top inset so the content below
742 // isn't hidden behind the overlay. The inset is measured from the actual overlay
743 // height (not a hardcoded 52dp/row) so accessibility font scaling — which can
744 // make the FilterChip labels and FindBar text field taller than 52dp — doesn't
745 // cause the first lines of content to hide behind the overlay.
746 // The raw text is on screen whenever we're NOT rendering the diff — i.e. either the
747 // file has no diff toggle at all (plain file: showToggle == false) or the toggle is
748 // set to Raw. Gate the find bar on this rather than on !showDiff: for a plain file
749 // showDiff stays at its `true` default forever (nothing ever flips it), so !showDiff
750 // would keep the find bar — and therefore find-in-file — permanently hidden.
751 val rawOnScreen = !(showToggle && showDiff)
752 val overlayRows = (if (showToggle) 1 else 0) + (if (findActive && rawOnScreen) 1 else 0)
753 var overlayHeightPx by remember { mutableIntStateOf(0) }
754 // Only apply the measured inset while an overlay is actually composed. When overlayRows
755 // drops to 0 the overlay Column is removed and onSizeChanged never fires to zero the
756 // height, so keying the inset off overlayRows (rather than the stale measurement) avoids
757 // a phantom top gap on the content below — e.g. after closing find on a plain file.
758 val topInset = if (overlayRows > 0) {
759 with(androidx.compose.ui.platform.LocalDensity.current) { overlayHeightPx.toDp() }
761 if (overlayRows > 0) {
764 .align(Alignment.TopCenter)
766 .onSizeChanged { overlayHeightPx = it.height }
767 .padding(top = 4.dp),
768 verticalArrangement = Arrangement.spacedBy(4.dp),
772 modifier = Modifier.fillMaxWidth(),
773 horizontalArrangement = Arrangement.Center,
777 onClick = { onSetShowDiff(true) },
778 label = { Text(stringResource(R.string.show_diff)) },
780 Spacer(Modifier.size(8.dp))
782 selected = !showDiff,
783 onClick = { onSetShowDiff(false) },
784 label = { Text(stringResource(R.string.show_raw)) },
788 if (findActive && rawOnScreen) {
791 onQueryChange = onQueryChange,
792 matchCount = matchIndices.size,
796 onClose = onCloseFind,
797 caseSensitive = caseSensitive,
798 onToggleCaseSensitive = onToggleCaseSensitive,
803 if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
804 // Use LazyDiffView so a 5000-line diff composes only the visible rows. The previous
805 // DiffView + verticalScroll composed every row eagerly, which OOM'd on large diffs.
806 soy.iko.opencode.ui.components.LazyDiffView(
810 .padding(top = topInset)
814 val text = content?.content.orEmpty()
815 if (text.isEmpty()) {
817 stringResource(R.string.empty_file),
818 modifier = Modifier.align(Alignment.Center).padding(24.dp),
819 color = MaterialTheme.colorScheme.onSurfaceVariant,
826 truncated = truncated,
827 moreLines = moreLines,
828 onLoadMore = onLoadMore,
830 findQuery = findQuery,
831 matchIndices = matchIndices,
832 highlightLineIndex = highlightLineIndex,
833 listState = listState,
834 onShareFull = onShareFull,
843 onQueryChange: (String) -> Unit,
849 caseSensitive: Boolean,
850 onToggleCaseSensitive: () -> Unit,
852 val keyboardController = LocalSoftwareKeyboardController.current
853 val matchCaseLabel = stringResource(R.string.match_case)
855 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
856 verticalAlignment = Alignment.CenterVertically,
860 onValueChange = onQueryChange,
861 modifier = Modifier.weight(1f),
862 placeholder = { Text(stringResource(R.string.find_hint)) },
864 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
865 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
867 Row(verticalAlignment = Alignment.CenterVertically) {
868 // Case-sensitivity toggle. A small "Aa" chip keeps the bar compact while
869 // surfacing a previously substring-only, always-case-insensitive search.
871 selected = caseSensitive,
872 onClick = onToggleCaseSensitive,
873 label = { Text("Aa", style = MaterialTheme.typography.labelSmall) },
874 modifier = Modifier.semantics { contentDescription = matchCaseLabel },
876 Spacer(Modifier.size(8.dp))
877 val countText = if (query.isBlank()) ""
878 else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
879 else stringResource(R.string.match_count, matchPos + 1, matchCount)
880 if (countText.isNotEmpty()) {
881 // Use error color for "no matches" so the zero-result state is
882 // distinguishable from a positive match count at a glance.
883 val color = if (query.isNotBlank() && matchCount == 0) MaterialTheme.colorScheme.error
884 else MaterialTheme.colorScheme.onSurfaceVariant
887 // labelMedium (not labelSmall) so the counter is legible for low-vision
888 // users at large font scales; the error color still distinguishes zero
889 // matches at a glance.
890 style = MaterialTheme.typography.labelMedium,
897 IconButton(onClick = onPrev, enabled = matchCount > 0) {
898 Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_previous))
900 IconButton(onClick = onNext, enabled = matchCount > 0) {
901 Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_next))
903 IconButton(onClick = onClose) {
904 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
910private fun FileTextContent(
913 topInset: androidx.compose.ui.unit.Dp,
916 onLoadMore: () -> Unit,
919 matchIndices: List<Int>,
920 highlightLineIndex: Int?,
921 listState: LazyListState,
922 onShareFull: () -> Unit,
924 // Honor the user's chat text-size preference (the same one the chat markdown honors) so
925 // a user who bumped the scale for readability gets the same scale in the code viewer.
926 // Applied by nesting a MaterialTheme with a scaled typography, exactly how MarkdownText
927 // does it — the gutter/code Text below read MaterialTheme.typography.bodySmall, so this
928 // reaches both without per-Text plumbing.
929 val scale = LocalChatTextScale.current
930 val base = MaterialTheme.typography
931 val scaled = remember(scale, base) { base.scaledBy(scale) }
932 // Gutter width scales with the digit count and the font scale so accessibility
933 // text scaling (e.g. 1.3x) and the app's chat-text scale don't make line numbers overflow
934 // the gutter and collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
935 // proportionally for larger font/app scales.
936 val fontScale = androidx.compose.ui.platform.LocalDensity.current.fontScale
937 val gutterWidth = remember(lines.size, fontScale, scale) {
938 ((lines.size.toString().length.coerceAtLeast(3) * 10) *
939 fontScale.coerceAtLeast(1f) *
940 scale.coerceAtLeast(1f)).dp
942 val hScrollState = rememberScrollState()
943 val palette = rememberHighlightPalette()
944 // Resolve the file's language once instead of re-parsing the extension for every line.
945 val syntax = remember(filename) { syntaxFor(filename) }
946 // Pre-compute per-line block-comment/triple-string state snapshots so a LazyColumn row
947 // can highlight its line correctly without re-walking every preceding line on each
948 // recomposition. Each snapshot is two booleans; for a 5000-line file that's ~10KB,
949 // trivial next to the line strings themselves. highlightLine(line, syntax, palette, state)
950 // mutates the passed state in place, so we give each line its own snapshot derived from
951 // the prior line's end state — this is what makes a 50-line block comment color correctly
952 // on every line, not just the opener.
953 val lineStates = remember(lines, syntax) {
954 if (syntax.lang == soy.iko.opencode.ui.components.Language.NONE) {
957 val states = ArrayList<soy.iko.opencode.ui.components.BlockCommentState>(lines.size)
958 var current = soy.iko.opencode.ui.components.BlockCommentState()
959 for (line in lines) {
960 val snapshot = soy.iko.opencode.ui.components.BlockCommentState().also {
961 it.inBlockComment = current.inBlockComment
962 it.inTripleString = current.inTripleString
965 // Advance the running state by highlighting into a throwaway builder — the
966 // side effect on `current` is what we need. We discard the AnnotatedString.
967 soy.iko.opencode.ui.components.highlightLine(line, syntax, palette, current)
972 val q = findQuery.trim()
973 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
974 MaterialTheme(typography = scaled) {
975 Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
977 androidx.compose.foundation.layout.Row(
978 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
979 verticalAlignment = Alignment.CenterVertically,
980 horizontalArrangement = Arrangement.SpaceBetween,
983 stringResource(R.string.file_truncated, lines.size),
984 style = MaterialTheme.typography.labelSmall,
985 color = MaterialTheme.colorScheme.onSurfaceVariant,
986 modifier = Modifier.weight(1f),
988 // Load the next chunk in-place so the user can keep reading the file without
989 // leaving the app. Falls back to Share full file for the whole content.
990 TextButton(onClick = onLoadMore) {
991 Text(stringResource(R.string.load_more_lines, moreLines))
993 TextButton(onClick = onShareFull) {
994 Text(stringResource(R.string.share_full_file))
998 // SelectionContainer lets the user select/copy a portion of the rendered code
999 // (not just the whole-file Copy in the overflow). Selection across recycled
1000 // LazyColumn items can drop spans that have scrolled away, but partial selection
1001 // of the visible region — the common case — works.
1002 SelectionContainer {
1010 itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
1011 val isMatch = q.isNotEmpty() && index in matchSet
1012 // The jump-to-line target keeps the same emphasis as a find match so the line
1013 // the viewer landed on is obvious until the user scrolls or starts a find.
1014 val isTarget = index == highlightLineIndex
1018 .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
1019 .then(if (isMatch || isTarget) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
1023 modifier = Modifier.width(gutterWidth),
1024 style = MaterialTheme.typography.bodySmall,
1025 fontFamily = FontFamily.Monospace,
1026 color = MaterialTheme.colorScheme.onSurfaceVariant,
1028 // Heuristic syntax highlighting per line. Falls back to plain text for
1029 // unknown extensions, so non-code files render unchanged. Memoized so the
1030 // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
1031 // file, or palette actually changes — not on every recomposition (e.g. every
1032 // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
1033 // Uses the stateful highlightLine so multi-line block comments and triple-quoted
1034 // strings color correctly on every line they span (the per-line snapshot in
1035 // lineStates carries the in-block/in-trString flag for this line's position).
1036 val highlighted = remember(line, syntax, palette, index) {
1037 val state = lineStates.getOrNull(index)
1038 if (state != null) {
1039 val s = soy.iko.opencode.ui.components.BlockCommentState().also {
1040 it.inBlockComment = state.inBlockComment
1041 it.inTripleString = state.inTripleString
1043 soy.iko.opencode.ui.components.highlightLine(line, syntax, palette, s)
1045 soy.iko.opencode.ui.components.highlightLine(line, syntax, palette)
1050 modifier = Modifier.padding(start = 8.dp),
1051 style = MaterialTheme.typography.bodySmall,
1062 * Renders an image file (PNG/JPG/GIF/WebP/BMP/SVG) inline in the file viewer instead of
1063 * the generic "Binary file" label. Decodes the base64 content from [FileContent.content]
1064 * into a [Bitmap] and displays it. Falls back to the binary-file label if decoding fails
1065 * (corrupt content or an unsupported encoding).
1068private fun ImageFilePreview(
1069 content: soy.iko.opencode.data.model.FileContent,
1070 modifier: Modifier = Modifier,
1072 val context = LocalContext.current
1073 val bitmap = remember(content.content) {
1075 val bytes = android.util.Base64.decode(content.content, android.util.Base64.DEFAULT)
1076 android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
1079 if (bitmap == null) {
1081 stringResource(R.string.binary_file),
1082 modifier = modifier,
1083 color = MaterialTheme.colorScheme.onSurfaceVariant,
1087 modifier = modifier.fillMaxWidth(),
1088 horizontalAlignment = Alignment.CenterHorizontally,
1091 bitmap = bitmap.asImageBitmap(),
1092 contentDescription = stringResource(R.string.image_file_preview),
1095 .heightIn(max = 400.dp)
1096 .clip(MaterialTheme.shapes.medium),
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