git.iko.soy/opencode-android
on a0ce9ce help sign in
1272 lines 64 kB history raw
1package soy.iko.opencode.ui.file
3import android.content.Intent
4import androidx.compose.animation.AnimatedContent
5import androidx.compose.animation.core.RepeatMode
6import androidx.compose.animation.core.animateFloat
7import androidx.compose.animation.core.infiniteRepeatable
8import androidx.compose.animation.core.rememberInfiniteTransition
9import androidx.compose.animation.core.tween
10import androidx.compose.animation.fadeIn
11import androidx.compose.animation.fadeOut
12import androidx.compose.animation.togetherWith
13import androidx.compose.foundation.background
14import androidx.compose.foundation.clickable
15import androidx.compose.foundation.horizontalScroll
16import androidx.compose.foundation.interaction.DragInteraction
17import androidx.compose.foundation.layout.Box
18import androidx.compose.foundation.layout.BoxScope
19import androidx.compose.foundation.layout.heightIn
20import androidx.compose.foundation.layout.Column
21import androidx.compose.foundation.layout.Arrangement
22import androidx.compose.foundation.layout.Row
23import androidx.compose.foundation.layout.Spacer
24import androidx.compose.foundation.layout.fillMaxSize
25import androidx.compose.foundation.layout.fillMaxWidth
26import androidx.compose.foundation.layout.height
27import androidx.compose.foundation.layout.imePadding
28import androidx.compose.foundation.layout.padding
29import androidx.compose.foundation.layout.size
30import androidx.compose.foundation.layout.width
31import androidx.compose.foundation.lazy.LazyColumn
32import androidx.compose.foundation.lazy.LazyListState
33import androidx.compose.foundation.lazy.itemsIndexed
34import androidx.compose.foundation.lazy.rememberLazyListState
35import androidx.compose.foundation.rememberScrollState
36import androidx.compose.foundation.text.KeyboardActions
37import androidx.compose.foundation.text.KeyboardOptions
38import androidx.compose.foundation.text.selection.SelectionContainer
39import androidx.compose.material.icons.Icons
40import androidx.compose.material.icons.automirrored.filled.ArrowBack
41import androidx.compose.material.icons.automirrored.filled.Chat
42import androidx.compose.material.icons.automirrored.filled.WrapText
43import androidx.compose.material.icons.filled.Close
44import androidx.compose.material.icons.filled.ContentCopy
45import androidx.compose.material.icons.filled.ErrorOutline
46import androidx.compose.material.icons.filled.KeyboardArrowDown
47import androidx.compose.material.icons.filled.KeyboardArrowUp
48import androidx.compose.material.icons.filled.Link
49import androidx.compose.material.icons.filled.MoreVert
50import androidx.compose.material.icons.filled.Refresh
51import androidx.compose.material.icons.filled.Search
52import androidx.compose.material.icons.filled.OpenInNew
53import androidx.compose.material.icons.filled.Share
54import androidx.compose.material3.AlertDialog
55import androidx.compose.material3.CircularProgressIndicator
56import androidx.compose.material3.DropdownMenu
57import androidx.compose.material3.DropdownMenuItem
58import androidx.compose.material3.ExperimentalMaterial3Api
59import androidx.compose.material3.FilterChip
60import androidx.compose.material3.Icon
61import androidx.compose.material3.IconButton
62import androidx.compose.material3.MaterialTheme
63import androidx.compose.material3.OutlinedTextField
64import androidx.compose.material3.Scaffold
65import androidx.compose.material3.SnackbarHost
66import androidx.compose.material3.SnackbarHostState
67import androidx.compose.material3.Text
68import androidx.compose.material3.TextButton
69import androidx.compose.material3.TopAppBar
70import androidx.compose.runtime.Composable
71import androidx.compose.runtime.LaunchedEffect
72import androidx.compose.runtime.MutableState
73import androidx.compose.runtime.getValue
74import androidx.compose.runtime.mutableIntStateOf
75import androidx.compose.runtime.mutableStateOf
76import androidx.compose.runtime.produceState
77import androidx.compose.runtime.remember
78import androidx.compose.runtime.rememberCoroutineScope
79import androidx.compose.runtime.saveable.rememberSaveable
80import androidx.compose.runtime.setValue
81import androidx.compose.ui.Alignment
82import androidx.compose.ui.Modifier
83import androidx.compose.ui.draw.alpha
84import androidx.compose.ui.draw.clip
85import androidx.compose.ui.layout.onSizeChanged
86import androidx.compose.ui.platform.LocalContext
87import androidx.compose.ui.platform.LocalDensity
88import androidx.compose.ui.platform.LocalSoftwareKeyboardController
89import androidx.compose.ui.semantics.Role
90import androidx.compose.ui.semantics.contentDescription
91import androidx.compose.ui.semantics.semantics
92import androidx.compose.ui.text.font.FontFamily
93import androidx.compose.ui.text.input.ImeAction
94import androidx.compose.ui.text.input.KeyboardType
95import androidx.compose.ui.text.style.TextOverflow
96import androidx.compose.ui.unit.dp
97import androidx.compose.ui.res.stringResource
98import androidx.lifecycle.compose.collectAsStateWithLifecycle
99import androidx.lifecycle.viewmodel.compose.viewModel
100import coil3.compose.SubcomposeAsyncImage
101import coil3.request.ImageRequest
102import coil3.request.crossfade
103import soy.iko.opencode.di.AppContainer
104import soy.iko.opencode.R
105import soy.iko.opencode.ui.components.ConnectionBannerFor
106import soy.iko.opencode.ui.components.DiffView
107import soy.iko.opencode.ui.components.FullscreenImageViewer
108import soy.iko.opencode.ui.components.LocalChatTextScale
109import soy.iko.opencode.ui.components.LocalCodeWrap
110import soy.iko.opencode.ui.components.copyToClipboard
111import soy.iko.opencode.ui.components.highlightLine
112import soy.iko.opencode.ui.components.relativeTime
113import soy.iko.opencode.ui.components.syntaxFor
114import soy.iko.opencode.ui.components.rememberHighlightPalette
115import soy.iko.opencode.ui.components.scaledBy
116import soy.iko.opencode.ui.vmFactory
117import soy.iko.opencode.util.runCatchingCancellable
118import kotlinx.coroutines.Dispatchers
119import kotlinx.coroutines.delay
120import kotlinx.coroutines.flow.collect
121import kotlinx.coroutines.launch
122import kotlinx.coroutines.withContext
124import soy.iko.opencode.data.network.NetworkConfig
126/** Cap on how many lines the raw viewer renders and searches. See [NetworkConfig.maxRenderedFileLines]. */
127private const val MAX_RENDERED_LINES = NetworkConfig.maxRenderedFileLines
129@OptIn(ExperimentalMaterial3Api::class)
130@Composable
131fun FileViewScreen(
132 container: AppContainer,
133 path: String,
134 onBack: () -> Unit,
135 initialLine: Int? = null,
136) {
137 // Key the ViewModel by path so navigating from file A to file B (which, with
138 // launchSingleTop = true on the FILE_VIEW route, reuses the same back-stack entry
139 // and ViewModelStoreOwner) yields a distinct ViewModel per file. Without a key,
140 // viewModel() would return file A's VM — its init fetches A's path only once, so
141 // file B's content would never load. Mirrors ChatScreen's viewModel(key = sessionId).
142 val vm: FileViewModel = viewModel(key = path, factory = vmFactory { FileViewModel(container, path) })
143 val state by vm.state.collectAsStateWithLifecycle()
144 val context = LocalContext.current
145 val scope = rememberCoroutineScope()
146 val snackbar = remember { SnackbarHostState() }
147 val filename = remember(path) {
148 val trimmed = path.trimEnd('/')
149 trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } }
150 }
151 val shareLabel = stringResource(R.string.share)
152 // Diff vs raw view toggle. Defaults to showing the diff when one exists; the user can
153 // switch to the raw new content. Persisted via rememberSaveable across rotation. When the
154 // viewer is opened at a specific line (from a search hit), start on the raw view so the
155 // targeted line is actually rendered (the diff view has no line list to scroll).
156 val hasDiff = state.content?.diff != null && state.content?.diff?.isNotBlank() == true
157 var showDiff by rememberSaveable(path) { mutableStateOf(initialLine == null) }
158 val showToggle = hasDiff && state.content?.content.orEmpty().isNotEmpty()
159 // Find-in-file and line-wrapping state, persisted so a rotation or reload keeps the
160 // user's query/mode.
161 var findActive by rememberSaveable(path) { mutableStateOf(false) }
162 var findQuery by rememberSaveable(path) { mutableStateOf("") }
163 var matchPos by rememberSaveable(path) { mutableIntStateOf(0) }
164 // Seed the per-file wrap toggle from the user's global code-wrap preference so the viewer
165 // respects it by default; they can still flip it per-file (persisted across rotation).
166 val codeWrap = LocalCodeWrap.current
167 var wrap by rememberSaveable(path) { mutableStateOf(codeWrap) }
168 val listState = rememberLazyListState()
169 val rawText = state.content?.content.orEmpty()
170 // Directory portion of the path, shown under the filename in the top bar so a user who
171 // has drilled into a deep workspace keeps context of where this file lives.
172 val pathSubtitle = remember(path) {
173 val trimmed = path.trimEnd('/')
174 val parent = trimmed.substringBeforeLast('/', "")
175 parent.ifBlank { path }
176 }
177 // Compact "12.3 KB · 2d ago" metadata line for the top bar. Built from the size/mtime the
178 // ViewModel resolved via the parent directory listing (null when the server doesn't emit
179 // them). Uses the same formatFileSize/relativeTime helpers as the file browser's FileRow so
180 // the formatting is identical across screens. Built here (not in the top bar) because it
181 // needs the Context and the state, and the top bar is a stateless composable.
182 val metadataLine = remember(state.size, state.mtime) {
183 buildMetadataLine(context, state.size, state.mtime)
184 }
185 // Split the raw text exactly once here (the line viewer and find-in-file both consume this
186 // list) and cap it so a huge file doesn't get split twice or overwhelm the LazyColumn.
187 // `take()` (not `subList()`) so the cap actually releases the tail: subList returns a view
188 // backed by the full list, which would keep every line (and the full rawText) in memory.
189 val allLines = remember(rawText) { rawText.split("\n") }
190 // The viewer renders the file in chunks of MAX_RENDERED_LINES so a huge file doesn't lay
191 // out thousands of lines at once. The user can load the next chunk via the truncation
192 // banner's "Load more" button instead of being stranded at the first cap (or forced to
193 // leave the app via Share full file).
194 var renderLimit by rememberSaveable(path) { mutableStateOf(MAX_RENDERED_LINES) }
195 val lines = remember(rawText, renderLimit) { allLines.take(renderLimit) }
196 val truncated = allLines.size > lines.size
197 val moreLines = (allLines.size - lines.size).coerceAtMost(MAX_RENDERED_LINES)
198 // The line the viewer was opened at (from a search hit), highlighted until the user scrolls
199 // or starts an in-file find so the landed line stands out. Null once cleared.
200 val highlightLine = rememberJumpToLineHighlight(
201 path = path,
202 initialLine = initialLine,
203 content = state.content,
204 isBinary = state.content?.isBinary == true,
205 lineCount = lines.size,
206 listState = listState,
207 )
208 // Find-in-file match-case toggle (default case-insensitive). Persisted so a rotation keeps
209 // the choice, and applied to the off-thread match scan below.
210 var caseSensitive by rememberSaveable(path) { mutableStateOf(false) }
211 // Regex mode: when on, the query is interpreted as a regular expression (Java Pattern)
212 // instead of a plain substring. A power-user affordance mirroring editor find bars.
213 var regex by rememberSaveable(path) { mutableStateOf(false) }
214 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
215 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
216 var debouncedFind by remember { mutableStateOf("") }
217 LaunchedEffect(findQuery) {
218 val q = findQuery.trim()
219 if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q }
220 }
221 val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind, caseSensitive, regex) {
222 val q = debouncedFind
223 value = if (q.isEmpty()) emptyList()
224 else withContext(Dispatchers.Default) {
225 // Compile the pattern once per scan when regex mode is on; a malformed pattern
226 // yields no matches (rather than throwing) and the FindBar surfaces the error
227 // state via the zero match count. Case-sensitivity maps to Pattern flags.
228 val pattern = if (regex) {
229 runCatching {
230 val flags = if (caseSensitive) 0 else java.util.regex.Pattern.CASE_INSENSITIVE
231 java.util.regex.Pattern.compile(q, flags)
232 }.getOrNull()
233 } else {
234 null
235 }
236 lines.mapIndexedNotNull { index, line ->
237 val matched = if (regex) {
238 pattern?.matcher(line)?.find() == true
239 } else {
240 line.contains(q, ignoreCase = !caseSensitive)
241 }
242 if (matched) index else null
243 }
244 }
245 }
246 // Keep the current match index in range when the match set shrinks (e.g. the file
247 // reloaded with fewer matches), so the counter never shows a stale "10 / 3".
248 LaunchedEffect(matchIndices.size) {
249 matchPos = matchPos.coerceIn(0, (matchIndices.size - 1).coerceAtLeast(0))
250 }
251 // Scroll to the current match whenever it (or the match set) changes.
252 LaunchedEffect(matchPos, matchIndices) {
253 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
254 }
255 // "Go to line" dialog state. Clears the jump highlight and scrolls the LazyColumn to the
256 // requested line (clamped to the rendered range) when the user confirms.
257 var showGoToLine by rememberSaveable(path) { mutableStateOf(false) }
258 fun goToLine(n: Int) {
259 val target = (n - 1).coerceIn(0, (lines.size - 1).coerceAtLeast(0))
260 highlightLine.value = null
261 scope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
262 }
263 Scaffold(
264 topBar = {
265 FileViewTopBar(
266 filename = filename,
267 subtitle = pathSubtitle,
268 metadata = metadataLine,
269 loading = state.loading,
270 content = rawText,
271 wrap = wrap,
272 diffShown = showToggle && showDiff,
273 onBack = onBack,
274 onReload = { vm.reload() },
275 // Find searches the raw line list, which isn't on screen under a diff. Rather
276 // than silently greying the icon out, keep it tappable and explain where find
277 // lives when the user is in diff mode.
278 onToggleFind = {
279 if (showToggle && showDiff) {
280 scope.launch { snackbar.showSnackbar(context.getString(R.string.switch_to_raw_to_search)) }
281 } else {
282 findActive = !findActive
283 if (!findActive) findQuery = ""
284 }
285 },
286 onToggleWrap = { wrap = !wrap },
287 onGoToLine = { showGoToLine = true },
288 onCopy = { copyToClipboard(context, filename, rawText) },
289 onCopyPath = { copyToClipboard(context, context.getString(R.string.clip_label_path), path) },
290 onShare = {
291 val send = Intent(Intent.ACTION_SEND).apply {
292 type = "text/plain"
293 putExtra(Intent.EXTRA_SUBJECT, filename)
294 putExtra(Intent.EXTRA_TEXT, rawText)
295 }
296 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
297 .onFailure {
298 // rawText is the full (untruncated) file content, so a file with very
299 // long lines can blow past the Binder transaction limit (~1MB) while
300 // staying under the render-line cap. Distinguish that from a missing
301 // share target so the message is honest — mirrors onShareFull below.
302 val msg = if (it is android.os.DeadObjectException ||
303 it is android.os.TransactionTooLargeException
304 ) {
305 context.getString(R.string.file_share_too_large)
306 } else {
307 context.getString(R.string.no_share_app)
308 }
309 scope.launch { snackbar.showSnackbar(msg) }
310 }
311 },
312 onOpenExternally = {
313 // Write the viewed content to a cache file and hand a content:// URI to an
314 // external app via ACTION_VIEW, so a file the in-app viewer can only show as
315 // text (or a large file beyond the render cap) can be opened in a real editor.
316 scope.launch {
317 val ok = runCatchingCancellable {
318 withContext(Dispatchers.IO) {
319 val dir = java.io.File(context.cacheDir, "external").apply { mkdirs() }
320 // Evict stale cache files handed to external apps so repeated
321 // opens of large files don't accumulate forever. A TTL (vs.
322 // wiping the dir) preserves files an external app may still be
323 // reading from a recent open. Pruned before writing the new one.
324 val cutoff = System.currentTimeMillis() - NetworkConfig.externalCacheTtlMs
325 dir.listFiles()?.forEach { f ->
326 runCatching { if (f.lastModified() < cutoff) f.delete() }
327 }
328 // Sanitize the filename: replace characters that are invalid in
329 // a filesystem filename or that FileProvider.getUriForFile
330 // rejects (path separators, NUL, and Windows-illegal chars that
331 // also trip the provider), and reject `..` traversal. Without
332 // this, a path ending in an unusual character (e.g. `:`, `?`)
333 // would throw at getUriForFile AFTER writeText succeeded, orphaning
334 // the cache file. Sanitize up front so the write and the URI use
335 // the same safe name.
336 val safeName = filename.ifBlank { "file.txt" }
337 .substringAfterLast('/')
338 .replace(Regex("[\\\\/:*?\"<>|\\u0000]"), "_")
339 .takeIf { it.isNotBlank() && it != "." && it != ".." }
340 ?: "file.txt"
341 val file = java.io.File(dir, safeName)
342 try {
343 file.writeText(rawText)
344 val authority = context.packageName + ".fileprovider"
345 val uri = androidx.core.content.FileProvider.getUriForFile(context, authority, file)
346 val view = Intent(Intent.ACTION_VIEW).apply {
347 setDataAndType(uri, android.webkit.MimeTypeMap.getSingleton()
348 .getMimeTypeFromExtension(safeName.substringAfterLast('.', ""))
349 ?: "text/plain")
350 addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
351 }
352 context.startActivity(view)
353 } catch (e: Throwable) {
354 // getUriForFile or startActivity can throw after the cache
355 // file was written; delete it so a failure doesn't orphan
356 // cache files that the TTL prune won't reap for a while.
357 runCatching { file.delete() }
358 throw e
359 }
360 }
361 }.isSuccess
362 if (!ok) scope.launch { snackbar.showSnackbar(context.getString(R.string.open_externally_failed)) }
363 }
364 },
365 onOpenInChat = {
366 // Start a new session with this file attached as an @path reference,
367 // mirroring the file browser row's "Open in chat" action — the viewer is
368 // the more natural place for it since the user is already looking at the file.
369 container.setPendingShare("@$path")
370 container.requestNewSession()
371 onBack()
372 },
373 )
374 },
375 snackbarHost = { SnackbarHost(snackbar) },
376 ) { padding ->
377 // Pull-to-refresh wraps the content so a user who pull-to-refreshes out of habit
378 // can reload the file, mirroring the file browser and session list gestures. The
379 // top-bar reload icon remains for a targeted tap.
380 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
381 isRefreshing = state.loading && state.content != null,
382 onRefresh = { vm.reload() },
383 modifier = Modifier.fillMaxSize().imePadding().padding(padding),
384 ) {
385 Box(modifier = Modifier.fillMaxSize()) {
386 // Surface SSE connection state so a dropped stream is visible while viewing a file.
387 ConnectionBannerFor(container)
388 // AnimatedContent cross-fades between loading / error / content states so the
389 // viewer doesn't snap abruptly when a reload finishes or fails.
390 AnimatedContent(
391 targetState = state,
392 transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
393 label = "fileViewState",
394 ) { s ->
395 // Single source of truth for matches: the off-thread `matchIndices` produceState
396 // (keyed on the file content + debounced query). Both navigation (onPrev/onNext +
397 // scroll) and display (counter + highlight) read from it, so the counter can't show
398 // a new query while next/prev act on a stale set. The earlier in-composition
399 // per-layer scan re-ran the whole-file O(n) match on the UI thread each keystroke
400 // (the exact work produceState was moved off-thread to avoid); it's acceptable for
401 // the crossfade's exiting layer to briefly use this unified set until it settles.
402 FileViewStateContent(
403 state = s,
404 path = path,
405 filename = filename,
406 showToggle = showToggle,
407 showDiff = showDiff,
408 onSetShowDiff = {
409 showDiff = it
410 // Close any open find bar when entering diff mode: find searches
411 // the raw content and scrolls the line LazyColumn, neither of
412 // which is visible in diff mode, so leaving it open would be a
413 // confusing no-op.
414 if (it) { findActive = false; findQuery = "" }
415 },
416 findActive = findActive,
417 findQuery = findQuery,
418 onQueryChange = { findQuery = it; matchPos = 0; if (it.isNotEmpty()) highlightLine.value = null },
419 matchIndices = matchIndices,
420 matchPos = matchPos,
421 onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
422 onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
423 onCloseFind = { findActive = false; findQuery = "" },
424 wrap = wrap,
425 lines = lines,
426 truncated = truncated,
427 moreLines = moreLines,
428 onLoadMore = { renderLimit += MAX_RENDERED_LINES },
429 highlightLineIndex = highlightLine.value,
430 listState = listState,
431 onRetry = { vm.reload() },
432 caseSensitive = caseSensitive,
433 onToggleCaseSensitive = { caseSensitive = !caseSensitive },
434 regex = regex,
435 onToggleRegex = { regex = !regex },
436 onShareFull = {
437 val send = Intent(Intent.ACTION_SEND).apply {
438 type = "text/plain"
439 putExtra(Intent.EXTRA_SUBJECT, filename)
440 putExtra(Intent.EXTRA_TEXT, rawText)
441 }
442 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
443 .onFailure {
444 // A large file can blow past the Binder transaction limit
445 // (~1MB); distinguish that from a missing share target so the
446 // message is honest instead of blaming a nonexistent app.
447 val msg = if (it is android.os.DeadObjectException ||
448 it is android.os.TransactionTooLargeException
449 ) {
450 context.getString(R.string.file_share_too_large)
451 } else {
452 context.getString(R.string.no_share_app)
453 }
454 scope.launch { snackbar.showSnackbar(msg) }
455 }
456 },
457 )
458 }
459 }
460 }
461 }
463 GoToLineLauncher(
464 visible = showGoToLine,
465 maxLine = lines.size,
466 onConfirm = { n -> showGoToLine = false; goToLine(n) },
467 onDismiss = { showGoToLine = false },
468 )
471// Wraps the go-to-line dialog's visibility gate so the branch stays out of FileViewScreen
472// (keeping that composable under the cyclomatic-complexity threshold).
473@Composable
474private fun GoToLineLauncher(
475 visible: Boolean,
476 maxLine: Int,
477 onConfirm: (Int) -> Unit,
478 onDismiss: () -> Unit,
479) {
480 if (visible) {
481 GoToLineDialog(maxLine = maxLine, onConfirm = onConfirm, onDismiss = onDismiss)
482 }
485@Composable
486private fun GoToLineDialog(maxLine: Int, onConfirm: (Int) -> Unit, onDismiss: () -> Unit) {
487 // Use plain remember (not rememberSaveable) so each open starts with an empty field.
488 // The dialog is conditionally composed (gated by `visible` in GoToLineLauncher); with
489 // rememberSaveable the typed text would be restored from the saved-state registry on
490 // re-open, pre-filling whatever the user typed — and dismissed without — last time.
491 var text by remember { mutableStateOf("") }
492 val parsed = text.trim().toIntOrNull()
493 val valid = parsed != null && parsed in 1..maxLine
494 AlertDialog(
495 onDismissRequest = onDismiss,
496 title = { Text(stringResource(R.string.go_to_line)) },
497 text = {
498 OutlinedTextField(
499 value = text,
500 onValueChange = { v -> text = v.filter { it.isDigit() } },
501 singleLine = true,
502 isError = text.isNotEmpty() && !valid,
503 supportingText = {
504 Text(
505 stringResource(R.string.go_to_line_hint, maxLine),
506 style = MaterialTheme.typography.bodySmall,
507 )
508 },
509 keyboardOptions = KeyboardOptions(
510 keyboardType = KeyboardType.Number,
511 imeAction = ImeAction.Done,
512 ),
513 keyboardActions = KeyboardActions(onDone = { if (valid) parsed?.let { onConfirm(it) } }),
514 )
515 },
516 confirmButton = {
517 TextButton(onClick = { parsed?.let { onConfirm(it) } }, enabled = valid) {
518 Text(stringResource(R.string.go_to_line))
519 }
520 },
521 dismissButton = {
522 TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
523 },
524 )
527// Owns the "jump to a line from a search hit" behaviour: scroll to the target once the raw
528// content loads (one-shot), and clear the highlight the moment the user drags the list by hand.
529// Extracted so FileViewScreen stays under the cyclomatic-complexity threshold.
530@Composable
531private fun rememberJumpToLineHighlight(
532 path: String,
533 initialLine: Int?,
534 content: Any?,
535 isBinary: Boolean,
536 lineCount: Int,
537 listState: LazyListState,
538): MutableState<Int?> {
539 val highlight = rememberSaveable(path) { mutableStateOf<Int?>(null) }
540 var didInitialScroll by rememberSaveable(path) { mutableStateOf(false) }
541 LaunchedEffect(content, initialLine) {
542 val line = initialLine ?: return@LaunchedEffect
543 if (didInitialScroll || content == null || isBinary) return@LaunchedEffect
544 val target = (line - 1).coerceIn(0, (lineCount - 1).coerceAtLeast(0))
545 runCatchingCancellable { listState.scrollToItem(target) }
546 highlight.value = target
547 didInitialScroll = true
548 }
549 LaunchedEffect(listState) {
550 if (initialLine == null) return@LaunchedEffect
551 listState.interactionSource.interactions.collect { interaction ->
552 if (interaction is DragInteraction.Start) highlight.value = null
553 }
554 }
555 return highlight
558/** Build the compact "12.3 KB · 2d ago" metadata line for the file viewer's top bar from the
559 * size/mtime the ViewModel resolved. Returns an empty string when neither is present so the
560 * caller can drop the line in unconditionally. Uses the same helpers as the file browser's
561 * FileRow so the formatting is identical across screens. */
562private fun buildMetadataLine(context: android.content.Context, size: Long?, mtime: Long?): String {
563 val parts = buildList {
564 size?.let { add(formatFileSize(it)) }
565 mtime?.let { add(relativeTime(context, it)) }
566 }
567 return parts.joinToString(" · ")
570@OptIn(ExperimentalMaterial3Api::class)
571@Composable
572private fun FileViewTopBar(
573 filename: String,
574 subtitle: String,
575 metadata: String,
576 loading: Boolean,
577 content: String,
578 wrap: Boolean,
579 diffShown: Boolean,
580 onBack: () -> Unit,
581 onReload: () -> Unit,
582 onToggleFind: () -> Unit,
583 onToggleWrap: () -> Unit,
584 onGoToLine: () -> Unit,
585 onCopy: () -> Unit,
586 onCopyPath: () -> Unit,
587 onShare: () -> Unit,
588 onOpenExternally: () -> Unit,
589 onOpenInChat: () -> Unit,
590) {
591 TopAppBar(
592 title = {
593 // Two- or three-line title: filename (primary), the directory portion of the path
594 // (secondary), and an optional muted metadata line (size · mtime) so a user who
595 // has drilled into a deep workspace keeps context of where this file lives and how
596 // stale it is without an extra breadcrumb row.
597 Column {
598 Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis)
599 if (subtitle.isNotBlank()) {
600 Text(
601 subtitle,
602 maxLines = 1,
603 overflow = TextOverflow.Ellipsis,
604 style = MaterialTheme.typography.bodySmall,
605 color = MaterialTheme.colorScheme.onSurfaceVariant,
606 )
607 }
608 if (metadata.isNotEmpty()) {
609 Text(
610 metadata,
611 maxLines = 1,
612 overflow = TextOverflow.Ellipsis,
613 style = MaterialTheme.typography.labelSmall,
614 color = MaterialTheme.colorScheme.onSurfaceVariant,
615 )
616 }
617 }
618 },
619 navigationIcon = {
620 IconButton(onClick = onBack) {
621 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
622 }
623 },
624 actions = {
625 // In-place refresh: a file's content can change on the server (the agent may
626 // be editing it), so reload without requiring a back-out. Shows a spinner while
627 // loading since the content now stays on screen during a reload (the spinner is
628 // the only progress signal).
629 val loadingLabel = stringResource(R.string.loading)
630 IconButton(onClick = onReload, enabled = !loading) {
631 if (loading) {
632 CircularProgressIndicator(
633 Modifier.size(20.dp).semantics { contentDescription = loadingLabel },
634 strokeWidth = 2.dp,
635 )
636 } else {
637 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
638 }
639 }
640 if (content.isNotEmpty()) {
641 // Find-in-file: toggles a search bar over the raw content. Stays enabled even in
642 // diff mode — the find bar searches the raw text and scrolls the line LazyColumn,
643 // neither of which is visible under a diff, so tapping it there surfaces a hint to
644 // switch to Raw rather than silently no-opping behind a greyed-out icon.
645 IconButton(onClick = onToggleFind) {
646 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
647 }
648 // Overflow the less-frequent actions (wrap / copy / share) so a long filename
649 // title isn't crowded off screen by five inline icons on a phone.
650 var menuExpanded by remember { mutableStateOf(false) }
651 IconButton(onClick = { menuExpanded = true }) {
652 Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options_for, filename))
653 }
654 DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
655 // Wrap long lines instead of horizontal-scrolling; useful for prose. Disabled
656 // in diff mode because DiffView ignores wrap, so an active-looking toggle there
657 // would be a silent no-op.
658 DropdownMenuItem(
659 text = { Text(stringResource(R.string.go_to_line)) },
660 onClick = { onGoToLine(); menuExpanded = false },
661 leadingIcon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
662 )
663 DropdownMenuItem(
664 text = { Text(stringResource(R.string.wrap_lines)) },
665 onClick = { onToggleWrap(); menuExpanded = false },
666 enabled = !diffShown,
667 leadingIcon = {
668 Icon(
669 Icons.AutoMirrored.Filled.WrapText,
670 contentDescription = null,
671 tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
672 )
673 },
674 )
675 DropdownMenuItem(
676 text = { Text(stringResource(R.string.copy)) },
677 onClick = { onCopy(); menuExpanded = false },
678 leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) },
679 )
680 DropdownMenuItem(
681 text = { Text(stringResource(R.string.copy_path)) },
682 onClick = { onCopyPath(); menuExpanded = false },
683 leadingIcon = { Icon(Icons.Filled.Link, contentDescription = null) },
684 )
685 DropdownMenuItem(
686 text = { Text(stringResource(R.string.open_in_chat)) },
687 onClick = { onOpenInChat(); menuExpanded = false },
688 leadingIcon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) },
689 )
690 DropdownMenuItem(
691 text = { Text(stringResource(R.string.share)) },
692 onClick = { onShare(); menuExpanded = false },
693 leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) },
694 )
695 DropdownMenuItem(
696 text = { Text(stringResource(R.string.open_externally)) },
697 onClick = { onOpenExternally(); menuExpanded = false },
698 leadingIcon = { Icon(Icons.Filled.OpenInNew, contentDescription = null) },
699 )
700 }
701 }
702 },
703 )
706@Composable
707private fun BoxScope.FileViewStateContent(
708 state: FileViewState,
709 path: String,
710 filename: String,
711 showToggle: Boolean,
712 showDiff: Boolean,
713 onSetShowDiff: (Boolean) -> Unit,
714 findActive: Boolean,
715 findQuery: String,
716 onQueryChange: (String) -> Unit,
717 matchIndices: List<Int>,
718 matchPos: Int,
719 onPrev: () -> Unit,
720 onNext: () -> Unit,
721 onCloseFind: () -> Unit,
722 wrap: Boolean,
723 lines: List<String>,
724 truncated: Boolean,
725 moreLines: Int,
726 onLoadMore: () -> Unit,
727 highlightLineIndex: Int?,
728 listState: LazyListState,
729 onRetry: () -> Unit,
730 onShareFull: () -> Unit,
731 caseSensitive: Boolean,
732 onToggleCaseSensitive: () -> Unit,
733 regex: Boolean,
734 onToggleRegex: () -> Unit,
735) {
736 when {
737 // Full-screen skeleton only on the initial load (no content yet). A reload keeps the
738 // existing content on screen; the top-bar reload button shows the in-flight progress.
739 // Skeleton "code line" rows pre-structure the layout so a large file's load feels
740 // faster than a bare spinner and the real content cross-fades into an already-shaped
741 // gutter+text area.
742 state.loading && state.content == null -> {
743 val loadingLabel = stringResource(R.string.loading)
744 val reducedMotion = soy.iko.opencode.ui.components.LocalReducedMotion.current
745 val transition = rememberInfiniteTransition(label = "file_skeleton")
746 val pulse by transition.animateFloat(
747 initialValue = 0.35f,
748 targetValue = 0.7f,
749 animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
750 label = "fileSkeletonAlpha",
751 )
752 val skeletonAlpha = if (reducedMotion) 0.5f else pulse
753 val skeletonColor = MaterialTheme.colorScheme.surfaceVariant
754 Column(
755 modifier = Modifier
756 .align(Alignment.TopCenter)
757 .padding(top = 24.dp)
758 .padding(horizontal = 16.dp)
759 .fillMaxWidth()
760 .semantics { contentDescription = loadingLabel },
761 verticalArrangement = Arrangement.spacedBy(8.dp),
762 ) {
763 repeat(10) { i ->
764 Row(
765 modifier = Modifier.fillMaxWidth(),
766 horizontalArrangement = Arrangement.spacedBy(8.dp),
767 ) {
768 Box(
769 Modifier
770 .width(36.dp)
771 .height(14.dp)
772 .clip(MaterialTheme.shapes.small)
773 .background(skeletonColor)
774 .alpha(skeletonAlpha),
775 )
776 Box(
777 Modifier
778 .fillMaxWidth(if (i % 3 == 0) 0.9f else 0.7f)
779 .height(14.dp)
780 .clip(MaterialTheme.shapes.small)
781 .background(skeletonColor)
782 .alpha(skeletonAlpha),
783 )
784 }
785 }
786 }
787 }
788 state.error != null -> Column(
789 modifier = Modifier.align(Alignment.Center).padding(24.dp),
790 horizontalAlignment = Alignment.CenterHorizontally,
791 ) {
792 Icon(
793 Icons.Filled.ErrorOutline,
794 contentDescription = null,
795 modifier = Modifier.size(56.dp),
796 tint = MaterialTheme.colorScheme.error,
797 )
798 Spacer(Modifier.size(12.dp))
799 Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
800 Spacer(Modifier.size(12.dp))
801 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
802 }
803 // Image preview: if the file has an image extension, render it inline instead of
804 // showing "Binary file". The server returns binary content as base64; decode and
805 // display. Falls back to the binary-file label if decoding fails.
806 state.content != null && state.content.isBinary && run {
807 val ext = path.substringAfterLast('.', "").lowercase()
808 ext in setOf("png", "jpg", "jpeg", "gif", "webp", "bmp", "svg")
809 } -> {
810 ImageFilePreview(
811 content = state.content,
812 modifier = Modifier.align(Alignment.Center).padding(16.dp),
813 )
814 }
815 state.content?.isBinary == true -> Text(
816 stringResource(R.string.binary_file),
817 modifier = Modifier.align(Alignment.Center).padding(24.dp),
818 color = MaterialTheme.colorScheme.onSurfaceVariant,
819 )
820 else -> FileViewContentBody(
821 content = state.content,
822 filename = filename,
823 showToggle = showToggle,
824 showDiff = showDiff,
825 onSetShowDiff = onSetShowDiff,
826 findActive = findActive,
827 findQuery = findQuery,
828 onQueryChange = onQueryChange,
829 matchIndices = matchIndices,
830 matchPos = matchPos,
831 onPrev = onPrev,
832 onNext = onNext,
833 onCloseFind = onCloseFind,
834 wrap = wrap,
835 lines = lines,
836 truncated = truncated,
837 moreLines = moreLines,
838 onLoadMore = onLoadMore,
839 highlightLineIndex = highlightLineIndex,
840 listState = listState,
841 onShareFull = onShareFull,
842 caseSensitive = caseSensitive,
843 onToggleCaseSensitive = onToggleCaseSensitive,
844 regex = regex,
845 onToggleRegex = onToggleRegex,
846 )
847 }
850@Composable
851private fun BoxScope.FileViewContentBody(
852 content: soy.iko.opencode.data.model.FileContent?,
853 filename: String,
854 showToggle: Boolean,
855 showDiff: Boolean,
856 onSetShowDiff: (Boolean) -> Unit,
857 findActive: Boolean,
858 findQuery: String,
859 onQueryChange: (String) -> Unit,
860 matchIndices: List<Int>,
861 matchPos: Int,
862 onPrev: () -> Unit,
863 onNext: () -> Unit,
864 onCloseFind: () -> Unit,
865 wrap: Boolean,
866 lines: List<String>,
867 truncated: Boolean,
868 moreLines: Int,
869 onLoadMore: () -> Unit,
870 highlightLineIndex: Int?,
871 listState: LazyListState,
872 onShareFull: () -> Unit,
873 caseSensitive: Boolean,
874 onToggleCaseSensitive: () -> Unit,
875 regex: Boolean,
876 onToggleRegex: () -> Unit,
877) {
878 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
879 // reachable while scrolling. Each contributes a top inset so the content below
880 // isn't hidden behind the overlay. The inset is measured from the actual overlay
881 // height (not a hardcoded 52dp/row) so accessibility font scaling — which can
882 // make the FilterChip labels and FindBar text field taller than 52dp — doesn't
883 // cause the first lines of content to hide behind the overlay.
884 // The raw text is on screen whenever we're NOT rendering the diff — i.e. either the
885 // file has no diff toggle at all (plain file: showToggle == false) or the toggle is
886 // set to Raw. Gate the find bar on this rather than on !showDiff: for a plain file
887 // showDiff stays at its `true` default forever (nothing ever flips it), so !showDiff
888 // would keep the find bar — and therefore find-in-file — permanently hidden.
889 val rawOnScreen = !(showToggle && showDiff)
890 val overlayRows = (if (showToggle) 1 else 0) + (if (findActive && rawOnScreen) 1 else 0)
891 var overlayHeightPx by remember { mutableIntStateOf(0) }
892 // Only apply the measured inset while an overlay is actually composed. When overlayRows
893 // drops to 0 the overlay Column is removed and onSizeChanged never fires to zero the
894 // height, so keying the inset off overlayRows (rather than the stale measurement) avoids
895 // a phantom top gap on the content below — e.g. after closing find on a plain file.
896 val topInset = if (overlayRows > 0) {
897 with(androidx.compose.ui.platform.LocalDensity.current) { overlayHeightPx.toDp() }
898 } else 0.dp
899 if (overlayRows > 0) {
900 Column(
901 modifier = Modifier
902 .align(Alignment.TopCenter)
903 .fillMaxWidth()
904 .onSizeChanged { overlayHeightPx = it.height }
905 .padding(top = 4.dp),
906 verticalArrangement = Arrangement.spacedBy(4.dp),
907 ) {
908 if (showToggle) {
909 Row(
910 modifier = Modifier.fillMaxWidth(),
911 horizontalArrangement = Arrangement.Center,
912 ) {
913 FilterChip(
914 selected = showDiff,
915 onClick = { onSetShowDiff(true) },
916 label = { Text(stringResource(R.string.show_diff)) },
917 )
918 Spacer(Modifier.size(8.dp))
919 FilterChip(
920 selected = !showDiff,
921 onClick = { onSetShowDiff(false) },
922 label = { Text(stringResource(R.string.show_raw)) },
923 )
924 }
925 }
926 if (findActive && rawOnScreen) {
927 FindBar(
928 query = findQuery,
929 onQueryChange = onQueryChange,
930 matchCount = matchIndices.size,
931 matchPos = matchPos,
932 onPrev = onPrev,
933 onNext = onNext,
934 onClose = onCloseFind,
935 caseSensitive = caseSensitive,
936 onToggleCaseSensitive = onToggleCaseSensitive,
937 regex = regex,
938 onToggleRegex = onToggleRegex,
939 )
940 }
941 }
942 }
943 if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
944 // Use LazyDiffView so a 5000-line diff composes only the visible rows. The previous
945 // DiffView + verticalScroll composed every row eagerly, which OOM'd on large diffs.
946 soy.iko.opencode.ui.components.LazyDiffView(
947 diff = content.diff,
948 modifier = Modifier
949 .fillMaxSize()
950 .padding(top = topInset)
951 .padding(8.dp),
952 )
953 } else {
954 val text = content?.content.orEmpty()
955 if (text.isEmpty()) {
956 Text(
957 stringResource(R.string.empty_file),
958 modifier = Modifier.align(Alignment.Center).padding(24.dp),
959 color = MaterialTheme.colorScheme.onSurfaceVariant,
960 )
961 } else {
962 FileTextContent(
963 lines = lines,
964 filename = filename,
965 topInset = topInset,
966 truncated = truncated,
967 moreLines = moreLines,
968 onLoadMore = onLoadMore,
969 wrap = wrap,
970 findQuery = findQuery,
971 matchIndices = matchIndices,
972 highlightLineIndex = highlightLineIndex,
973 listState = listState,
974 onShareFull = onShareFull,
975 )
976 }
977 }
980@Composable
981private fun FindBar(
982 query: String,
983 onQueryChange: (String) -> Unit,
984 matchCount: Int,
985 matchPos: Int,
986 onPrev: () -> Unit,
987 onNext: () -> Unit,
988 onClose: () -> Unit,
989 caseSensitive: Boolean,
990 onToggleCaseSensitive: () -> Unit,
991 regex: Boolean,
992 onToggleRegex: () -> Unit,
993) {
994 val keyboardController = LocalSoftwareKeyboardController.current
995 val matchCaseLabel = stringResource(R.string.match_case)
996 val regexLabel = stringResource(R.string.regex_search)
997 Row(
998 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
999 verticalAlignment = Alignment.CenterVertically,
1000 ) {
1001 OutlinedTextField(
1002 value = query,
1003 onValueChange = onQueryChange,
1004 modifier = Modifier.weight(1f),
1005 placeholder = { Text(stringResource(R.string.find_hint)) },
1006 singleLine = true,
1007 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
1008 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
1009 trailingIcon = {
1010 Row(verticalAlignment = Alignment.CenterVertically) {
1011 // Case-sensitivity toggle. A small "Aa" chip keeps the bar compact while
1012 // surfacing a previously substring-only, always-case-insensitive search.
1013 FilterChip(
1014 selected = caseSensitive,
1015 onClick = onToggleCaseSensitive,
1016 label = { Text("Aa", style = MaterialTheme.typography.labelSmall) },
1017 modifier = Modifier.semantics { contentDescription = matchCaseLabel },
1019 Spacer(Modifier.size(8.dp))
1020 // Regex toggle (".*"): when on, the query is a Java Pattern. Mirrors
1021 // editor find bars; the label stays compact to match the "Aa" chip.
1022 FilterChip(
1023 selected = regex,
1024 onClick = onToggleRegex,
1025 label = { Text(".*", style = MaterialTheme.typography.labelSmall) },
1026 modifier = Modifier.semantics { contentDescription = regexLabel },
1028 Spacer(Modifier.size(8.dp))
1029 val countText = if (query.isBlank()) ""
1030 else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
1031 else stringResource(R.string.match_count, matchPos + 1, matchCount)
1032 if (countText.isNotEmpty()) {
1033 // Use error color for "no matches" so the zero-result state is
1034 // distinguishable from a positive match count at a glance.
1035 val color = if (query.isNotBlank() && matchCount == 0) MaterialTheme.colorScheme.error
1036 else MaterialTheme.colorScheme.onSurfaceVariant
1037 Text(
1038 countText,
1039 // labelMedium (not labelSmall) so the counter is legible for low-vision
1040 // users at large font scales; the error color still distinguishes zero
1041 // matches at a glance.
1042 style = MaterialTheme.typography.labelMedium,
1043 color = color,
1047 },
1049 IconButton(onClick = onPrev, enabled = matchCount > 0) {
1050 Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_previous))
1052 IconButton(onClick = onNext, enabled = matchCount > 0) {
1053 Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_next))
1055 IconButton(onClick = onClose) {
1056 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
1061@Composable
1062private fun FileTextContent(
1063 lines: List<String>,
1064 filename: String,
1065 topInset: androidx.compose.ui.unit.Dp,
1066 truncated: Boolean,
1067 moreLines: Int,
1068 onLoadMore: () -> Unit,
1069 wrap: Boolean,
1070 findQuery: String,
1071 matchIndices: List<Int>,
1072 highlightLineIndex: Int?,
1073 listState: LazyListState,
1074 onShareFull: () -> Unit,
1075) {
1076 // Honor the user's chat text-size preference (the same one the chat markdown honors) so
1077 // a user who bumped the scale for readability gets the same scale in the code viewer.
1078 // Applied by nesting a MaterialTheme with a scaled typography, exactly how MarkdownText
1079 // does it — the gutter/code Text below read MaterialTheme.typography.bodySmall, so this
1080 // reaches both without per-Text plumbing.
1081 val scale = LocalChatTextScale.current
1082 val base = MaterialTheme.typography
1083 val scaled = remember(scale, base) { base.scaledBy(scale) }
1084 // Gutter width scales with the digit count and the font scale so accessibility
1085 // text scaling (e.g. 1.3x) and the app's chat-text scale don't make line numbers overflow
1086 // the gutter and collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
1087 // proportionally for larger font/app scales.
1088 val fontScale = androidx.compose.ui.platform.LocalDensity.current.fontScale
1089 val gutterWidth = remember(lines.size, fontScale, scale) {
1090 ((lines.size.toString().length.coerceAtLeast(3) * 10) *
1091 fontScale.coerceAtLeast(1f) *
1092 scale.coerceAtLeast(1f)).dp
1094 val hScrollState = rememberScrollState()
1095 val palette = rememberHighlightPalette()
1096 // Resolve the file's language once instead of re-parsing the extension for every line.
1097 val syntax = remember(filename) { syntaxFor(filename) }
1098 // Pre-compute per-line block-comment/triple-string state snapshots so a LazyColumn row
1099 // can highlight its line correctly without re-walking every preceding line on each
1100 // recomposition. Each snapshot is two booleans; for a 5000-line file that's ~10KB,
1101 // trivial next to the line strings themselves. highlightLine(line, syntax, palette, state)
1102 // mutates the passed state in place, so we give each line its own snapshot derived from
1103 // the prior line's end state — this is what makes a 50-line block comment color correctly
1104 // on every line, not just the opener.
1105 val lineStates = remember(lines, syntax) {
1106 if (syntax.lang == soy.iko.opencode.ui.components.Language.NONE) {
1107 emptyList()
1108 } else {
1109 val states = ArrayList<soy.iko.opencode.ui.components.BlockCommentState>(lines.size)
1110 var current = soy.iko.opencode.ui.components.BlockCommentState()
1111 for (line in lines) {
1112 val snapshot = soy.iko.opencode.ui.components.BlockCommentState().also {
1113 it.inBlockComment = current.inBlockComment
1114 it.inTripleString = current.inTripleString
1116 states.add(snapshot)
1117 // Advance the running state by highlighting into a throwaway builder — the
1118 // side effect on `current` is what we need. We discard the AnnotatedString.
1119 soy.iko.opencode.ui.components.highlightLine(line, syntax, palette, current)
1121 states
1124 val q = findQuery.trim()
1125 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
1126 MaterialTheme(typography = scaled) {
1127 Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
1128 if (truncated) {
1129 androidx.compose.foundation.layout.Row(
1130 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
1131 verticalAlignment = Alignment.CenterVertically,
1132 horizontalArrangement = Arrangement.SpaceBetween,
1133 ) {
1134 Text(
1135 stringResource(R.string.file_truncated, lines.size),
1136 style = MaterialTheme.typography.labelSmall,
1137 color = MaterialTheme.colorScheme.onSurfaceVariant,
1138 modifier = Modifier.weight(1f),
1140 // Load the next chunk in-place so the user can keep reading the file without
1141 // leaving the app. Falls back to Share full file for the whole content.
1142 TextButton(onClick = onLoadMore) {
1143 Text(stringResource(R.string.load_more_lines, moreLines))
1145 TextButton(onClick = onShareFull) {
1146 Text(stringResource(R.string.share_full_file))
1150 // SelectionContainer lets the user select/copy a portion of the rendered code
1151 // (not just the whole-file Copy in the overflow). Selection across recycled
1152 // LazyColumn items can drop spans that have scrolled away, but partial selection
1153 // of the visible region — the common case — works.
1154 SelectionContainer {
1155 LazyColumn(
1156 state = listState,
1157 modifier = Modifier
1158 .weight(1f)
1159 .fillMaxWidth()
1160 .padding(8.dp),
1161 ) {
1162 itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
1163 val isMatch = q.isNotEmpty() && index in matchSet
1164 // The jump-to-line target keeps the same emphasis as a find match so the line
1165 // the viewer landed on is obvious until the user scrolls or starts a find.
1166 val isTarget = index == highlightLineIndex
1167 Row(
1168 modifier = Modifier
1169 .fillMaxWidth()
1170 .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
1171 .then(if (isMatch || isTarget) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
1172 ) {
1173 Text(
1174 "${index + 1}",
1175 modifier = Modifier.width(gutterWidth),
1176 style = MaterialTheme.typography.bodySmall,
1177 fontFamily = FontFamily.Monospace,
1178 color = MaterialTheme.colorScheme.onSurfaceVariant,
1180 // Heuristic syntax highlighting per line. Falls back to plain text for
1181 // unknown extensions, so non-code files render unchanged. Memoized so the
1182 // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
1183 // file, or palette actually changes — not on every recomposition (e.g. every
1184 // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
1185 // Uses the stateful highlightLine so multi-line block comments and triple-quoted
1186 // strings color correctly on every line they span (the per-line snapshot in
1187 // lineStates carries the in-block/in-trString flag for this line's position).
1188 val highlighted = remember(line, syntax, palette, index) {
1189 val state = lineStates.getOrNull(index)
1190 if (state != null) {
1191 val s = soy.iko.opencode.ui.components.BlockCommentState().also {
1192 it.inBlockComment = state.inBlockComment
1193 it.inTripleString = state.inTripleString
1195 soy.iko.opencode.ui.components.highlightLine(line, syntax, palette, s)
1196 } else {
1197 soy.iko.opencode.ui.components.highlightLine(line, syntax, palette)
1200 Text(
1201 highlighted,
1202 modifier = Modifier.padding(start = 8.dp),
1203 style = MaterialTheme.typography.bodySmall,
1213/**
1214 * Renders an image file (PNG/JPG/GIF/WebP/BMP/SVG) inline in the file viewer instead of
1215 * the generic "Binary file" label. Decodes the base64 content from [FileContent.content]
1216 * into bytes and hands them to Coil, which renders the image and powers the fullscreen
1217 * zoomable viewer (pinch-zoom, save, share) on tap — the same [FullscreenImageViewer] the
1218 * chat attachments use. Falls back to the binary-file label if the base64 payload can't be
1219 * decoded (corrupt content or an unsupported encoding).
1220 */
1221@Composable
1222private fun ImageFilePreview(
1223 content: soy.iko.opencode.data.model.FileContent,
1224 modifier: Modifier = Modifier,
1225) {
1226 val context = LocalContext.current
1227 val desc = stringResource(R.string.image_file_preview)
1228 // Decode the base64 payload to bytes once (off-thread via produceState so a large image
1229 // doesn't block the first frame). Coil takes the ByteArray as its model and decodes it to
1230 // a Bitmap internally — the same request drives both the inline preview and the fullscreen
1231 // viewer, so a tap reuses the already-cached decode for the zoomed view.
1232 val bytesState by produceState<ByteArray?>(initialValue = null, content.content) {
1233 value = withContext(Dispatchers.Default) {
1234 runCatching {
1235 android.util.Base64.decode(content.content, android.util.Base64.DEFAULT)
1236 }.getOrNull()
1239 val bytes = bytesState
1240 if (bytes == null) {
1241 Text(
1242 stringResource(R.string.binary_file),
1243 modifier = modifier,
1244 color = MaterialTheme.colorScheme.onSurfaceVariant,
1246 return
1248 var showFullscreen by remember { mutableStateOf(false) }
1249 val request = remember(bytes) {
1250 ImageRequest.Builder(context)
1251 .data(bytes)
1252 .crossfade(true)
1253 .build()
1255 Column(
1256 modifier = modifier.fillMaxWidth(),
1257 horizontalAlignment = Alignment.CenterHorizontally,
1258 ) {
1259 SubcomposeAsyncImage(
1260 model = request,
1261 contentDescription = desc,
1262 modifier = Modifier
1263 .fillMaxWidth()
1264 .heightIn(max = 400.dp)
1265 .clip(MaterialTheme.shapes.medium)
1266 .clickable(role = Role.Button) { showFullscreen = true },
1269 if (showFullscreen) {
1270 FullscreenImageViewer(request = request, contentDescription = desc) { showFullscreen = false }