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.remember
58import androidx.compose.runtime.saveable.rememberSaveable
59import androidx.compose.runtime.setValue
60import androidx.compose.ui.Alignment
61import androidx.compose.ui.Modifier
62import androidx.compose.ui.layout.onSizeChanged
63import androidx.compose.ui.platform.LocalContext
64import androidx.compose.ui.platform.LocalDensity
65import androidx.compose.ui.platform.LocalSoftwareKeyboardController
66import androidx.compose.ui.semantics.contentDescription
67import androidx.compose.ui.semantics.semantics
68import androidx.compose.ui.text.font.FontFamily
69import androidx.compose.ui.text.input.ImeAction
70import androidx.compose.ui.text.style.TextOverflow
71import androidx.compose.ui.unit.dp
72import androidx.compose.ui.res.stringResource
73import androidx.lifecycle.compose.collectAsStateWithLifecycle
74import androidx.lifecycle.viewmodel.compose.viewModel
75import soy.iko.opencode.di.AppContainer
76import soy.iko.opencode.R
77import soy.iko.opencode.ui.components.DiffView
78import soy.iko.opencode.ui.components.copyToClipboard
79import soy.iko.opencode.ui.components.highlightLine
80import soy.iko.opencode.ui.components.rememberHighlightPalette
81import soy.iko.opencode.ui.vmFactory
82import soy.iko.opencode.util.runCatchingCancellable
84@OptIn(ExperimentalMaterial3Api::class)
87 container: AppContainer,
91 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
92 val state by vm.state.collectAsStateWithLifecycle()
93 val context = LocalContext.current
94 val filename = remember(path) {
95 val trimmed = path.trimEnd('/')
96 trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } }
98 val shareLabel = stringResource(R.string.share)
99 // Diff vs raw view toggle. Defaults to showing the diff when one exists; the user can
100 // switch to the raw new content. Persisted via rememberSaveable across rotation.
101 val hasDiff = state.content?.diff != null && state.content?.diff?.isNotBlank() == true
102 var showDiff by rememberSaveable(path) { mutableStateOf(true) }
103 val showToggle = hasDiff && state.content?.content.orEmpty().isNotEmpty()
104 // Find-in-file and line-wrapping state, persisted so a rotation or reload keeps the
105 // user's query/mode.
106 var findActive by rememberSaveable(path) { mutableStateOf(false) }
107 var findQuery by rememberSaveable(path) { mutableStateOf("") }
108 var matchPos by rememberSaveable(path) { mutableIntStateOf(0) }
109 var wrap by rememberSaveable(path) { mutableStateOf(false) }
110 val listState = rememberLazyListState()
111 val rawText = state.content?.content.orEmpty()
112 val lines = remember(rawText) { rawText.split("\n") }
113 val matchIndices = remember(lines, findQuery) {
114 val q = findQuery.trim()
115 if (q.isEmpty()) emptyList()
116 else lines.mapIndexed { index, line -> if (line.contains(q, ignoreCase = true)) index else -1 }
119 // Keep the current match index in range when the match set shrinks (e.g. the file
120 // reloaded with fewer matches), so the counter never shows a stale "10 / 3".
121 LaunchedEffect(matchIndices.size) {
122 matchPos = matchPos.coerceIn(0, (matchIndices.size - 1).coerceAtLeast(0))
124 // Scroll to the current match whenever it (or the match set) changes.
125 LaunchedEffect(matchPos, matchIndices) {
126 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
133 loading = state.loading,
136 inDiffMode = showToggle && showDiff,
138 onReload = { vm.reload() },
139 onToggleFind = { findActive = !findActive; if (!findActive) findQuery = "" },
140 onToggleWrap = { wrap = !wrap },
141 onCopy = { copyToClipboard(context, filename, rawText) },
143 val send = Intent(Intent.ACTION_SEND).apply {
145 putExtra(Intent.EXTRA_SUBJECT, filename)
146 putExtra(Intent.EXTRA_TEXT, rawText)
148 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
149 .onFailure { showToast(context, context.getString(R.string.no_share_app)) }
154 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
155 // AnimatedContent cross-fades between loading / error / content states so the
156 // viewer doesn't snap abruptly when a reload finishes or fails.
159 transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
160 label = "fileViewState",
162 // Derive match indices from THIS layer's content (s), not the outer/newest
163 // state. During the crossfade the exiting layer still renders the old
164 // content; passing the outer matchIndices (computed from the new content)
165 // would highlight and scroll the wrong lines until the animation settles.
166 val sMatchIndices = remember(s.content?.content, findQuery) {
167 val q = findQuery.trim()
168 if (q.isEmpty()) emptyList()
169 else (s.content?.content.orEmpty()).split("\n")
170 .mapIndexed { index, line -> if (line.contains(q, ignoreCase = true)) index else -1 }
173 FileViewStateContent(
176 showToggle = showToggle,
180 // Close any open find bar when entering diff mode: find searches
181 // the raw content and scrolls the line LazyColumn, neither of
182 // which is visible in diff mode, so leaving it open would be a
184 if (it) { findActive = false; findQuery = "" }
186 findActive = findActive,
187 findQuery = findQuery,
188 onQueryChange = { findQuery = it; matchPos = 0 },
189 matchIndices = sMatchIndices,
191 onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
192 onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
193 onCloseFind = { findActive = false; findQuery = "" },
195 listState = listState,
196 onRetry = { vm.reload() },
203@OptIn(ExperimentalMaterial3Api::class)
205private fun FileViewTopBar(
212 onReload: () -> Unit,
213 onToggleFind: () -> Unit,
214 onToggleWrap: () -> Unit,
219 title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
221 IconButton(onClick = onBack) {
222 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
226 // In-place refresh: a file's content can change on the server (the agent may
227 // be editing it), so reload without requiring a back-out.
228 IconButton(onClick = onReload, enabled = !loading) {
229 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
231 if (content.isNotEmpty()) {
232 // Find-in-file: toggles a search bar over the raw content. Disabled only when
233 // the DiffView is actually on screen ([inDiffMode]) — the find bar searches the
234 // raw text and scrolls the line LazyColumn, neither of which is visible under a
235 // diff, so find would silently no-op there. A plain file with no diff (the common
236 // case) shows raw text, so find must stay enabled. The user can switch to Raw to find.
237 IconButton(onClick = onToggleFind, enabled = !inDiffMode) {
238 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
240 // Wrap long lines instead of horizontal-scrolling, useful for prose.
241 IconButton(onClick = onToggleWrap) {
243 Icons.AutoMirrored.Filled.WrapText,
244 contentDescription = stringResource(R.string.wrap_lines),
245 tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
248 IconButton(onClick = onCopy) {
249 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
251 IconButton(onClick = onShare) {
252 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
260private fun BoxScope.FileViewStateContent(
261 state: FileViewState,
265 onSetShowDiff: (Boolean) -> Unit,
268 onQueryChange: (String) -> Unit,
269 matchIndices: List<Int>,
273 onCloseFind: () -> Unit,
275 listState: LazyListState,
280 val loadingLabel = stringResource(R.string.loading)
281 CircularProgressIndicator(
282 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
285 state.error != null -> Column(
286 modifier = Modifier.align(Alignment.Center).padding(24.dp),
287 horizontalAlignment = Alignment.CenterHorizontally,
289 Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
290 Spacer(Modifier.size(12.dp))
291 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
293 state.content?.isBinary == true -> Text(
294 stringResource(R.string.binary_file),
295 modifier = Modifier.align(Alignment.Center).padding(24.dp),
296 color = MaterialTheme.colorScheme.onSurfaceVariant,
298 else -> FileViewContentBody(
299 content = state.content,
301 showToggle = showToggle,
303 onSetShowDiff = onSetShowDiff,
304 findActive = findActive,
305 findQuery = findQuery,
306 onQueryChange = onQueryChange,
307 matchIndices = matchIndices,
311 onCloseFind = onCloseFind,
313 listState = listState,
319private fun BoxScope.FileViewContentBody(
320 content: soy.iko.opencode.data.model.FileContent?,
324 onSetShowDiff: (Boolean) -> Unit,
327 onQueryChange: (String) -> Unit,
328 matchIndices: List<Int>,
332 onCloseFind: () -> Unit,
334 listState: LazyListState,
336 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
337 // reachable while scrolling. Each contributes a top inset so the content below
338 // isn't hidden behind the overlay. The inset is measured from the actual overlay
339 // height (not a hardcoded 52dp/row) so accessibility font scaling — which can
340 // make the FilterChip labels and FindBar text field taller than 52dp — doesn't
341 // cause the first lines of content to hide behind the overlay.
342 // The raw text is on screen whenever we're NOT rendering the diff — i.e. either the
343 // file has no diff toggle at all (plain file: showToggle == false) or the toggle is
344 // set to Raw. Gate the find bar on this rather than on !showDiff: for a plain file
345 // showDiff stays at its `true` default forever (nothing ever flips it), so !showDiff
346 // would keep the find bar — and therefore find-in-file — permanently hidden.
347 val rawOnScreen = !(showToggle && showDiff)
348 val overlayRows = (if (showToggle) 1 else 0) + (if (findActive && rawOnScreen) 1 else 0)
349 var overlayHeightPx by remember { mutableIntStateOf(0) }
350 // Only apply the measured inset while an overlay is actually composed. When overlayRows
351 // drops to 0 the overlay Column is removed and onSizeChanged never fires to zero the
352 // height, so keying the inset off overlayRows (rather than the stale measurement) avoids
353 // a phantom top gap on the content below — e.g. after closing find on a plain file.
354 val topInset = if (overlayRows > 0) {
355 with(androidx.compose.ui.platform.LocalDensity.current) { overlayHeightPx.toDp() }
357 if (overlayRows > 0) {
360 .align(Alignment.TopCenter)
362 .onSizeChanged { overlayHeightPx = it.height }
363 .padding(top = 4.dp),
364 verticalArrangement = Arrangement.spacedBy(4.dp),
368 modifier = Modifier.fillMaxWidth(),
369 horizontalArrangement = Arrangement.Center,
373 onClick = { onSetShowDiff(true) },
374 label = { Text(stringResource(R.string.show_diff)) },
376 Spacer(Modifier.size(8.dp))
378 selected = !showDiff,
379 onClick = { onSetShowDiff(false) },
380 label = { Text(stringResource(R.string.show_raw)) },
384 if (findActive && rawOnScreen) {
387 onQueryChange = onQueryChange,
388 matchCount = matchIndices.size,
392 onClose = onCloseFind,
397 if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
402 .padding(top = topInset)
403 .verticalScroll(rememberScrollState())
407 val text = content?.content.orEmpty()
408 if (text.isEmpty()) {
410 stringResource(R.string.empty_file),
411 modifier = Modifier.align(Alignment.Center).padding(24.dp),
412 color = MaterialTheme.colorScheme.onSurfaceVariant,
420 findQuery = findQuery,
421 matchIndices = matchIndices,
422 listState = listState,
431 onQueryChange: (String) -> Unit,
438 val keyboardController = LocalSoftwareKeyboardController.current
440 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
441 verticalAlignment = Alignment.CenterVertically,
445 onValueChange = onQueryChange,
446 modifier = Modifier.weight(1f),
447 placeholder = { Text(stringResource(R.string.find_hint)) },
449 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
450 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
452 val countText = if (query.isBlank()) ""
453 else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
454 else stringResource(R.string.match_count, matchPos + 1, matchCount)
455 if (countText.isNotEmpty()) {
456 // Use error color for "no matches" so the zero-result state is
457 // distinguishable from a positive match count at a glance.
458 val color = if (query.isNotBlank() && matchCount == 0) MaterialTheme.colorScheme.error
459 else MaterialTheme.colorScheme.onSurfaceVariant
462 style = MaterialTheme.typography.labelSmall,
468 IconButton(onClick = onPrev, enabled = matchCount > 0) {
469 Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_previous))
471 IconButton(onClick = onNext, enabled = matchCount > 0) {
472 Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_next))
474 IconButton(onClick = onClose) {
475 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
481private fun FileTextContent(
484 topInset: androidx.compose.ui.unit.Dp,
487 matchIndices: List<Int>,
488 listState: LazyListState,
490 val lines = remember(text) { text.split("\n") }
491 // Gutter width scales with the digit count and the font scale so accessibility
492 // text scaling (e.g. 1.3x) doesn't make line numbers overflow the gutter and
493 // collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
494 // proportionally for larger font scales.
495 val fontScale = androidx.compose.ui.platform.LocalDensity.current.fontScale
496 val gutterWidth = remember(lines.size, fontScale) {
497 ((lines.size.toString().length.coerceAtLeast(3) * 10) * fontScale.coerceAtLeast(1f)).dp
499 val hScrollState = rememberScrollState()
500 val palette = rememberHighlightPalette()
501 val q = findQuery.trim()
502 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
507 .padding(top = topInset)
510 itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
511 val isMatch = q.isNotEmpty() && index in matchSet
515 .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
516 .then(if (isMatch) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
520 modifier = Modifier.width(gutterWidth),
521 style = MaterialTheme.typography.bodySmall,
522 fontFamily = FontFamily.Monospace,
523 color = MaterialTheme.colorScheme.onSurfaceVariant,
525 // Heuristic syntax highlighting per line. Falls back to plain text for
526 // unknown extensions, so non-code files render unchanged.
528 highlightLine(line, filename, palette),
529 modifier = Modifier.padding(start = 8.dp),
530 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