1package soy.iko.opencode.ui.file
3import android.content.Intent
4import androidx.compose.animation.AnimatedContent
5import androidx.compose.animation.core.tween
6import androidx.compose.animation.fadeIn
7import androidx.compose.animation.fadeOut
8import androidx.compose.animation.togetherWith
9import androidx.compose.foundation.background
10import androidx.compose.foundation.horizontalScroll
11import androidx.compose.foundation.interaction.DragInteraction
12import androidx.compose.foundation.layout.Box
13import androidx.compose.foundation.layout.BoxScope
14import androidx.compose.foundation.layout.Column
15import androidx.compose.foundation.layout.Arrangement
16import androidx.compose.foundation.layout.Row
17import androidx.compose.foundation.layout.Spacer
18import androidx.compose.foundation.layout.fillMaxSize
19import androidx.compose.foundation.layout.fillMaxWidth
20import androidx.compose.foundation.layout.padding
21import androidx.compose.foundation.layout.size
22import androidx.compose.foundation.layout.width
23import androidx.compose.foundation.lazy.LazyColumn
24import androidx.compose.foundation.lazy.LazyListState
25import androidx.compose.foundation.lazy.itemsIndexed
26import androidx.compose.foundation.lazy.rememberLazyListState
27import androidx.compose.foundation.rememberScrollState
28import androidx.compose.foundation.text.KeyboardActions
29import androidx.compose.foundation.text.KeyboardOptions
30import androidx.compose.foundation.text.selection.SelectionContainer
31import androidx.compose.foundation.verticalScroll
32import androidx.compose.material.icons.Icons
33import androidx.compose.material.icons.automirrored.filled.ArrowBack
34import androidx.compose.material.icons.automirrored.filled.WrapText
35import androidx.compose.material.icons.filled.Close
36import androidx.compose.material.icons.filled.ContentCopy
37import androidx.compose.material.icons.filled.KeyboardArrowDown
38import androidx.compose.material.icons.filled.KeyboardArrowUp
39import androidx.compose.material.icons.filled.Link
40import androidx.compose.material.icons.filled.MoreVert
41import androidx.compose.material.icons.filled.Refresh
42import androidx.compose.material.icons.filled.Search
43import androidx.compose.material.icons.filled.OpenInNew
44import androidx.compose.material.icons.filled.Share
45import androidx.compose.material3.AlertDialog
46import androidx.compose.material3.CircularProgressIndicator
47import androidx.compose.material3.DropdownMenu
48import androidx.compose.material3.DropdownMenuItem
49import androidx.compose.material3.ExperimentalMaterial3Api
50import androidx.compose.material3.FilterChip
51import androidx.compose.material3.Icon
52import androidx.compose.material3.IconButton
53import androidx.compose.material3.MaterialTheme
54import androidx.compose.material3.OutlinedTextField
55import androidx.compose.material3.Scaffold
56import androidx.compose.material3.SnackbarHost
57import androidx.compose.material3.SnackbarHostState
58import androidx.compose.material3.Text
59import androidx.compose.material3.TextButton
60import androidx.compose.material3.TopAppBar
61import androidx.compose.runtime.Composable
62import androidx.compose.runtime.LaunchedEffect
63import androidx.compose.runtime.MutableState
64import androidx.compose.runtime.getValue
65import androidx.compose.runtime.mutableIntStateOf
66import androidx.compose.runtime.mutableStateOf
67import androidx.compose.runtime.produceState
68import androidx.compose.runtime.remember
69import androidx.compose.runtime.rememberCoroutineScope
70import androidx.compose.runtime.saveable.rememberSaveable
71import androidx.compose.runtime.setValue
72import androidx.compose.ui.Alignment
73import androidx.compose.ui.Modifier
74import androidx.compose.ui.layout.onSizeChanged
75import androidx.compose.ui.platform.LocalContext
76import androidx.compose.ui.platform.LocalDensity
77import androidx.compose.ui.platform.LocalSoftwareKeyboardController
78import androidx.compose.ui.semantics.contentDescription
79import androidx.compose.ui.semantics.semantics
80import androidx.compose.ui.text.font.FontFamily
81import androidx.compose.ui.text.input.ImeAction
82import androidx.compose.ui.text.input.KeyboardType
83import androidx.compose.ui.text.style.TextOverflow
84import androidx.compose.ui.unit.dp
85import androidx.compose.ui.res.stringResource
86import androidx.lifecycle.compose.collectAsStateWithLifecycle
87import androidx.lifecycle.viewmodel.compose.viewModel
88import soy.iko.opencode.di.AppContainer
89import soy.iko.opencode.R
90import soy.iko.opencode.ui.components.ConnectionBannerFor
91import soy.iko.opencode.ui.components.DiffView
92import soy.iko.opencode.ui.components.LocalChatTextScale
93import soy.iko.opencode.ui.components.LocalCodeWrap
94import soy.iko.opencode.ui.components.copyToClipboard
95import soy.iko.opencode.ui.components.highlightLine
96import soy.iko.opencode.ui.components.syntaxFor
97import soy.iko.opencode.ui.components.rememberHighlightPalette
98import soy.iko.opencode.ui.components.scaledBy
99import soy.iko.opencode.ui.vmFactory
100import soy.iko.opencode.util.runCatchingCancellable
101import kotlinx.coroutines.Dispatchers
102import kotlinx.coroutines.delay
103import kotlinx.coroutines.flow.collect
104import kotlinx.coroutines.launch
105import kotlinx.coroutines.withContext
107import soy.iko.opencode.data.network.NetworkConfig
109/** Cap on how many lines the raw viewer renders and searches. See [NetworkConfig.maxRenderedFileLines]. */
110private const val MAX_RENDERED_LINES = NetworkConfig.maxRenderedFileLines
112@OptIn(ExperimentalMaterial3Api::class)
115 container: AppContainer,
118 initialLine: Int? = null,
120 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
121 val state by vm.state.collectAsStateWithLifecycle()
122 val context = LocalContext.current
123 val scope = rememberCoroutineScope()
124 val snackbar = remember { SnackbarHostState() }
125 val filename = remember(path) {
126 val trimmed = path.trimEnd('/')
127 trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } }
129 val shareLabel = stringResource(R.string.share)
130 // Diff vs raw view toggle. Defaults to showing the diff when one exists; the user can
131 // switch to the raw new content. Persisted via rememberSaveable across rotation. When the
132 // viewer is opened at a specific line (from a search hit), start on the raw view so the
133 // targeted line is actually rendered (the diff view has no line list to scroll).
134 val hasDiff = state.content?.diff != null && state.content?.diff?.isNotBlank() == true
135 var showDiff by rememberSaveable(path) { mutableStateOf(initialLine == null) }
136 val showToggle = hasDiff && state.content?.content.orEmpty().isNotEmpty()
137 // Find-in-file and line-wrapping state, persisted so a rotation or reload keeps the
138 // user's query/mode.
139 var findActive by rememberSaveable(path) { mutableStateOf(false) }
140 var findQuery by rememberSaveable(path) { mutableStateOf("") }
141 var matchPos by rememberSaveable(path) { mutableIntStateOf(0) }
142 // Seed the per-file wrap toggle from the user's global code-wrap preference so the viewer
143 // respects it by default; they can still flip it per-file (persisted across rotation).
144 val codeWrap = LocalCodeWrap.current
145 var wrap by rememberSaveable(path) { mutableStateOf(codeWrap) }
146 val listState = rememberLazyListState()
147 val rawText = state.content?.content.orEmpty()
148 // Directory portion of the path, shown under the filename in the top bar so a user who
149 // has drilled into a deep workspace keeps context of where this file lives.
150 val pathSubtitle = remember(path) {
151 val trimmed = path.trimEnd('/')
152 val parent = trimmed.substringBeforeLast('/', "")
153 parent.ifBlank { path }
155 // Split the raw text exactly once here (the line viewer and find-in-file both consume this
156 // list) and cap it so a huge file doesn't get split twice or overwhelm the LazyColumn.
157 // `take()` (not `subList()`) so the cap actually releases the tail: subList returns a view
158 // backed by the full list, which would keep every line (and the full rawText) in memory.
159 val allLines = remember(rawText) { rawText.split("\n") }
160 val truncated = allLines.size > MAX_RENDERED_LINES
161 val lines = remember(rawText) {
162 if (allLines.size > MAX_RENDERED_LINES) allLines.take(MAX_RENDERED_LINES) else allLines
164 // The line the viewer was opened at (from a search hit), highlighted until the user scrolls
165 // or starts an in-file find so the landed line stands out. Null once cleared.
166 val highlightLine = rememberJumpToLineHighlight(
168 initialLine = initialLine,
169 content = state.content,
170 isBinary = state.content?.isBinary == true,
171 lineCount = lines.size,
172 listState = listState,
174 // Find-in-file match-case toggle (default case-insensitive). Persisted so a rotation keeps
175 // the choice, and applied to the off-thread match scan below.
176 var caseSensitive by rememberSaveable(path) { mutableStateOf(false) }
177 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
178 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
179 var debouncedFind by remember { mutableStateOf("") }
180 LaunchedEffect(findQuery) {
181 val q = findQuery.trim()
182 if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q }
184 val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind, caseSensitive) {
185 val q = debouncedFind
186 value = if (q.isEmpty()) emptyList()
187 else withContext(Dispatchers.Default) {
188 lines.mapIndexedNotNull { index, line ->
189 index.takeIf { line.contains(q, ignoreCase = !caseSensitive) }
193 // Keep the current match index in range when the match set shrinks (e.g. the file
194 // reloaded with fewer matches), so the counter never shows a stale "10 / 3".
195 LaunchedEffect(matchIndices.size) {
196 matchPos = matchPos.coerceIn(0, (matchIndices.size - 1).coerceAtLeast(0))
198 // Scroll to the current match whenever it (or the match set) changes.
199 LaunchedEffect(matchPos, matchIndices) {
200 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
202 // "Go to line" dialog state. Clears the jump highlight and scrolls the LazyColumn to the
203 // requested line (clamped to the rendered range) when the user confirms.
204 var showGoToLine by rememberSaveable(path) { mutableStateOf(false) }
205 fun goToLine(n: Int) {
206 val target = (n - 1).coerceIn(0, (lines.size - 1).coerceAtLeast(0))
207 highlightLine.value = null
208 scope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
214 subtitle = pathSubtitle,
215 loading = state.loading,
218 diffShown = showToggle && showDiff,
220 onReload = { vm.reload() },
221 // Find searches the raw line list, which isn't on screen under a diff. Rather
222 // than silently greying the icon out, keep it tappable and explain where find
223 // lives when the user is in diff mode.
225 if (showToggle && showDiff) {
226 scope.launch { snackbar.showSnackbar(context.getString(R.string.switch_to_raw_to_search)) }
228 findActive = !findActive
229 if (!findActive) findQuery = ""
232 onToggleWrap = { wrap = !wrap },
233 onGoToLine = { showGoToLine = true },
234 onCopy = { copyToClipboard(context, filename, rawText) },
235 onCopyPath = { copyToClipboard(context, context.getString(R.string.clip_label_path), path) },
237 val send = Intent(Intent.ACTION_SEND).apply {
239 putExtra(Intent.EXTRA_SUBJECT, filename)
240 putExtra(Intent.EXTRA_TEXT, rawText)
242 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
243 .onFailure { scope.launch { snackbar.showSnackbar(context.getString(R.string.no_share_app)) } }
246 // Write the viewed content to a cache file and hand a content:// URI to an
247 // external app via ACTION_VIEW, so a file the in-app viewer can only show as
248 // text (or a large file beyond the render cap) can be opened in a real editor.
250 val ok = runCatchingCancellable {
251 withContext(Dispatchers.IO) {
252 val dir = java.io.File(context.cacheDir, "external").apply { mkdirs() }
253 val file = java.io.File(dir, filename.ifBlank { "file.txt" })
254 file.writeText(rawText)
255 val authority = context.packageName + ".fileprovider"
256 val uri = androidx.core.content.FileProvider.getUriForFile(context, authority, file)
257 val view = Intent(Intent.ACTION_VIEW).apply {
258 setDataAndType(uri, android.webkit.MimeTypeMap.getSingleton()
259 .getMimeTypeFromExtension(filename.substringAfterLast('.', ""))
261 addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
263 context.startActivity(view)
266 if (!ok) scope.launch { snackbar.showSnackbar(context.getString(R.string.open_externally_failed)) }
271 snackbarHost = { SnackbarHost(snackbar) },
273 // Pull-to-refresh wraps the content so a user who pull-to-refreshes out of habit
274 // can reload the file, mirroring the file browser and session list gestures. The
275 // top-bar reload icon remains for a targeted tap.
276 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
277 isRefreshing = state.loading && state.content != null,
278 onRefresh = { vm.reload() },
279 modifier = Modifier.fillMaxSize().padding(padding),
281 Box(modifier = Modifier.fillMaxSize()) {
282 // Surface SSE connection state so a dropped stream is visible while viewing a file.
283 ConnectionBannerFor(container)
284 // AnimatedContent cross-fades between loading / error / content states so the
285 // viewer doesn't snap abruptly when a reload finishes or fails.
288 transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
289 label = "fileViewState",
291 // Single source of truth for matches: the off-thread `matchIndices` produceState
292 // (keyed on the file content + debounced query). Both navigation (onPrev/onNext +
293 // scroll) and display (counter + highlight) read from it, so the counter can't show
294 // a new query while next/prev act on a stale set. The earlier in-composition
295 // per-layer scan re-ran the whole-file O(n) match on the UI thread each keystroke
296 // (the exact work produceState was moved off-thread to avoid); it's acceptable for
297 // the crossfade's exiting layer to briefly use this unified set until it settles.
298 FileViewStateContent(
301 showToggle = showToggle,
305 // Close any open find bar when entering diff mode: find searches
306 // the raw content and scrolls the line LazyColumn, neither of
307 // which is visible in diff mode, so leaving it open would be a
309 if (it) { findActive = false; findQuery = "" }
311 findActive = findActive,
312 findQuery = findQuery,
313 onQueryChange = { findQuery = it; matchPos = 0; if (it.isNotEmpty()) highlightLine.value = null },
314 matchIndices = matchIndices,
316 onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
317 onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
318 onCloseFind = { findActive = false; findQuery = "" },
321 truncated = truncated,
322 highlightLineIndex = highlightLine.value,
323 listState = listState,
324 onRetry = { vm.reload() },
325 caseSensitive = caseSensitive,
326 onToggleCaseSensitive = { caseSensitive = !caseSensitive },
328 val send = Intent(Intent.ACTION_SEND).apply {
330 putExtra(Intent.EXTRA_SUBJECT, filename)
331 putExtra(Intent.EXTRA_TEXT, rawText)
333 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
334 .onFailure { scope.launch { snackbar.showSnackbar(context.getString(R.string.no_share_app)) } }
343 visible = showGoToLine,
344 maxLine = lines.size,
345 onConfirm = { n -> showGoToLine = false; goToLine(n) },
346 onDismiss = { showGoToLine = false },
350// Wraps the go-to-line dialog's visibility gate so the branch stays out of FileViewScreen
351// (keeping that composable under the cyclomatic-complexity threshold).
353private fun GoToLineLauncher(
356 onConfirm: (Int) -> Unit,
357 onDismiss: () -> Unit,
360 GoToLineDialog(maxLine = maxLine, onConfirm = onConfirm, onDismiss = onDismiss)
365private fun GoToLineDialog(maxLine: Int, onConfirm: (Int) -> Unit, onDismiss: () -> Unit) {
366 var text by rememberSaveable { mutableStateOf("") }
367 val parsed = text.trim().toIntOrNull()
368 val valid = parsed != null && parsed >= 1
370 onDismissRequest = onDismiss,
371 title = { Text(stringResource(R.string.go_to_line)) },
375 onValueChange = { v -> text = v.filter { it.isDigit() } },
377 isError = text.isNotEmpty() && !valid,
380 stringResource(R.string.go_to_line_hint, maxLine),
381 style = MaterialTheme.typography.bodySmall,
384 keyboardOptions = KeyboardOptions(
385 keyboardType = KeyboardType.Number,
386 imeAction = ImeAction.Done,
388 keyboardActions = KeyboardActions(onDone = { parsed?.let { onConfirm(it) } }),
392 TextButton(onClick = { parsed?.let { onConfirm(it) } }, enabled = valid) {
393 Text(stringResource(R.string.go_to_line))
397 TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
402// Owns the "jump to a line from a search hit" behaviour: scroll to the target once the raw
403// content loads (one-shot), and clear the highlight the moment the user drags the list by hand.
404// Extracted so FileViewScreen stays under the cyclomatic-complexity threshold.
406private fun rememberJumpToLineHighlight(
412 listState: LazyListState,
413): MutableState<Int?> {
414 val highlight = rememberSaveable(path) { mutableStateOf<Int?>(null) }
415 var didInitialScroll by rememberSaveable(path) { mutableStateOf(false) }
416 LaunchedEffect(content, initialLine) {
417 val line = initialLine ?: return@LaunchedEffect
418 if (didInitialScroll || content == null || isBinary) return@LaunchedEffect
419 val target = (line - 1).coerceIn(0, (lineCount - 1).coerceAtLeast(0))
420 runCatchingCancellable { listState.scrollToItem(target) }
421 highlight.value = target
422 didInitialScroll = true
424 LaunchedEffect(listState) {
425 if (initialLine == null) return@LaunchedEffect
426 listState.interactionSource.interactions.collect { interaction ->
427 if (interaction is DragInteraction.Start) highlight.value = null
433@OptIn(ExperimentalMaterial3Api::class)
435private fun FileViewTopBar(
443 onReload: () -> Unit,
444 onToggleFind: () -> Unit,
445 onToggleWrap: () -> Unit,
446 onGoToLine: () -> Unit,
448 onCopyPath: () -> Unit,
450 onOpenExternally: () -> Unit,
454 // Two-line title: filename (primary) + the directory portion of the path
455 // (secondary), so a file opened from deep in a workspace keeps its location
456 // visible without an extra breadcrumb row.
458 Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis)
459 if (subtitle.isNotBlank()) {
463 overflow = TextOverflow.Ellipsis,
464 style = MaterialTheme.typography.bodySmall,
465 color = MaterialTheme.colorScheme.onSurfaceVariant,
471 IconButton(onClick = onBack) {
472 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
476 // In-place refresh: a file's content can change on the server (the agent may
477 // be editing it), so reload without requiring a back-out. Shows a spinner while
478 // loading since the content now stays on screen during a reload (the spinner is
479 // the only progress signal).
480 val loadingLabel = stringResource(R.string.loading)
481 IconButton(onClick = onReload, enabled = !loading) {
483 CircularProgressIndicator(
484 Modifier.size(20.dp).semantics { contentDescription = loadingLabel },
488 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
491 if (content.isNotEmpty()) {
492 // Find-in-file: toggles a search bar over the raw content. Stays enabled even in
493 // diff mode — the find bar searches the raw text and scrolls the line LazyColumn,
494 // neither of which is visible under a diff, so tapping it there surfaces a hint to
495 // switch to Raw rather than silently no-opping behind a greyed-out icon.
496 IconButton(onClick = onToggleFind) {
497 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
499 // Overflow the less-frequent actions (wrap / copy / share) so a long filename
500 // title isn't crowded off screen by five inline icons on a phone.
501 var menuExpanded by remember { mutableStateOf(false) }
502 IconButton(onClick = { menuExpanded = true }) {
503 Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options))
505 DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
506 // Wrap long lines instead of horizontal-scrolling; useful for prose. Disabled
507 // in diff mode because DiffView ignores wrap, so an active-looking toggle there
508 // would be a silent no-op.
510 text = { Text(stringResource(R.string.go_to_line)) },
511 onClick = { onGoToLine(); menuExpanded = false },
512 leadingIcon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
515 text = { Text(stringResource(R.string.wrap_lines)) },
516 onClick = { onToggleWrap(); menuExpanded = false },
517 enabled = !diffShown,
520 Icons.AutoMirrored.Filled.WrapText,
521 contentDescription = null,
522 tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
527 text = { Text(stringResource(R.string.copy)) },
528 onClick = { onCopy(); menuExpanded = false },
529 leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) },
532 text = { Text(stringResource(R.string.copy_path)) },
533 onClick = { onCopyPath(); menuExpanded = false },
534 leadingIcon = { Icon(Icons.Filled.Link, contentDescription = null) },
537 text = { Text(stringResource(R.string.share)) },
538 onClick = { onShare(); menuExpanded = false },
539 leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) },
542 text = { Text(stringResource(R.string.open_externally)) },
543 onClick = { onOpenExternally(); menuExpanded = false },
544 leadingIcon = { Icon(Icons.Filled.OpenInNew, contentDescription = null) },
553private fun BoxScope.FileViewStateContent(
554 state: FileViewState,
558 onSetShowDiff: (Boolean) -> Unit,
561 onQueryChange: (String) -> Unit,
562 matchIndices: List<Int>,
566 onCloseFind: () -> Unit,
570 highlightLineIndex: Int?,
571 listState: LazyListState,
573 onShareFull: () -> Unit,
574 caseSensitive: Boolean,
575 onToggleCaseSensitive: () -> Unit,
578 // Full-screen spinner only on the initial load (no content yet). A reload keeps the
579 // existing content on screen; the top-bar reload button shows the in-flight progress.
580 state.loading && state.content == null -> {
581 val loadingLabel = stringResource(R.string.loading)
582 CircularProgressIndicator(
583 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
586 state.error != null -> Column(
587 modifier = Modifier.align(Alignment.Center).padding(24.dp),
588 horizontalAlignment = Alignment.CenterHorizontally,
590 Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
591 Spacer(Modifier.size(12.dp))
592 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
594 state.content?.isBinary == true -> Text(
595 stringResource(R.string.binary_file),
596 modifier = Modifier.align(Alignment.Center).padding(24.dp),
597 color = MaterialTheme.colorScheme.onSurfaceVariant,
599 else -> FileViewContentBody(
600 content = state.content,
602 showToggle = showToggle,
604 onSetShowDiff = onSetShowDiff,
605 findActive = findActive,
606 findQuery = findQuery,
607 onQueryChange = onQueryChange,
608 matchIndices = matchIndices,
612 onCloseFind = onCloseFind,
615 truncated = truncated,
616 highlightLineIndex = highlightLineIndex,
617 listState = listState,
618 onShareFull = onShareFull,
619 caseSensitive = caseSensitive,
620 onToggleCaseSensitive = onToggleCaseSensitive,
626private fun BoxScope.FileViewContentBody(
627 content: soy.iko.opencode.data.model.FileContent?,
631 onSetShowDiff: (Boolean) -> Unit,
634 onQueryChange: (String) -> Unit,
635 matchIndices: List<Int>,
639 onCloseFind: () -> Unit,
643 highlightLineIndex: Int?,
644 listState: LazyListState,
645 onShareFull: () -> Unit,
646 caseSensitive: Boolean,
647 onToggleCaseSensitive: () -> Unit,
649 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
650 // reachable while scrolling. Each contributes a top inset so the content below
651 // isn't hidden behind the overlay. The inset is measured from the actual overlay
652 // height (not a hardcoded 52dp/row) so accessibility font scaling — which can
653 // make the FilterChip labels and FindBar text field taller than 52dp — doesn't
654 // cause the first lines of content to hide behind the overlay.
655 // The raw text is on screen whenever we're NOT rendering the diff — i.e. either the
656 // file has no diff toggle at all (plain file: showToggle == false) or the toggle is
657 // set to Raw. Gate the find bar on this rather than on !showDiff: for a plain file
658 // showDiff stays at its `true` default forever (nothing ever flips it), so !showDiff
659 // would keep the find bar — and therefore find-in-file — permanently hidden.
660 val rawOnScreen = !(showToggle && showDiff)
661 val overlayRows = (if (showToggle) 1 else 0) + (if (findActive && rawOnScreen) 1 else 0)
662 var overlayHeightPx by remember { mutableIntStateOf(0) }
663 // Only apply the measured inset while an overlay is actually composed. When overlayRows
664 // drops to 0 the overlay Column is removed and onSizeChanged never fires to zero the
665 // height, so keying the inset off overlayRows (rather than the stale measurement) avoids
666 // a phantom top gap on the content below — e.g. after closing find on a plain file.
667 val topInset = if (overlayRows > 0) {
668 with(androidx.compose.ui.platform.LocalDensity.current) { overlayHeightPx.toDp() }
670 if (overlayRows > 0) {
673 .align(Alignment.TopCenter)
675 .onSizeChanged { overlayHeightPx = it.height }
676 .padding(top = 4.dp),
677 verticalArrangement = Arrangement.spacedBy(4.dp),
681 modifier = Modifier.fillMaxWidth(),
682 horizontalArrangement = Arrangement.Center,
686 onClick = { onSetShowDiff(true) },
687 label = { Text(stringResource(R.string.show_diff)) },
689 Spacer(Modifier.size(8.dp))
691 selected = !showDiff,
692 onClick = { onSetShowDiff(false) },
693 label = { Text(stringResource(R.string.show_raw)) },
697 if (findActive && rawOnScreen) {
700 onQueryChange = onQueryChange,
701 matchCount = matchIndices.size,
705 onClose = onCloseFind,
706 caseSensitive = caseSensitive,
707 onToggleCaseSensitive = onToggleCaseSensitive,
712 if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
717 .padding(top = topInset)
718 .verticalScroll(rememberScrollState())
722 val text = content?.content.orEmpty()
723 if (text.isEmpty()) {
725 stringResource(R.string.empty_file),
726 modifier = Modifier.align(Alignment.Center).padding(24.dp),
727 color = MaterialTheme.colorScheme.onSurfaceVariant,
734 truncated = truncated,
736 findQuery = findQuery,
737 matchIndices = matchIndices,
738 highlightLineIndex = highlightLineIndex,
739 listState = listState,
740 onShareFull = onShareFull,
749 onQueryChange: (String) -> Unit,
755 caseSensitive: Boolean,
756 onToggleCaseSensitive: () -> Unit,
758 val keyboardController = LocalSoftwareKeyboardController.current
759 val matchCaseLabel = stringResource(R.string.match_case)
761 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
762 verticalAlignment = Alignment.CenterVertically,
766 onValueChange = onQueryChange,
767 modifier = Modifier.weight(1f),
768 placeholder = { Text(stringResource(R.string.find_hint)) },
770 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
771 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
773 Row(verticalAlignment = Alignment.CenterVertically) {
774 // Case-sensitivity toggle. A small "Aa" chip keeps the bar compact while
775 // surfacing a previously substring-only, always-case-insensitive search.
777 selected = caseSensitive,
778 onClick = onToggleCaseSensitive,
779 label = { Text("Aa", style = MaterialTheme.typography.labelSmall) },
780 modifier = Modifier.semantics { contentDescription = matchCaseLabel },
782 Spacer(Modifier.size(8.dp))
783 val countText = if (query.isBlank()) ""
784 else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
785 else stringResource(R.string.match_count, matchPos + 1, matchCount)
786 if (countText.isNotEmpty()) {
787 // Use error color for "no matches" so the zero-result state is
788 // distinguishable from a positive match count at a glance.
789 val color = if (query.isNotBlank() && matchCount == 0) MaterialTheme.colorScheme.error
790 else MaterialTheme.colorScheme.onSurfaceVariant
793 // labelMedium (not labelSmall) so the counter is legible for low-vision
794 // users at large font scales; the error color still distinguishes zero
795 // matches at a glance.
796 style = MaterialTheme.typography.labelMedium,
803 IconButton(onClick = onPrev, enabled = matchCount > 0) {
804 Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_previous))
806 IconButton(onClick = onNext, enabled = matchCount > 0) {
807 Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_next))
809 IconButton(onClick = onClose) {
810 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
816private fun FileTextContent(
819 topInset: androidx.compose.ui.unit.Dp,
823 matchIndices: List<Int>,
824 highlightLineIndex: Int?,
825 listState: LazyListState,
826 onShareFull: () -> Unit,
828 // Honor the user's chat text-size preference (the same one the chat markdown honors) so
829 // a user who bumped the scale for readability gets the same scale in the code viewer.
830 // Applied by nesting a MaterialTheme with a scaled typography, exactly how MarkdownText
831 // does it — the gutter/code Text below read MaterialTheme.typography.bodySmall, so this
832 // reaches both without per-Text plumbing.
833 val scale = LocalChatTextScale.current
834 val base = MaterialTheme.typography
835 val scaled = remember(scale, base) { base.scaledBy(scale) }
836 // Gutter width scales with the digit count and the font scale so accessibility
837 // text scaling (e.g. 1.3x) and the app's chat-text scale don't make line numbers overflow
838 // the gutter and collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
839 // proportionally for larger font/app scales.
840 val fontScale = androidx.compose.ui.platform.LocalDensity.current.fontScale
841 val gutterWidth = remember(lines.size, fontScale, scale) {
842 ((lines.size.toString().length.coerceAtLeast(3) * 10) *
843 fontScale.coerceAtLeast(1f) *
844 scale.coerceAtLeast(1f)).dp
846 val hScrollState = rememberScrollState()
847 val palette = rememberHighlightPalette()
848 // Resolve the file's language once instead of re-parsing the extension for every line.
849 val syntax = remember(filename) { syntaxFor(filename) }
850 val q = findQuery.trim()
851 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
852 MaterialTheme(typography = scaled) {
853 Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
855 androidx.compose.foundation.layout.Row(
856 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
857 verticalAlignment = Alignment.CenterVertically,
858 horizontalArrangement = Arrangement.SpaceBetween,
861 stringResource(R.string.file_truncated, MAX_RENDERED_LINES),
862 style = MaterialTheme.typography.labelSmall,
863 color = MaterialTheme.colorScheme.onSurfaceVariant,
864 modifier = Modifier.weight(1f),
866 // Escape hatch: when only the first N lines render, offer the (already
867 // full-content) Share action inline so the user isn't stranded without the
868 // rest of the file. The overflow Share shares the same full content.
869 TextButton(onClick = onShareFull) {
870 Text(stringResource(R.string.share_full_file))
874 // SelectionContainer lets the user select/copy a portion of the rendered code
875 // (not just the whole-file Copy in the overflow). Selection across recycled
876 // LazyColumn items can drop spans that have scrolled away, but partial selection
877 // of the visible region — the common case — works.
886 itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
887 val isMatch = q.isNotEmpty() && index in matchSet
888 // The jump-to-line target keeps the same emphasis as a find match so the line
889 // the viewer landed on is obvious until the user scrolls or starts a find.
890 val isTarget = index == highlightLineIndex
894 .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
895 .then(if (isMatch || isTarget) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
899 modifier = Modifier.width(gutterWidth),
900 style = MaterialTheme.typography.bodySmall,
901 fontFamily = FontFamily.Monospace,
902 color = MaterialTheme.colorScheme.onSurfaceVariant,
904 // Heuristic syntax highlighting per line. Falls back to plain text for
905 // unknown extensions, so non-code files render unchanged. Memoized so the
906 // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
907 // file, or palette actually changes — not on every recomposition (e.g. every
908 // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
909 val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
912 modifier = Modifier.padding(start = 8.dp),
913 style = MaterialTheme.typography.bodySmall,
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