git.iko.soy/opencode-android
on bc6b45e help sign in
511 lines 22 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 showDiff = 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 FileViewStateContent(
163 state = s,
164 filename = filename,
165 showToggle = showToggle,
166 showDiff = showDiff,
167 onSetShowDiff = {
168 showDiff = it
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
172 // confusing no-op.
173 if (it) { findActive = false; findQuery = "" }
174 },
175 findActive = findActive,
176 findQuery = findQuery,
177 onQueryChange = { findQuery = it; matchPos = 0 },
178 matchIndices = matchIndices,
179 matchPos = matchPos,
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 = "" },
183 wrap = wrap,
184 listState = listState,
185 onRetry = { vm.reload() },
186 )
187 }
188 }
189 }
192@OptIn(ExperimentalMaterial3Api::class)
193@Composable
194private fun FileViewTopBar(
195 filename: String,
196 loading: Boolean,
197 content: String,
198 wrap: Boolean,
199 showDiff: Boolean,
200 onBack: () -> Unit,
201 onReload: () -> Unit,
202 onToggleFind: () -> Unit,
203 onToggleWrap: () -> Unit,
204 onCopy: () -> Unit,
205 onShare: () -> Unit,
206) {
207 TopAppBar(
208 title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
209 navigationIcon = {
210 IconButton(onClick = onBack) {
211 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
212 }
213 },
214 actions = {
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))
219 }
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))
227 }
228 // Wrap long lines instead of horizontal-scrolling, useful for prose.
229 IconButton(onClick = onToggleWrap) {
230 Icon(
231 Icons.AutoMirrored.Filled.WrapText,
232 contentDescription = stringResource(R.string.wrap_lines),
233 tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
234 )
235 }
236 IconButton(onClick = onCopy) {
237 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
238 }
239 IconButton(onClick = onShare) {
240 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
241 }
242 }
243 },
244 )
247@Composable
248private fun BoxScope.FileViewStateContent(
249 state: FileViewState,
250 filename: String,
251 showToggle: Boolean,
252 showDiff: Boolean,
253 onSetShowDiff: (Boolean) -> Unit,
254 findActive: Boolean,
255 findQuery: String,
256 onQueryChange: (String) -> Unit,
257 matchIndices: List<Int>,
258 matchPos: Int,
259 onPrev: () -> Unit,
260 onNext: () -> Unit,
261 onCloseFind: () -> Unit,
262 wrap: Boolean,
263 listState: LazyListState,
264 onRetry: () -> Unit,
265) {
266 when {
267 state.loading -> {
268 val loadingLabel = stringResource(R.string.loading)
269 CircularProgressIndicator(
270 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
271 )
272 }
273 state.error != null -> Column(
274 modifier = Modifier.align(Alignment.Center).padding(24.dp),
275 horizontalAlignment = Alignment.CenterHorizontally,
276 ) {
277 Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
278 Spacer(Modifier.size(12.dp))
279 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
280 }
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,
285 )
286 else -> FileViewContentBody(
287 content = state.content,
288 filename = filename,
289 showToggle = showToggle,
290 showDiff = showDiff,
291 onSetShowDiff = onSetShowDiff,
292 findActive = findActive,
293 findQuery = findQuery,
294 onQueryChange = onQueryChange,
295 matchIndices = matchIndices,
296 matchPos = matchPos,
297 onPrev = onPrev,
298 onNext = onNext,
299 onCloseFind = onCloseFind,
300 wrap = wrap,
301 listState = listState,
302 )
303 }
306@Composable
307private fun BoxScope.FileViewContentBody(
308 content: soy.iko.opencode.data.model.FileContent?,
309 filename: String,
310 showToggle: Boolean,
311 showDiff: Boolean,
312 onSetShowDiff: (Boolean) -> Unit,
313 findActive: Boolean,
314 findQuery: String,
315 onQueryChange: (String) -> Unit,
316 matchIndices: List<Int>,
317 matchPos: Int,
318 onPrev: () -> Unit,
319 onNext: () -> Unit,
320 onCloseFind: () -> Unit,
321 wrap: Boolean,
322 listState: LazyListState,
323) {
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) {
334 Column(
335 modifier = Modifier
336 .align(Alignment.TopCenter)
337 .fillMaxWidth()
338 .onSizeChanged { overlayHeightPx = it.height }
339 .padding(top = 4.dp),
340 verticalArrangement = Arrangement.spacedBy(4.dp),
341 ) {
342 if (showToggle) {
343 Row(
344 modifier = Modifier.fillMaxWidth(),
345 horizontalArrangement = Arrangement.Center,
346 ) {
347 FilterChip(
348 selected = showDiff,
349 onClick = { onSetShowDiff(true) },
350 label = { Text(stringResource(R.string.show_diff)) },
351 )
352 Spacer(Modifier.size(8.dp))
353 FilterChip(
354 selected = !showDiff,
355 onClick = { onSetShowDiff(false) },
356 label = { Text(stringResource(R.string.show_raw)) },
357 )
358 }
359 }
360 if (findActive && !showDiff) {
361 FindBar(
362 query = findQuery,
363 onQueryChange = onQueryChange,
364 matchCount = matchIndices.size,
365 matchPos = matchPos,
366 onPrev = onPrev,
367 onNext = onNext,
368 onClose = onCloseFind,
369 )
370 }
371 }
372 }
373 if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
374 DiffView(
375 diff = content.diff,
376 modifier = Modifier
377 .fillMaxSize()
378 .padding(top = topInset)
379 .verticalScroll(rememberScrollState())
380 .padding(8.dp),
381 )
382 } else {
383 val text = content?.content.orEmpty()
384 if (text.isEmpty()) {
385 Text(
386 stringResource(R.string.empty_file),
387 modifier = Modifier.align(Alignment.Center).padding(24.dp),
388 color = MaterialTheme.colorScheme.onSurfaceVariant,
389 )
390 } else {
391 FileTextContent(
392 text = text,
393 filename = filename,
394 topInset = topInset,
395 wrap = wrap,
396 findQuery = findQuery,
397 matchIndices = matchIndices,
398 listState = listState,
399 )
400 }
401 }
404@Composable
405private fun FindBar(
406 query: String,
407 onQueryChange: (String) -> Unit,
408 matchCount: Int,
409 matchPos: Int,
410 onPrev: () -> Unit,
411 onNext: () -> Unit,
412 onClose: () -> Unit,
413) {
414 val keyboardController = LocalSoftwareKeyboardController.current
415 Row(
416 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
417 verticalAlignment = Alignment.CenterVertically,
418 ) {
419 OutlinedTextField(
420 value = query,
421 onValueChange = onQueryChange,
422 modifier = Modifier.weight(1f),
423 placeholder = { Text(stringResource(R.string.find_hint)) },
424 singleLine = true,
425 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
426 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
427 trailingIcon = {
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
436 Text(
437 countText,
438 style = MaterialTheme.typography.labelSmall,
439 color = color,
440 )
441 }
442 },
443 )
444 IconButton(onClick = onPrev, enabled = matchCount > 0) {
445 Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_previous))
446 }
447 IconButton(onClick = onNext, enabled = matchCount > 0) {
448 Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_next))
449 }
450 IconButton(onClick = onClose) {
451 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
452 }
453 }
456@Composable
457private fun FileTextContent(
458 text: String,
459 filename: String,
460 topInset: androidx.compose.ui.unit.Dp,
461 wrap: Boolean,
462 findQuery: String,
463 matchIndices: List<Int>,
464 listState: LazyListState,
465) {
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
474 }
475 val hScrollState = rememberScrollState()
476 val palette = rememberHighlightPalette()
477 val q = findQuery.trim()
478 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
479 LazyColumn(
480 state = listState,
481 modifier = Modifier
482 .fillMaxSize()
483 .padding(top = topInset)
484 .padding(8.dp),
485 ) {
486 itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
487 val isMatch = q.isNotEmpty() && index in matchSet
488 Row(
489 modifier = Modifier
490 .fillMaxWidth()
491 .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
492 .then(if (isMatch) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
493 ) {
494 Text(
495 "${index + 1}",
496 modifier = Modifier.width(gutterWidth),
497 style = MaterialTheme.typography.bodySmall,
498 fontFamily = FontFamily.Monospace,
499 color = MaterialTheme.colorScheme.onSurfaceVariant,
500 )
501 // Heuristic syntax highlighting per line. Falls back to plain text for
502 // unknown extensions, so non-code files render unchanged.
503 Text(
504 highlightLine(line, filename, palette),
505 modifier = Modifier.padding(start = 8.dp),
506 style = MaterialTheme.typography.bodySmall,
507 )
508 }
509 }
510 }