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