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)
132 container: AppContainer,
135 initialLine: Int? = null,
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 { "/" } }
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 }
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)
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(
202 initialLine = initialLine,
203 content = state.content,
204 isBinary = state.content?.isBinary == true,
205 lineCount = lines.size,
206 listState = listState,
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 }
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) {
230 val flags = if (caseSensitive) 0 else java.util.regex.Pattern.CASE_INSENSITIVE
231 java.util.regex.Pattern.compile(q, flags)
236 lines.mapIndexedNotNull { index, line ->
237 val matched = if (regex) {
238 pattern?.matcher(line)?.find() == true
240 line.contains(q, ignoreCase = !caseSensitive)
242 if (matched) index else null
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))
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) } }
255 // "Go to line" dialog state. Clears the jump highlight and scrolls the LazyColumn to the
256 // requested line (clamped to the rendered range) when the user confirms.
257 var showGoToLine by rememberSaveable(path) { mutableStateOf(false) }
258 fun goToLine(n: Int) {
259 val target = (n - 1).coerceIn(0, (lines.size - 1).coerceAtLeast(0))
260 highlightLine.value = null
261 scope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
267 subtitle = pathSubtitle,
268 metadata = metadataLine,
269 loading = state.loading,
272 diffShown = showToggle && showDiff,
274 onReload = { vm.reload() },
275 // Find searches the raw line list, which isn't on screen under a diff. Rather
276 // than silently greying the icon out, keep it tappable and explain where find
277 // lives when the user is in diff mode.
279 if (showToggle && showDiff) {
280 scope.launch { snackbar.showSnackbar(context.getString(R.string.switch_to_raw_to_search)) }
282 findActive = !findActive
283 if (!findActive) findQuery = ""
286 onToggleWrap = { wrap = !wrap },
287 onGoToLine = { showGoToLine = true },
288 onCopy = { copyToClipboard(context, filename, rawText) },
289 onCopyPath = { copyToClipboard(context, context.getString(R.string.clip_label_path), path) },
291 val send = Intent(Intent.ACTION_SEND).apply {
293 putExtra(Intent.EXTRA_SUBJECT, filename)
294 putExtra(Intent.EXTRA_TEXT, rawText)
296 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
298 // rawText is the full (untruncated) file content, so a file with very
299 // long lines can blow past the Binder transaction limit (~1MB) while
300 // staying under the render-line cap. Distinguish that from a missing
301 // share target so the message is honest — mirrors onShareFull below.
302 val msg = if (it is android.os.DeadObjectException ||
303 it is android.os.TransactionTooLargeException
305 context.getString(R.string.file_share_too_large)
307 context.getString(R.string.no_share_app)
309 scope.launch { snackbar.showSnackbar(msg) }
313 // Write the viewed content to a cache file and hand a content:// URI to an
314 // external app via ACTION_VIEW, so a file the in-app viewer can only show as
315 // text (or a large file beyond the render cap) can be opened in a real editor.
317 val ok = runCatchingCancellable {
318 withContext(Dispatchers.IO) {
319 val dir = java.io.File(context.cacheDir, "external").apply { mkdirs() }
320 // Evict stale cache files handed to external apps so repeated
321 // opens of large files don't accumulate forever. A TTL (vs.
322 // wiping the dir) preserves files an external app may still be
323 // reading from a recent open. Pruned before writing the new one.
324 val cutoff = System.currentTimeMillis() - NetworkConfig.externalCacheTtlMs
325 dir.listFiles()?.forEach { f ->
326 runCatching { if (f.lastModified() < cutoff) f.delete() }
328 // Sanitize the filename: replace characters that are invalid in
329 // a filesystem filename or that FileProvider.getUriForFile
330 // rejects (path separators, NUL, and Windows-illegal chars that
331 // also trip the provider), and reject `..` traversal. Without
332 // this, a path ending in an unusual character (e.g. `:`, `?`)
333 // would throw at getUriForFile AFTER writeText succeeded, orphaning
334 // the cache file. Sanitize up front so the write and the URI use
335 // the same safe name.
336 val safeName = filename.ifBlank { "file.txt" }
337 .substringAfterLast('/')
338 .replace(Regex("[\\\\/:*?\"<>|\\u0000]"), "_")
339 .takeIf { it.isNotBlank() && it != "." && it != ".." }
341 val file = java.io.File(dir, safeName)
343 file.writeText(rawText)
344 val authority = context.packageName + ".fileprovider"
345 val uri = androidx.core.content.FileProvider.getUriForFile(context, authority, file)
346 val view = Intent(Intent.ACTION_VIEW).apply {
347 setDataAndType(uri, android.webkit.MimeTypeMap.getSingleton()
348 .getMimeTypeFromExtension(safeName.substringAfterLast('.', ""))
350 addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
352 context.startActivity(view)
353 } catch (e: Throwable) {
354 // getUriForFile or startActivity can throw after the cache
355 // file was written; delete it so a failure doesn't orphan
356 // cache files that the TTL prune won't reap for a while.
357 runCatching { file.delete() }
362 if (!ok) scope.launch { snackbar.showSnackbar(context.getString(R.string.open_externally_failed)) }
366 // Start a new session with this file attached as an @path reference,
367 // mirroring the file browser row's "Open in chat" action — the viewer is
368 // the more natural place for it since the user is already looking at the file.
369 container.setPendingShare("@$path")
370 container.requestNewSession()
375 snackbarHost = { SnackbarHost(snackbar) },
377 // Pull-to-refresh wraps the content so a user who pull-to-refreshes out of habit
378 // can reload the file, mirroring the file browser and session list gestures. The
379 // top-bar reload icon remains for a targeted tap.
380 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
381 isRefreshing = state.loading && state.content != null,
382 onRefresh = { vm.reload() },
383 modifier = Modifier.fillMaxSize().imePadding().padding(padding),
385 Box(modifier = Modifier.fillMaxSize()) {
386 // Surface SSE connection state so a dropped stream is visible while viewing a file.
387 ConnectionBannerFor(container)
388 // AnimatedContent cross-fades between loading / error / content states so the
389 // viewer doesn't snap abruptly when a reload finishes or fails.
392 transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
393 label = "fileViewState",
395 // Single source of truth for matches: the off-thread `matchIndices` produceState
396 // (keyed on the file content + debounced query). Both navigation (onPrev/onNext +
397 // scroll) and display (counter + highlight) read from it, so the counter can't show
398 // a new query while next/prev act on a stale set. The earlier in-composition
399 // per-layer scan re-ran the whole-file O(n) match on the UI thread each keystroke
400 // (the exact work produceState was moved off-thread to avoid); it's acceptable for
401 // the crossfade's exiting layer to briefly use this unified set until it settles.
402 FileViewStateContent(
406 showToggle = showToggle,
410 // Close any open find bar when entering diff mode: find searches
411 // the raw content and scrolls the line LazyColumn, neither of
412 // which is visible in diff mode, so leaving it open would be a
414 if (it) { findActive = false; findQuery = "" }
416 findActive = findActive,
417 findQuery = findQuery,
418 onQueryChange = { findQuery = it; matchPos = 0; if (it.isNotEmpty()) highlightLine.value = null },
419 matchIndices = matchIndices,
421 onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
422 onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
423 onCloseFind = { findActive = false; findQuery = "" },
426 truncated = truncated,
427 moreLines = moreLines,
428 onLoadMore = { renderLimit += MAX_RENDERED_LINES },
429 highlightLineIndex = highlightLine.value,
430 listState = listState,
431 onRetry = { vm.reload() },
432 caseSensitive = caseSensitive,
433 onToggleCaseSensitive = { caseSensitive = !caseSensitive },
435 onToggleRegex = { regex = !regex },
437 val send = Intent(Intent.ACTION_SEND).apply {
439 putExtra(Intent.EXTRA_SUBJECT, filename)
440 putExtra(Intent.EXTRA_TEXT, rawText)
442 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
444 // A large file can blow past the Binder transaction limit
445 // (~1MB); distinguish that from a missing share target so the
446 // message is honest instead of blaming a nonexistent app.
447 val msg = if (it is android.os.DeadObjectException ||
448 it is android.os.TransactionTooLargeException
450 context.getString(R.string.file_share_too_large)
452 context.getString(R.string.no_share_app)
454 scope.launch { snackbar.showSnackbar(msg) }
464 visible = showGoToLine,
465 maxLine = lines.size,
466 onConfirm = { n -> showGoToLine = false; goToLine(n) },
467 onDismiss = { showGoToLine = false },
471// Wraps the go-to-line dialog's visibility gate so the branch stays out of FileViewScreen
472// (keeping that composable under the cyclomatic-complexity threshold).
474private fun GoToLineLauncher(
477 onConfirm: (Int) -> Unit,
478 onDismiss: () -> Unit,
481 GoToLineDialog(maxLine = maxLine, onConfirm = onConfirm, onDismiss = onDismiss)
486private fun GoToLineDialog(maxLine: Int, onConfirm: (Int) -> Unit, onDismiss: () -> Unit) {
487 // Use plain remember (not rememberSaveable) so each open starts with an empty field.
488 // The dialog is conditionally composed (gated by `visible` in GoToLineLauncher); with
489 // rememberSaveable the typed text would be restored from the saved-state registry on
490 // re-open, pre-filling whatever the user typed — and dismissed without — last time.
491 var text by remember { mutableStateOf("") }
492 val parsed = text.trim().toIntOrNull()
493 val valid = parsed != null && parsed in 1..maxLine
495 onDismissRequest = onDismiss,
496 title = { Text(stringResource(R.string.go_to_line)) },
500 onValueChange = { v -> text = v.filter { it.isDigit() } },
502 isError = text.isNotEmpty() && !valid,
505 stringResource(R.string.go_to_line_hint, maxLine),
506 style = MaterialTheme.typography.bodySmall,
509 keyboardOptions = KeyboardOptions(
510 keyboardType = KeyboardType.Number,
511 imeAction = ImeAction.Done,
513 keyboardActions = KeyboardActions(onDone = { if (valid) parsed?.let { onConfirm(it) } }),
517 TextButton(onClick = { parsed?.let { onConfirm(it) } }, enabled = valid) {
518 Text(stringResource(R.string.go_to_line))
522 TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
527// Owns the "jump to a line from a search hit" behaviour: scroll to the target once the raw
528// content loads (one-shot), and clear the highlight the moment the user drags the list by hand.
529// Extracted so FileViewScreen stays under the cyclomatic-complexity threshold.
531private fun rememberJumpToLineHighlight(
537 listState: LazyListState,
538): MutableState<Int?> {
539 val highlight = rememberSaveable(path) { mutableStateOf<Int?>(null) }
540 var didInitialScroll by rememberSaveable(path) { mutableStateOf(false) }
541 LaunchedEffect(content, initialLine) {
542 val line = initialLine ?: return@LaunchedEffect
543 if (didInitialScroll || content == null || isBinary) return@LaunchedEffect
544 val target = (line - 1).coerceIn(0, (lineCount - 1).coerceAtLeast(0))
545 runCatchingCancellable { listState.scrollToItem(target) }
546 highlight.value = target
547 didInitialScroll = true
549 LaunchedEffect(listState) {
550 if (initialLine == null) return@LaunchedEffect
551 listState.interactionSource.interactions.collect { interaction ->
552 if (interaction is DragInteraction.Start) highlight.value = null
558/** Build the compact "12.3 KB · 2d ago" metadata line for the file viewer's top bar from the
559 * size/mtime the ViewModel resolved. Returns an empty string when neither is present so the
560 * caller can drop the line in unconditionally. Uses the same helpers as the file browser's
561 * FileRow so the formatting is identical across screens. */
562private fun buildMetadataLine(context: android.content.Context, size: Long?, mtime: Long?): String {
563 val parts = buildList {
564 size?.let { add(formatFileSize(it)) }
565 mtime?.let { add(relativeTime(context, it)) }
567 return parts.joinToString(" · ")
570@OptIn(ExperimentalMaterial3Api::class)
572private fun FileViewTopBar(
581 onReload: () -> Unit,
582 onToggleFind: () -> Unit,
583 onToggleWrap: () -> Unit,
584 onGoToLine: () -> Unit,
586 onCopyPath: () -> Unit,
588 onOpenExternally: () -> Unit,
589 onOpenInChat: () -> Unit,
593 // Two- or three-line title: filename (primary), the directory portion of the path
594 // (secondary), and an optional muted metadata line (size · mtime) so a user who
595 // has drilled into a deep workspace keeps context of where this file lives and how
596 // stale it is without an extra breadcrumb row.
598 Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis)
599 if (subtitle.isNotBlank()) {
603 overflow = TextOverflow.Ellipsis,
604 style = MaterialTheme.typography.bodySmall,
605 color = MaterialTheme.colorScheme.onSurfaceVariant,
608 if (metadata.isNotEmpty()) {
612 overflow = TextOverflow.Ellipsis,
613 style = MaterialTheme.typography.labelSmall,
614 color = MaterialTheme.colorScheme.onSurfaceVariant,
620 IconButton(onClick = onBack) {
621 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
625 // In-place refresh: a file's content can change on the server (the agent may
626 // be editing it), so reload without requiring a back-out. Shows a spinner while
627 // loading since the content now stays on screen during a reload (the spinner is
628 // the only progress signal).
629 val loadingLabel = stringResource(R.string.loading)
630 IconButton(onClick = onReload, enabled = !loading) {
632 CircularProgressIndicator(
633 Modifier.size(20.dp).semantics { contentDescription = loadingLabel },
637 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
640 if (content.isNotEmpty()) {
641 // Find-in-file: toggles a search bar over the raw content. Stays enabled even in
642 // diff mode — the find bar searches the raw text and scrolls the line LazyColumn,
643 // neither of which is visible under a diff, so tapping it there surfaces a hint to
644 // switch to Raw rather than silently no-opping behind a greyed-out icon.
645 IconButton(onClick = onToggleFind) {
646 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
648 // Overflow the less-frequent actions (wrap / copy / share) so a long filename
649 // title isn't crowded off screen by five inline icons on a phone.
650 var menuExpanded by remember { mutableStateOf(false) }
651 IconButton(onClick = { menuExpanded = true }) {
652 Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options_for, filename))
654 DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
655 // Wrap long lines instead of horizontal-scrolling; useful for prose. Disabled
656 // in diff mode because DiffView ignores wrap, so an active-looking toggle there
657 // would be a silent no-op.
659 text = { Text(stringResource(R.string.go_to_line)) },
660 onClick = { onGoToLine(); menuExpanded = false },
661 leadingIcon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
664 text = { Text(stringResource(R.string.wrap_lines)) },
665 onClick = { onToggleWrap(); menuExpanded = false },
666 enabled = !diffShown,
669 Icons.AutoMirrored.Filled.WrapText,
670 contentDescription = null,
671 tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
676 text = { Text(stringResource(R.string.copy)) },
677 onClick = { onCopy(); menuExpanded = false },
678 leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) },
681 text = { Text(stringResource(R.string.copy_path)) },
682 onClick = { onCopyPath(); menuExpanded = false },
683 leadingIcon = { Icon(Icons.Filled.Link, contentDescription = null) },
686 text = { Text(stringResource(R.string.open_in_chat)) },
687 onClick = { onOpenInChat(); menuExpanded = false },
688 leadingIcon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) },
691 text = { Text(stringResource(R.string.share)) },
692 onClick = { onShare(); menuExpanded = false },
693 leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) },
696 text = { Text(stringResource(R.string.open_externally)) },
697 onClick = { onOpenExternally(); menuExpanded = false },
698 leadingIcon = { Icon(Icons.Filled.OpenInNew, contentDescription = null) },
707private fun BoxScope.FileViewStateContent(
708 state: FileViewState,
713 onSetShowDiff: (Boolean) -> Unit,
716 onQueryChange: (String) -> Unit,
717 matchIndices: List<Int>,
721 onCloseFind: () -> Unit,
726 onLoadMore: () -> Unit,
727 highlightLineIndex: Int?,
728 listState: LazyListState,
730 onShareFull: () -> Unit,
731 caseSensitive: Boolean,
732 onToggleCaseSensitive: () -> Unit,
734 onToggleRegex: () -> Unit,
737 // Full-screen skeleton only on the initial load (no content yet). A reload keeps the
738 // existing content on screen; the top-bar reload button shows the in-flight progress.
739 // Skeleton "code line" rows pre-structure the layout so a large file's load feels
740 // faster than a bare spinner and the real content cross-fades into an already-shaped
742 state.loading && state.content == null -> {
743 val loadingLabel = stringResource(R.string.loading)
744 val reducedMotion = soy.iko.opencode.ui.components.LocalReducedMotion.current
745 val transition = rememberInfiniteTransition(label = "file_skeleton")
746 val pulse by transition.animateFloat(
747 initialValue = 0.35f,
749 animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
750 label = "fileSkeletonAlpha",
752 val skeletonAlpha = if (reducedMotion) 0.5f else pulse
753 val skeletonColor = MaterialTheme.colorScheme.surfaceVariant
756 .align(Alignment.TopCenter)
757 .padding(top = 24.dp)
758 .padding(horizontal = 16.dp)
760 .semantics { contentDescription = loadingLabel },
761 verticalArrangement = Arrangement.spacedBy(8.dp),
765 modifier = Modifier.fillMaxWidth(),
766 horizontalArrangement = Arrangement.spacedBy(8.dp),
772 .clip(MaterialTheme.shapes.small)
773 .background(skeletonColor)
774 .alpha(skeletonAlpha),
778 .fillMaxWidth(if (i % 3 == 0) 0.9f else 0.7f)
780 .clip(MaterialTheme.shapes.small)
781 .background(skeletonColor)
782 .alpha(skeletonAlpha),
788 state.error != null -> Column(
789 modifier = Modifier.align(Alignment.Center).padding(24.dp),
790 horizontalAlignment = Alignment.CenterHorizontally,
793 Icons.Filled.ErrorOutline,
794 contentDescription = null,
795 modifier = Modifier.size(56.dp),
796 tint = MaterialTheme.colorScheme.error,
798 Spacer(Modifier.size(12.dp))
799 Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
800 Spacer(Modifier.size(12.dp))
801 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
803 // Image preview: if the file has an image extension, render it inline instead of
804 // showing "Binary file". The server returns binary content as base64; decode and
805 // display. Falls back to the binary-file label if decoding fails.
806 state.content != null && state.content.isBinary && run {
807 val ext = path.substringAfterLast('.', "").lowercase()
808 ext in setOf("png", "jpg", "jpeg", "gif", "webp", "bmp", "svg")
811 content = state.content,
812 modifier = Modifier.align(Alignment.Center).padding(16.dp),
815 state.content?.isBinary == true -> Text(
816 stringResource(R.string.binary_file),
817 modifier = Modifier.align(Alignment.Center).padding(24.dp),
818 color = MaterialTheme.colorScheme.onSurfaceVariant,
820 else -> FileViewContentBody(
821 content = state.content,
823 showToggle = showToggle,
825 onSetShowDiff = onSetShowDiff,
826 findActive = findActive,
827 findQuery = findQuery,
828 onQueryChange = onQueryChange,
829 matchIndices = matchIndices,
833 onCloseFind = onCloseFind,
836 truncated = truncated,
837 moreLines = moreLines,
838 onLoadMore = onLoadMore,
839 highlightLineIndex = highlightLineIndex,
840 listState = listState,
841 onShareFull = onShareFull,
842 caseSensitive = caseSensitive,
843 onToggleCaseSensitive = onToggleCaseSensitive,
845 onToggleRegex = onToggleRegex,
851private fun BoxScope.FileViewContentBody(
852 content: soy.iko.opencode.data.model.FileContent?,
856 onSetShowDiff: (Boolean) -> Unit,
859 onQueryChange: (String) -> Unit,
860 matchIndices: List<Int>,
864 onCloseFind: () -> Unit,
869 onLoadMore: () -> Unit,
870 highlightLineIndex: Int?,
871 listState: LazyListState,
872 onShareFull: () -> Unit,
873 caseSensitive: Boolean,
874 onToggleCaseSensitive: () -> Unit,
876 onToggleRegex: () -> Unit,
878 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
879 // reachable while scrolling. Each contributes a top inset so the content below
880 // isn't hidden behind the overlay. The inset is measured from the actual overlay
881 // height (not a hardcoded 52dp/row) so accessibility font scaling — which can
882 // make the FilterChip labels and FindBar text field taller than 52dp — doesn't
883 // cause the first lines of content to hide behind the overlay.
884 // The raw text is on screen whenever we're NOT rendering the diff — i.e. either the
885 // file has no diff toggle at all (plain file: showToggle == false) or the toggle is
886 // set to Raw. Gate the find bar on this rather than on !showDiff: for a plain file
887 // showDiff stays at its `true` default forever (nothing ever flips it), so !showDiff
888 // would keep the find bar — and therefore find-in-file — permanently hidden.
889 val rawOnScreen = !(showToggle && showDiff)
890 val overlayRows = (if (showToggle) 1 else 0) + (if (findActive && rawOnScreen) 1 else 0)
891 var overlayHeightPx by remember { mutableIntStateOf(0) }
892 // Only apply the measured inset while an overlay is actually composed. When overlayRows
893 // drops to 0 the overlay Column is removed and onSizeChanged never fires to zero the
894 // height, so keying the inset off overlayRows (rather than the stale measurement) avoids
895 // a phantom top gap on the content below — e.g. after closing find on a plain file.
896 val topInset = if (overlayRows > 0) {
897 with(androidx.compose.ui.platform.LocalDensity.current) { overlayHeightPx.toDp() }
899 if (overlayRows > 0) {
902 .align(Alignment.TopCenter)
904 .onSizeChanged { overlayHeightPx = it.height }
905 .padding(top = 4.dp),
906 verticalArrangement = Arrangement.spacedBy(4.dp),
910 modifier = Modifier.fillMaxWidth(),
911 horizontalArrangement = Arrangement.Center,
915 onClick = { onSetShowDiff(true) },
916 label = { Text(stringResource(R.string.show_diff)) },
918 Spacer(Modifier.size(8.dp))
920 selected = !showDiff,
921 onClick = { onSetShowDiff(false) },
922 label = { Text(stringResource(R.string.show_raw)) },
926 if (findActive && rawOnScreen) {
929 onQueryChange = onQueryChange,
930 matchCount = matchIndices.size,
934 onClose = onCloseFind,
935 caseSensitive = caseSensitive,
936 onToggleCaseSensitive = onToggleCaseSensitive,
938 onToggleRegex = onToggleRegex,
943 if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
944 // Use LazyDiffView so a 5000-line diff composes only the visible rows. The previous
945 // DiffView + verticalScroll composed every row eagerly, which OOM'd on large diffs.
946 soy.iko.opencode.ui.components.LazyDiffView(
950 .padding(top = topInset)
954 val text = content?.content.orEmpty()
955 if (text.isEmpty()) {
957 stringResource(R.string.empty_file),
958 modifier = Modifier.align(Alignment.Center).padding(24.dp),
959 color = MaterialTheme.colorScheme.onSurfaceVariant,
966 truncated = truncated,
967 moreLines = moreLines,
968 onLoadMore = onLoadMore,
970 findQuery = findQuery,
971 matchIndices = matchIndices,
972 highlightLineIndex = highlightLineIndex,
973 listState = listState,
974 onShareFull = onShareFull,
983 onQueryChange: (String) -> Unit,
989 caseSensitive: Boolean,
990 onToggleCaseSensitive: () -> Unit,
992 onToggleRegex: () -> Unit,
994 val keyboardController = LocalSoftwareKeyboardController.current
995 val matchCaseLabel = stringResource(R.string.match_case)
996 val regexLabel = stringResource(R.string.regex_search)
998 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
999 verticalAlignment = Alignment.CenterVertically,
1003 onValueChange = onQueryChange,
1004 modifier = Modifier.weight(1f),
1005 placeholder = { Text(stringResource(R.string.find_hint)) },
1007 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
1008 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
1010 Row(verticalAlignment = Alignment.CenterVertically) {
1011 // Case-sensitivity toggle. A small "Aa" chip keeps the bar compact while
1012 // surfacing a previously substring-only, always-case-insensitive search.
1014 selected = caseSensitive,
1015 onClick = onToggleCaseSensitive,
1016 label = { Text("Aa", style = MaterialTheme.typography.labelSmall) },
1017 modifier = Modifier.semantics { contentDescription = matchCaseLabel },
1019 Spacer(Modifier.size(8.dp))
1020 // Regex toggle (".*"): when on, the query is a Java Pattern. Mirrors
1021 // editor find bars; the label stays compact to match the "Aa" chip.
1024 onClick = onToggleRegex,
1025 label = { Text(".*", style = MaterialTheme.typography.labelSmall) },
1026 modifier = Modifier.semantics { contentDescription = regexLabel },
1028 Spacer(Modifier.size(8.dp))
1029 val countText = if (query.isBlank()) ""
1030 else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
1031 else stringResource(R.string.match_count, matchPos + 1, matchCount)
1032 if (countText.isNotEmpty()) {
1033 // Use error color for "no matches" so the zero-result state is
1034 // distinguishable from a positive match count at a glance.
1035 val color = if (query.isNotBlank() && matchCount == 0) MaterialTheme.colorScheme.error
1036 else MaterialTheme.colorScheme.onSurfaceVariant
1039 // labelMedium (not labelSmall) so the counter is legible for low-vision
1040 // users at large font scales; the error color still distinguishes zero
1041 // matches at a glance.
1042 style = MaterialTheme.typography.labelMedium,
1049 IconButton(onClick = onPrev, enabled = matchCount > 0) {
1050 Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_previous))
1052 IconButton(onClick = onNext, enabled = matchCount > 0) {
1053 Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_next))
1055 IconButton(onClick = onClose) {
1056 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
1062private fun FileTextContent(
1063 lines: List<String>,
1065 topInset: androidx.compose.ui.unit.Dp,
1068 onLoadMore: () -> Unit,
1071 matchIndices: List<Int>,
1072 highlightLineIndex: Int?,
1073 listState: LazyListState,
1074 onShareFull: () -> Unit,
1076 // Honor the user's chat text-size preference (the same one the chat markdown honors) so
1077 // a user who bumped the scale for readability gets the same scale in the code viewer.
1078 // Applied by nesting a MaterialTheme with a scaled typography, exactly how MarkdownText
1079 // does it — the gutter/code Text below read MaterialTheme.typography.bodySmall, so this
1080 // reaches both without per-Text plumbing.
1081 val scale = LocalChatTextScale.current
1082 val base = MaterialTheme.typography
1083 val scaled = remember(scale, base) { base.scaledBy(scale) }
1084 // Gutter width scales with the digit count and the font scale so accessibility
1085 // text scaling (e.g. 1.3x) and the app's chat-text scale don't make line numbers overflow
1086 // the gutter and collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
1087 // proportionally for larger font/app scales.
1088 val fontScale = androidx.compose.ui.platform.LocalDensity.current.fontScale
1089 val gutterWidth = remember(lines.size, fontScale, scale) {
1090 ((lines.size.toString().length.coerceAtLeast(3) * 10) *
1091 fontScale.coerceAtLeast(1f) *
1092 scale.coerceAtLeast(1f)).dp
1094 val hScrollState = rememberScrollState()
1095 val palette = rememberHighlightPalette()
1096 // Resolve the file's language once instead of re-parsing the extension for every line.
1097 val syntax = remember(filename) { syntaxFor(filename) }
1098 // Pre-compute per-line block-comment/triple-string state snapshots so a LazyColumn row
1099 // can highlight its line correctly without re-walking every preceding line on each
1100 // recomposition. Each snapshot is two booleans; for a 5000-line file that's ~10KB,
1101 // trivial next to the line strings themselves. highlightLine(line, syntax, palette, state)
1102 // mutates the passed state in place, so we give each line its own snapshot derived from
1103 // the prior line's end state — this is what makes a 50-line block comment color correctly
1104 // on every line, not just the opener.
1105 val lineStates = remember(lines, syntax) {
1106 if (syntax.lang == soy.iko.opencode.ui.components.Language.NONE) {
1109 val states = ArrayList<soy.iko.opencode.ui.components.BlockCommentState>(lines.size)
1110 var current = soy.iko.opencode.ui.components.BlockCommentState()
1111 for (line in lines) {
1112 val snapshot = soy.iko.opencode.ui.components.BlockCommentState().also {
1113 it.inBlockComment = current.inBlockComment
1114 it.inTripleString = current.inTripleString
1116 states.add(snapshot)
1117 // Advance the running state by highlighting into a throwaway builder — the
1118 // side effect on `current` is what we need. We discard the AnnotatedString.
1119 soy.iko.opencode.ui.components.highlightLine(line, syntax, palette, current)
1124 val q = findQuery.trim()
1125 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
1126 MaterialTheme(typography = scaled) {
1127 Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
1129 androidx.compose.foundation.layout.Row(
1130 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
1131 verticalAlignment = Alignment.CenterVertically,
1132 horizontalArrangement = Arrangement.SpaceBetween,
1135 stringResource(R.string.file_truncated, lines.size),
1136 style = MaterialTheme.typography.labelSmall,
1137 color = MaterialTheme.colorScheme.onSurfaceVariant,
1138 modifier = Modifier.weight(1f),
1140 // Load the next chunk in-place so the user can keep reading the file without
1141 // leaving the app. Falls back to Share full file for the whole content.
1142 TextButton(onClick = onLoadMore) {
1143 Text(stringResource(R.string.load_more_lines, moreLines))
1145 TextButton(onClick = onShareFull) {
1146 Text(stringResource(R.string.share_full_file))
1150 // SelectionContainer lets the user select/copy a portion of the rendered code
1151 // (not just the whole-file Copy in the overflow). Selection across recycled
1152 // LazyColumn items can drop spans that have scrolled away, but partial selection
1153 // of the visible region — the common case — works.
1154 SelectionContainer {
1162 itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
1163 val isMatch = q.isNotEmpty() && index in matchSet
1164 // The jump-to-line target keeps the same emphasis as a find match so the line
1165 // the viewer landed on is obvious until the user scrolls or starts a find.
1166 val isTarget = index == highlightLineIndex
1170 .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
1171 .then(if (isMatch || isTarget) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
1175 modifier = Modifier.width(gutterWidth),
1176 style = MaterialTheme.typography.bodySmall,
1177 fontFamily = FontFamily.Monospace,
1178 color = MaterialTheme.colorScheme.onSurfaceVariant,
1180 // Heuristic syntax highlighting per line. Falls back to plain text for
1181 // unknown extensions, so non-code files render unchanged. Memoized so the
1182 // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
1183 // file, or palette actually changes — not on every recomposition (e.g. every
1184 // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
1185 // Uses the stateful highlightLine so multi-line block comments and triple-quoted
1186 // strings color correctly on every line they span (the per-line snapshot in
1187 // lineStates carries the in-block/in-trString flag for this line's position).
1188 val highlighted = remember(line, syntax, palette, index) {
1189 val state = lineStates.getOrNull(index)
1190 if (state != null) {
1191 val s = soy.iko.opencode.ui.components.BlockCommentState().also {
1192 it.inBlockComment = state.inBlockComment
1193 it.inTripleString = state.inTripleString
1195 soy.iko.opencode.ui.components.highlightLine(line, syntax, palette, s)
1197 soy.iko.opencode.ui.components.highlightLine(line, syntax, palette)
1202 modifier = Modifier.padding(start = 8.dp),
1203 style = MaterialTheme.typography.bodySmall,
1214 * Renders an image file (PNG/JPG/GIF/WebP/BMP/SVG) inline in the file viewer instead of
1215 * the generic "Binary file" label. Decodes the base64 content from [FileContent.content]
1216 * into bytes and hands them to Coil, which renders the image and powers the fullscreen
1217 * zoomable viewer (pinch-zoom, save, share) on tap — the same [FullscreenImageViewer] the
1218 * chat attachments use. Falls back to the binary-file label if the base64 payload can't be
1219 * decoded (corrupt content or an unsupported encoding).
1222private fun ImageFilePreview(
1223 content: soy.iko.opencode.data.model.FileContent,
1224 modifier: Modifier = Modifier,
1226 val context = LocalContext.current
1227 val desc = stringResource(R.string.image_file_preview)
1228 // Decode the base64 payload to bytes once (off-thread via produceState so a large image
1229 // doesn't block the first frame). Coil takes the ByteArray as its model and decodes it to
1230 // a Bitmap internally — the same request drives both the inline preview and the fullscreen
1231 // viewer, so a tap reuses the already-cached decode for the zoomed view.
1232 val bytesState by produceState<ByteArray?>(initialValue = null, content.content) {
1233 value = withContext(Dispatchers.Default) {
1235 android.util.Base64.decode(content.content, android.util.Base64.DEFAULT)
1239 val bytes = bytesState
1240 if (bytes == null) {
1242 stringResource(R.string.binary_file),
1243 modifier = modifier,
1244 color = MaterialTheme.colorScheme.onSurfaceVariant,
1248 var showFullscreen by remember { mutableStateOf(false) }
1249 val request = remember(bytes) {
1250 ImageRequest.Builder(context)
1256 modifier = modifier.fillMaxWidth(),
1257 horizontalAlignment = Alignment.CenterHorizontally,
1259 SubcomposeAsyncImage(
1261 contentDescription = desc,
1264 .heightIn(max = 400.dp)
1265 .clip(MaterialTheme.shapes.medium)
1266 .clickable(role = Role.Button) { showFullscreen = true },
1269 if (showFullscreen) {
1270 FullscreenImageViewer(request = request, contentDescription = desc) { showFullscreen = false }
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list