git.iko.soy/opencode-android
on 066cded help sign in
682 lines 32 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.interaction.DragInteraction
13import androidx.compose.foundation.layout.Box
14import androidx.compose.foundation.layout.BoxScope
15import androidx.compose.foundation.layout.Column
16import androidx.compose.foundation.layout.Arrangement
17import androidx.compose.foundation.layout.Row
18import androidx.compose.foundation.layout.Spacer
19import androidx.compose.foundation.layout.fillMaxSize
20import androidx.compose.foundation.layout.fillMaxWidth
21import androidx.compose.foundation.layout.padding
22import androidx.compose.foundation.layout.size
23import androidx.compose.foundation.layout.width
24import androidx.compose.foundation.lazy.LazyColumn
25import androidx.compose.foundation.lazy.LazyListState
26import androidx.compose.foundation.lazy.itemsIndexed
27import androidx.compose.foundation.lazy.rememberLazyListState
28import androidx.compose.foundation.rememberScrollState
29import androidx.compose.foundation.text.KeyboardActions
30import androidx.compose.foundation.text.KeyboardOptions
31import androidx.compose.foundation.verticalScroll
32import androidx.compose.material.icons.Icons
33import androidx.compose.material.icons.automirrored.filled.ArrowBack
34import androidx.compose.material.icons.automirrored.filled.WrapText
35import androidx.compose.material.icons.filled.Close
36import androidx.compose.material.icons.filled.ContentCopy
37import androidx.compose.material.icons.filled.KeyboardArrowDown
38import androidx.compose.material.icons.filled.KeyboardArrowUp
39import androidx.compose.material.icons.filled.MoreVert
40import androidx.compose.material.icons.filled.Refresh
41import androidx.compose.material.icons.filled.Search
42import androidx.compose.material.icons.filled.Share
43import androidx.compose.material3.CircularProgressIndicator
44import androidx.compose.material3.DropdownMenu
45import androidx.compose.material3.DropdownMenuItem
46import androidx.compose.material3.ExperimentalMaterial3Api
47import androidx.compose.material3.FilterChip
48import androidx.compose.material3.Icon
49import androidx.compose.material3.IconButton
50import androidx.compose.material3.MaterialTheme
51import androidx.compose.material3.OutlinedTextField
52import androidx.compose.material3.Scaffold
53import androidx.compose.material3.Text
54import androidx.compose.material3.TextButton
55import androidx.compose.material3.TopAppBar
56import androidx.compose.runtime.Composable
57import androidx.compose.runtime.LaunchedEffect
58import androidx.compose.runtime.MutableState
59import androidx.compose.runtime.getValue
60import androidx.compose.runtime.mutableIntStateOf
61import androidx.compose.runtime.mutableStateOf
62import androidx.compose.runtime.produceState
63import androidx.compose.runtime.remember
64import androidx.compose.runtime.saveable.rememberSaveable
65import androidx.compose.runtime.setValue
66import androidx.compose.ui.Alignment
67import androidx.compose.ui.Modifier
68import androidx.compose.ui.layout.onSizeChanged
69import androidx.compose.ui.platform.LocalContext
70import androidx.compose.ui.platform.LocalDensity
71import androidx.compose.ui.platform.LocalSoftwareKeyboardController
72import androidx.compose.ui.semantics.contentDescription
73import androidx.compose.ui.semantics.semantics
74import androidx.compose.ui.text.font.FontFamily
75import androidx.compose.ui.text.input.ImeAction
76import androidx.compose.ui.text.style.TextOverflow
77import androidx.compose.ui.unit.dp
78import androidx.compose.ui.res.stringResource
79import androidx.lifecycle.compose.collectAsStateWithLifecycle
80import androidx.lifecycle.viewmodel.compose.viewModel
81import soy.iko.opencode.di.AppContainer
82import soy.iko.opencode.R
83import soy.iko.opencode.ui.components.DiffView
84import soy.iko.opencode.ui.components.LocalCodeWrap
85import soy.iko.opencode.ui.components.copyToClipboard
86import soy.iko.opencode.ui.components.highlightLine
87import soy.iko.opencode.ui.components.syntaxFor
88import soy.iko.opencode.ui.components.rememberHighlightPalette
89import soy.iko.opencode.ui.vmFactory
90import soy.iko.opencode.util.runCatchingCancellable
91import kotlinx.coroutines.Dispatchers
92import kotlinx.coroutines.delay
93import kotlinx.coroutines.flow.collect
94import kotlinx.coroutines.withContext
96/** Cap on how many lines the raw viewer renders and searches. A multi-megabyte file is
97 * split once (not twice) into this many lines at most, so it can't hold two full line
98 * lists in memory or stall the LazyColumn; beyond the cap a truncation banner is shown. */
99private const val MAX_RENDERED_LINES = 5000
101@OptIn(ExperimentalMaterial3Api::class)
102@Composable
103fun FileViewScreen(
104 container: AppContainer,
105 path: String,
106 onBack: () -> Unit,
107 initialLine: Int? = null,
108) {
109 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
110 val state by vm.state.collectAsStateWithLifecycle()
111 val context = LocalContext.current
112 val filename = remember(path) {
113 val trimmed = path.trimEnd('/')
114 trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } }
115 }
116 val shareLabel = stringResource(R.string.share)
117 // Diff vs raw view toggle. Defaults to showing the diff when one exists; the user can
118 // switch to the raw new content. Persisted via rememberSaveable across rotation. When the
119 // viewer is opened at a specific line (from a search hit), start on the raw view so the
120 // targeted line is actually rendered (the diff view has no line list to scroll).
121 val hasDiff = state.content?.diff != null && state.content?.diff?.isNotBlank() == true
122 var showDiff by rememberSaveable(path) { mutableStateOf(initialLine == null) }
123 val showToggle = hasDiff && state.content?.content.orEmpty().isNotEmpty()
124 // Find-in-file and line-wrapping state, persisted so a rotation or reload keeps the
125 // user's query/mode.
126 var findActive by rememberSaveable(path) { mutableStateOf(false) }
127 var findQuery by rememberSaveable(path) { mutableStateOf("") }
128 var matchPos by rememberSaveable(path) { mutableIntStateOf(0) }
129 // Seed the per-file wrap toggle from the user's global code-wrap preference so the viewer
130 // respects it by default; they can still flip it per-file (persisted across rotation).
131 val codeWrap = LocalCodeWrap.current
132 var wrap by rememberSaveable(path) { mutableStateOf(codeWrap) }
133 val listState = rememberLazyListState()
134 val rawText = state.content?.content.orEmpty()
135 // Split the raw text exactly once here (the line viewer and find-in-file both consume this
136 // list) and cap it so a huge file doesn't get split twice or overwhelm the LazyColumn.
137 val allLines = remember(rawText) { rawText.split("\n") }
138 val truncated = allLines.size > MAX_RENDERED_LINES
139 val lines = remember(allLines) {
140 if (allLines.size > MAX_RENDERED_LINES) allLines.subList(0, MAX_RENDERED_LINES) else allLines
141 }
142 // The line the viewer was opened at (from a search hit), highlighted until the user scrolls
143 // or starts an in-file find so the landed line stands out. Null once cleared.
144 val highlightLine = rememberJumpToLineHighlight(
145 path = path,
146 initialLine = initialLine,
147 content = state.content,
148 isBinary = state.content?.isBinary == true,
149 lineCount = lines.size,
150 listState = listState,
151 )
152 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
153 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
154 var debouncedFind by remember { mutableStateOf("") }
155 LaunchedEffect(findQuery) {
156 val q = findQuery.trim()
157 if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q }
158 }
159 val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind) {
160 val q = debouncedFind
161 value = if (q.isEmpty()) emptyList()
162 else withContext(Dispatchers.Default) {
163 lines.mapIndexedNotNull { index, line -> index.takeIf { line.contains(q, ignoreCase = true) } }
164 }
165 }
166 // Keep the current match index in range when the match set shrinks (e.g. the file
167 // reloaded with fewer matches), so the counter never shows a stale "10 / 3".
168 LaunchedEffect(matchIndices.size) {
169 matchPos = matchPos.coerceIn(0, (matchIndices.size - 1).coerceAtLeast(0))
170 }
171 // Scroll to the current match whenever it (or the match set) changes.
172 LaunchedEffect(matchPos, matchIndices) {
173 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
174 }
175 Scaffold(
176 topBar = {
177 FileViewTopBar(
178 filename = filename,
179 loading = state.loading,
180 content = rawText,
181 wrap = wrap,
182 diffShown = showToggle && showDiff,
183 onBack = onBack,
184 onReload = { vm.reload() },
185 // Find searches the raw line list, which isn't on screen under a diff. Rather
186 // than silently greying the icon out, keep it tappable and explain where find
187 // lives when the user is in diff mode.
188 onToggleFind = {
189 if (showToggle && showDiff) {
190 showToast(context, context.getString(R.string.switch_to_raw_to_search))
191 } else {
192 findActive = !findActive
193 if (!findActive) findQuery = ""
194 }
195 },
196 onToggleWrap = { wrap = !wrap },
197 onCopy = { copyToClipboard(context, filename, rawText) },
198 onShare = {
199 val send = Intent(Intent.ACTION_SEND).apply {
200 type = "text/plain"
201 putExtra(Intent.EXTRA_SUBJECT, filename)
202 putExtra(Intent.EXTRA_TEXT, rawText)
203 }
204 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
205 .onFailure { showToast(context, context.getString(R.string.no_share_app)) }
206 },
207 )
208 },
209 ) { padding ->
210 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
211 // AnimatedContent cross-fades between loading / error / content states so the
212 // viewer doesn't snap abruptly when a reload finishes or fails.
213 AnimatedContent(
214 targetState = state,
215 transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
216 label = "fileViewState",
217 ) { s ->
218 // Single source of truth for matches: the off-thread `matchIndices` produceState
219 // (keyed on the file content + debounced query). Both navigation (onPrev/onNext +
220 // scroll) and display (counter + highlight) read from it, so the counter can't show
221 // a new query while next/prev act on a stale set. The earlier in-composition
222 // per-layer scan re-ran the whole-file O(n) match on the UI thread each keystroke
223 // (the exact work produceState was moved off-thread to avoid); it's acceptable for
224 // the crossfade's exiting layer to briefly use this unified set until it settles.
225 FileViewStateContent(
226 state = s,
227 filename = filename,
228 showToggle = showToggle,
229 showDiff = showDiff,
230 onSetShowDiff = {
231 showDiff = it
232 // Close any open find bar when entering diff mode: find searches
233 // the raw content and scrolls the line LazyColumn, neither of
234 // which is visible in diff mode, so leaving it open would be a
235 // confusing no-op.
236 if (it) { findActive = false; findQuery = "" }
237 },
238 findActive = findActive,
239 findQuery = findQuery,
240 onQueryChange = { findQuery = it; matchPos = 0; if (it.isNotEmpty()) highlightLine.value = null },
241 matchIndices = matchIndices,
242 matchPos = matchPos,
243 onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
244 onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
245 onCloseFind = { findActive = false; findQuery = "" },
246 wrap = wrap,
247 lines = lines,
248 truncated = truncated,
249 highlightLineIndex = highlightLine.value,
250 listState = listState,
251 onRetry = { vm.reload() },
252 )
253 }
254 }
255 }
258// Owns the "jump to a line from a search hit" behaviour: scroll to the target once the raw
259// content loads (one-shot), and clear the highlight the moment the user drags the list by hand.
260// Extracted so FileViewScreen stays under the cyclomatic-complexity threshold.
261@Composable
262private fun rememberJumpToLineHighlight(
263 path: String,
264 initialLine: Int?,
265 content: Any?,
266 isBinary: Boolean,
267 lineCount: Int,
268 listState: LazyListState,
269): MutableState<Int?> {
270 val highlight = rememberSaveable(path) { mutableStateOf<Int?>(null) }
271 var didInitialScroll by rememberSaveable(path) { mutableStateOf(false) }
272 LaunchedEffect(content, initialLine) {
273 val line = initialLine ?: return@LaunchedEffect
274 if (didInitialScroll || content == null || isBinary) return@LaunchedEffect
275 val target = (line - 1).coerceIn(0, (lineCount - 1).coerceAtLeast(0))
276 runCatchingCancellable { listState.scrollToItem(target) }
277 highlight.value = target
278 didInitialScroll = true
279 }
280 LaunchedEffect(listState) {
281 if (initialLine == null) return@LaunchedEffect
282 listState.interactionSource.interactions.collect { interaction ->
283 if (interaction is DragInteraction.Start) highlight.value = null
284 }
285 }
286 return highlight
289@OptIn(ExperimentalMaterial3Api::class)
290@Composable
291private fun FileViewTopBar(
292 filename: String,
293 loading: Boolean,
294 content: String,
295 wrap: Boolean,
296 diffShown: Boolean,
297 onBack: () -> Unit,
298 onReload: () -> Unit,
299 onToggleFind: () -> Unit,
300 onToggleWrap: () -> Unit,
301 onCopy: () -> Unit,
302 onShare: () -> Unit,
303) {
304 TopAppBar(
305 title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
306 navigationIcon = {
307 IconButton(onClick = onBack) {
308 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
309 }
310 },
311 actions = {
312 // In-place refresh: a file's content can change on the server (the agent may
313 // be editing it), so reload without requiring a back-out. Shows a spinner while
314 // loading since the content now stays on screen during a reload (the spinner is
315 // the only progress signal).
316 val loadingLabel = stringResource(R.string.loading)
317 IconButton(onClick = onReload, enabled = !loading) {
318 if (loading) {
319 CircularProgressIndicator(
320 Modifier.size(20.dp).semantics { contentDescription = loadingLabel },
321 strokeWidth = 2.dp,
322 )
323 } else {
324 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
325 }
326 }
327 if (content.isNotEmpty()) {
328 // Find-in-file: toggles a search bar over the raw content. Stays enabled even in
329 // diff mode — the find bar searches the raw text and scrolls the line LazyColumn,
330 // neither of which is visible under a diff, so tapping it there surfaces a hint to
331 // switch to Raw rather than silently no-opping behind a greyed-out icon.
332 IconButton(onClick = onToggleFind) {
333 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
334 }
335 // Overflow the less-frequent actions (wrap / copy / share) so a long filename
336 // title isn't crowded off screen by five inline icons on a phone.
337 var menuExpanded by remember { mutableStateOf(false) }
338 IconButton(onClick = { menuExpanded = true }) {
339 Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options))
340 }
341 DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
342 // Wrap long lines instead of horizontal-scrolling; useful for prose. Disabled
343 // in diff mode because DiffView ignores wrap, so an active-looking toggle there
344 // would be a silent no-op.
345 DropdownMenuItem(
346 text = { Text(stringResource(R.string.wrap_lines)) },
347 onClick = { onToggleWrap(); menuExpanded = false },
348 enabled = !diffShown,
349 leadingIcon = {
350 Icon(
351 Icons.AutoMirrored.Filled.WrapText,
352 contentDescription = null,
353 tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
354 )
355 },
356 )
357 DropdownMenuItem(
358 text = { Text(stringResource(R.string.copy)) },
359 onClick = { onCopy(); menuExpanded = false },
360 leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) },
361 )
362 DropdownMenuItem(
363 text = { Text(stringResource(R.string.share)) },
364 onClick = { onShare(); menuExpanded = false },
365 leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) },
366 )
367 }
368 }
369 },
370 )
373@Composable
374private fun BoxScope.FileViewStateContent(
375 state: FileViewState,
376 filename: String,
377 showToggle: Boolean,
378 showDiff: Boolean,
379 onSetShowDiff: (Boolean) -> Unit,
380 findActive: Boolean,
381 findQuery: String,
382 onQueryChange: (String) -> Unit,
383 matchIndices: List<Int>,
384 matchPos: Int,
385 onPrev: () -> Unit,
386 onNext: () -> Unit,
387 onCloseFind: () -> Unit,
388 wrap: Boolean,
389 lines: List<String>,
390 truncated: Boolean,
391 highlightLineIndex: Int?,
392 listState: LazyListState,
393 onRetry: () -> Unit,
394) {
395 when {
396 // Full-screen spinner only on the initial load (no content yet). A reload keeps the
397 // existing content on screen; the top-bar reload button shows the in-flight progress.
398 state.loading && state.content == null -> {
399 val loadingLabel = stringResource(R.string.loading)
400 CircularProgressIndicator(
401 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
402 )
403 }
404 state.error != null -> Column(
405 modifier = Modifier.align(Alignment.Center).padding(24.dp),
406 horizontalAlignment = Alignment.CenterHorizontally,
407 ) {
408 Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
409 Spacer(Modifier.size(12.dp))
410 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
411 }
412 state.content?.isBinary == true -> Text(
413 stringResource(R.string.binary_file),
414 modifier = Modifier.align(Alignment.Center).padding(24.dp),
415 color = MaterialTheme.colorScheme.onSurfaceVariant,
416 )
417 else -> FileViewContentBody(
418 content = state.content,
419 filename = filename,
420 showToggle = showToggle,
421 showDiff = showDiff,
422 onSetShowDiff = onSetShowDiff,
423 findActive = findActive,
424 findQuery = findQuery,
425 onQueryChange = onQueryChange,
426 matchIndices = matchIndices,
427 matchPos = matchPos,
428 onPrev = onPrev,
429 onNext = onNext,
430 onCloseFind = onCloseFind,
431 wrap = wrap,
432 lines = lines,
433 truncated = truncated,
434 highlightLineIndex = highlightLineIndex,
435 listState = listState,
436 )
437 }
440@Composable
441private fun BoxScope.FileViewContentBody(
442 content: soy.iko.opencode.data.model.FileContent?,
443 filename: String,
444 showToggle: Boolean,
445 showDiff: Boolean,
446 onSetShowDiff: (Boolean) -> Unit,
447 findActive: Boolean,
448 findQuery: String,
449 onQueryChange: (String) -> Unit,
450 matchIndices: List<Int>,
451 matchPos: Int,
452 onPrev: () -> Unit,
453 onNext: () -> Unit,
454 onCloseFind: () -> Unit,
455 wrap: Boolean,
456 lines: List<String>,
457 truncated: Boolean,
458 highlightLineIndex: Int?,
459 listState: LazyListState,
460) {
461 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
462 // reachable while scrolling. Each contributes a top inset so the content below
463 // isn't hidden behind the overlay. The inset is measured from the actual overlay
464 // height (not a hardcoded 52dp/row) so accessibility font scaling — which can
465 // make the FilterChip labels and FindBar text field taller than 52dp — doesn't
466 // cause the first lines of content to hide behind the overlay.
467 // The raw text is on screen whenever we're NOT rendering the diff — i.e. either the
468 // file has no diff toggle at all (plain file: showToggle == false) or the toggle is
469 // set to Raw. Gate the find bar on this rather than on !showDiff: for a plain file
470 // showDiff stays at its `true` default forever (nothing ever flips it), so !showDiff
471 // would keep the find bar — and therefore find-in-file — permanently hidden.
472 val rawOnScreen = !(showToggle && showDiff)
473 val overlayRows = (if (showToggle) 1 else 0) + (if (findActive && rawOnScreen) 1 else 0)
474 var overlayHeightPx by remember { mutableIntStateOf(0) }
475 // Only apply the measured inset while an overlay is actually composed. When overlayRows
476 // drops to 0 the overlay Column is removed and onSizeChanged never fires to zero the
477 // height, so keying the inset off overlayRows (rather than the stale measurement) avoids
478 // a phantom top gap on the content below — e.g. after closing find on a plain file.
479 val topInset = if (overlayRows > 0) {
480 with(androidx.compose.ui.platform.LocalDensity.current) { overlayHeightPx.toDp() }
481 } else 0.dp
482 if (overlayRows > 0) {
483 Column(
484 modifier = Modifier
485 .align(Alignment.TopCenter)
486 .fillMaxWidth()
487 .onSizeChanged { overlayHeightPx = it.height }
488 .padding(top = 4.dp),
489 verticalArrangement = Arrangement.spacedBy(4.dp),
490 ) {
491 if (showToggle) {
492 Row(
493 modifier = Modifier.fillMaxWidth(),
494 horizontalArrangement = Arrangement.Center,
495 ) {
496 FilterChip(
497 selected = showDiff,
498 onClick = { onSetShowDiff(true) },
499 label = { Text(stringResource(R.string.show_diff)) },
500 )
501 Spacer(Modifier.size(8.dp))
502 FilterChip(
503 selected = !showDiff,
504 onClick = { onSetShowDiff(false) },
505 label = { Text(stringResource(R.string.show_raw)) },
506 )
507 }
508 }
509 if (findActive && rawOnScreen) {
510 FindBar(
511 query = findQuery,
512 onQueryChange = onQueryChange,
513 matchCount = matchIndices.size,
514 matchPos = matchPos,
515 onPrev = onPrev,
516 onNext = onNext,
517 onClose = onCloseFind,
518 )
519 }
520 }
521 }
522 if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
523 DiffView(
524 diff = content.diff,
525 modifier = Modifier
526 .fillMaxSize()
527 .padding(top = topInset)
528 .verticalScroll(rememberScrollState())
529 .padding(8.dp),
530 )
531 } else {
532 val text = content?.content.orEmpty()
533 if (text.isEmpty()) {
534 Text(
535 stringResource(R.string.empty_file),
536 modifier = Modifier.align(Alignment.Center).padding(24.dp),
537 color = MaterialTheme.colorScheme.onSurfaceVariant,
538 )
539 } else {
540 FileTextContent(
541 lines = lines,
542 filename = filename,
543 topInset = topInset,
544 truncated = truncated,
545 wrap = wrap,
546 findQuery = findQuery,
547 matchIndices = matchIndices,
548 highlightLineIndex = highlightLineIndex,
549 listState = listState,
550 )
551 }
552 }
555@Composable
556private fun FindBar(
557 query: String,
558 onQueryChange: (String) -> Unit,
559 matchCount: Int,
560 matchPos: Int,
561 onPrev: () -> Unit,
562 onNext: () -> Unit,
563 onClose: () -> Unit,
564) {
565 val keyboardController = LocalSoftwareKeyboardController.current
566 Row(
567 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
568 verticalAlignment = Alignment.CenterVertically,
569 ) {
570 OutlinedTextField(
571 value = query,
572 onValueChange = onQueryChange,
573 modifier = Modifier.weight(1f),
574 placeholder = { Text(stringResource(R.string.find_hint)) },
575 singleLine = true,
576 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
577 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
578 trailingIcon = {
579 val countText = if (query.isBlank()) ""
580 else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
581 else stringResource(R.string.match_count, matchPos + 1, matchCount)
582 if (countText.isNotEmpty()) {
583 // Use error color for "no matches" so the zero-result state is
584 // distinguishable from a positive match count at a glance.
585 val color = if (query.isNotBlank() && matchCount == 0) MaterialTheme.colorScheme.error
586 else MaterialTheme.colorScheme.onSurfaceVariant
587 Text(
588 countText,
589 style = MaterialTheme.typography.labelSmall,
590 color = color,
591 )
592 }
593 },
594 )
595 IconButton(onClick = onPrev, enabled = matchCount > 0) {
596 Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_previous))
597 }
598 IconButton(onClick = onNext, enabled = matchCount > 0) {
599 Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_next))
600 }
601 IconButton(onClick = onClose) {
602 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
603 }
604 }
607@Composable
608private fun FileTextContent(
609 lines: List<String>,
610 filename: String,
611 topInset: androidx.compose.ui.unit.Dp,
612 truncated: Boolean,
613 wrap: Boolean,
614 findQuery: String,
615 matchIndices: List<Int>,
616 highlightLineIndex: Int?,
617 listState: LazyListState,
618) {
619 // Gutter width scales with the digit count and the font scale so accessibility
620 // text scaling (e.g. 1.3x) doesn't make line numbers overflow the gutter and
621 // collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
622 // proportionally for larger font scales.
623 val fontScale = androidx.compose.ui.platform.LocalDensity.current.fontScale
624 val gutterWidth = remember(lines.size, fontScale) {
625 ((lines.size.toString().length.coerceAtLeast(3) * 10) * fontScale.coerceAtLeast(1f)).dp
626 }
627 val hScrollState = rememberScrollState()
628 val palette = rememberHighlightPalette()
629 // Resolve the file's language once instead of re-parsing the extension for every line.
630 val syntax = remember(filename) { syntaxFor(filename) }
631 val q = findQuery.trim()
632 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
633 Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
634 if (truncated) {
635 Text(
636 stringResource(R.string.file_truncated, MAX_RENDERED_LINES),
637 style = MaterialTheme.typography.labelSmall,
638 color = MaterialTheme.colorScheme.onSurfaceVariant,
639 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
640 )
641 }
642 LazyColumn(
643 state = listState,
644 modifier = Modifier
645 .weight(1f)
646 .fillMaxWidth()
647 .padding(8.dp),
648 ) {
649 itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
650 val isMatch = q.isNotEmpty() && index in matchSet
651 // The jump-to-line target keeps the same emphasis as a find match so the line
652 // the viewer landed on is obvious until the user scrolls or starts a find.
653 val isTarget = index == highlightLineIndex
654 Row(
655 modifier = Modifier
656 .fillMaxWidth()
657 .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
658 .then(if (isMatch || isTarget) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
659 ) {
660 Text(
661 "${index + 1}",
662 modifier = Modifier.width(gutterWidth),
663 style = MaterialTheme.typography.bodySmall,
664 fontFamily = FontFamily.Monospace,
665 color = MaterialTheme.colorScheme.onSurfaceVariant,
666 )
667 // Heuristic syntax highlighting per line. Falls back to plain text for
668 // unknown extensions, so non-code files render unchanged. Memoized so the
669 // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
670 // file, or palette actually changes — not on every recomposition (e.g. every
671 // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
672 val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
673 Text(
674 highlighted,
675 modifier = Modifier.padding(start = 8.dp),
676 style = MaterialTheme.typography.bodySmall,
677 )
678 }
679 }
680 }
681 }