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