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.horizontalScroll
15import androidx.compose.foundation.interaction.DragInteraction
16import androidx.compose.foundation.layout.Box
17import androidx.compose.foundation.layout.BoxScope
18import androidx.compose.foundation.layout.Column
19import androidx.compose.foundation.layout.Arrangement
20import androidx.compose.foundation.layout.Row
21import androidx.compose.foundation.layout.Spacer
22import androidx.compose.foundation.layout.fillMaxSize
23import androidx.compose.foundation.layout.fillMaxWidth
24import androidx.compose.foundation.layout.height
25import androidx.compose.foundation.layout.imePadding
26import androidx.compose.foundation.layout.padding
27import androidx.compose.foundation.layout.size
28import androidx.compose.foundation.layout.width
29import androidx.compose.foundation.lazy.LazyColumn
30import androidx.compose.foundation.lazy.LazyListState
31import androidx.compose.foundation.lazy.itemsIndexed
32import androidx.compose.foundation.lazy.rememberLazyListState
33import androidx.compose.foundation.rememberScrollState
34import androidx.compose.foundation.text.KeyboardActions
35import androidx.compose.foundation.text.KeyboardOptions
36import androidx.compose.foundation.text.selection.SelectionContainer
37import androidx.compose.material.icons.Icons
38import androidx.compose.material.icons.automirrored.filled.ArrowBack
39import androidx.compose.material.icons.automirrored.filled.WrapText
40import androidx.compose.material.icons.filled.Close
41import androidx.compose.material.icons.filled.ContentCopy
42import androidx.compose.material.icons.filled.KeyboardArrowDown
43import androidx.compose.material.icons.filled.KeyboardArrowUp
44import androidx.compose.material.icons.filled.Link
45import androidx.compose.material.icons.filled.MoreVert
46import androidx.compose.material.icons.filled.Refresh
47import androidx.compose.material.icons.filled.Search
48import androidx.compose.material.icons.filled.OpenInNew
49import androidx.compose.material.icons.filled.Share
50import androidx.compose.material3.AlertDialog
51import androidx.compose.material3.CircularProgressIndicator
52import androidx.compose.material3.DropdownMenu
53import androidx.compose.material3.DropdownMenuItem
54import androidx.compose.material3.ExperimentalMaterial3Api
55import androidx.compose.material3.FilterChip
56import androidx.compose.material3.Icon
57import androidx.compose.material3.IconButton
58import androidx.compose.material3.MaterialTheme
59import androidx.compose.material3.OutlinedTextField
60import androidx.compose.material3.Scaffold
61import androidx.compose.material3.SnackbarHost
62import androidx.compose.material3.SnackbarHostState
63import androidx.compose.material3.Text
64import androidx.compose.material3.TextButton
65import androidx.compose.material3.TopAppBar
66import androidx.compose.runtime.Composable
67import androidx.compose.runtime.LaunchedEffect
68import androidx.compose.runtime.MutableState
69import androidx.compose.runtime.getValue
70import androidx.compose.runtime.mutableIntStateOf
71import androidx.compose.runtime.mutableStateOf
72import androidx.compose.runtime.produceState
73import androidx.compose.runtime.remember
74import androidx.compose.runtime.rememberCoroutineScope
75import androidx.compose.runtime.saveable.rememberSaveable
76import androidx.compose.runtime.setValue
77import androidx.compose.ui.Alignment
78import androidx.compose.ui.Modifier
79import androidx.compose.ui.draw.alpha
80import androidx.compose.ui.draw.clip
81import androidx.compose.ui.layout.onSizeChanged
82import androidx.compose.ui.platform.LocalContext
83import androidx.compose.ui.platform.LocalDensity
84import androidx.compose.ui.platform.LocalSoftwareKeyboardController
85import androidx.compose.ui.semantics.contentDescription
86import androidx.compose.ui.semantics.semantics
87import androidx.compose.ui.text.font.FontFamily
88import androidx.compose.ui.text.input.ImeAction
89import androidx.compose.ui.text.input.KeyboardType
90import androidx.compose.ui.text.style.TextOverflow
91import androidx.compose.ui.unit.dp
92import androidx.compose.ui.res.stringResource
93import androidx.lifecycle.compose.collectAsStateWithLifecycle
94import androidx.lifecycle.viewmodel.compose.viewModel
95import soy.iko.opencode.di.AppContainer
96import soy.iko.opencode.R
97import soy.iko.opencode.ui.components.ConnectionBannerFor
98import soy.iko.opencode.ui.components.DiffView
99import soy.iko.opencode.ui.components.LocalChatTextScale
100import soy.iko.opencode.ui.components.LocalCodeWrap
101import soy.iko.opencode.ui.components.copyToClipboard
102import soy.iko.opencode.ui.components.highlightLine
103import soy.iko.opencode.ui.components.syntaxFor
104import soy.iko.opencode.ui.components.rememberHighlightPalette
105import soy.iko.opencode.ui.components.scaledBy
106import soy.iko.opencode.ui.vmFactory
107import soy.iko.opencode.util.runCatchingCancellable
108import kotlinx.coroutines.Dispatchers
109import kotlinx.coroutines.delay
110import kotlinx.coroutines.flow.collect
111import kotlinx.coroutines.launch
112import kotlinx.coroutines.withContext
114import soy.iko.opencode.data.network.NetworkConfig
116/** Cap on how many lines the raw viewer renders and searches. See [NetworkConfig.maxRenderedFileLines]. */
117private const val MAX_RENDERED_LINES = NetworkConfig.maxRenderedFileLines
119@OptIn(ExperimentalMaterial3Api::class)
122 container: AppContainer,
125 initialLine: Int? = null,
127 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
128 val state by vm.state.collectAsStateWithLifecycle()
129 val context = LocalContext.current
130 val scope = rememberCoroutineScope()
131 val snackbar = remember { SnackbarHostState() }
132 val filename = remember(path) {
133 val trimmed = path.trimEnd('/')
134 trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } }
136 val shareLabel = stringResource(R.string.share)
137 // Diff vs raw view toggle. Defaults to showing the diff when one exists; the user can
138 // switch to the raw new content. Persisted via rememberSaveable across rotation. When the
139 // viewer is opened at a specific line (from a search hit), start on the raw view so the
140 // targeted line is actually rendered (the diff view has no line list to scroll).
141 val hasDiff = state.content?.diff != null && state.content?.diff?.isNotBlank() == true
142 var showDiff by rememberSaveable(path) { mutableStateOf(initialLine == null) }
143 val showToggle = hasDiff && state.content?.content.orEmpty().isNotEmpty()
144 // Find-in-file and line-wrapping state, persisted so a rotation or reload keeps the
145 // user's query/mode.
146 var findActive by rememberSaveable(path) { mutableStateOf(false) }
147 var findQuery by rememberSaveable(path) { mutableStateOf("") }
148 var matchPos by rememberSaveable(path) { mutableIntStateOf(0) }
149 // Seed the per-file wrap toggle from the user's global code-wrap preference so the viewer
150 // respects it by default; they can still flip it per-file (persisted across rotation).
151 val codeWrap = LocalCodeWrap.current
152 var wrap by rememberSaveable(path) { mutableStateOf(codeWrap) }
153 val listState = rememberLazyListState()
154 val rawText = state.content?.content.orEmpty()
155 // Directory portion of the path, shown under the filename in the top bar so a user who
156 // has drilled into a deep workspace keeps context of where this file lives.
157 val pathSubtitle = remember(path) {
158 val trimmed = path.trimEnd('/')
159 val parent = trimmed.substringBeforeLast('/', "")
160 parent.ifBlank { path }
162 // Split the raw text exactly once here (the line viewer and find-in-file both consume this
163 // list) and cap it so a huge file doesn't get split twice or overwhelm the LazyColumn.
164 // `take()` (not `subList()`) so the cap actually releases the tail: subList returns a view
165 // backed by the full list, which would keep every line (and the full rawText) in memory.
166 val allLines = remember(rawText) { rawText.split("\n") }
167 // The viewer renders the file in chunks of MAX_RENDERED_LINES so a huge file doesn't lay
168 // out thousands of lines at once. The user can load the next chunk via the truncation
169 // banner's "Load more" button instead of being stranded at the first cap (or forced to
170 // leave the app via Share full file).
171 var renderLimit by rememberSaveable(path) { mutableStateOf(MAX_RENDERED_LINES) }
172 val lines = remember(rawText, renderLimit) { allLines.take(renderLimit) }
173 val truncated = allLines.size > lines.size
174 val moreLines = (allLines.size - lines.size).coerceAtMost(MAX_RENDERED_LINES)
175 // The line the viewer was opened at (from a search hit), highlighted until the user scrolls
176 // or starts an in-file find so the landed line stands out. Null once cleared.
177 val highlightLine = rememberJumpToLineHighlight(
179 initialLine = initialLine,
180 content = state.content,
181 isBinary = state.content?.isBinary == true,
182 lineCount = lines.size,
183 listState = listState,
185 // Find-in-file match-case toggle (default case-insensitive). Persisted so a rotation keeps
186 // the choice, and applied to the off-thread match scan below.
187 var caseSensitive by rememberSaveable(path) { mutableStateOf(false) }
188 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
189 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
190 var debouncedFind by remember { mutableStateOf("") }
191 LaunchedEffect(findQuery) {
192 val q = findQuery.trim()
193 if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q }
195 val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind, caseSensitive) {
196 val q = debouncedFind
197 value = if (q.isEmpty()) emptyList()
198 else withContext(Dispatchers.Default) {
199 lines.mapIndexedNotNull { index, line ->
200 index.takeIf { line.contains(q, ignoreCase = !caseSensitive) }
204 // Keep the current match index in range when the match set shrinks (e.g. the file
205 // reloaded with fewer matches), so the counter never shows a stale "10 / 3".
206 LaunchedEffect(matchIndices.size) {
207 matchPos = matchPos.coerceIn(0, (matchIndices.size - 1).coerceAtLeast(0))
209 // Scroll to the current match whenever it (or the match set) changes.
210 LaunchedEffect(matchPos, matchIndices) {
211 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
213 // "Go to line" dialog state. Clears the jump highlight and scrolls the LazyColumn to the
214 // requested line (clamped to the rendered range) when the user confirms.
215 var showGoToLine by rememberSaveable(path) { mutableStateOf(false) }
216 fun goToLine(n: Int) {
217 val target = (n - 1).coerceIn(0, (lines.size - 1).coerceAtLeast(0))
218 highlightLine.value = null
219 scope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
225 subtitle = pathSubtitle,
226 loading = state.loading,
229 diffShown = showToggle && showDiff,
231 onReload = { vm.reload() },
232 // Find searches the raw line list, which isn't on screen under a diff. Rather
233 // than silently greying the icon out, keep it tappable and explain where find
234 // lives when the user is in diff mode.
236 if (showToggle && showDiff) {
237 scope.launch { snackbar.showSnackbar(context.getString(R.string.switch_to_raw_to_search)) }
239 findActive = !findActive
240 if (!findActive) findQuery = ""
243 onToggleWrap = { wrap = !wrap },
244 onGoToLine = { showGoToLine = true },
245 onCopy = { copyToClipboard(context, filename, rawText) },
246 onCopyPath = { copyToClipboard(context, context.getString(R.string.clip_label_path), path) },
248 val send = Intent(Intent.ACTION_SEND).apply {
250 putExtra(Intent.EXTRA_SUBJECT, filename)
251 putExtra(Intent.EXTRA_TEXT, rawText)
253 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
254 .onFailure { scope.launch { snackbar.showSnackbar(context.getString(R.string.no_share_app)) } }
257 // Write the viewed content to a cache file and hand a content:// URI to an
258 // external app via ACTION_VIEW, so a file the in-app viewer can only show as
259 // text (or a large file beyond the render cap) can be opened in a real editor.
261 val ok = runCatchingCancellable {
262 withContext(Dispatchers.IO) {
263 val dir = java.io.File(context.cacheDir, "external").apply { mkdirs() }
264 val file = java.io.File(dir, filename.ifBlank { "file.txt" })
265 file.writeText(rawText)
266 val authority = context.packageName + ".fileprovider"
267 val uri = androidx.core.content.FileProvider.getUriForFile(context, authority, file)
268 val view = Intent(Intent.ACTION_VIEW).apply {
269 setDataAndType(uri, android.webkit.MimeTypeMap.getSingleton()
270 .getMimeTypeFromExtension(filename.substringAfterLast('.', ""))
272 addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
274 context.startActivity(view)
277 if (!ok) scope.launch { snackbar.showSnackbar(context.getString(R.string.open_externally_failed)) }
282 snackbarHost = { SnackbarHost(snackbar) },
284 // Pull-to-refresh wraps the content so a user who pull-to-refreshes out of habit
285 // can reload the file, mirroring the file browser and session list gestures. The
286 // top-bar reload icon remains for a targeted tap.
287 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
288 isRefreshing = state.loading && state.content != null,
289 onRefresh = { vm.reload() },
290 modifier = Modifier.fillMaxSize().imePadding().padding(padding),
292 Box(modifier = Modifier.fillMaxSize()) {
293 // Surface SSE connection state so a dropped stream is visible while viewing a file.
294 ConnectionBannerFor(container)
295 // AnimatedContent cross-fades between loading / error / content states so the
296 // viewer doesn't snap abruptly when a reload finishes or fails.
299 transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
300 label = "fileViewState",
302 // Single source of truth for matches: the off-thread `matchIndices` produceState
303 // (keyed on the file content + debounced query). Both navigation (onPrev/onNext +
304 // scroll) and display (counter + highlight) read from it, so the counter can't show
305 // a new query while next/prev act on a stale set. The earlier in-composition
306 // per-layer scan re-ran the whole-file O(n) match on the UI thread each keystroke
307 // (the exact work produceState was moved off-thread to avoid); it's acceptable for
308 // the crossfade's exiting layer to briefly use this unified set until it settles.
309 FileViewStateContent(
312 showToggle = showToggle,
316 // Close any open find bar when entering diff mode: find searches
317 // the raw content and scrolls the line LazyColumn, neither of
318 // which is visible in diff mode, so leaving it open would be a
320 if (it) { findActive = false; findQuery = "" }
322 findActive = findActive,
323 findQuery = findQuery,
324 onQueryChange = { findQuery = it; matchPos = 0; if (it.isNotEmpty()) highlightLine.value = null },
325 matchIndices = matchIndices,
327 onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
328 onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
329 onCloseFind = { findActive = false; findQuery = "" },
332 truncated = truncated,
333 moreLines = moreLines,
334 onLoadMore = { renderLimit += MAX_RENDERED_LINES },
335 highlightLineIndex = highlightLine.value,
336 listState = listState,
337 onRetry = { vm.reload() },
338 caseSensitive = caseSensitive,
339 onToggleCaseSensitive = { caseSensitive = !caseSensitive },
341 val send = Intent(Intent.ACTION_SEND).apply {
343 putExtra(Intent.EXTRA_SUBJECT, filename)
344 putExtra(Intent.EXTRA_TEXT, rawText)
346 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
348 // A large file can blow past the Binder transaction limit
349 // (~1MB); distinguish that from a missing share target so the
350 // message is honest instead of blaming a nonexistent app.
351 val msg = if (it is android.os.DeadObjectException ||
352 it is android.os.TransactionTooLargeException
354 context.getString(R.string.file_share_too_large)
356 context.getString(R.string.no_share_app)
358 scope.launch { snackbar.showSnackbar(msg) }
368 visible = showGoToLine,
369 maxLine = lines.size,
370 onConfirm = { n -> showGoToLine = false; goToLine(n) },
371 onDismiss = { showGoToLine = false },
375// Wraps the go-to-line dialog's visibility gate so the branch stays out of FileViewScreen
376// (keeping that composable under the cyclomatic-complexity threshold).
378private fun GoToLineLauncher(
381 onConfirm: (Int) -> Unit,
382 onDismiss: () -> Unit,
385 GoToLineDialog(maxLine = maxLine, onConfirm = onConfirm, onDismiss = onDismiss)
390private fun GoToLineDialog(maxLine: Int, onConfirm: (Int) -> Unit, onDismiss: () -> Unit) {
391 var text by rememberSaveable { mutableStateOf("") }
392 val parsed = text.trim().toIntOrNull()
393 val valid = parsed != null && parsed in 1..maxLine
395 onDismissRequest = onDismiss,
396 title = { Text(stringResource(R.string.go_to_line)) },
400 onValueChange = { v -> text = v.filter { it.isDigit() } },
402 isError = text.isNotEmpty() && !valid,
405 stringResource(R.string.go_to_line_hint, maxLine),
406 style = MaterialTheme.typography.bodySmall,
409 keyboardOptions = KeyboardOptions(
410 keyboardType = KeyboardType.Number,
411 imeAction = ImeAction.Done,
413 keyboardActions = KeyboardActions(onDone = { if (valid) parsed?.let { onConfirm(it) } }),
417 TextButton(onClick = { parsed?.let { onConfirm(it) } }, enabled = valid) {
418 Text(stringResource(R.string.go_to_line))
422 TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
427// Owns the "jump to a line from a search hit" behaviour: scroll to the target once the raw
428// content loads (one-shot), and clear the highlight the moment the user drags the list by hand.
429// Extracted so FileViewScreen stays under the cyclomatic-complexity threshold.
431private fun rememberJumpToLineHighlight(
437 listState: LazyListState,
438): MutableState<Int?> {
439 val highlight = rememberSaveable(path) { mutableStateOf<Int?>(null) }
440 var didInitialScroll by rememberSaveable(path) { mutableStateOf(false) }
441 LaunchedEffect(content, initialLine) {
442 val line = initialLine ?: return@LaunchedEffect
443 if (didInitialScroll || content == null || isBinary) return@LaunchedEffect
444 val target = (line - 1).coerceIn(0, (lineCount - 1).coerceAtLeast(0))
445 runCatchingCancellable { listState.scrollToItem(target) }
446 highlight.value = target
447 didInitialScroll = true
449 LaunchedEffect(listState) {
450 if (initialLine == null) return@LaunchedEffect
451 listState.interactionSource.interactions.collect { interaction ->
452 if (interaction is DragInteraction.Start) highlight.value = null
458@OptIn(ExperimentalMaterial3Api::class)
460private fun FileViewTopBar(
468 onReload: () -> Unit,
469 onToggleFind: () -> Unit,
470 onToggleWrap: () -> Unit,
471 onGoToLine: () -> Unit,
473 onCopyPath: () -> Unit,
475 onOpenExternally: () -> Unit,
479 // Two-line title: filename (primary) + the directory portion of the path
480 // (secondary), so a file opened from deep in a workspace keeps its location
481 // visible without an extra breadcrumb row.
483 Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis)
484 if (subtitle.isNotBlank()) {
488 overflow = TextOverflow.Ellipsis,
489 style = MaterialTheme.typography.bodySmall,
490 color = MaterialTheme.colorScheme.onSurfaceVariant,
496 IconButton(onClick = onBack) {
497 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
501 // In-place refresh: a file's content can change on the server (the agent may
502 // be editing it), so reload without requiring a back-out. Shows a spinner while
503 // loading since the content now stays on screen during a reload (the spinner is
504 // the only progress signal).
505 val loadingLabel = stringResource(R.string.loading)
506 IconButton(onClick = onReload, enabled = !loading) {
508 CircularProgressIndicator(
509 Modifier.size(20.dp).semantics { contentDescription = loadingLabel },
513 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
516 if (content.isNotEmpty()) {
517 // Find-in-file: toggles a search bar over the raw content. Stays enabled even in
518 // diff mode — the find bar searches the raw text and scrolls the line LazyColumn,
519 // neither of which is visible under a diff, so tapping it there surfaces a hint to
520 // switch to Raw rather than silently no-opping behind a greyed-out icon.
521 IconButton(onClick = onToggleFind) {
522 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
524 // Overflow the less-frequent actions (wrap / copy / share) so a long filename
525 // title isn't crowded off screen by five inline icons on a phone.
526 var menuExpanded by remember { mutableStateOf(false) }
527 IconButton(onClick = { menuExpanded = true }) {
528 Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options))
530 DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
531 // Wrap long lines instead of horizontal-scrolling; useful for prose. Disabled
532 // in diff mode because DiffView ignores wrap, so an active-looking toggle there
533 // would be a silent no-op.
535 text = { Text(stringResource(R.string.go_to_line)) },
536 onClick = { onGoToLine(); menuExpanded = false },
537 leadingIcon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
540 text = { Text(stringResource(R.string.wrap_lines)) },
541 onClick = { onToggleWrap(); menuExpanded = false },
542 enabled = !diffShown,
545 Icons.AutoMirrored.Filled.WrapText,
546 contentDescription = null,
547 tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
552 text = { Text(stringResource(R.string.copy)) },
553 onClick = { onCopy(); menuExpanded = false },
554 leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) },
557 text = { Text(stringResource(R.string.copy_path)) },
558 onClick = { onCopyPath(); menuExpanded = false },
559 leadingIcon = { Icon(Icons.Filled.Link, contentDescription = null) },
562 text = { Text(stringResource(R.string.share)) },
563 onClick = { onShare(); menuExpanded = false },
564 leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) },
567 text = { Text(stringResource(R.string.open_externally)) },
568 onClick = { onOpenExternally(); menuExpanded = false },
569 leadingIcon = { Icon(Icons.Filled.OpenInNew, contentDescription = null) },
578private fun BoxScope.FileViewStateContent(
579 state: FileViewState,
583 onSetShowDiff: (Boolean) -> Unit,
586 onQueryChange: (String) -> Unit,
587 matchIndices: List<Int>,
591 onCloseFind: () -> Unit,
596 onLoadMore: () -> Unit,
597 highlightLineIndex: Int?,
598 listState: LazyListState,
600 onShareFull: () -> Unit,
601 caseSensitive: Boolean,
602 onToggleCaseSensitive: () -> Unit,
605 // Full-screen skeleton only on the initial load (no content yet). A reload keeps the
606 // existing content on screen; the top-bar reload button shows the in-flight progress.
607 // Skeleton "code line" rows pre-structure the layout so a large file's load feels
608 // faster than a bare spinner and the real content cross-fades into an already-shaped
610 state.loading && state.content == null -> {
611 val loadingLabel = stringResource(R.string.loading)
612 val reducedMotion = soy.iko.opencode.ui.components.LocalReducedMotion.current
613 val transition = rememberInfiniteTransition(label = "file_skeleton")
614 val pulse by transition.animateFloat(
615 initialValue = 0.35f,
617 animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
618 label = "fileSkeletonAlpha",
620 val skeletonAlpha = if (reducedMotion) 0.5f else pulse
621 val skeletonColor = MaterialTheme.colorScheme.surfaceVariant
624 .align(Alignment.TopCenter)
625 .padding(top = 24.dp)
626 .padding(horizontal = 16.dp)
628 .semantics { contentDescription = loadingLabel },
629 verticalArrangement = Arrangement.spacedBy(8.dp),
633 modifier = Modifier.fillMaxWidth(),
634 horizontalArrangement = Arrangement.spacedBy(8.dp),
640 .clip(MaterialTheme.shapes.small)
641 .background(skeletonColor)
642 .alpha(skeletonAlpha),
646 .fillMaxWidth(if (i % 3 == 0) 0.9f else 0.7f)
648 .clip(MaterialTheme.shapes.small)
649 .background(skeletonColor)
650 .alpha(skeletonAlpha),
656 state.error != null -> Column(
657 modifier = Modifier.align(Alignment.Center).padding(24.dp),
658 horizontalAlignment = Alignment.CenterHorizontally,
660 Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
661 Spacer(Modifier.size(12.dp))
662 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
664 state.content?.isBinary == true -> Text(
665 stringResource(R.string.binary_file),
666 modifier = Modifier.align(Alignment.Center).padding(24.dp),
667 color = MaterialTheme.colorScheme.onSurfaceVariant,
669 else -> FileViewContentBody(
670 content = state.content,
672 showToggle = showToggle,
674 onSetShowDiff = onSetShowDiff,
675 findActive = findActive,
676 findQuery = findQuery,
677 onQueryChange = onQueryChange,
678 matchIndices = matchIndices,
682 onCloseFind = onCloseFind,
685 truncated = truncated,
686 moreLines = moreLines,
687 onLoadMore = onLoadMore,
688 highlightLineIndex = highlightLineIndex,
689 listState = listState,
690 onShareFull = onShareFull,
691 caseSensitive = caseSensitive,
692 onToggleCaseSensitive = onToggleCaseSensitive,
698private fun BoxScope.FileViewContentBody(
699 content: soy.iko.opencode.data.model.FileContent?,
703 onSetShowDiff: (Boolean) -> Unit,
706 onQueryChange: (String) -> Unit,
707 matchIndices: List<Int>,
711 onCloseFind: () -> Unit,
716 onLoadMore: () -> Unit,
717 highlightLineIndex: Int?,
718 listState: LazyListState,
719 onShareFull: () -> Unit,
720 caseSensitive: Boolean,
721 onToggleCaseSensitive: () -> Unit,
723 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
724 // reachable while scrolling. Each contributes a top inset so the content below
725 // isn't hidden behind the overlay. The inset is measured from the actual overlay
726 // height (not a hardcoded 52dp/row) so accessibility font scaling — which can
727 // make the FilterChip labels and FindBar text field taller than 52dp — doesn't
728 // cause the first lines of content to hide behind the overlay.
729 // The raw text is on screen whenever we're NOT rendering the diff — i.e. either the
730 // file has no diff toggle at all (plain file: showToggle == false) or the toggle is
731 // set to Raw. Gate the find bar on this rather than on !showDiff: for a plain file
732 // showDiff stays at its `true` default forever (nothing ever flips it), so !showDiff
733 // would keep the find bar — and therefore find-in-file — permanently hidden.
734 val rawOnScreen = !(showToggle && showDiff)
735 val overlayRows = (if (showToggle) 1 else 0) + (if (findActive && rawOnScreen) 1 else 0)
736 var overlayHeightPx by remember { mutableIntStateOf(0) }
737 // Only apply the measured inset while an overlay is actually composed. When overlayRows
738 // drops to 0 the overlay Column is removed and onSizeChanged never fires to zero the
739 // height, so keying the inset off overlayRows (rather than the stale measurement) avoids
740 // a phantom top gap on the content below — e.g. after closing find on a plain file.
741 val topInset = if (overlayRows > 0) {
742 with(androidx.compose.ui.platform.LocalDensity.current) { overlayHeightPx.toDp() }
744 if (overlayRows > 0) {
747 .align(Alignment.TopCenter)
749 .onSizeChanged { overlayHeightPx = it.height }
750 .padding(top = 4.dp),
751 verticalArrangement = Arrangement.spacedBy(4.dp),
755 modifier = Modifier.fillMaxWidth(),
756 horizontalArrangement = Arrangement.Center,
760 onClick = { onSetShowDiff(true) },
761 label = { Text(stringResource(R.string.show_diff)) },
763 Spacer(Modifier.size(8.dp))
765 selected = !showDiff,
766 onClick = { onSetShowDiff(false) },
767 label = { Text(stringResource(R.string.show_raw)) },
771 if (findActive && rawOnScreen) {
774 onQueryChange = onQueryChange,
775 matchCount = matchIndices.size,
779 onClose = onCloseFind,
780 caseSensitive = caseSensitive,
781 onToggleCaseSensitive = onToggleCaseSensitive,
786 if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
787 // Use LazyDiffView so a 5000-line diff composes only the visible rows. The previous
788 // DiffView + verticalScroll composed every row eagerly, which OOM'd on large diffs.
789 soy.iko.opencode.ui.components.LazyDiffView(
793 .padding(top = topInset)
797 val text = content?.content.orEmpty()
798 if (text.isEmpty()) {
800 stringResource(R.string.empty_file),
801 modifier = Modifier.align(Alignment.Center).padding(24.dp),
802 color = MaterialTheme.colorScheme.onSurfaceVariant,
809 truncated = truncated,
810 moreLines = moreLines,
811 onLoadMore = onLoadMore,
813 findQuery = findQuery,
814 matchIndices = matchIndices,
815 highlightLineIndex = highlightLineIndex,
816 listState = listState,
817 onShareFull = onShareFull,
826 onQueryChange: (String) -> Unit,
832 caseSensitive: Boolean,
833 onToggleCaseSensitive: () -> Unit,
835 val keyboardController = LocalSoftwareKeyboardController.current
836 val matchCaseLabel = stringResource(R.string.match_case)
838 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
839 verticalAlignment = Alignment.CenterVertically,
843 onValueChange = onQueryChange,
844 modifier = Modifier.weight(1f),
845 placeholder = { Text(stringResource(R.string.find_hint)) },
847 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
848 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
850 Row(verticalAlignment = Alignment.CenterVertically) {
851 // Case-sensitivity toggle. A small "Aa" chip keeps the bar compact while
852 // surfacing a previously substring-only, always-case-insensitive search.
854 selected = caseSensitive,
855 onClick = onToggleCaseSensitive,
856 label = { Text("Aa", style = MaterialTheme.typography.labelSmall) },
857 modifier = Modifier.semantics { contentDescription = matchCaseLabel },
859 Spacer(Modifier.size(8.dp))
860 val countText = if (query.isBlank()) ""
861 else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
862 else stringResource(R.string.match_count, matchPos + 1, matchCount)
863 if (countText.isNotEmpty()) {
864 // Use error color for "no matches" so the zero-result state is
865 // distinguishable from a positive match count at a glance.
866 val color = if (query.isNotBlank() && matchCount == 0) MaterialTheme.colorScheme.error
867 else MaterialTheme.colorScheme.onSurfaceVariant
870 // labelMedium (not labelSmall) so the counter is legible for low-vision
871 // users at large font scales; the error color still distinguishes zero
872 // matches at a glance.
873 style = MaterialTheme.typography.labelMedium,
880 IconButton(onClick = onPrev, enabled = matchCount > 0) {
881 Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_previous))
883 IconButton(onClick = onNext, enabled = matchCount > 0) {
884 Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_next))
886 IconButton(onClick = onClose) {
887 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
893private fun FileTextContent(
896 topInset: androidx.compose.ui.unit.Dp,
899 onLoadMore: () -> Unit,
902 matchIndices: List<Int>,
903 highlightLineIndex: Int?,
904 listState: LazyListState,
905 onShareFull: () -> Unit,
907 // Honor the user's chat text-size preference (the same one the chat markdown honors) so
908 // a user who bumped the scale for readability gets the same scale in the code viewer.
909 // Applied by nesting a MaterialTheme with a scaled typography, exactly how MarkdownText
910 // does it — the gutter/code Text below read MaterialTheme.typography.bodySmall, so this
911 // reaches both without per-Text plumbing.
912 val scale = LocalChatTextScale.current
913 val base = MaterialTheme.typography
914 val scaled = remember(scale, base) { base.scaledBy(scale) }
915 // Gutter width scales with the digit count and the font scale so accessibility
916 // text scaling (e.g. 1.3x) and the app's chat-text scale don't make line numbers overflow
917 // the gutter and collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
918 // proportionally for larger font/app scales.
919 val fontScale = androidx.compose.ui.platform.LocalDensity.current.fontScale
920 val gutterWidth = remember(lines.size, fontScale, scale) {
921 ((lines.size.toString().length.coerceAtLeast(3) * 10) *
922 fontScale.coerceAtLeast(1f) *
923 scale.coerceAtLeast(1f)).dp
925 val hScrollState = rememberScrollState()
926 val palette = rememberHighlightPalette()
927 // Resolve the file's language once instead of re-parsing the extension for every line.
928 val syntax = remember(filename) { syntaxFor(filename) }
929 // Pre-compute per-line block-comment/triple-string state snapshots so a LazyColumn row
930 // can highlight its line correctly without re-walking every preceding line on each
931 // recomposition. Each snapshot is two booleans; for a 5000-line file that's ~10KB,
932 // trivial next to the line strings themselves. highlightLine(line, syntax, palette, state)
933 // mutates the passed state in place, so we give each line its own snapshot derived from
934 // the prior line's end state — this is what makes a 50-line block comment color correctly
935 // on every line, not just the opener.
936 val lineStates = remember(lines, syntax) {
937 if (syntax.lang == soy.iko.opencode.ui.components.Language.NONE) {
940 val states = ArrayList<soy.iko.opencode.ui.components.BlockCommentState>(lines.size)
941 var current = soy.iko.opencode.ui.components.BlockCommentState()
942 for (line in lines) {
943 val snapshot = soy.iko.opencode.ui.components.BlockCommentState().also {
944 it.inBlockComment = current.inBlockComment
945 it.inTripleString = current.inTripleString
948 // Advance the running state by highlighting into a throwaway builder — the
949 // side effect on `current` is what we need. We discard the AnnotatedString.
950 soy.iko.opencode.ui.components.highlightLine(line, syntax, palette, current)
955 val q = findQuery.trim()
956 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
957 MaterialTheme(typography = scaled) {
958 Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
960 androidx.compose.foundation.layout.Row(
961 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
962 verticalAlignment = Alignment.CenterVertically,
963 horizontalArrangement = Arrangement.SpaceBetween,
966 stringResource(R.string.file_truncated, lines.size),
967 style = MaterialTheme.typography.labelSmall,
968 color = MaterialTheme.colorScheme.onSurfaceVariant,
969 modifier = Modifier.weight(1f),
971 // Load the next chunk in-place so the user can keep reading the file without
972 // leaving the app. Falls back to Share full file for the whole content.
973 TextButton(onClick = onLoadMore) {
974 Text(stringResource(R.string.load_more_lines, moreLines))
976 TextButton(onClick = onShareFull) {
977 Text(stringResource(R.string.share_full_file))
981 // SelectionContainer lets the user select/copy a portion of the rendered code
982 // (not just the whole-file Copy in the overflow). Selection across recycled
983 // LazyColumn items can drop spans that have scrolled away, but partial selection
984 // of the visible region — the common case — works.
993 itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
994 val isMatch = q.isNotEmpty() && index in matchSet
995 // The jump-to-line target keeps the same emphasis as a find match so the line
996 // the viewer landed on is obvious until the user scrolls or starts a find.
997 val isTarget = index == highlightLineIndex
1001 .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
1002 .then(if (isMatch || isTarget) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
1006 modifier = Modifier.width(gutterWidth),
1007 style = MaterialTheme.typography.bodySmall,
1008 fontFamily = FontFamily.Monospace,
1009 color = MaterialTheme.colorScheme.onSurfaceVariant,
1011 // Heuristic syntax highlighting per line. Falls back to plain text for
1012 // unknown extensions, so non-code files render unchanged. Memoized so the
1013 // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
1014 // file, or palette actually changes — not on every recomposition (e.g. every
1015 // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
1016 // Uses the stateful highlightLine so multi-line block comments and triple-quoted
1017 // strings color correctly on every line they span (the per-line snapshot in
1018 // lineStates carries the in-block/in-trString flag for this line's position).
1019 val highlighted = remember(line, syntax, palette, index) {
1020 val state = lineStates.getOrNull(index)
1021 if (state != null) {
1022 val s = soy.iko.opencode.ui.components.BlockCommentState().also {
1023 it.inBlockComment = state.inBlockComment
1024 it.inTripleString = state.inTripleString
1026 soy.iko.opencode.ui.components.highlightLine(line, syntax, palette, s)
1028 soy.iko.opencode.ui.components.highlightLine(line, syntax, palette)
1033 modifier = Modifier.padding(start = 8.dp),
1034 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