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,
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 FileViewStateContent(
165 showToggle = showToggle,
169 // Close any open find bar when entering diff mode: find searches
170 // the raw content and scrolls the line LazyColumn, neither of
171 // which is visible in diff mode, so leaving it open would be a
173 if (it) { findActive = false; findQuery = "" }
175 findActive = findActive,
176 findQuery = findQuery,
177 onQueryChange = { findQuery = it; matchPos = 0 },
178 matchIndices = matchIndices,
180 onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
181 onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
182 onCloseFind = { findActive = false; findQuery = "" },
184 listState = listState,
185 onRetry = { vm.reload() },
192@OptIn(ExperimentalMaterial3Api::class)
194private fun FileViewTopBar(
201 onReload: () -> Unit,
202 onToggleFind: () -> Unit,
203 onToggleWrap: () -> Unit,
208 title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
210 IconButton(onClick = onBack) {
211 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
215 // In-place refresh: a file's content can change on the server (the agent may
216 // be editing it), so reload without requiring a back-out.
217 IconButton(onClick = onReload, enabled = !loading) {
218 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
220 if (content.isNotEmpty()) {
221 // Find-in-file: toggles a search bar over the raw content. Disabled in
222 // diff mode — the find bar searches the raw text and scrolls the line
223 // LazyColumn, neither of which is visible when the DiffView is shown, so
224 // find would silently no-op. The user can switch to Raw to find.
225 IconButton(onClick = onToggleFind, enabled = !showDiff) {
226 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
228 // Wrap long lines instead of horizontal-scrolling, useful for prose.
229 IconButton(onClick = onToggleWrap) {
231 Icons.AutoMirrored.Filled.WrapText,
232 contentDescription = stringResource(R.string.wrap_lines),
233 tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
236 IconButton(onClick = onCopy) {
237 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
239 IconButton(onClick = onShare) {
240 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
248private fun BoxScope.FileViewStateContent(
249 state: FileViewState,
253 onSetShowDiff: (Boolean) -> Unit,
256 onQueryChange: (String) -> Unit,
257 matchIndices: List<Int>,
261 onCloseFind: () -> Unit,
263 listState: LazyListState,
268 val loadingLabel = stringResource(R.string.loading)
269 CircularProgressIndicator(
270 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
273 state.error != null -> Column(
274 modifier = Modifier.align(Alignment.Center).padding(24.dp),
275 horizontalAlignment = Alignment.CenterHorizontally,
277 Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
278 Spacer(Modifier.size(12.dp))
279 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
281 state.content?.isBinary == true -> Text(
282 stringResource(R.string.binary_file),
283 modifier = Modifier.align(Alignment.Center).padding(24.dp),
284 color = MaterialTheme.colorScheme.onSurfaceVariant,
286 else -> FileViewContentBody(
287 content = state.content,
289 showToggle = showToggle,
291 onSetShowDiff = onSetShowDiff,
292 findActive = findActive,
293 findQuery = findQuery,
294 onQueryChange = onQueryChange,
295 matchIndices = matchIndices,
299 onCloseFind = onCloseFind,
301 listState = listState,
307private fun BoxScope.FileViewContentBody(
308 content: soy.iko.opencode.data.model.FileContent?,
312 onSetShowDiff: (Boolean) -> Unit,
315 onQueryChange: (String) -> Unit,
316 matchIndices: List<Int>,
320 onCloseFind: () -> Unit,
322 listState: LazyListState,
324 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
325 // reachable while scrolling. Each contributes a top inset so the content below
326 // isn't hidden behind the overlay. The inset is measured from the actual overlay
327 // height (not a hardcoded 52dp/row) so accessibility font scaling — which can
328 // make the FilterChip labels and FindBar text field taller than 52dp — doesn't
329 // cause the first lines of content to hide behind the overlay.
330 val overlayRows = (if (showToggle) 1 else 0) + (if (findActive && !showDiff) 1 else 0)
331 var overlayHeightPx by remember { mutableIntStateOf(0) }
332 val topInset = with(androidx.compose.ui.platform.LocalDensity.current) { overlayHeightPx.toDp() }
333 if (overlayRows > 0) {
336 .align(Alignment.TopCenter)
338 .onSizeChanged { overlayHeightPx = it.height }
339 .padding(top = 4.dp),
340 verticalArrangement = Arrangement.spacedBy(4.dp),
344 modifier = Modifier.fillMaxWidth(),
345 horizontalArrangement = Arrangement.Center,
349 onClick = { onSetShowDiff(true) },
350 label = { Text(stringResource(R.string.show_diff)) },
352 Spacer(Modifier.size(8.dp))
354 selected = !showDiff,
355 onClick = { onSetShowDiff(false) },
356 label = { Text(stringResource(R.string.show_raw)) },
360 if (findActive && !showDiff) {
363 onQueryChange = onQueryChange,
364 matchCount = matchIndices.size,
368 onClose = onCloseFind,
373 if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
378 .padding(top = topInset)
379 .verticalScroll(rememberScrollState())
383 val text = content?.content.orEmpty()
384 if (text.isEmpty()) {
386 stringResource(R.string.empty_file),
387 modifier = Modifier.align(Alignment.Center).padding(24.dp),
388 color = MaterialTheme.colorScheme.onSurfaceVariant,
396 findQuery = findQuery,
397 matchIndices = matchIndices,
398 listState = listState,
407 onQueryChange: (String) -> Unit,
414 val keyboardController = LocalSoftwareKeyboardController.current
416 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
417 verticalAlignment = Alignment.CenterVertically,
421 onValueChange = onQueryChange,
422 modifier = Modifier.weight(1f),
423 placeholder = { Text(stringResource(R.string.find_hint)) },
425 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
426 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
428 val countText = if (query.isBlank()) ""
429 else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
430 else stringResource(R.string.match_count, matchPos + 1, matchCount)
431 if (countText.isNotEmpty()) {
432 // Use error color for "no matches" so the zero-result state is
433 // distinguishable from a positive match count at a glance.
434 val color = if (query.isNotBlank() && matchCount == 0) MaterialTheme.colorScheme.error
435 else MaterialTheme.colorScheme.onSurfaceVariant
438 style = MaterialTheme.typography.labelSmall,
444 IconButton(onClick = onPrev, enabled = matchCount > 0) {
445 Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_previous))
447 IconButton(onClick = onNext, enabled = matchCount > 0) {
448 Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_next))
450 IconButton(onClick = onClose) {
451 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
457private fun FileTextContent(
460 topInset: androidx.compose.ui.unit.Dp,
463 matchIndices: List<Int>,
464 listState: LazyListState,
466 val lines = remember(text) { text.split("\n") }
467 // Gutter width scales with the digit count and the font scale so accessibility
468 // text scaling (e.g. 1.3x) doesn't make line numbers overflow the gutter and
469 // collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
470 // proportionally for larger font scales.
471 val fontScale = androidx.compose.ui.platform.LocalDensity.current.fontScale
472 val gutterWidth = remember(lines.size, fontScale) {
473 ((lines.size.toString().length.coerceAtLeast(3) * 10) * fontScale.coerceAtLeast(1f)).dp
475 val hScrollState = rememberScrollState()
476 val palette = rememberHighlightPalette()
477 val q = findQuery.trim()
478 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
483 .padding(top = topInset)
486 itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
487 val isMatch = q.isNotEmpty() && index in matchSet
491 .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
492 .then(if (isMatch) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
496 modifier = Modifier.width(gutterWidth),
497 style = MaterialTheme.typography.bodySmall,
498 fontFamily = FontFamily.Monospace,
499 color = MaterialTheme.colorScheme.onSurfaceVariant,
501 // Heuristic syntax highlighting per line. Falls back to plain text for
502 // unknown extensions, so non-code files render unchanged.
504 highlightLine(line, filename, palette),
505 modifier = Modifier.padding(start = 8.dp),
506 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