git.iko.soy/opencode-android
on 8bc2cce help sign in
535 lines 24 kB history raw
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)
85@Composable
86fun FileViewScreen(
87 container: AppContainer,
88 path: String,
89 onBack: () -> Unit,
90) {
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 { "/" } }
97 }
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 }
117 .filter { it >= 0 }
118 }
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))
123 }
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) } }
127 }
129 Scaffold(
130 topBar = {
131 FileViewTopBar(
132 filename = filename,
133 loading = state.loading,
134 content = rawText,
135 wrap = wrap,
136 inDiffMode = showToggle && showDiff,
137 onBack = onBack,
138 onReload = { vm.reload() },
139 onToggleFind = { findActive = !findActive; if (!findActive) findQuery = "" },
140 onToggleWrap = { wrap = !wrap },
141 onCopy = { copyToClipboard(context, filename, rawText) },
142 onShare = {
143 val send = Intent(Intent.ACTION_SEND).apply {
144 type = "text/plain"
145 putExtra(Intent.EXTRA_SUBJECT, filename)
146 putExtra(Intent.EXTRA_TEXT, rawText)
147 }
148 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
149 .onFailure { showToast(context, context.getString(R.string.no_share_app)) }
150 },
151 )
152 },
153 ) { padding ->
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.
157 AnimatedContent(
158 targetState = state,
159 transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
160 label = "fileViewState",
161 ) { s ->
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 }
171 .filter { it >= 0 }
172 }
173 FileViewStateContent(
174 state = s,
175 filename = filename,
176 showToggle = showToggle,
177 showDiff = showDiff,
178 onSetShowDiff = {
179 showDiff = it
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
183 // confusing no-op.
184 if (it) { findActive = false; findQuery = "" }
185 },
186 findActive = findActive,
187 findQuery = findQuery,
188 onQueryChange = { findQuery = it; matchPos = 0 },
189 matchIndices = sMatchIndices,
190 matchPos = matchPos,
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 = "" },
194 wrap = wrap,
195 listState = listState,
196 onRetry = { vm.reload() },
197 )
198 }
199 }
200 }
203@OptIn(ExperimentalMaterial3Api::class)
204@Composable
205private fun FileViewTopBar(
206 filename: String,
207 loading: Boolean,
208 content: String,
209 wrap: Boolean,
210 inDiffMode: Boolean,
211 onBack: () -> Unit,
212 onReload: () -> Unit,
213 onToggleFind: () -> Unit,
214 onToggleWrap: () -> Unit,
215 onCopy: () -> Unit,
216 onShare: () -> Unit,
217) {
218 TopAppBar(
219 title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
220 navigationIcon = {
221 IconButton(onClick = onBack) {
222 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
223 }
224 },
225 actions = {
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))
230 }
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))
239 }
240 // Wrap long lines instead of horizontal-scrolling, useful for prose.
241 IconButton(onClick = onToggleWrap) {
242 Icon(
243 Icons.AutoMirrored.Filled.WrapText,
244 contentDescription = stringResource(R.string.wrap_lines),
245 tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
246 )
247 }
248 IconButton(onClick = onCopy) {
249 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
250 }
251 IconButton(onClick = onShare) {
252 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
253 }
254 }
255 },
256 )
259@Composable
260private fun BoxScope.FileViewStateContent(
261 state: FileViewState,
262 filename: String,
263 showToggle: Boolean,
264 showDiff: Boolean,
265 onSetShowDiff: (Boolean) -> Unit,
266 findActive: Boolean,
267 findQuery: String,
268 onQueryChange: (String) -> Unit,
269 matchIndices: List<Int>,
270 matchPos: Int,
271 onPrev: () -> Unit,
272 onNext: () -> Unit,
273 onCloseFind: () -> Unit,
274 wrap: Boolean,
275 listState: LazyListState,
276 onRetry: () -> Unit,
277) {
278 when {
279 state.loading -> {
280 val loadingLabel = stringResource(R.string.loading)
281 CircularProgressIndicator(
282 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
283 )
284 }
285 state.error != null -> Column(
286 modifier = Modifier.align(Alignment.Center).padding(24.dp),
287 horizontalAlignment = Alignment.CenterHorizontally,
288 ) {
289 Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
290 Spacer(Modifier.size(12.dp))
291 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
292 }
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,
297 )
298 else -> FileViewContentBody(
299 content = state.content,
300 filename = filename,
301 showToggle = showToggle,
302 showDiff = showDiff,
303 onSetShowDiff = onSetShowDiff,
304 findActive = findActive,
305 findQuery = findQuery,
306 onQueryChange = onQueryChange,
307 matchIndices = matchIndices,
308 matchPos = matchPos,
309 onPrev = onPrev,
310 onNext = onNext,
311 onCloseFind = onCloseFind,
312 wrap = wrap,
313 listState = listState,
314 )
315 }
318@Composable
319private fun BoxScope.FileViewContentBody(
320 content: soy.iko.opencode.data.model.FileContent?,
321 filename: String,
322 showToggle: Boolean,
323 showDiff: Boolean,
324 onSetShowDiff: (Boolean) -> Unit,
325 findActive: Boolean,
326 findQuery: String,
327 onQueryChange: (String) -> Unit,
328 matchIndices: List<Int>,
329 matchPos: Int,
330 onPrev: () -> Unit,
331 onNext: () -> Unit,
332 onCloseFind: () -> Unit,
333 wrap: Boolean,
334 listState: LazyListState,
335) {
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() }
356 } else 0.dp
357 if (overlayRows > 0) {
358 Column(
359 modifier = Modifier
360 .align(Alignment.TopCenter)
361 .fillMaxWidth()
362 .onSizeChanged { overlayHeightPx = it.height }
363 .padding(top = 4.dp),
364 verticalArrangement = Arrangement.spacedBy(4.dp),
365 ) {
366 if (showToggle) {
367 Row(
368 modifier = Modifier.fillMaxWidth(),
369 horizontalArrangement = Arrangement.Center,
370 ) {
371 FilterChip(
372 selected = showDiff,
373 onClick = { onSetShowDiff(true) },
374 label = { Text(stringResource(R.string.show_diff)) },
375 )
376 Spacer(Modifier.size(8.dp))
377 FilterChip(
378 selected = !showDiff,
379 onClick = { onSetShowDiff(false) },
380 label = { Text(stringResource(R.string.show_raw)) },
381 )
382 }
383 }
384 if (findActive && rawOnScreen) {
385 FindBar(
386 query = findQuery,
387 onQueryChange = onQueryChange,
388 matchCount = matchIndices.size,
389 matchPos = matchPos,
390 onPrev = onPrev,
391 onNext = onNext,
392 onClose = onCloseFind,
393 )
394 }
395 }
396 }
397 if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
398 DiffView(
399 diff = content.diff,
400 modifier = Modifier
401 .fillMaxSize()
402 .padding(top = topInset)
403 .verticalScroll(rememberScrollState())
404 .padding(8.dp),
405 )
406 } else {
407 val text = content?.content.orEmpty()
408 if (text.isEmpty()) {
409 Text(
410 stringResource(R.string.empty_file),
411 modifier = Modifier.align(Alignment.Center).padding(24.dp),
412 color = MaterialTheme.colorScheme.onSurfaceVariant,
413 )
414 } else {
415 FileTextContent(
416 text = text,
417 filename = filename,
418 topInset = topInset,
419 wrap = wrap,
420 findQuery = findQuery,
421 matchIndices = matchIndices,
422 listState = listState,
423 )
424 }
425 }
428@Composable
429private fun FindBar(
430 query: String,
431 onQueryChange: (String) -> Unit,
432 matchCount: Int,
433 matchPos: Int,
434 onPrev: () -> Unit,
435 onNext: () -> Unit,
436 onClose: () -> Unit,
437) {
438 val keyboardController = LocalSoftwareKeyboardController.current
439 Row(
440 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
441 verticalAlignment = Alignment.CenterVertically,
442 ) {
443 OutlinedTextField(
444 value = query,
445 onValueChange = onQueryChange,
446 modifier = Modifier.weight(1f),
447 placeholder = { Text(stringResource(R.string.find_hint)) },
448 singleLine = true,
449 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
450 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
451 trailingIcon = {
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
460 Text(
461 countText,
462 style = MaterialTheme.typography.labelSmall,
463 color = color,
464 )
465 }
466 },
467 )
468 IconButton(onClick = onPrev, enabled = matchCount > 0) {
469 Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_previous))
470 }
471 IconButton(onClick = onNext, enabled = matchCount > 0) {
472 Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_next))
473 }
474 IconButton(onClick = onClose) {
475 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
476 }
477 }
480@Composable
481private fun FileTextContent(
482 text: String,
483 filename: String,
484 topInset: androidx.compose.ui.unit.Dp,
485 wrap: Boolean,
486 findQuery: String,
487 matchIndices: List<Int>,
488 listState: LazyListState,
489) {
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
498 }
499 val hScrollState = rememberScrollState()
500 val palette = rememberHighlightPalette()
501 val q = findQuery.trim()
502 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
503 LazyColumn(
504 state = listState,
505 modifier = Modifier
506 .fillMaxSize()
507 .padding(top = topInset)
508 .padding(8.dp),
509 ) {
510 itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
511 val isMatch = q.isNotEmpty() && index in matchSet
512 Row(
513 modifier = Modifier
514 .fillMaxWidth()
515 .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
516 .then(if (isMatch) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
517 ) {
518 Text(
519 "${index + 1}",
520 modifier = Modifier.width(gutterWidth),
521 style = MaterialTheme.typography.bodySmall,
522 fontFamily = FontFamily.Monospace,
523 color = MaterialTheme.colorScheme.onSurfaceVariant,
524 )
525 // Heuristic syntax highlighting per line. Falls back to plain text for
526 // unknown extensions, so non-code files render unchanged.
527 Text(
528 highlightLine(line, filename, palette),
529 modifier = Modifier.padding(start = 8.dp),
530 style = MaterialTheme.typography.bodySmall,
531 )
532 }
533 }
534 }