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