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