1package soy.iko.opencode.ui.file
3import android.content.Intent
4import soy.iko.opencode.ui.components.showToast
5import androidx.compose.animation.AnimatedContent
6import androidx.compose.animation.core.tween
7import androidx.compose.animation.fadeIn
8import androidx.compose.animation.fadeOut
9import androidx.compose.animation.togetherWith
10import androidx.compose.foundation.background
11import androidx.compose.foundation.horizontalScroll
12import androidx.compose.foundation.interaction.DragInteraction
13import androidx.compose.foundation.layout.Box
14import androidx.compose.foundation.layout.BoxScope
15import androidx.compose.foundation.layout.Column
16import androidx.compose.foundation.layout.Arrangement
17import androidx.compose.foundation.layout.Row
18import androidx.compose.foundation.layout.Spacer
19import androidx.compose.foundation.layout.fillMaxSize
20import androidx.compose.foundation.layout.fillMaxWidth
21import androidx.compose.foundation.layout.padding
22import androidx.compose.foundation.layout.size
23import androidx.compose.foundation.layout.width
24import androidx.compose.foundation.lazy.LazyColumn
25import androidx.compose.foundation.lazy.LazyListState
26import androidx.compose.foundation.lazy.itemsIndexed
27import androidx.compose.foundation.lazy.rememberLazyListState
28import androidx.compose.foundation.rememberScrollState
29import androidx.compose.foundation.text.KeyboardActions
30import androidx.compose.foundation.text.KeyboardOptions
31import androidx.compose.foundation.verticalScroll
32import androidx.compose.material.icons.Icons
33import androidx.compose.material.icons.automirrored.filled.ArrowBack
34import androidx.compose.material.icons.automirrored.filled.WrapText
35import androidx.compose.material.icons.filled.Close
36import androidx.compose.material.icons.filled.ContentCopy
37import androidx.compose.material.icons.filled.KeyboardArrowDown
38import androidx.compose.material.icons.filled.KeyboardArrowUp
39import androidx.compose.material.icons.filled.MoreVert
40import androidx.compose.material.icons.filled.Refresh
41import androidx.compose.material.icons.filled.Search
42import androidx.compose.material.icons.filled.Share
43import androidx.compose.material3.CircularProgressIndicator
44import androidx.compose.material3.DropdownMenu
45import androidx.compose.material3.DropdownMenuItem
46import androidx.compose.material3.ExperimentalMaterial3Api
47import androidx.compose.material3.FilterChip
48import androidx.compose.material3.Icon
49import androidx.compose.material3.IconButton
50import androidx.compose.material3.MaterialTheme
51import androidx.compose.material3.OutlinedTextField
52import androidx.compose.material3.Scaffold
53import androidx.compose.material3.Text
54import androidx.compose.material3.TextButton
55import androidx.compose.material3.TopAppBar
56import androidx.compose.runtime.Composable
57import androidx.compose.runtime.LaunchedEffect
58import androidx.compose.runtime.MutableState
59import androidx.compose.runtime.getValue
60import androidx.compose.runtime.mutableIntStateOf
61import androidx.compose.runtime.mutableStateOf
62import androidx.compose.runtime.produceState
63import androidx.compose.runtime.remember
64import androidx.compose.runtime.saveable.rememberSaveable
65import androidx.compose.runtime.setValue
66import androidx.compose.ui.Alignment
67import androidx.compose.ui.Modifier
68import androidx.compose.ui.layout.onSizeChanged
69import androidx.compose.ui.platform.LocalContext
70import androidx.compose.ui.platform.LocalDensity
71import androidx.compose.ui.platform.LocalSoftwareKeyboardController
72import androidx.compose.ui.semantics.contentDescription
73import androidx.compose.ui.semantics.semantics
74import androidx.compose.ui.text.font.FontFamily
75import androidx.compose.ui.text.input.ImeAction
76import androidx.compose.ui.text.style.TextOverflow
77import androidx.compose.ui.unit.dp
78import androidx.compose.ui.res.stringResource
79import androidx.lifecycle.compose.collectAsStateWithLifecycle
80import androidx.lifecycle.viewmodel.compose.viewModel
81import soy.iko.opencode.di.AppContainer
82import soy.iko.opencode.R
83import soy.iko.opencode.ui.components.DiffView
84import soy.iko.opencode.ui.components.LocalCodeWrap
85import soy.iko.opencode.ui.components.copyToClipboard
86import soy.iko.opencode.ui.components.highlightLine
87import soy.iko.opencode.ui.components.syntaxFor
88import soy.iko.opencode.ui.components.rememberHighlightPalette
89import soy.iko.opencode.ui.vmFactory
90import soy.iko.opencode.util.runCatchingCancellable
91import kotlinx.coroutines.Dispatchers
92import kotlinx.coroutines.delay
93import kotlinx.coroutines.flow.collect
94import kotlinx.coroutines.withContext
96/** Cap on how many lines the raw viewer renders and searches. A multi-megabyte file is
97 * split once (not twice) into this many lines at most, so it can't hold two full line
98 * lists in memory or stall the LazyColumn; beyond the cap a truncation banner is shown. */
99private const val MAX_RENDERED_LINES = 5000
101@OptIn(ExperimentalMaterial3Api::class)
104 container: AppContainer,
107 initialLine: Int? = null,
109 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
110 val state by vm.state.collectAsStateWithLifecycle()
111 val context = LocalContext.current
112 val filename = remember(path) {
113 val trimmed = path.trimEnd('/')
114 trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } }
116 val shareLabel = stringResource(R.string.share)
117 // Diff vs raw view toggle. Defaults to showing the diff when one exists; the user can
118 // switch to the raw new content. Persisted via rememberSaveable across rotation. When the
119 // viewer is opened at a specific line (from a search hit), start on the raw view so the
120 // targeted line is actually rendered (the diff view has no line list to scroll).
121 val hasDiff = state.content?.diff != null && state.content?.diff?.isNotBlank() == true
122 var showDiff by rememberSaveable(path) { mutableStateOf(initialLine == null) }
123 val showToggle = hasDiff && state.content?.content.orEmpty().isNotEmpty()
124 // Find-in-file and line-wrapping state, persisted so a rotation or reload keeps the
125 // user's query/mode.
126 var findActive by rememberSaveable(path) { mutableStateOf(false) }
127 var findQuery by rememberSaveable(path) { mutableStateOf("") }
128 var matchPos by rememberSaveable(path) { mutableIntStateOf(0) }
129 // Seed the per-file wrap toggle from the user's global code-wrap preference so the viewer
130 // respects it by default; they can still flip it per-file (persisted across rotation).
131 val codeWrap = LocalCodeWrap.current
132 var wrap by rememberSaveable(path) { mutableStateOf(codeWrap) }
133 val listState = rememberLazyListState()
134 val rawText = state.content?.content.orEmpty()
135 // Split the raw text exactly once here (the line viewer and find-in-file both consume this
136 // list) and cap it so a huge file doesn't get split twice or overwhelm the LazyColumn.
137 val allLines = remember(rawText) { rawText.split("\n") }
138 val truncated = allLines.size > MAX_RENDERED_LINES
139 val lines = remember(allLines) {
140 if (allLines.size > MAX_RENDERED_LINES) allLines.subList(0, MAX_RENDERED_LINES) else allLines
142 // The line the viewer was opened at (from a search hit), highlighted until the user scrolls
143 // or starts an in-file find so the landed line stands out. Null once cleared.
144 val highlightLine = rememberJumpToLineHighlight(
146 initialLine = initialLine,
147 content = state.content,
148 isBinary = state.content?.isBinary == true,
149 lineCount = lines.size,
150 listState = listState,
152 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
153 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
154 var debouncedFind by remember { mutableStateOf("") }
155 LaunchedEffect(findQuery) {
156 val q = findQuery.trim()
157 if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q }
159 val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind) {
160 val q = debouncedFind
161 value = if (q.isEmpty()) emptyList()
162 else withContext(Dispatchers.Default) {
163 lines.mapIndexedNotNull { index, line -> index.takeIf { line.contains(q, ignoreCase = true) } }
166 // Keep the current match index in range when the match set shrinks (e.g. the file
167 // reloaded with fewer matches), so the counter never shows a stale "10 / 3".
168 LaunchedEffect(matchIndices.size) {
169 matchPos = matchPos.coerceIn(0, (matchIndices.size - 1).coerceAtLeast(0))
171 // Scroll to the current match whenever it (or the match set) changes.
172 LaunchedEffect(matchPos, matchIndices) {
173 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
179 loading = state.loading,
182 diffShown = showToggle && showDiff,
184 onReload = { vm.reload() },
185 // Find searches the raw line list, which isn't on screen under a diff. Rather
186 // than silently greying the icon out, keep it tappable and explain where find
187 // lives when the user is in diff mode.
189 if (showToggle && showDiff) {
190 showToast(context, context.getString(R.string.switch_to_raw_to_search))
192 findActive = !findActive
193 if (!findActive) findQuery = ""
196 onToggleWrap = { wrap = !wrap },
197 onCopy = { copyToClipboard(context, filename, rawText) },
199 val send = Intent(Intent.ACTION_SEND).apply {
201 putExtra(Intent.EXTRA_SUBJECT, filename)
202 putExtra(Intent.EXTRA_TEXT, rawText)
204 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
205 .onFailure { showToast(context, context.getString(R.string.no_share_app)) }
210 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
211 // AnimatedContent cross-fades between loading / error / content states so the
212 // viewer doesn't snap abruptly when a reload finishes or fails.
215 transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
216 label = "fileViewState",
218 // Single source of truth for matches: the off-thread `matchIndices` produceState
219 // (keyed on the file content + debounced query). Both navigation (onPrev/onNext +
220 // scroll) and display (counter + highlight) read from it, so the counter can't show
221 // a new query while next/prev act on a stale set. The earlier in-composition
222 // per-layer scan re-ran the whole-file O(n) match on the UI thread each keystroke
223 // (the exact work produceState was moved off-thread to avoid); it's acceptable for
224 // the crossfade's exiting layer to briefly use this unified set until it settles.
225 FileViewStateContent(
228 showToggle = showToggle,
232 // Close any open find bar when entering diff mode: find searches
233 // the raw content and scrolls the line LazyColumn, neither of
234 // which is visible in diff mode, so leaving it open would be a
236 if (it) { findActive = false; findQuery = "" }
238 findActive = findActive,
239 findQuery = findQuery,
240 onQueryChange = { findQuery = it; matchPos = 0; if (it.isNotEmpty()) highlightLine.value = null },
241 matchIndices = matchIndices,
243 onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
244 onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
245 onCloseFind = { findActive = false; findQuery = "" },
248 truncated = truncated,
249 highlightLineIndex = highlightLine.value,
250 listState = listState,
251 onRetry = { vm.reload() },
258// Owns the "jump to a line from a search hit" behaviour: scroll to the target once the raw
259// content loads (one-shot), and clear the highlight the moment the user drags the list by hand.
260// Extracted so FileViewScreen stays under the cyclomatic-complexity threshold.
262private fun rememberJumpToLineHighlight(
268 listState: LazyListState,
269): MutableState<Int?> {
270 val highlight = rememberSaveable(path) { mutableStateOf<Int?>(null) }
271 var didInitialScroll by rememberSaveable(path) { mutableStateOf(false) }
272 LaunchedEffect(content, initialLine) {
273 val line = initialLine ?: return@LaunchedEffect
274 if (didInitialScroll || content == null || isBinary) return@LaunchedEffect
275 val target = (line - 1).coerceIn(0, (lineCount - 1).coerceAtLeast(0))
276 runCatchingCancellable { listState.scrollToItem(target) }
277 highlight.value = target
278 didInitialScroll = true
280 LaunchedEffect(listState) {
281 if (initialLine == null) return@LaunchedEffect
282 listState.interactionSource.interactions.collect { interaction ->
283 if (interaction is DragInteraction.Start) highlight.value = null
289@OptIn(ExperimentalMaterial3Api::class)
291private fun FileViewTopBar(
298 onReload: () -> Unit,
299 onToggleFind: () -> Unit,
300 onToggleWrap: () -> Unit,
305 title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
307 IconButton(onClick = onBack) {
308 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
312 // In-place refresh: a file's content can change on the server (the agent may
313 // be editing it), so reload without requiring a back-out. Shows a spinner while
314 // loading since the content now stays on screen during a reload (the spinner is
315 // the only progress signal).
316 val loadingLabel = stringResource(R.string.loading)
317 IconButton(onClick = onReload, enabled = !loading) {
319 CircularProgressIndicator(
320 Modifier.size(20.dp).semantics { contentDescription = loadingLabel },
324 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
327 if (content.isNotEmpty()) {
328 // Find-in-file: toggles a search bar over the raw content. Stays enabled even in
329 // diff mode — the find bar searches the raw text and scrolls the line LazyColumn,
330 // neither of which is visible under a diff, so tapping it there surfaces a hint to
331 // switch to Raw rather than silently no-opping behind a greyed-out icon.
332 IconButton(onClick = onToggleFind) {
333 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
335 // Overflow the less-frequent actions (wrap / copy / share) so a long filename
336 // title isn't crowded off screen by five inline icons on a phone.
337 var menuExpanded by remember { mutableStateOf(false) }
338 IconButton(onClick = { menuExpanded = true }) {
339 Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options))
341 DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
342 // Wrap long lines instead of horizontal-scrolling; useful for prose. Disabled
343 // in diff mode because DiffView ignores wrap, so an active-looking toggle there
344 // would be a silent no-op.
346 text = { Text(stringResource(R.string.wrap_lines)) },
347 onClick = { onToggleWrap(); menuExpanded = false },
348 enabled = !diffShown,
351 Icons.AutoMirrored.Filled.WrapText,
352 contentDescription = null,
353 tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
358 text = { Text(stringResource(R.string.copy)) },
359 onClick = { onCopy(); menuExpanded = false },
360 leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) },
363 text = { Text(stringResource(R.string.share)) },
364 onClick = { onShare(); menuExpanded = false },
365 leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) },
374private fun BoxScope.FileViewStateContent(
375 state: FileViewState,
379 onSetShowDiff: (Boolean) -> Unit,
382 onQueryChange: (String) -> Unit,
383 matchIndices: List<Int>,
387 onCloseFind: () -> Unit,
391 highlightLineIndex: Int?,
392 listState: LazyListState,
396 // Full-screen spinner only on the initial load (no content yet). A reload keeps the
397 // existing content on screen; the top-bar reload button shows the in-flight progress.
398 state.loading && state.content == null -> {
399 val loadingLabel = stringResource(R.string.loading)
400 CircularProgressIndicator(
401 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
404 state.error != null -> Column(
405 modifier = Modifier.align(Alignment.Center).padding(24.dp),
406 horizontalAlignment = Alignment.CenterHorizontally,
408 Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
409 Spacer(Modifier.size(12.dp))
410 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
412 state.content?.isBinary == true -> Text(
413 stringResource(R.string.binary_file),
414 modifier = Modifier.align(Alignment.Center).padding(24.dp),
415 color = MaterialTheme.colorScheme.onSurfaceVariant,
417 else -> FileViewContentBody(
418 content = state.content,
420 showToggle = showToggle,
422 onSetShowDiff = onSetShowDiff,
423 findActive = findActive,
424 findQuery = findQuery,
425 onQueryChange = onQueryChange,
426 matchIndices = matchIndices,
430 onCloseFind = onCloseFind,
433 truncated = truncated,
434 highlightLineIndex = highlightLineIndex,
435 listState = listState,
441private fun BoxScope.FileViewContentBody(
442 content: soy.iko.opencode.data.model.FileContent?,
446 onSetShowDiff: (Boolean) -> Unit,
449 onQueryChange: (String) -> Unit,
450 matchIndices: List<Int>,
454 onCloseFind: () -> Unit,
458 highlightLineIndex: Int?,
459 listState: LazyListState,
461 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
462 // reachable while scrolling. Each contributes a top inset so the content below
463 // isn't hidden behind the overlay. The inset is measured from the actual overlay
464 // height (not a hardcoded 52dp/row) so accessibility font scaling — which can
465 // make the FilterChip labels and FindBar text field taller than 52dp — doesn't
466 // cause the first lines of content to hide behind the overlay.
467 // The raw text is on screen whenever we're NOT rendering the diff — i.e. either the
468 // file has no diff toggle at all (plain file: showToggle == false) or the toggle is
469 // set to Raw. Gate the find bar on this rather than on !showDiff: for a plain file
470 // showDiff stays at its `true` default forever (nothing ever flips it), so !showDiff
471 // would keep the find bar — and therefore find-in-file — permanently hidden.
472 val rawOnScreen = !(showToggle && showDiff)
473 val overlayRows = (if (showToggle) 1 else 0) + (if (findActive && rawOnScreen) 1 else 0)
474 var overlayHeightPx by remember { mutableIntStateOf(0) }
475 // Only apply the measured inset while an overlay is actually composed. When overlayRows
476 // drops to 0 the overlay Column is removed and onSizeChanged never fires to zero the
477 // height, so keying the inset off overlayRows (rather than the stale measurement) avoids
478 // a phantom top gap on the content below — e.g. after closing find on a plain file.
479 val topInset = if (overlayRows > 0) {
480 with(androidx.compose.ui.platform.LocalDensity.current) { overlayHeightPx.toDp() }
482 if (overlayRows > 0) {
485 .align(Alignment.TopCenter)
487 .onSizeChanged { overlayHeightPx = it.height }
488 .padding(top = 4.dp),
489 verticalArrangement = Arrangement.spacedBy(4.dp),
493 modifier = Modifier.fillMaxWidth(),
494 horizontalArrangement = Arrangement.Center,
498 onClick = { onSetShowDiff(true) },
499 label = { Text(stringResource(R.string.show_diff)) },
501 Spacer(Modifier.size(8.dp))
503 selected = !showDiff,
504 onClick = { onSetShowDiff(false) },
505 label = { Text(stringResource(R.string.show_raw)) },
509 if (findActive && rawOnScreen) {
512 onQueryChange = onQueryChange,
513 matchCount = matchIndices.size,
517 onClose = onCloseFind,
522 if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
527 .padding(top = topInset)
528 .verticalScroll(rememberScrollState())
532 val text = content?.content.orEmpty()
533 if (text.isEmpty()) {
535 stringResource(R.string.empty_file),
536 modifier = Modifier.align(Alignment.Center).padding(24.dp),
537 color = MaterialTheme.colorScheme.onSurfaceVariant,
544 truncated = truncated,
546 findQuery = findQuery,
547 matchIndices = matchIndices,
548 highlightLineIndex = highlightLineIndex,
549 listState = listState,
558 onQueryChange: (String) -> Unit,
565 val keyboardController = LocalSoftwareKeyboardController.current
567 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
568 verticalAlignment = Alignment.CenterVertically,
572 onValueChange = onQueryChange,
573 modifier = Modifier.weight(1f),
574 placeholder = { Text(stringResource(R.string.find_hint)) },
576 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
577 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
579 val countText = if (query.isBlank()) ""
580 else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
581 else stringResource(R.string.match_count, matchPos + 1, matchCount)
582 if (countText.isNotEmpty()) {
583 // Use error color for "no matches" so the zero-result state is
584 // distinguishable from a positive match count at a glance.
585 val color = if (query.isNotBlank() && matchCount == 0) MaterialTheme.colorScheme.error
586 else MaterialTheme.colorScheme.onSurfaceVariant
589 style = MaterialTheme.typography.labelSmall,
595 IconButton(onClick = onPrev, enabled = matchCount > 0) {
596 Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_previous))
598 IconButton(onClick = onNext, enabled = matchCount > 0) {
599 Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_next))
601 IconButton(onClick = onClose) {
602 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
608private fun FileTextContent(
611 topInset: androidx.compose.ui.unit.Dp,
615 matchIndices: List<Int>,
616 highlightLineIndex: Int?,
617 listState: LazyListState,
619 // Gutter width scales with the digit count and the font scale so accessibility
620 // text scaling (e.g. 1.3x) doesn't make line numbers overflow the gutter and
621 // collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
622 // proportionally for larger font scales.
623 val fontScale = androidx.compose.ui.platform.LocalDensity.current.fontScale
624 val gutterWidth = remember(lines.size, fontScale) {
625 ((lines.size.toString().length.coerceAtLeast(3) * 10) * fontScale.coerceAtLeast(1f)).dp
627 val hScrollState = rememberScrollState()
628 val palette = rememberHighlightPalette()
629 // Resolve the file's language once instead of re-parsing the extension for every line.
630 val syntax = remember(filename) { syntaxFor(filename) }
631 val q = findQuery.trim()
632 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
633 Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
636 stringResource(R.string.file_truncated, MAX_RENDERED_LINES),
637 style = MaterialTheme.typography.labelSmall,
638 color = MaterialTheme.colorScheme.onSurfaceVariant,
639 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
649 itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
650 val isMatch = q.isNotEmpty() && index in matchSet
651 // The jump-to-line target keeps the same emphasis as a find match so the line
652 // the viewer landed on is obvious until the user scrolls or starts a find.
653 val isTarget = index == highlightLineIndex
657 .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
658 .then(if (isMatch || isTarget) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
662 modifier = Modifier.width(gutterWidth),
663 style = MaterialTheme.typography.bodySmall,
664 fontFamily = FontFamily.Monospace,
665 color = MaterialTheme.colorScheme.onSurfaceVariant,
667 // Heuristic syntax highlighting per line. Falls back to plain text for
668 // unknown extensions, so non-code files render unchanged. Memoized so the
669 // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
670 // file, or palette actually changes — not on every recomposition (e.g. every
671 // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
672 val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
675 modifier = Modifier.padding(start = 8.dp),
676 style = MaterialTheme.typography.bodySmall,
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