1package soy.iko.opencode.ui.file
3import android.content.Intent
4import soy.iko.opencode.ui.components.showToast
5import androidx.compose.animation.AnimatedContent
6import androidx.compose.animation.core.tween
7import androidx.compose.animation.fadeIn
8import androidx.compose.animation.fadeOut
9import androidx.compose.animation.togetherWith
10import androidx.compose.foundation.background
11import androidx.compose.foundation.horizontalScroll
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.verticalScroll
31import androidx.compose.material.icons.Icons
32import androidx.compose.material.icons.automirrored.filled.ArrowBack
33import androidx.compose.material.icons.automirrored.filled.WrapText
34import androidx.compose.material.icons.filled.Close
35import androidx.compose.material.icons.filled.ContentCopy
36import androidx.compose.material.icons.filled.KeyboardArrowDown
37import androidx.compose.material.icons.filled.KeyboardArrowUp
38import androidx.compose.material.icons.filled.Refresh
39import androidx.compose.material.icons.filled.Search
40import androidx.compose.material.icons.filled.Share
41import androidx.compose.material3.CircularProgressIndicator
42import androidx.compose.material3.ExperimentalMaterial3Api
43import androidx.compose.material3.FilterChip
44import androidx.compose.material3.Icon
45import androidx.compose.material3.IconButton
46import androidx.compose.material3.MaterialTheme
47import androidx.compose.material3.OutlinedTextField
48import androidx.compose.material3.Scaffold
49import androidx.compose.material3.Text
50import androidx.compose.material3.TextButton
51import androidx.compose.material3.TopAppBar
52import androidx.compose.runtime.Composable
53import androidx.compose.runtime.LaunchedEffect
54import androidx.compose.runtime.getValue
55import androidx.compose.runtime.mutableIntStateOf
56import androidx.compose.runtime.mutableStateOf
57import androidx.compose.runtime.produceState
58import androidx.compose.runtime.remember
59import androidx.compose.runtime.saveable.rememberSaveable
60import androidx.compose.runtime.setValue
61import androidx.compose.ui.Alignment
62import androidx.compose.ui.Modifier
63import androidx.compose.ui.layout.onSizeChanged
64import androidx.compose.ui.platform.LocalContext
65import androidx.compose.ui.platform.LocalDensity
66import androidx.compose.ui.platform.LocalSoftwareKeyboardController
67import androidx.compose.ui.semantics.contentDescription
68import androidx.compose.ui.semantics.semantics
69import androidx.compose.ui.text.font.FontFamily
70import androidx.compose.ui.text.input.ImeAction
71import androidx.compose.ui.text.style.TextOverflow
72import androidx.compose.ui.unit.dp
73import androidx.compose.ui.res.stringResource
74import androidx.lifecycle.compose.collectAsStateWithLifecycle
75import androidx.lifecycle.viewmodel.compose.viewModel
76import soy.iko.opencode.di.AppContainer
77import soy.iko.opencode.R
78import soy.iko.opencode.ui.components.DiffView
79import soy.iko.opencode.ui.components.copyToClipboard
80import soy.iko.opencode.ui.components.highlightLine
81import soy.iko.opencode.ui.components.syntaxFor
82import soy.iko.opencode.ui.components.rememberHighlightPalette
83import soy.iko.opencode.ui.vmFactory
84import soy.iko.opencode.util.runCatchingCancellable
85import kotlinx.coroutines.Dispatchers
86import kotlinx.coroutines.delay
87import kotlinx.coroutines.withContext
89@OptIn(ExperimentalMaterial3Api::class)
92 container: AppContainer,
95 initialLine: Int? = null,
97 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
98 val state by vm.state.collectAsStateWithLifecycle()
99 val context = LocalContext.current
100 val filename = remember(path) {
101 val trimmed = path.trimEnd('/')
102 trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } }
104 val shareLabel = stringResource(R.string.share)
105 // Diff vs raw view toggle. Defaults to showing the diff when one exists; the user can
106 // switch to the raw new content. Persisted via rememberSaveable across rotation. When the
107 // viewer is opened at a specific line (from a search hit), start on the raw view so the
108 // targeted line is actually rendered (the diff view has no line list to scroll).
109 val hasDiff = state.content?.diff != null && state.content?.diff?.isNotBlank() == true
110 var showDiff by rememberSaveable(path) { mutableStateOf(initialLine == null) }
111 val showToggle = hasDiff && state.content?.content.orEmpty().isNotEmpty()
112 // Find-in-file and line-wrapping state, persisted so a rotation or reload keeps the
113 // user's query/mode.
114 var findActive by rememberSaveable(path) { mutableStateOf(false) }
115 var findQuery by rememberSaveable(path) { mutableStateOf("") }
116 var matchPos by rememberSaveable(path) { mutableIntStateOf(0) }
117 var wrap by rememberSaveable(path) { mutableStateOf(false) }
118 val listState = rememberLazyListState()
119 val rawText = state.content?.content.orEmpty()
120 val lines = remember(rawText) { rawText.split("\n") }
121 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
122 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
123 var debouncedFind by remember { mutableStateOf("") }
124 LaunchedEffect(findQuery) {
125 val q = findQuery.trim()
126 if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q }
128 val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind) {
129 val q = debouncedFind
130 value = if (q.isEmpty()) emptyList()
131 else withContext(Dispatchers.Default) {
132 lines.mapIndexedNotNull { index, line -> index.takeIf { line.contains(q, ignoreCase = true) } }
135 // Keep the current match index in range when the match set shrinks (e.g. the file
136 // reloaded with fewer matches), so the counter never shows a stale "10 / 3".
137 LaunchedEffect(matchIndices.size) {
138 matchPos = matchPos.coerceIn(0, (matchIndices.size - 1).coerceAtLeast(0))
140 // Scroll to the current match whenever it (or the match set) changes.
141 LaunchedEffect(matchPos, matchIndices) {
142 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
144 // Opened at a specific line (from a search hit): scroll there once the raw content loads.
145 // One-shot so a later find-in-file navigation isn't overridden.
146 var didInitialLineScroll by rememberSaveable(path) { mutableStateOf(false) }
147 LaunchedEffect(state.content, initialLine) {
148 val line = initialLine ?: return@LaunchedEffect
149 if (didInitialLineScroll) return@LaunchedEffect
150 val content = state.content ?: return@LaunchedEffect
151 if (content.isBinary) return@LaunchedEffect
152 val target = (line - 1).coerceIn(0, (lines.size - 1).coerceAtLeast(0))
153 runCatchingCancellable { listState.scrollToItem(target) }
154 didInitialLineScroll = true
161 loading = state.loading,
164 inDiffMode = showToggle && showDiff,
166 onReload = { vm.reload() },
167 onToggleFind = { findActive = !findActive; if (!findActive) findQuery = "" },
168 onToggleWrap = { wrap = !wrap },
169 onCopy = { copyToClipboard(context, filename, rawText) },
171 val send = Intent(Intent.ACTION_SEND).apply {
173 putExtra(Intent.EXTRA_SUBJECT, filename)
174 putExtra(Intent.EXTRA_TEXT, rawText)
176 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
177 .onFailure { showToast(context, context.getString(R.string.no_share_app)) }
182 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
183 // AnimatedContent cross-fades between loading / error / content states so the
184 // viewer doesn't snap abruptly when a reload finishes or fails.
187 transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
188 label = "fileViewState",
190 // Single source of truth for matches: the off-thread `matchIndices` produceState
191 // (keyed on the file content + debounced query). Both navigation (onPrev/onNext +
192 // scroll) and display (counter + highlight) read from it, so the counter can't show
193 // a new query while next/prev act on a stale set. The earlier in-composition
194 // per-layer scan re-ran the whole-file O(n) match on the UI thread each keystroke
195 // (the exact work produceState was moved off-thread to avoid); it's acceptable for
196 // the crossfade's exiting layer to briefly use this unified set until it settles.
197 FileViewStateContent(
200 showToggle = showToggle,
204 // Close any open find bar when entering diff mode: find searches
205 // the raw content and scrolls the line LazyColumn, neither of
206 // which is visible in diff mode, so leaving it open would be a
208 if (it) { findActive = false; findQuery = "" }
210 findActive = findActive,
211 findQuery = findQuery,
212 onQueryChange = { findQuery = it; matchPos = 0 },
213 matchIndices = matchIndices,
215 onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
216 onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
217 onCloseFind = { findActive = false; findQuery = "" },
219 listState = listState,
220 onRetry = { vm.reload() },
227@OptIn(ExperimentalMaterial3Api::class)
229private fun FileViewTopBar(
236 onReload: () -> Unit,
237 onToggleFind: () -> Unit,
238 onToggleWrap: () -> Unit,
243 title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
245 IconButton(onClick = onBack) {
246 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
250 // In-place refresh: a file's content can change on the server (the agent may
251 // be editing it), so reload without requiring a back-out.
252 IconButton(onClick = onReload, enabled = !loading) {
253 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
255 if (content.isNotEmpty()) {
256 // Find-in-file: toggles a search bar over the raw content. Disabled only when
257 // the DiffView is actually on screen ([inDiffMode]) — the find bar searches the
258 // raw text and scrolls the line LazyColumn, neither of which is visible under a
259 // diff, so find would silently no-op there. A plain file with no diff (the common
260 // case) shows raw text, so find must stay enabled. The user can switch to Raw to find.
261 IconButton(onClick = onToggleFind, enabled = !inDiffMode) {
262 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
264 // Wrap long lines instead of horizontal-scrolling, useful for prose.
265 IconButton(onClick = onToggleWrap) {
267 Icons.AutoMirrored.Filled.WrapText,
268 contentDescription = stringResource(R.string.wrap_lines),
269 tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
272 IconButton(onClick = onCopy) {
273 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
275 IconButton(onClick = onShare) {
276 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
284private fun BoxScope.FileViewStateContent(
285 state: FileViewState,
289 onSetShowDiff: (Boolean) -> Unit,
292 onQueryChange: (String) -> Unit,
293 matchIndices: List<Int>,
297 onCloseFind: () -> Unit,
299 listState: LazyListState,
304 val loadingLabel = stringResource(R.string.loading)
305 CircularProgressIndicator(
306 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
309 state.error != null -> Column(
310 modifier = Modifier.align(Alignment.Center).padding(24.dp),
311 horizontalAlignment = Alignment.CenterHorizontally,
313 Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
314 Spacer(Modifier.size(12.dp))
315 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
317 state.content?.isBinary == true -> Text(
318 stringResource(R.string.binary_file),
319 modifier = Modifier.align(Alignment.Center).padding(24.dp),
320 color = MaterialTheme.colorScheme.onSurfaceVariant,
322 else -> FileViewContentBody(
323 content = state.content,
325 showToggle = showToggle,
327 onSetShowDiff = onSetShowDiff,
328 findActive = findActive,
329 findQuery = findQuery,
330 onQueryChange = onQueryChange,
331 matchIndices = matchIndices,
335 onCloseFind = onCloseFind,
337 listState = listState,
343private fun BoxScope.FileViewContentBody(
344 content: soy.iko.opencode.data.model.FileContent?,
348 onSetShowDiff: (Boolean) -> Unit,
351 onQueryChange: (String) -> Unit,
352 matchIndices: List<Int>,
356 onCloseFind: () -> Unit,
358 listState: LazyListState,
360 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
361 // reachable while scrolling. Each contributes a top inset so the content below
362 // isn't hidden behind the overlay. The inset is measured from the actual overlay
363 // height (not a hardcoded 52dp/row) so accessibility font scaling — which can
364 // make the FilterChip labels and FindBar text field taller than 52dp — doesn't
365 // cause the first lines of content to hide behind the overlay.
366 // The raw text is on screen whenever we're NOT rendering the diff — i.e. either the
367 // file has no diff toggle at all (plain file: showToggle == false) or the toggle is
368 // set to Raw. Gate the find bar on this rather than on !showDiff: for a plain file
369 // showDiff stays at its `true` default forever (nothing ever flips it), so !showDiff
370 // would keep the find bar — and therefore find-in-file — permanently hidden.
371 val rawOnScreen = !(showToggle && showDiff)
372 val overlayRows = (if (showToggle) 1 else 0) + (if (findActive && rawOnScreen) 1 else 0)
373 var overlayHeightPx by remember { mutableIntStateOf(0) }
374 // Only apply the measured inset while an overlay is actually composed. When overlayRows
375 // drops to 0 the overlay Column is removed and onSizeChanged never fires to zero the
376 // height, so keying the inset off overlayRows (rather than the stale measurement) avoids
377 // a phantom top gap on the content below — e.g. after closing find on a plain file.
378 val topInset = if (overlayRows > 0) {
379 with(androidx.compose.ui.platform.LocalDensity.current) { overlayHeightPx.toDp() }
381 if (overlayRows > 0) {
384 .align(Alignment.TopCenter)
386 .onSizeChanged { overlayHeightPx = it.height }
387 .padding(top = 4.dp),
388 verticalArrangement = Arrangement.spacedBy(4.dp),
392 modifier = Modifier.fillMaxWidth(),
393 horizontalArrangement = Arrangement.Center,
397 onClick = { onSetShowDiff(true) },
398 label = { Text(stringResource(R.string.show_diff)) },
400 Spacer(Modifier.size(8.dp))
402 selected = !showDiff,
403 onClick = { onSetShowDiff(false) },
404 label = { Text(stringResource(R.string.show_raw)) },
408 if (findActive && rawOnScreen) {
411 onQueryChange = onQueryChange,
412 matchCount = matchIndices.size,
416 onClose = onCloseFind,
421 if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
426 .padding(top = topInset)
427 .verticalScroll(rememberScrollState())
431 val text = content?.content.orEmpty()
432 if (text.isEmpty()) {
434 stringResource(R.string.empty_file),
435 modifier = Modifier.align(Alignment.Center).padding(24.dp),
436 color = MaterialTheme.colorScheme.onSurfaceVariant,
444 findQuery = findQuery,
445 matchIndices = matchIndices,
446 listState = listState,
455 onQueryChange: (String) -> Unit,
462 val keyboardController = LocalSoftwareKeyboardController.current
464 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
465 verticalAlignment = Alignment.CenterVertically,
469 onValueChange = onQueryChange,
470 modifier = Modifier.weight(1f),
471 placeholder = { Text(stringResource(R.string.find_hint)) },
473 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
474 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
476 val countText = if (query.isBlank()) ""
477 else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
478 else stringResource(R.string.match_count, matchPos + 1, matchCount)
479 if (countText.isNotEmpty()) {
480 // Use error color for "no matches" so the zero-result state is
481 // distinguishable from a positive match count at a glance.
482 val color = if (query.isNotBlank() && matchCount == 0) MaterialTheme.colorScheme.error
483 else MaterialTheme.colorScheme.onSurfaceVariant
486 style = MaterialTheme.typography.labelSmall,
492 IconButton(onClick = onPrev, enabled = matchCount > 0) {
493 Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_previous))
495 IconButton(onClick = onNext, enabled = matchCount > 0) {
496 Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_next))
498 IconButton(onClick = onClose) {
499 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
505private fun FileTextContent(
508 topInset: androidx.compose.ui.unit.Dp,
511 matchIndices: List<Int>,
512 listState: LazyListState,
514 val lines = remember(text) { text.split("\n") }
515 // Gutter width scales with the digit count and the font scale so accessibility
516 // text scaling (e.g. 1.3x) doesn't make line numbers overflow the gutter and
517 // collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
518 // proportionally for larger font scales.
519 val fontScale = androidx.compose.ui.platform.LocalDensity.current.fontScale
520 val gutterWidth = remember(lines.size, fontScale) {
521 ((lines.size.toString().length.coerceAtLeast(3) * 10) * fontScale.coerceAtLeast(1f)).dp
523 val hScrollState = rememberScrollState()
524 val palette = rememberHighlightPalette()
525 // Resolve the file's language once instead of re-parsing the extension for every line.
526 val syntax = remember(filename) { syntaxFor(filename) }
527 val q = findQuery.trim()
528 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
533 .padding(top = topInset)
536 itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
537 val isMatch = q.isNotEmpty() && index in matchSet
541 .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
542 .then(if (isMatch) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
546 modifier = Modifier.width(gutterWidth),
547 style = MaterialTheme.typography.bodySmall,
548 fontFamily = FontFamily.Monospace,
549 color = MaterialTheme.colorScheme.onSurfaceVariant,
551 // Heuristic syntax highlighting per line. Falls back to plain text for
552 // unknown extensions, so non-code files render unchanged. Memoized so the
553 // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
554 // file, or palette actually changes — not on every recomposition (e.g. every
555 // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
556 val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
559 modifier = Modifier.padding(start = 8.dp),
560 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