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 // Derive match indices from THIS layer's content (s), not the outer/newest
191 // state. During the crossfade the exiting layer still renders the old
192 // content; passing the outer matchIndices (computed from the new content)
193 // would highlight and scroll the wrong lines until the animation settles.
194 val sMatchIndices = remember(s.content?.content, debouncedFind, lines, rawText) {
195 val q = debouncedFind
196 if (q.isEmpty()) emptyList()
198 // Reuse the already-split `lines` when this layer shows the current
199 // content (the common case); only re-split for the exiting layer's
200 // stale content during the crossfade.
201 val sContent = s.content?.content
202 val sLines = if (sContent == rawText) lines else sContent.orEmpty().split("\n")
203 sLines.mapIndexedNotNull { index, line -> index.takeIf { line.contains(q, ignoreCase = true) } }
206 FileViewStateContent(
209 showToggle = showToggle,
213 // Close any open find bar when entering diff mode: find searches
214 // the raw content and scrolls the line LazyColumn, neither of
215 // which is visible in diff mode, so leaving it open would be a
217 if (it) { findActive = false; findQuery = "" }
219 findActive = findActive,
220 findQuery = findQuery,
221 onQueryChange = { findQuery = it; matchPos = 0 },
222 matchIndices = sMatchIndices,
224 onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
225 onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
226 onCloseFind = { findActive = false; findQuery = "" },
228 listState = listState,
229 onRetry = { vm.reload() },
236@OptIn(ExperimentalMaterial3Api::class)
238private fun FileViewTopBar(
245 onReload: () -> Unit,
246 onToggleFind: () -> Unit,
247 onToggleWrap: () -> Unit,
252 title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
254 IconButton(onClick = onBack) {
255 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
259 // In-place refresh: a file's content can change on the server (the agent may
260 // be editing it), so reload without requiring a back-out.
261 IconButton(onClick = onReload, enabled = !loading) {
262 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
264 if (content.isNotEmpty()) {
265 // Find-in-file: toggles a search bar over the raw content. Disabled only when
266 // the DiffView is actually on screen ([inDiffMode]) — the find bar searches the
267 // raw text and scrolls the line LazyColumn, neither of which is visible under a
268 // diff, so find would silently no-op there. A plain file with no diff (the common
269 // case) shows raw text, so find must stay enabled. The user can switch to Raw to find.
270 IconButton(onClick = onToggleFind, enabled = !inDiffMode) {
271 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
273 // Wrap long lines instead of horizontal-scrolling, useful for prose.
274 IconButton(onClick = onToggleWrap) {
276 Icons.AutoMirrored.Filled.WrapText,
277 contentDescription = stringResource(R.string.wrap_lines),
278 tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
281 IconButton(onClick = onCopy) {
282 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
284 IconButton(onClick = onShare) {
285 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
293private fun BoxScope.FileViewStateContent(
294 state: FileViewState,
298 onSetShowDiff: (Boolean) -> Unit,
301 onQueryChange: (String) -> Unit,
302 matchIndices: List<Int>,
306 onCloseFind: () -> Unit,
308 listState: LazyListState,
313 val loadingLabel = stringResource(R.string.loading)
314 CircularProgressIndicator(
315 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
318 state.error != null -> Column(
319 modifier = Modifier.align(Alignment.Center).padding(24.dp),
320 horizontalAlignment = Alignment.CenterHorizontally,
322 Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
323 Spacer(Modifier.size(12.dp))
324 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
326 state.content?.isBinary == true -> Text(
327 stringResource(R.string.binary_file),
328 modifier = Modifier.align(Alignment.Center).padding(24.dp),
329 color = MaterialTheme.colorScheme.onSurfaceVariant,
331 else -> FileViewContentBody(
332 content = state.content,
334 showToggle = showToggle,
336 onSetShowDiff = onSetShowDiff,
337 findActive = findActive,
338 findQuery = findQuery,
339 onQueryChange = onQueryChange,
340 matchIndices = matchIndices,
344 onCloseFind = onCloseFind,
346 listState = listState,
352private fun BoxScope.FileViewContentBody(
353 content: soy.iko.opencode.data.model.FileContent?,
357 onSetShowDiff: (Boolean) -> Unit,
360 onQueryChange: (String) -> Unit,
361 matchIndices: List<Int>,
365 onCloseFind: () -> Unit,
367 listState: LazyListState,
369 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
370 // reachable while scrolling. Each contributes a top inset so the content below
371 // isn't hidden behind the overlay. The inset is measured from the actual overlay
372 // height (not a hardcoded 52dp/row) so accessibility font scaling — which can
373 // make the FilterChip labels and FindBar text field taller than 52dp — doesn't
374 // cause the first lines of content to hide behind the overlay.
375 // The raw text is on screen whenever we're NOT rendering the diff — i.e. either the
376 // file has no diff toggle at all (plain file: showToggle == false) or the toggle is
377 // set to Raw. Gate the find bar on this rather than on !showDiff: for a plain file
378 // showDiff stays at its `true` default forever (nothing ever flips it), so !showDiff
379 // would keep the find bar — and therefore find-in-file — permanently hidden.
380 val rawOnScreen = !(showToggle && showDiff)
381 val overlayRows = (if (showToggle) 1 else 0) + (if (findActive && rawOnScreen) 1 else 0)
382 var overlayHeightPx by remember { mutableIntStateOf(0) }
383 // Only apply the measured inset while an overlay is actually composed. When overlayRows
384 // drops to 0 the overlay Column is removed and onSizeChanged never fires to zero the
385 // height, so keying the inset off overlayRows (rather than the stale measurement) avoids
386 // a phantom top gap on the content below — e.g. after closing find on a plain file.
387 val topInset = if (overlayRows > 0) {
388 with(androidx.compose.ui.platform.LocalDensity.current) { overlayHeightPx.toDp() }
390 if (overlayRows > 0) {
393 .align(Alignment.TopCenter)
395 .onSizeChanged { overlayHeightPx = it.height }
396 .padding(top = 4.dp),
397 verticalArrangement = Arrangement.spacedBy(4.dp),
401 modifier = Modifier.fillMaxWidth(),
402 horizontalArrangement = Arrangement.Center,
406 onClick = { onSetShowDiff(true) },
407 label = { Text(stringResource(R.string.show_diff)) },
409 Spacer(Modifier.size(8.dp))
411 selected = !showDiff,
412 onClick = { onSetShowDiff(false) },
413 label = { Text(stringResource(R.string.show_raw)) },
417 if (findActive && rawOnScreen) {
420 onQueryChange = onQueryChange,
421 matchCount = matchIndices.size,
425 onClose = onCloseFind,
430 if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
435 .padding(top = topInset)
436 .verticalScroll(rememberScrollState())
440 val text = content?.content.orEmpty()
441 if (text.isEmpty()) {
443 stringResource(R.string.empty_file),
444 modifier = Modifier.align(Alignment.Center).padding(24.dp),
445 color = MaterialTheme.colorScheme.onSurfaceVariant,
453 findQuery = findQuery,
454 matchIndices = matchIndices,
455 listState = listState,
464 onQueryChange: (String) -> Unit,
471 val keyboardController = LocalSoftwareKeyboardController.current
473 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
474 verticalAlignment = Alignment.CenterVertically,
478 onValueChange = onQueryChange,
479 modifier = Modifier.weight(1f),
480 placeholder = { Text(stringResource(R.string.find_hint)) },
482 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
483 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
485 val countText = if (query.isBlank()) ""
486 else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
487 else stringResource(R.string.match_count, matchPos + 1, matchCount)
488 if (countText.isNotEmpty()) {
489 // Use error color for "no matches" so the zero-result state is
490 // distinguishable from a positive match count at a glance.
491 val color = if (query.isNotBlank() && matchCount == 0) MaterialTheme.colorScheme.error
492 else MaterialTheme.colorScheme.onSurfaceVariant
495 style = MaterialTheme.typography.labelSmall,
501 IconButton(onClick = onPrev, enabled = matchCount > 0) {
502 Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_previous))
504 IconButton(onClick = onNext, enabled = matchCount > 0) {
505 Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_next))
507 IconButton(onClick = onClose) {
508 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
514private fun FileTextContent(
517 topInset: androidx.compose.ui.unit.Dp,
520 matchIndices: List<Int>,
521 listState: LazyListState,
523 val lines = remember(text) { text.split("\n") }
524 // Gutter width scales with the digit count and the font scale so accessibility
525 // text scaling (e.g. 1.3x) doesn't make line numbers overflow the gutter and
526 // collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
527 // proportionally for larger font scales.
528 val fontScale = androidx.compose.ui.platform.LocalDensity.current.fontScale
529 val gutterWidth = remember(lines.size, fontScale) {
530 ((lines.size.toString().length.coerceAtLeast(3) * 10) * fontScale.coerceAtLeast(1f)).dp
532 val hScrollState = rememberScrollState()
533 val palette = rememberHighlightPalette()
534 // Resolve the file's language once instead of re-parsing the extension for every line.
535 val syntax = remember(filename) { syntaxFor(filename) }
536 val q = findQuery.trim()
537 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
542 .padding(top = topInset)
545 itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
546 val isMatch = q.isNotEmpty() && index in matchSet
550 .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
551 .then(if (isMatch) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
555 modifier = Modifier.width(gutterWidth),
556 style = MaterialTheme.typography.bodySmall,
557 fontFamily = FontFamily.Monospace,
558 color = MaterialTheme.colorScheme.onSurfaceVariant,
560 // Heuristic syntax highlighting per line. Falls back to plain text for
561 // unknown extensions, so non-code files render unchanged. Memoized so the
562 // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
563 // file, or palette actually changes — not on every recomposition (e.g. every
564 // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
565 val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
568 modifier = Modifier.padding(start = 8.dp),
569 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