git.iko.soy/opencode-android
on 06fd41b help sign in
921 lines 45 kB history raw
1package soy.iko.opencode.ui.file
3import android.content.Intent
4import androidx.compose.animation.AnimatedContent
5import androidx.compose.animation.core.tween
6import androidx.compose.animation.fadeIn
7import androidx.compose.animation.fadeOut
8import androidx.compose.animation.togetherWith
9import androidx.compose.foundation.background
10import androidx.compose.foundation.horizontalScroll
11import androidx.compose.foundation.interaction.DragInteraction
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.text.selection.SelectionContainer
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.Link
40import androidx.compose.material.icons.filled.MoreVert
41import androidx.compose.material.icons.filled.Refresh
42import androidx.compose.material.icons.filled.Search
43import androidx.compose.material.icons.filled.OpenInNew
44import androidx.compose.material.icons.filled.Share
45import androidx.compose.material3.AlertDialog
46import androidx.compose.material3.CircularProgressIndicator
47import androidx.compose.material3.DropdownMenu
48import androidx.compose.material3.DropdownMenuItem
49import androidx.compose.material3.ExperimentalMaterial3Api
50import androidx.compose.material3.FilterChip
51import androidx.compose.material3.Icon
52import androidx.compose.material3.IconButton
53import androidx.compose.material3.MaterialTheme
54import androidx.compose.material3.OutlinedTextField
55import androidx.compose.material3.Scaffold
56import androidx.compose.material3.SnackbarHost
57import androidx.compose.material3.SnackbarHostState
58import androidx.compose.material3.Text
59import androidx.compose.material3.TextButton
60import androidx.compose.material3.TopAppBar
61import androidx.compose.runtime.Composable
62import androidx.compose.runtime.LaunchedEffect
63import androidx.compose.runtime.MutableState
64import androidx.compose.runtime.getValue
65import androidx.compose.runtime.mutableIntStateOf
66import androidx.compose.runtime.mutableStateOf
67import androidx.compose.runtime.produceState
68import androidx.compose.runtime.remember
69import androidx.compose.runtime.rememberCoroutineScope
70import androidx.compose.runtime.saveable.rememberSaveable
71import androidx.compose.runtime.setValue
72import androidx.compose.ui.Alignment
73import androidx.compose.ui.Modifier
74import androidx.compose.ui.layout.onSizeChanged
75import androidx.compose.ui.platform.LocalContext
76import androidx.compose.ui.platform.LocalDensity
77import androidx.compose.ui.platform.LocalSoftwareKeyboardController
78import androidx.compose.ui.semantics.contentDescription
79import androidx.compose.ui.semantics.semantics
80import androidx.compose.ui.text.font.FontFamily
81import androidx.compose.ui.text.input.ImeAction
82import androidx.compose.ui.text.input.KeyboardType
83import androidx.compose.ui.text.style.TextOverflow
84import androidx.compose.ui.unit.dp
85import androidx.compose.ui.res.stringResource
86import androidx.lifecycle.compose.collectAsStateWithLifecycle
87import androidx.lifecycle.viewmodel.compose.viewModel
88import soy.iko.opencode.di.AppContainer
89import soy.iko.opencode.R
90import soy.iko.opencode.ui.components.ConnectionBannerFor
91import soy.iko.opencode.ui.components.DiffView
92import soy.iko.opencode.ui.components.LocalChatTextScale
93import soy.iko.opencode.ui.components.LocalCodeWrap
94import soy.iko.opencode.ui.components.copyToClipboard
95import soy.iko.opencode.ui.components.highlightLine
96import soy.iko.opencode.ui.components.syntaxFor
97import soy.iko.opencode.ui.components.rememberHighlightPalette
98import soy.iko.opencode.ui.components.scaledBy
99import soy.iko.opencode.ui.vmFactory
100import soy.iko.opencode.util.runCatchingCancellable
101import kotlinx.coroutines.Dispatchers
102import kotlinx.coroutines.delay
103import kotlinx.coroutines.flow.collect
104import kotlinx.coroutines.launch
105import kotlinx.coroutines.withContext
107import soy.iko.opencode.data.network.NetworkConfig
109/** Cap on how many lines the raw viewer renders and searches. See [NetworkConfig.maxRenderedFileLines]. */
110private const val MAX_RENDERED_LINES = NetworkConfig.maxRenderedFileLines
112@OptIn(ExperimentalMaterial3Api::class)
113@Composable
114fun FileViewScreen(
115 container: AppContainer,
116 path: String,
117 onBack: () -> Unit,
118 initialLine: Int? = null,
119) {
120 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
121 val state by vm.state.collectAsStateWithLifecycle()
122 val context = LocalContext.current
123 val scope = rememberCoroutineScope()
124 val snackbar = remember { SnackbarHostState() }
125 val filename = remember(path) {
126 val trimmed = path.trimEnd('/')
127 trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } }
128 }
129 val shareLabel = stringResource(R.string.share)
130 // Diff vs raw view toggle. Defaults to showing the diff when one exists; the user can
131 // switch to the raw new content. Persisted via rememberSaveable across rotation. When the
132 // viewer is opened at a specific line (from a search hit), start on the raw view so the
133 // targeted line is actually rendered (the diff view has no line list to scroll).
134 val hasDiff = state.content?.diff != null && state.content?.diff?.isNotBlank() == true
135 var showDiff by rememberSaveable(path) { mutableStateOf(initialLine == null) }
136 val showToggle = hasDiff && state.content?.content.orEmpty().isNotEmpty()
137 // Find-in-file and line-wrapping state, persisted so a rotation or reload keeps the
138 // user's query/mode.
139 var findActive by rememberSaveable(path) { mutableStateOf(false) }
140 var findQuery by rememberSaveable(path) { mutableStateOf("") }
141 var matchPos by rememberSaveable(path) { mutableIntStateOf(0) }
142 // Seed the per-file wrap toggle from the user's global code-wrap preference so the viewer
143 // respects it by default; they can still flip it per-file (persisted across rotation).
144 val codeWrap = LocalCodeWrap.current
145 var wrap by rememberSaveable(path) { mutableStateOf(codeWrap) }
146 val listState = rememberLazyListState()
147 val rawText = state.content?.content.orEmpty()
148 // Directory portion of the path, shown under the filename in the top bar so a user who
149 // has drilled into a deep workspace keeps context of where this file lives.
150 val pathSubtitle = remember(path) {
151 val trimmed = path.trimEnd('/')
152 val parent = trimmed.substringBeforeLast('/', "")
153 parent.ifBlank { path }
154 }
155 // Split the raw text exactly once here (the line viewer and find-in-file both consume this
156 // list) and cap it so a huge file doesn't get split twice or overwhelm the LazyColumn.
157 // `take()` (not `subList()`) so the cap actually releases the tail: subList returns a view
158 // backed by the full list, which would keep every line (and the full rawText) in memory.
159 val allLines = remember(rawText) { rawText.split("\n") }
160 val truncated = allLines.size > MAX_RENDERED_LINES
161 val lines = remember(rawText) {
162 if (allLines.size > MAX_RENDERED_LINES) allLines.take(MAX_RENDERED_LINES) else allLines
163 }
164 // The line the viewer was opened at (from a search hit), highlighted until the user scrolls
165 // or starts an in-file find so the landed line stands out. Null once cleared.
166 val highlightLine = rememberJumpToLineHighlight(
167 path = path,
168 initialLine = initialLine,
169 content = state.content,
170 isBinary = state.content?.isBinary == true,
171 lineCount = lines.size,
172 listState = listState,
173 )
174 // Find-in-file match-case toggle (default case-insensitive). Persisted so a rotation keeps
175 // the choice, and applied to the off-thread match scan below.
176 var caseSensitive by rememberSaveable(path) { mutableStateOf(false) }
177 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
178 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
179 var debouncedFind by remember { mutableStateOf("") }
180 LaunchedEffect(findQuery) {
181 val q = findQuery.trim()
182 if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q }
183 }
184 val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind, caseSensitive) {
185 val q = debouncedFind
186 value = if (q.isEmpty()) emptyList()
187 else withContext(Dispatchers.Default) {
188 lines.mapIndexedNotNull { index, line ->
189 index.takeIf { line.contains(q, ignoreCase = !caseSensitive) }
190 }
191 }
192 }
193 // Keep the current match index in range when the match set shrinks (e.g. the file
194 // reloaded with fewer matches), so the counter never shows a stale "10 / 3".
195 LaunchedEffect(matchIndices.size) {
196 matchPos = matchPos.coerceIn(0, (matchIndices.size - 1).coerceAtLeast(0))
197 }
198 // Scroll to the current match whenever it (or the match set) changes.
199 LaunchedEffect(matchPos, matchIndices) {
200 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
201 }
202 // "Go to line" dialog state. Clears the jump highlight and scrolls the LazyColumn to the
203 // requested line (clamped to the rendered range) when the user confirms.
204 var showGoToLine by rememberSaveable(path) { mutableStateOf(false) }
205 fun goToLine(n: Int) {
206 val target = (n - 1).coerceIn(0, (lines.size - 1).coerceAtLeast(0))
207 highlightLine.value = null
208 scope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
209 }
210 Scaffold(
211 topBar = {
212 FileViewTopBar(
213 filename = filename,
214 subtitle = pathSubtitle,
215 loading = state.loading,
216 content = rawText,
217 wrap = wrap,
218 diffShown = showToggle && showDiff,
219 onBack = onBack,
220 onReload = { vm.reload() },
221 // Find searches the raw line list, which isn't on screen under a diff. Rather
222 // than silently greying the icon out, keep it tappable and explain where find
223 // lives when the user is in diff mode.
224 onToggleFind = {
225 if (showToggle && showDiff) {
226 scope.launch { snackbar.showSnackbar(context.getString(R.string.switch_to_raw_to_search)) }
227 } else {
228 findActive = !findActive
229 if (!findActive) findQuery = ""
230 }
231 },
232 onToggleWrap = { wrap = !wrap },
233 onGoToLine = { showGoToLine = true },
234 onCopy = { copyToClipboard(context, filename, rawText) },
235 onCopyPath = { copyToClipboard(context, context.getString(R.string.clip_label_path), path) },
236 onShare = {
237 val send = Intent(Intent.ACTION_SEND).apply {
238 type = "text/plain"
239 putExtra(Intent.EXTRA_SUBJECT, filename)
240 putExtra(Intent.EXTRA_TEXT, rawText)
241 }
242 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
243 .onFailure { scope.launch { snackbar.showSnackbar(context.getString(R.string.no_share_app)) } }
244 },
245 onOpenExternally = {
246 // Write the viewed content to a cache file and hand a content:// URI to an
247 // external app via ACTION_VIEW, so a file the in-app viewer can only show as
248 // text (or a large file beyond the render cap) can be opened in a real editor.
249 scope.launch {
250 val ok = runCatchingCancellable {
251 withContext(Dispatchers.IO) {
252 val dir = java.io.File(context.cacheDir, "external").apply { mkdirs() }
253 val file = java.io.File(dir, filename.ifBlank { "file.txt" })
254 file.writeText(rawText)
255 val authority = context.packageName + ".fileprovider"
256 val uri = androidx.core.content.FileProvider.getUriForFile(context, authority, file)
257 val view = Intent(Intent.ACTION_VIEW).apply {
258 setDataAndType(uri, android.webkit.MimeTypeMap.getSingleton()
259 .getMimeTypeFromExtension(filename.substringAfterLast('.', ""))
260 ?: "text/plain")
261 addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
262 }
263 context.startActivity(view)
264 }
265 }.isSuccess
266 if (!ok) scope.launch { snackbar.showSnackbar(context.getString(R.string.open_externally_failed)) }
267 }
268 },
269 )
270 },
271 snackbarHost = { SnackbarHost(snackbar) },
272 ) { padding ->
273 // Pull-to-refresh wraps the content so a user who pull-to-refreshes out of habit
274 // can reload the file, mirroring the file browser and session list gestures. The
275 // top-bar reload icon remains for a targeted tap.
276 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
277 isRefreshing = state.loading && state.content != null,
278 onRefresh = { vm.reload() },
279 modifier = Modifier.fillMaxSize().padding(padding),
280 ) {
281 Box(modifier = Modifier.fillMaxSize()) {
282 // Surface SSE connection state so a dropped stream is visible while viewing a file.
283 ConnectionBannerFor(container)
284 // AnimatedContent cross-fades between loading / error / content states so the
285 // viewer doesn't snap abruptly when a reload finishes or fails.
286 AnimatedContent(
287 targetState = state,
288 transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
289 label = "fileViewState",
290 ) { s ->
291 // Single source of truth for matches: the off-thread `matchIndices` produceState
292 // (keyed on the file content + debounced query). Both navigation (onPrev/onNext +
293 // scroll) and display (counter + highlight) read from it, so the counter can't show
294 // a new query while next/prev act on a stale set. The earlier in-composition
295 // per-layer scan re-ran the whole-file O(n) match on the UI thread each keystroke
296 // (the exact work produceState was moved off-thread to avoid); it's acceptable for
297 // the crossfade's exiting layer to briefly use this unified set until it settles.
298 FileViewStateContent(
299 state = s,
300 filename = filename,
301 showToggle = showToggle,
302 showDiff = showDiff,
303 onSetShowDiff = {
304 showDiff = it
305 // Close any open find bar when entering diff mode: find searches
306 // the raw content and scrolls the line LazyColumn, neither of
307 // which is visible in diff mode, so leaving it open would be a
308 // confusing no-op.
309 if (it) { findActive = false; findQuery = "" }
310 },
311 findActive = findActive,
312 findQuery = findQuery,
313 onQueryChange = { findQuery = it; matchPos = 0; if (it.isNotEmpty()) highlightLine.value = null },
314 matchIndices = matchIndices,
315 matchPos = matchPos,
316 onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
317 onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
318 onCloseFind = { findActive = false; findQuery = "" },
319 wrap = wrap,
320 lines = lines,
321 truncated = truncated,
322 highlightLineIndex = highlightLine.value,
323 listState = listState,
324 onRetry = { vm.reload() },
325 caseSensitive = caseSensitive,
326 onToggleCaseSensitive = { caseSensitive = !caseSensitive },
327 onShareFull = {
328 val send = Intent(Intent.ACTION_SEND).apply {
329 type = "text/plain"
330 putExtra(Intent.EXTRA_SUBJECT, filename)
331 putExtra(Intent.EXTRA_TEXT, rawText)
332 }
333 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
334 .onFailure { scope.launch { snackbar.showSnackbar(context.getString(R.string.no_share_app)) } }
335 },
336 )
337 }
338 }
339 }
340 }
342 GoToLineLauncher(
343 visible = showGoToLine,
344 maxLine = lines.size,
345 onConfirm = { n -> showGoToLine = false; goToLine(n) },
346 onDismiss = { showGoToLine = false },
347 )
350// Wraps the go-to-line dialog's visibility gate so the branch stays out of FileViewScreen
351// (keeping that composable under the cyclomatic-complexity threshold).
352@Composable
353private fun GoToLineLauncher(
354 visible: Boolean,
355 maxLine: Int,
356 onConfirm: (Int) -> Unit,
357 onDismiss: () -> Unit,
358) {
359 if (visible) {
360 GoToLineDialog(maxLine = maxLine, onConfirm = onConfirm, onDismiss = onDismiss)
361 }
364@Composable
365private fun GoToLineDialog(maxLine: Int, onConfirm: (Int) -> Unit, onDismiss: () -> Unit) {
366 var text by rememberSaveable { mutableStateOf("") }
367 val parsed = text.trim().toIntOrNull()
368 val valid = parsed != null && parsed >= 1
369 AlertDialog(
370 onDismissRequest = onDismiss,
371 title = { Text(stringResource(R.string.go_to_line)) },
372 text = {
373 OutlinedTextField(
374 value = text,
375 onValueChange = { v -> text = v.filter { it.isDigit() } },
376 singleLine = true,
377 isError = text.isNotEmpty() && !valid,
378 supportingText = {
379 Text(
380 stringResource(R.string.go_to_line_hint, maxLine),
381 style = MaterialTheme.typography.bodySmall,
382 )
383 },
384 keyboardOptions = KeyboardOptions(
385 keyboardType = KeyboardType.Number,
386 imeAction = ImeAction.Done,
387 ),
388 keyboardActions = KeyboardActions(onDone = { parsed?.let { onConfirm(it) } }),
389 )
390 },
391 confirmButton = {
392 TextButton(onClick = { parsed?.let { onConfirm(it) } }, enabled = valid) {
393 Text(stringResource(R.string.go_to_line))
394 }
395 },
396 dismissButton = {
397 TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
398 },
399 )
402// Owns the "jump to a line from a search hit" behaviour: scroll to the target once the raw
403// content loads (one-shot), and clear the highlight the moment the user drags the list by hand.
404// Extracted so FileViewScreen stays under the cyclomatic-complexity threshold.
405@Composable
406private fun rememberJumpToLineHighlight(
407 path: String,
408 initialLine: Int?,
409 content: Any?,
410 isBinary: Boolean,
411 lineCount: Int,
412 listState: LazyListState,
413): MutableState<Int?> {
414 val highlight = rememberSaveable(path) { mutableStateOf<Int?>(null) }
415 var didInitialScroll by rememberSaveable(path) { mutableStateOf(false) }
416 LaunchedEffect(content, initialLine) {
417 val line = initialLine ?: return@LaunchedEffect
418 if (didInitialScroll || content == null || isBinary) return@LaunchedEffect
419 val target = (line - 1).coerceIn(0, (lineCount - 1).coerceAtLeast(0))
420 runCatchingCancellable { listState.scrollToItem(target) }
421 highlight.value = target
422 didInitialScroll = true
423 }
424 LaunchedEffect(listState) {
425 if (initialLine == null) return@LaunchedEffect
426 listState.interactionSource.interactions.collect { interaction ->
427 if (interaction is DragInteraction.Start) highlight.value = null
428 }
429 }
430 return highlight
433@OptIn(ExperimentalMaterial3Api::class)
434@Composable
435private fun FileViewTopBar(
436 filename: String,
437 subtitle: String,
438 loading: Boolean,
439 content: String,
440 wrap: Boolean,
441 diffShown: Boolean,
442 onBack: () -> Unit,
443 onReload: () -> Unit,
444 onToggleFind: () -> Unit,
445 onToggleWrap: () -> Unit,
446 onGoToLine: () -> Unit,
447 onCopy: () -> Unit,
448 onCopyPath: () -> Unit,
449 onShare: () -> Unit,
450 onOpenExternally: () -> Unit,
451) {
452 TopAppBar(
453 title = {
454 // Two-line title: filename (primary) + the directory portion of the path
455 // (secondary), so a file opened from deep in a workspace keeps its location
456 // visible without an extra breadcrumb row.
457 Column {
458 Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis)
459 if (subtitle.isNotBlank()) {
460 Text(
461 subtitle,
462 maxLines = 1,
463 overflow = TextOverflow.Ellipsis,
464 style = MaterialTheme.typography.bodySmall,
465 color = MaterialTheme.colorScheme.onSurfaceVariant,
466 )
467 }
468 }
469 },
470 navigationIcon = {
471 IconButton(onClick = onBack) {
472 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
473 }
474 },
475 actions = {
476 // In-place refresh: a file's content can change on the server (the agent may
477 // be editing it), so reload without requiring a back-out. Shows a spinner while
478 // loading since the content now stays on screen during a reload (the spinner is
479 // the only progress signal).
480 val loadingLabel = stringResource(R.string.loading)
481 IconButton(onClick = onReload, enabled = !loading) {
482 if (loading) {
483 CircularProgressIndicator(
484 Modifier.size(20.dp).semantics { contentDescription = loadingLabel },
485 strokeWidth = 2.dp,
486 )
487 } else {
488 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
489 }
490 }
491 if (content.isNotEmpty()) {
492 // Find-in-file: toggles a search bar over the raw content. Stays enabled even in
493 // diff mode — the find bar searches the raw text and scrolls the line LazyColumn,
494 // neither of which is visible under a diff, so tapping it there surfaces a hint to
495 // switch to Raw rather than silently no-opping behind a greyed-out icon.
496 IconButton(onClick = onToggleFind) {
497 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
498 }
499 // Overflow the less-frequent actions (wrap / copy / share) so a long filename
500 // title isn't crowded off screen by five inline icons on a phone.
501 var menuExpanded by remember { mutableStateOf(false) }
502 IconButton(onClick = { menuExpanded = true }) {
503 Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options))
504 }
505 DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
506 // Wrap long lines instead of horizontal-scrolling; useful for prose. Disabled
507 // in diff mode because DiffView ignores wrap, so an active-looking toggle there
508 // would be a silent no-op.
509 DropdownMenuItem(
510 text = { Text(stringResource(R.string.go_to_line)) },
511 onClick = { onGoToLine(); menuExpanded = false },
512 leadingIcon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
513 )
514 DropdownMenuItem(
515 text = { Text(stringResource(R.string.wrap_lines)) },
516 onClick = { onToggleWrap(); menuExpanded = false },
517 enabled = !diffShown,
518 leadingIcon = {
519 Icon(
520 Icons.AutoMirrored.Filled.WrapText,
521 contentDescription = null,
522 tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
523 )
524 },
525 )
526 DropdownMenuItem(
527 text = { Text(stringResource(R.string.copy)) },
528 onClick = { onCopy(); menuExpanded = false },
529 leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) },
530 )
531 DropdownMenuItem(
532 text = { Text(stringResource(R.string.copy_path)) },
533 onClick = { onCopyPath(); menuExpanded = false },
534 leadingIcon = { Icon(Icons.Filled.Link, contentDescription = null) },
535 )
536 DropdownMenuItem(
537 text = { Text(stringResource(R.string.share)) },
538 onClick = { onShare(); menuExpanded = false },
539 leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) },
540 )
541 DropdownMenuItem(
542 text = { Text(stringResource(R.string.open_externally)) },
543 onClick = { onOpenExternally(); menuExpanded = false },
544 leadingIcon = { Icon(Icons.Filled.OpenInNew, contentDescription = null) },
545 )
546 }
547 }
548 },
549 )
552@Composable
553private fun BoxScope.FileViewStateContent(
554 state: FileViewState,
555 filename: String,
556 showToggle: Boolean,
557 showDiff: Boolean,
558 onSetShowDiff: (Boolean) -> Unit,
559 findActive: Boolean,
560 findQuery: String,
561 onQueryChange: (String) -> Unit,
562 matchIndices: List<Int>,
563 matchPos: Int,
564 onPrev: () -> Unit,
565 onNext: () -> Unit,
566 onCloseFind: () -> Unit,
567 wrap: Boolean,
568 lines: List<String>,
569 truncated: Boolean,
570 highlightLineIndex: Int?,
571 listState: LazyListState,
572 onRetry: () -> Unit,
573 onShareFull: () -> Unit,
574 caseSensitive: Boolean,
575 onToggleCaseSensitive: () -> Unit,
576) {
577 when {
578 // Full-screen spinner only on the initial load (no content yet). A reload keeps the
579 // existing content on screen; the top-bar reload button shows the in-flight progress.
580 state.loading && state.content == null -> {
581 val loadingLabel = stringResource(R.string.loading)
582 CircularProgressIndicator(
583 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
584 )
585 }
586 state.error != null -> Column(
587 modifier = Modifier.align(Alignment.Center).padding(24.dp),
588 horizontalAlignment = Alignment.CenterHorizontally,
589 ) {
590 Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
591 Spacer(Modifier.size(12.dp))
592 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
593 }
594 state.content?.isBinary == true -> Text(
595 stringResource(R.string.binary_file),
596 modifier = Modifier.align(Alignment.Center).padding(24.dp),
597 color = MaterialTheme.colorScheme.onSurfaceVariant,
598 )
599 else -> FileViewContentBody(
600 content = state.content,
601 filename = filename,
602 showToggle = showToggle,
603 showDiff = showDiff,
604 onSetShowDiff = onSetShowDiff,
605 findActive = findActive,
606 findQuery = findQuery,
607 onQueryChange = onQueryChange,
608 matchIndices = matchIndices,
609 matchPos = matchPos,
610 onPrev = onPrev,
611 onNext = onNext,
612 onCloseFind = onCloseFind,
613 wrap = wrap,
614 lines = lines,
615 truncated = truncated,
616 highlightLineIndex = highlightLineIndex,
617 listState = listState,
618 onShareFull = onShareFull,
619 caseSensitive = caseSensitive,
620 onToggleCaseSensitive = onToggleCaseSensitive,
621 )
622 }
625@Composable
626private fun BoxScope.FileViewContentBody(
627 content: soy.iko.opencode.data.model.FileContent?,
628 filename: String,
629 showToggle: Boolean,
630 showDiff: Boolean,
631 onSetShowDiff: (Boolean) -> Unit,
632 findActive: Boolean,
633 findQuery: String,
634 onQueryChange: (String) -> Unit,
635 matchIndices: List<Int>,
636 matchPos: Int,
637 onPrev: () -> Unit,
638 onNext: () -> Unit,
639 onCloseFind: () -> Unit,
640 wrap: Boolean,
641 lines: List<String>,
642 truncated: Boolean,
643 highlightLineIndex: Int?,
644 listState: LazyListState,
645 onShareFull: () -> Unit,
646 caseSensitive: Boolean,
647 onToggleCaseSensitive: () -> Unit,
648) {
649 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
650 // reachable while scrolling. Each contributes a top inset so the content below
651 // isn't hidden behind the overlay. The inset is measured from the actual overlay
652 // height (not a hardcoded 52dp/row) so accessibility font scaling — which can
653 // make the FilterChip labels and FindBar text field taller than 52dp — doesn't
654 // cause the first lines of content to hide behind the overlay.
655 // The raw text is on screen whenever we're NOT rendering the diff — i.e. either the
656 // file has no diff toggle at all (plain file: showToggle == false) or the toggle is
657 // set to Raw. Gate the find bar on this rather than on !showDiff: for a plain file
658 // showDiff stays at its `true` default forever (nothing ever flips it), so !showDiff
659 // would keep the find bar — and therefore find-in-file — permanently hidden.
660 val rawOnScreen = !(showToggle && showDiff)
661 val overlayRows = (if (showToggle) 1 else 0) + (if (findActive && rawOnScreen) 1 else 0)
662 var overlayHeightPx by remember { mutableIntStateOf(0) }
663 // Only apply the measured inset while an overlay is actually composed. When overlayRows
664 // drops to 0 the overlay Column is removed and onSizeChanged never fires to zero the
665 // height, so keying the inset off overlayRows (rather than the stale measurement) avoids
666 // a phantom top gap on the content below — e.g. after closing find on a plain file.
667 val topInset = if (overlayRows > 0) {
668 with(androidx.compose.ui.platform.LocalDensity.current) { overlayHeightPx.toDp() }
669 } else 0.dp
670 if (overlayRows > 0) {
671 Column(
672 modifier = Modifier
673 .align(Alignment.TopCenter)
674 .fillMaxWidth()
675 .onSizeChanged { overlayHeightPx = it.height }
676 .padding(top = 4.dp),
677 verticalArrangement = Arrangement.spacedBy(4.dp),
678 ) {
679 if (showToggle) {
680 Row(
681 modifier = Modifier.fillMaxWidth(),
682 horizontalArrangement = Arrangement.Center,
683 ) {
684 FilterChip(
685 selected = showDiff,
686 onClick = { onSetShowDiff(true) },
687 label = { Text(stringResource(R.string.show_diff)) },
688 )
689 Spacer(Modifier.size(8.dp))
690 FilterChip(
691 selected = !showDiff,
692 onClick = { onSetShowDiff(false) },
693 label = { Text(stringResource(R.string.show_raw)) },
694 )
695 }
696 }
697 if (findActive && rawOnScreen) {
698 FindBar(
699 query = findQuery,
700 onQueryChange = onQueryChange,
701 matchCount = matchIndices.size,
702 matchPos = matchPos,
703 onPrev = onPrev,
704 onNext = onNext,
705 onClose = onCloseFind,
706 caseSensitive = caseSensitive,
707 onToggleCaseSensitive = onToggleCaseSensitive,
708 )
709 }
710 }
711 }
712 if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
713 DiffView(
714 diff = content.diff,
715 modifier = Modifier
716 .fillMaxSize()
717 .padding(top = topInset)
718 .verticalScroll(rememberScrollState())
719 .padding(8.dp),
720 )
721 } else {
722 val text = content?.content.orEmpty()
723 if (text.isEmpty()) {
724 Text(
725 stringResource(R.string.empty_file),
726 modifier = Modifier.align(Alignment.Center).padding(24.dp),
727 color = MaterialTheme.colorScheme.onSurfaceVariant,
728 )
729 } else {
730 FileTextContent(
731 lines = lines,
732 filename = filename,
733 topInset = topInset,
734 truncated = truncated,
735 wrap = wrap,
736 findQuery = findQuery,
737 matchIndices = matchIndices,
738 highlightLineIndex = highlightLineIndex,
739 listState = listState,
740 onShareFull = onShareFull,
741 )
742 }
743 }
746@Composable
747private fun FindBar(
748 query: String,
749 onQueryChange: (String) -> Unit,
750 matchCount: Int,
751 matchPos: Int,
752 onPrev: () -> Unit,
753 onNext: () -> Unit,
754 onClose: () -> Unit,
755 caseSensitive: Boolean,
756 onToggleCaseSensitive: () -> Unit,
757) {
758 val keyboardController = LocalSoftwareKeyboardController.current
759 val matchCaseLabel = stringResource(R.string.match_case)
760 Row(
761 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
762 verticalAlignment = Alignment.CenterVertically,
763 ) {
764 OutlinedTextField(
765 value = query,
766 onValueChange = onQueryChange,
767 modifier = Modifier.weight(1f),
768 placeholder = { Text(stringResource(R.string.find_hint)) },
769 singleLine = true,
770 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
771 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
772 trailingIcon = {
773 Row(verticalAlignment = Alignment.CenterVertically) {
774 // Case-sensitivity toggle. A small "Aa" chip keeps the bar compact while
775 // surfacing a previously substring-only, always-case-insensitive search.
776 FilterChip(
777 selected = caseSensitive,
778 onClick = onToggleCaseSensitive,
779 label = { Text("Aa", style = MaterialTheme.typography.labelSmall) },
780 modifier = Modifier.semantics { contentDescription = matchCaseLabel },
781 )
782 Spacer(Modifier.size(8.dp))
783 val countText = if (query.isBlank()) ""
784 else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
785 else stringResource(R.string.match_count, matchPos + 1, matchCount)
786 if (countText.isNotEmpty()) {
787 // Use error color for "no matches" so the zero-result state is
788 // distinguishable from a positive match count at a glance.
789 val color = if (query.isNotBlank() && matchCount == 0) MaterialTheme.colorScheme.error
790 else MaterialTheme.colorScheme.onSurfaceVariant
791 Text(
792 countText,
793 // labelMedium (not labelSmall) so the counter is legible for low-vision
794 // users at large font scales; the error color still distinguishes zero
795 // matches at a glance.
796 style = MaterialTheme.typography.labelMedium,
797 color = color,
798 )
799 }
800 }
801 },
802 )
803 IconButton(onClick = onPrev, enabled = matchCount > 0) {
804 Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_previous))
805 }
806 IconButton(onClick = onNext, enabled = matchCount > 0) {
807 Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_next))
808 }
809 IconButton(onClick = onClose) {
810 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
811 }
812 }
815@Composable
816private fun FileTextContent(
817 lines: List<String>,
818 filename: String,
819 topInset: androidx.compose.ui.unit.Dp,
820 truncated: Boolean,
821 wrap: Boolean,
822 findQuery: String,
823 matchIndices: List<Int>,
824 highlightLineIndex: Int?,
825 listState: LazyListState,
826 onShareFull: () -> Unit,
827) {
828 // Honor the user's chat text-size preference (the same one the chat markdown honors) so
829 // a user who bumped the scale for readability gets the same scale in the code viewer.
830 // Applied by nesting a MaterialTheme with a scaled typography, exactly how MarkdownText
831 // does it — the gutter/code Text below read MaterialTheme.typography.bodySmall, so this
832 // reaches both without per-Text plumbing.
833 val scale = LocalChatTextScale.current
834 val base = MaterialTheme.typography
835 val scaled = remember(scale, base) { base.scaledBy(scale) }
836 // Gutter width scales with the digit count and the font scale so accessibility
837 // text scaling (e.g. 1.3x) and the app's chat-text scale don't make line numbers overflow
838 // the gutter and collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
839 // proportionally for larger font/app scales.
840 val fontScale = androidx.compose.ui.platform.LocalDensity.current.fontScale
841 val gutterWidth = remember(lines.size, fontScale, scale) {
842 ((lines.size.toString().length.coerceAtLeast(3) * 10) *
843 fontScale.coerceAtLeast(1f) *
844 scale.coerceAtLeast(1f)).dp
845 }
846 val hScrollState = rememberScrollState()
847 val palette = rememberHighlightPalette()
848 // Resolve the file's language once instead of re-parsing the extension for every line.
849 val syntax = remember(filename) { syntaxFor(filename) }
850 val q = findQuery.trim()
851 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
852 MaterialTheme(typography = scaled) {
853 Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
854 if (truncated) {
855 androidx.compose.foundation.layout.Row(
856 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
857 verticalAlignment = Alignment.CenterVertically,
858 horizontalArrangement = Arrangement.SpaceBetween,
859 ) {
860 Text(
861 stringResource(R.string.file_truncated, MAX_RENDERED_LINES),
862 style = MaterialTheme.typography.labelSmall,
863 color = MaterialTheme.colorScheme.onSurfaceVariant,
864 modifier = Modifier.weight(1f),
865 )
866 // Escape hatch: when only the first N lines render, offer the (already
867 // full-content) Share action inline so the user isn't stranded without the
868 // rest of the file. The overflow Share shares the same full content.
869 TextButton(onClick = onShareFull) {
870 Text(stringResource(R.string.share_full_file))
871 }
872 }
873 }
874 // SelectionContainer lets the user select/copy a portion of the rendered code
875 // (not just the whole-file Copy in the overflow). Selection across recycled
876 // LazyColumn items can drop spans that have scrolled away, but partial selection
877 // of the visible region — the common case — works.
878 SelectionContainer {
879 LazyColumn(
880 state = listState,
881 modifier = Modifier
882 .weight(1f)
883 .fillMaxWidth()
884 .padding(8.dp),
885 ) {
886 itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
887 val isMatch = q.isNotEmpty() && index in matchSet
888 // The jump-to-line target keeps the same emphasis as a find match so the line
889 // the viewer landed on is obvious until the user scrolls or starts a find.
890 val isTarget = index == highlightLineIndex
891 Row(
892 modifier = Modifier
893 .fillMaxWidth()
894 .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
895 .then(if (isMatch || isTarget) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
896 ) {
897 Text(
898 "${index + 1}",
899 modifier = Modifier.width(gutterWidth),
900 style = MaterialTheme.typography.bodySmall,
901 fontFamily = FontFamily.Monospace,
902 color = MaterialTheme.colorScheme.onSurfaceVariant,
903 )
904 // Heuristic syntax highlighting per line. Falls back to plain text for
905 // unknown extensions, so non-code files render unchanged. Memoized so the
906 // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
907 // file, or palette actually changes — not on every recomposition (e.g. every
908 // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
909 val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
910 Text(
911 highlighted,
912 modifier = Modifier.padding(start = 8.dp),
913 style = MaterialTheme.typography.bodySmall,
914 )
915 }
916 }
917 }
918 }
919 }
920 }