package soy.iko.opencode.ui.file import android.content.Intent import soy.iko.opencode.ui.components.showToast import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.WrapText import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Share import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import soy.iko.opencode.di.AppContainer import soy.iko.opencode.R import soy.iko.opencode.ui.components.DiffView import soy.iko.opencode.ui.components.copyToClipboard import soy.iko.opencode.ui.components.highlightLine import soy.iko.opencode.ui.components.syntaxFor import soy.iko.opencode.ui.components.rememberHighlightPalette import soy.iko.opencode.ui.vmFactory import soy.iko.opencode.util.runCatchingCancellable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @Composable fun FileViewScreen( container: AppContainer, path: String, onBack: () -> Unit, initialLine: Int? = null, ) { val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) }) val state by vm.state.collectAsStateWithLifecycle() val context = LocalContext.current val filename = remember(path) { val trimmed = path.trimEnd('/') trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } } } val shareLabel = stringResource(R.string.share) // Diff vs raw view toggle. Defaults to showing the diff when one exists; the user can // switch to the raw new content. Persisted via rememberSaveable across rotation. When the // viewer is opened at a specific line (from a search hit), start on the raw view so the // targeted line is actually rendered (the diff view has no line list to scroll). val hasDiff = state.content?.diff != null && state.content?.diff?.isNotBlank() == true var showDiff by rememberSaveable(path) { mutableStateOf(initialLine == null) } val showToggle = hasDiff && state.content?.content.orEmpty().isNotEmpty() // Find-in-file and line-wrapping state, persisted so a rotation or reload keeps the // user's query/mode. var findActive by rememberSaveable(path) { mutableStateOf(false) } var findQuery by rememberSaveable(path) { mutableStateOf("") } var matchPos by rememberSaveable(path) { mutableIntStateOf(0) } var wrap by rememberSaveable(path) { mutableStateOf(false) } val listState = rememberLazyListState() val rawText = state.content?.content.orEmpty() val lines = remember(rawText) { rawText.split("\n") } // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing. var debouncedFind by remember { mutableStateOf("") } LaunchedEffect(findQuery) { val q = findQuery.trim() if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q } } val matchIndices by produceState(emptyList(), lines, debouncedFind) { val q = debouncedFind value = if (q.isEmpty()) emptyList() else withContext(Dispatchers.Default) { lines.mapIndexedNotNull { index, line -> index.takeIf { line.contains(q, ignoreCase = true) } } } } // Keep the current match index in range when the match set shrinks (e.g. the file // reloaded with fewer matches), so the counter never shows a stale "10 / 3". LaunchedEffect(matchIndices.size) { matchPos = matchPos.coerceIn(0, (matchIndices.size - 1).coerceAtLeast(0)) } // Scroll to the current match whenever it (or the match set) changes. LaunchedEffect(matchPos, matchIndices) { matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } } } // Opened at a specific line (from a search hit): scroll there once the raw content loads. // One-shot so a later find-in-file navigation isn't overridden. var didInitialLineScroll by rememberSaveable(path) { mutableStateOf(false) } LaunchedEffect(state.content, initialLine) { val line = initialLine ?: return@LaunchedEffect if (didInitialLineScroll) return@LaunchedEffect val content = state.content ?: return@LaunchedEffect if (content.isBinary) return@LaunchedEffect val target = (line - 1).coerceIn(0, (lines.size - 1).coerceAtLeast(0)) runCatchingCancellable { listState.scrollToItem(target) } didInitialLineScroll = true } Scaffold( topBar = { FileViewTopBar( filename = filename, loading = state.loading, content = rawText, wrap = wrap, inDiffMode = showToggle && showDiff, onBack = onBack, onReload = { vm.reload() }, onToggleFind = { findActive = !findActive; if (!findActive) findQuery = "" }, onToggleWrap = { wrap = !wrap }, onCopy = { copyToClipboard(context, filename, rawText) }, onShare = { val send = Intent(Intent.ACTION_SEND).apply { type = "text/plain" putExtra(Intent.EXTRA_SUBJECT, filename) putExtra(Intent.EXTRA_TEXT, rawText) } runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) } .onFailure { showToast(context, context.getString(R.string.no_share_app)) } }, ) }, ) { padding -> Box(modifier = Modifier.fillMaxSize().padding(padding)) { // AnimatedContent cross-fades between loading / error / content states so the // viewer doesn't snap abruptly when a reload finishes or fails. AnimatedContent( targetState = state, transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) }, label = "fileViewState", ) { s -> // Derive match indices from THIS layer's content (s), not the outer/newest // state. During the crossfade the exiting layer still renders the old // content; passing the outer matchIndices (computed from the new content) // would highlight and scroll the wrong lines until the animation settles. val sMatchIndices = remember(s.content?.content, debouncedFind, lines, rawText) { val q = debouncedFind if (q.isEmpty()) emptyList() else { // Reuse the already-split `lines` when this layer shows the current // content (the common case); only re-split for the exiting layer's // stale content during the crossfade. val sContent = s.content?.content val sLines = if (sContent == rawText) lines else sContent.orEmpty().split("\n") sLines.mapIndexedNotNull { index, line -> index.takeIf { line.contains(q, ignoreCase = true) } } } } FileViewStateContent( state = s, filename = filename, showToggle = showToggle, showDiff = showDiff, onSetShowDiff = { showDiff = it // Close any open find bar when entering diff mode: find searches // the raw content and scrolls the line LazyColumn, neither of // which is visible in diff mode, so leaving it open would be a // confusing no-op. if (it) { findActive = false; findQuery = "" } }, findActive = findActive, findQuery = findQuery, onQueryChange = { findQuery = it; matchPos = 0 }, matchIndices = sMatchIndices, matchPos = matchPos, onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size }, onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size }, onCloseFind = { findActive = false; findQuery = "" }, wrap = wrap, listState = listState, onRetry = { vm.reload() }, ) } } } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun FileViewTopBar( filename: String, loading: Boolean, content: String, wrap: Boolean, inDiffMode: Boolean, onBack: () -> Unit, onReload: () -> Unit, onToggleFind: () -> Unit, onToggleWrap: () -> Unit, onCopy: () -> Unit, onShare: () -> Unit, ) { TopAppBar( title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) } }, actions = { // In-place refresh: a file's content can change on the server (the agent may // be editing it), so reload without requiring a back-out. IconButton(onClick = onReload, enabled = !loading) { Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh)) } if (content.isNotEmpty()) { // Find-in-file: toggles a search bar over the raw content. Disabled only when // the DiffView is actually on screen ([inDiffMode]) — the find bar searches the // raw text and scrolls the line LazyColumn, neither of which is visible under a // diff, so find would silently no-op there. A plain file with no diff (the common // case) shows raw text, so find must stay enabled. The user can switch to Raw to find. IconButton(onClick = onToggleFind, enabled = !inDiffMode) { Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file)) } // Wrap long lines instead of horizontal-scrolling, useful for prose. IconButton(onClick = onToggleWrap) { Icon( Icons.AutoMirrored.Filled.WrapText, contentDescription = stringResource(R.string.wrap_lines), tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, ) } IconButton(onClick = onCopy) { Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy)) } IconButton(onClick = onShare) { Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share)) } } }, ) } @Composable private fun BoxScope.FileViewStateContent( state: FileViewState, filename: String, showToggle: Boolean, showDiff: Boolean, onSetShowDiff: (Boolean) -> Unit, findActive: Boolean, findQuery: String, onQueryChange: (String) -> Unit, matchIndices: List, matchPos: Int, onPrev: () -> Unit, onNext: () -> Unit, onCloseFind: () -> Unit, wrap: Boolean, listState: LazyListState, onRetry: () -> Unit, ) { when { state.loading -> { val loadingLabel = stringResource(R.string.loading) CircularProgressIndicator( Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel }, ) } state.error != null -> Column( modifier = Modifier.align(Alignment.Center).padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Text(state.error ?: "", color = MaterialTheme.colorScheme.error) Spacer(Modifier.size(12.dp)) TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) } } state.content?.isBinary == true -> Text( stringResource(R.string.binary_file), modifier = Modifier.align(Alignment.Center).padding(24.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, ) else -> FileViewContentBody( content = state.content, filename = filename, showToggle = showToggle, showDiff = showDiff, onSetShowDiff = onSetShowDiff, findActive = findActive, findQuery = findQuery, onQueryChange = onQueryChange, matchIndices = matchIndices, matchPos = matchPos, onPrev = onPrev, onNext = onNext, onCloseFind = onCloseFind, wrap = wrap, listState = listState, ) } } @Composable private fun BoxScope.FileViewContentBody( content: soy.iko.opencode.data.model.FileContent?, filename: String, showToggle: Boolean, showDiff: Boolean, onSetShowDiff: (Boolean) -> Unit, findActive: Boolean, findQuery: String, onQueryChange: (String) -> Unit, matchIndices: List, matchPos: Int, onPrev: () -> Unit, onNext: () -> Unit, onCloseFind: () -> Unit, wrap: Boolean, listState: LazyListState, ) { // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay // reachable while scrolling. Each contributes a top inset so the content below // isn't hidden behind the overlay. The inset is measured from the actual overlay // height (not a hardcoded 52dp/row) so accessibility font scaling — which can // make the FilterChip labels and FindBar text field taller than 52dp — doesn't // cause the first lines of content to hide behind the overlay. // The raw text is on screen whenever we're NOT rendering the diff — i.e. either the // file has no diff toggle at all (plain file: showToggle == false) or the toggle is // set to Raw. Gate the find bar on this rather than on !showDiff: for a plain file // showDiff stays at its `true` default forever (nothing ever flips it), so !showDiff // would keep the find bar — and therefore find-in-file — permanently hidden. val rawOnScreen = !(showToggle && showDiff) val overlayRows = (if (showToggle) 1 else 0) + (if (findActive && rawOnScreen) 1 else 0) var overlayHeightPx by remember { mutableIntStateOf(0) } // Only apply the measured inset while an overlay is actually composed. When overlayRows // drops to 0 the overlay Column is removed and onSizeChanged never fires to zero the // height, so keying the inset off overlayRows (rather than the stale measurement) avoids // a phantom top gap on the content below — e.g. after closing find on a plain file. val topInset = if (overlayRows > 0) { with(androidx.compose.ui.platform.LocalDensity.current) { overlayHeightPx.toDp() } } else 0.dp if (overlayRows > 0) { Column( modifier = Modifier .align(Alignment.TopCenter) .fillMaxWidth() .onSizeChanged { overlayHeightPx = it.height } .padding(top = 4.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { if (showToggle) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, ) { FilterChip( selected = showDiff, onClick = { onSetShowDiff(true) }, label = { Text(stringResource(R.string.show_diff)) }, ) Spacer(Modifier.size(8.dp)) FilterChip( selected = !showDiff, onClick = { onSetShowDiff(false) }, label = { Text(stringResource(R.string.show_raw)) }, ) } } if (findActive && rawOnScreen) { FindBar( query = findQuery, onQueryChange = onQueryChange, matchCount = matchIndices.size, matchPos = matchPos, onPrev = onPrev, onNext = onNext, onClose = onCloseFind, ) } } } if (showDiff && content?.diff != null && content.diff.isNotBlank()) { DiffView( diff = content.diff, modifier = Modifier .fillMaxSize() .padding(top = topInset) .verticalScroll(rememberScrollState()) .padding(8.dp), ) } else { val text = content?.content.orEmpty() if (text.isEmpty()) { Text( stringResource(R.string.empty_file), modifier = Modifier.align(Alignment.Center).padding(24.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { FileTextContent( text = text, filename = filename, topInset = topInset, wrap = wrap, findQuery = findQuery, matchIndices = matchIndices, listState = listState, ) } } } @Composable private fun FindBar( query: String, onQueryChange: (String) -> Unit, matchCount: Int, matchPos: Int, onPrev: () -> Unit, onNext: () -> Unit, onClose: () -> Unit, ) { val keyboardController = LocalSoftwareKeyboardController.current Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { OutlinedTextField( value = query, onValueChange = onQueryChange, modifier = Modifier.weight(1f), placeholder = { Text(stringResource(R.string.find_hint)) }, singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }), trailingIcon = { val countText = if (query.isBlank()) "" else if (matchCount == 0) stringResource(R.string.no_matches_in_file) else stringResource(R.string.match_count, matchPos + 1, matchCount) if (countText.isNotEmpty()) { // Use error color for "no matches" so the zero-result state is // distinguishable from a positive match count at a glance. val color = if (query.isNotBlank() && matchCount == 0) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant Text( countText, style = MaterialTheme.typography.labelSmall, color = color, ) } }, ) IconButton(onClick = onPrev, enabled = matchCount > 0) { Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_previous)) } IconButton(onClick = onNext, enabled = matchCount > 0) { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_next)) } IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close)) } } } @Composable private fun FileTextContent( text: String, filename: String, topInset: androidx.compose.ui.unit.Dp, wrap: Boolean, findQuery: String, matchIndices: List, listState: LazyListState, ) { val lines = remember(text) { text.split("\n") } // Gutter width scales with the digit count and the font scale so accessibility // text scaling (e.g. 1.3x) doesn't make line numbers overflow the gutter and // collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up // proportionally for larger font scales. val fontScale = androidx.compose.ui.platform.LocalDensity.current.fontScale val gutterWidth = remember(lines.size, fontScale) { ((lines.size.toString().length.coerceAtLeast(3) * 10) * fontScale.coerceAtLeast(1f)).dp } val hScrollState = rememberScrollState() val palette = rememberHighlightPalette() // Resolve the file's language once instead of re-parsing the extension for every line. val syntax = remember(filename) { syntaxFor(filename) } val q = findQuery.trim() val matchSet = remember(matchIndices) { matchIndices.toHashSet() } LazyColumn( state = listState, modifier = Modifier .fillMaxSize() .padding(top = topInset) .padding(8.dp), ) { itemsIndexed(lines, key = { index, _ -> index }) { index, line -> val isMatch = q.isNotEmpty() && index in matchSet Row( modifier = Modifier .fillMaxWidth() .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState)) .then(if (isMatch) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier), ) { Text( "${index + 1}", modifier = Modifier.width(gutterWidth), style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant, ) // Heuristic syntax highlighting per line. Falls back to plain text for // unknown extensions, so non-code files render unchanged. Memoized so the // O(n) tokenizer + AnnotatedString allocation runs only when the line text, // file, or palette actually changes — not on every recomposition (e.g. every // keystroke into find-in-file, which would otherwise re-highlight all visible lines). val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) } Text( highlighted, modifier = Modifier.padding(start = 8.dp), style = MaterialTheme.typography.bodySmall, ) } } } }