1package soy.iko.opencode.ui.chat
3import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.foundation.background
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.combinedClickable
7import androidx.compose.foundation.horizontalScroll
8import androidx.compose.foundation.rememberScrollState
9import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.defaultMinSize
12import androidx.compose.foundation.layout.fillMaxWidth
13import androidx.compose.foundation.layout.padding
14import androidx.compose.foundation.layout.size
15import androidx.compose.foundation.text.selection.SelectionContainer
16import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.automirrored.filled.HelpOutline
18import androidx.compose.material.icons.filled.Bolt
19import androidx.compose.material.icons.filled.CheckCircle
20import androidx.compose.material.icons.filled.ContentCopy
21import androidx.compose.material.icons.filled.Description
22import androidx.compose.material.icons.filled.Error
23import androidx.compose.material.icons.filled.ExpandLess
24import androidx.compose.material.icons.filled.ExpandMore
25import androidx.compose.material.icons.filled.Psychology
26import androidx.compose.material.icons.filled.Schedule
27import androidx.compose.material3.CircularProgressIndicator
28import androidx.compose.material3.Icon
29import androidx.compose.material3.IconButton
30import androidx.compose.material3.MaterialTheme
31import androidx.compose.material3.Text
32import androidx.compose.material3.TextButton
33import androidx.compose.runtime.Composable
34import androidx.compose.runtime.LaunchedEffect
35import androidx.compose.runtime.getValue
36import androidx.compose.runtime.mutableStateOf
37import androidx.compose.runtime.remember
38import androidx.compose.runtime.saveable.rememberSaveable
39import androidx.compose.runtime.setValue
40import androidx.compose.ui.Alignment
41import androidx.compose.ui.Modifier
42import androidx.compose.ui.draw.clip
43import androidx.compose.ui.hapticfeedback.HapticFeedbackType
44import androidx.compose.ui.platform.LocalContext
45import androidx.compose.ui.platform.LocalHapticFeedback
46import androidx.compose.ui.semantics.Role
47import androidx.compose.ui.semantics.contentDescription
48import androidx.compose.ui.semantics.semantics
49import androidx.compose.ui.semantics.stateDescription
50import androidx.compose.ui.text.font.FontFamily
51import androidx.compose.ui.res.pluralStringResource
52import androidx.compose.ui.res.stringResource
53import androidx.compose.ui.unit.dp
54import kotlinx.serialization.json.Json
55import kotlinx.serialization.json.JsonElement
56import soy.iko.opencode.data.model.FilePart
57import soy.iko.opencode.data.model.Part
58import soy.iko.opencode.data.model.ReasoningPart
59import soy.iko.opencode.data.model.StepFinishPart
60import soy.iko.opencode.data.model.StepStartPart
61import soy.iko.opencode.data.model.TextPart
62import soy.iko.opencode.data.model.ToolCompleted
63import soy.iko.opencode.data.model.ToolError
64import soy.iko.opencode.data.model.ToolPart
65import soy.iko.opencode.data.model.ToolPending
66import soy.iko.opencode.data.model.ToolRunning
67import soy.iko.opencode.data.model.ToolState
68import soy.iko.opencode.data.model.ToolUnknown
69import soy.iko.opencode.data.model.TODO_WRITE_TOOL
70import soy.iko.opencode.data.model.UnknownPart
71import soy.iko.opencode.data.model.inputElement
72import soy.iko.opencode.data.model.parseTodos
73import soy.iko.opencode.data.model.sourcePath
74import soy.iko.opencode.ui.components.DiffView
75import soy.iko.opencode.ui.components.ImageLoadContext
76import soy.iko.opencode.ui.components.MarkdownText
77import soy.iko.opencode.ui.components.RemoteImage
78import soy.iko.opencode.ui.components.copyToClipboard
79import soy.iko.opencode.ui.components.isImage
80import soy.iko.opencode.ui.components.looksLikeDiff
81import soy.iko.opencode.ui.components.rememberVisibilityTransitions
82import soy.iko.opencode.ui.components.showCopyToast
83import soy.iko.opencode.R
85private const val COLLAPSED_LIMIT = 4000
87// Pretty-printer for tool inputs. Separate from OpencodeJson (which is tuned for
88// resilient decoding) so this stays human-readable; constructed lazily and memoized.
89private val prettyJson: Json = Json { prettyPrint = true }
91/** Extract a human-readable title (e.g. "Reading src/main.kt") from a tool state. */
92private fun ToolState.titleText(): String? = when (this) {
93 is ToolRunning -> title
94 is ToolCompleted -> title
98/** The elapsed wall-clock seconds a tool state ran for, when its time window is known.
99 * Returns null while a tool is still pending or when the server didn't report timing. */
100private fun ToolState.durationMs(): Long? {
101 val time = when (this) {
102 is ToolRunning -> time
103 is ToolCompleted -> time
107 val start = time.start ?: return null
108 val end = time.end ?: return null
109 return (end - start).coerceAtLeast(0L)
112/** Compact human-readable duration for a tool call (e.g. "2.3s", "1m 04s"). Sub-second
113 * runs render in milliseconds so a quick read still shows something distinguishable. */
114fun formatToolDuration(ms: Long): String {
115 if (ms < 1_000) return "${ms}ms"
116 val totalSeconds = ms / 1_000
117 val tenths = (ms % 1_000) / 100
118 if (totalSeconds < 60) {
119 return if (tenths == 0L) "${totalSeconds}s" else "${totalSeconds}.${tenths}s"
121 val minutes = totalSeconds / 60
122 val seconds = totalSeconds % 60
123 return if (minutes < 60) {
124 "${minutes}m ${seconds.toString().padStart(2, '0')}s"
126 val hours = minutes / 60
127 val mins = minutes % 60
128 "${hours}h ${mins.toString().padStart(2, '0')}m"
133 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
134 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
136 * [onOpenFile] is invoked when the user taps a [FilePart] chip that references a
137 * source path the viewer can open; null leaves the chip as a copy-path affordance
138 * (used when no file navigation is wired, e.g. in tests).
143 modifier: Modifier = Modifier,
144 isRunning: Boolean = false,
145 imageContext: ImageLoadContext? = null,
146 onOpenFile: ((path: String) -> Unit)? = null,
149 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
150 // A synthetic TextPart is server-generated content (e.g. a compacted-conversation
151 // summary) standing in for real exchanged messages. Mark it with a small muted
152 // chip above the body so a reader can tell it isn't a verbatim assistant reply —
153 // important when reviewing a compacted history where the "summary" framing would
154 // otherwise be indistinguishable from the agent speaking in its own voice.
155 if (part.synthetic) {
157 modifier = modifier.fillMaxWidth(),
158 verticalAlignment = Alignment.CenterVertically,
161 stringResource(R.string.synthetic_chip),
162 style = MaterialTheme.typography.labelSmall,
163 color = MaterialTheme.colorScheme.onSurfaceVariant,
165 .clip(MaterialTheme.shapes.extraSmall)
166 .background(MaterialTheme.colorScheme.surfaceVariant)
167 .padding(horizontal = 6.dp, vertical = 2.dp),
171 MarkdownText(part.text, modifier = modifier, streaming = isRunning)
173 is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, keyId = part.id, modifier = modifier)
174 is ToolPart -> ToolCallView(part, modifier)
175 is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
176 RemoteImage(part, imageContext, modifier)
178 FileChip(part, modifier, onOpenFile)
180 is StepStartPart -> {} // boundary marker — nothing to draw
181 is StepFinishPart -> {} // metrics handled at message level
182 is UnknownPart -> UnknownPartNote(modifier)
187private fun UnknownPartNote(modifier: Modifier) {
188 // Forward-compat: a part type the client doesn't model. Render a muted note (matching
189 // UnknownMessageBlock's pattern) so the user can tell content was dropped instead of
190 // the part vanishing silently — important when a new server release adds a part type.
192 modifier = modifier.fillMaxWidth(),
193 verticalAlignment = Alignment.CenterVertically,
196 Icons.AutoMirrored.Filled.HelpOutline,
197 contentDescription = null,
198 modifier = Modifier.size(14.dp),
199 tint = MaterialTheme.colorScheme.onSurfaceVariant,
202 stringResource(R.string.unknown_part),
203 style = MaterialTheme.typography.labelSmall,
204 color = MaterialTheme.colorScheme.onSurfaceVariant,
205 modifier = Modifier.padding(start = 6.dp),
211private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modifier: Modifier) {
212 if (text.isBlank()) return
213 val context = LocalContext.current
214 // Key the expanded state on the part's stable id so a new ReasoningPart doesn't
215 // inherit the positional saveable key of a prior block. A content-prefix key would
216 // change on nearly every token while streaming and snap the block back to collapsed;
217 // when the part has no id, use a constant — the key(part.id, index) wrapper at the
218 // call site already disambiguates sibling parts positionally, so a constant per
219 // block type won't collide.
220 val saveableKey = keyId.ifBlank { "reasoning" }
221 val haptics = LocalHapticFeedback.current
222 // While the reasoning is still streaming, default to expanded so live "Thinking…" is
223 // visible; once complete it collapses. A non-null override records an explicit user
224 // toggle and wins over the streaming default, so we don't fight a user who collapsed it.
225 var userOverride by rememberSaveable(saveableKey) { mutableStateOf<Boolean?>(null) }
226 // Sticky expand: once a block has streamed this session, pin it open after streaming ends
227 // (until the user explicitly collapses it) so it isn't yanked away mid-read the instant the
228 // stream finishes. A historical block that never streamed this session (streaming is false
229 // from the first composition) never sets streamedThisSession, so it stays collapsed.
230 var streamedThisSession by remember(saveableKey) { mutableStateOf(false) }
231 LaunchedEffect(streaming) {
232 if (streaming) streamedThisSession = true
233 else if (streamedThisSession && userOverride == null) userOverride = true
235 val expanded = userOverride ?: streaming
236 val expandedState = stringResource(R.string.state_expanded)
237 val collapsedState = stringResource(R.string.state_collapsed)
238 val thinkingLabel = stringResource(R.string.thinking)
239 val thoughtsLabel = stringResource(R.string.thoughts)
240 // Hoist the collapsed word count so it can drive both the visible "(N)" label and the
241 // row's a11y stateDescription. Previously the count rendered as a bare "(12)" that
242 // TalkBack read as "12" with no context; folding it into the stateDescription makes the
243 // hidden-content size reachable to screen-reader users.
244 val collapsedWordCount = remember(text) {
245 text.trim().split(Regex("\\s+")).count { it.isNotEmpty() }
247 // Resolve the readable "N words hidden" form once (in the composable body — the semantics
248 // lambda below is not a composable scope, so it can't call pluralStringResource itself).
249 val collapsedWordsLabel = pluralStringResource(
250 R.plurals.reasoning_word_count,
254 Column(modifier = modifier.fillMaxWidth()) {
258 .defaultMinSize(minHeight = 48.dp)
259 .clickable(role = Role.Button) { userOverride = !expanded }
260 .padding(vertical = 4.dp)
262 // When collapsed (and not streaming), append how much reasoning is hidden
263 // so the state reads e.g. "Collapsed, 42 words hidden" — giving a TalkBack
264 // user the same size signal the visible "(42)" gives a sighted user.
265 stateDescription = when {
266 expanded -> expandedState
267 streaming -> collapsedState
268 else -> "$collapsedState, $collapsedWordsLabel"
271 verticalAlignment = Alignment.CenterVertically,
274 CircularProgressIndicator(
275 Modifier.size(14.dp).semantics { contentDescription = thinkingLabel },
277 color = MaterialTheme.colorScheme.onSurfaceVariant,
281 Icons.Filled.Psychology,
282 contentDescription = null,
283 modifier = Modifier.size(16.dp),
284 tint = MaterialTheme.colorScheme.onSurfaceVariant,
288 if (streaming) thinkingLabel else thoughtsLabel,
289 style = MaterialTheme.typography.labelMedium,
290 color = MaterialTheme.colorScheme.onSurfaceVariant,
291 modifier = Modifier.padding(start = 6.dp),
293 // When collapsed, show a muted word count so the user has a signal of how much
294 // content is hidden before deciding to expand. Skipped while streaming (the
295 // content is growing and the label already says "Thinking…").
296 if (!streaming && !expanded) {
298 "($collapsedWordCount)",
299 style = MaterialTheme.typography.labelSmall,
300 color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
301 modifier = Modifier.padding(start = 4.dp),
305 val expandMotion = rememberVisibilityTransitions()
308 enter = expandMotion.enter,
309 exit = expandMotion.exit,
312 // Wrap in SelectionContainer so the user can select a portion of the
313 // reasoning (e.g. a single step) instead of the all-or-nothing copy
314 // button below. Matches the markdown text's selectability.
318 style = MaterialTheme.typography.bodySmall,
319 color = MaterialTheme.colorScheme.onSurfaceVariant,
324 // Copy affordance lives outside AnimatedVisibility so it's reachable when the
325 // block is collapsed — previously the copy button was inside the expanded content,
326 // leaving a collapsed reasoning block with no copy affordance at all.
329 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
330 copyToClipboard(context, context.getString(R.string.clip_label_reasoning), text)
332 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
334 .defaultMinSize(minHeight = 48.dp)
335 .semantics(mergeDescendants = true) {},
337 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
338 Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
343@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
345private fun ToolCallView(part: ToolPart, modifier: Modifier) {
346 val context = LocalContext.current
347 val haptics = LocalHapticFeedback.current
351 .clip(MaterialTheme.shapes.small)
352 .background(MaterialTheme.colorScheme.surfaceVariant)
355 Row(verticalAlignment = Alignment.CenterVertically) {
356 ToolStatusIcon(part.state)
359 style = MaterialTheme.typography.labelLarge,
360 fontFamily = FontFamily.Monospace,
361 modifier = Modifier.padding(start = 6.dp),
363 // Wall-clock duration of the tool call (e.g. "· 2.3s"), shown when the server
364 // reports both start and end. Omitted while still running (no end yet) and when
365 // timing wasn't reported — a bare "· 0ms" would be noise. Placed inline with the
366 // tool name so a glance at the row tells both *what* and *how long*.
367 part.state.durationMs()?.let { ms ->
369 stringResource(R.string.tool_duration_format, formatToolDuration(ms)),
370 style = MaterialTheme.typography.labelSmall,
371 color = MaterialTheme.colorScheme.onSurfaceVariant,
372 modifier = Modifier.padding(start = 6.dp),
376 // A human-readable summary of what the tool was asked to do (e.g. "Reading
377 // src/main.kt"), when the server provides one. Sits directly under the tool
378 // name so the user understands the call before diving into input/output.
379 part.state.titleText()?.takeIf { it.isNotBlank() }?.let { title ->
382 style = MaterialTheme.typography.bodySmall,
383 color = MaterialTheme.colorScheme.onSurfaceVariant,
384 modifier = Modifier.padding(start = 22.dp, top = 2.dp),
387 // A todowrite call carries the agent's task plan as its input; render it as a
388 // checklist rather than raw JSON so each step's progress reads at a glance.
389 val planTodos = remember(part) {
390 if (part.tool.equals(TODO_WRITE_TOOL, ignoreCase = true)) parseTodos(part.state.inputElement()) else emptyList()
392 if (planTodos.isNotEmpty()) {
393 TodoPlanChecklist(planTodos, modifier = Modifier.padding(top = 8.dp))
395 // The tool's arguments (pretty-printed JSON), collapsible. Rendered before the
396 // output so the call reads top-to-bottom: name → what → input → result.
397 part.state.inputElement()?.let { input ->
398 val inputLabel = stringResource(R.string.tool_input)
399 val pretty = remember(input) {
400 runCatching { prettyJson.encodeToString(JsonElement.serializer(), input) }.getOrDefault(input.toString())
407 // The input block has a label row whose collapse toggle announces its state
408 // via stateDescription; pass the strings so TalkBack reads "expanded"/"collapsed"
409 // rather than an empty state (the defaults are "").
410 expandedState = stringResource(R.string.state_expanded),
411 collapsedState = stringResource(R.string.state_collapsed),
412 // Mirror the output block's copy affordance so a tool's arguments (e.g. a
413 // bash command or file-write content) can be copied for reuse without manual
416 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
417 copyToClipboard(context, context.getString(R.string.clip_label_input), pretty)
422 val detail = when (val s = part.state) {
423 is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
424 is ToolError -> s.error ?: stringResource(R.string.error_generic)
427 if (detail != null) {
428 // looksLikeDiff scans the whole string; memoize on `detail` so a recomposition that
429 // doesn't change the content (e.g. the expand/collapse flip) doesn't re-scan.
430 val isDiff = remember(detail) { looksLikeDiff(detail) }
431 val collapsed = remember(detail, isDiff) {
432 val head = detail.take(COLLAPSED_LIMIT)
433 // Truncating a diff mid-line makes DiffView render a malformed final line, so
434 // trim back to the last complete line when the content is a diff.
435 if (isDiff) head.substringBeforeLast('\n') else head
437 var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
438 val expandedState = stringResource(R.string.state_expanded)
439 val collapsedState = stringResource(R.string.state_collapsed)
440 val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail else collapsed
441 // How many lines are hidden while collapsed. detail.lines() splits the whole
442 // (potentially multi-KB) output, so memoize it — otherwise it re-splits on
443 // every recomposition, including each streaming update of a running tool.
444 // `collapsed` is a prefix of `detail`, so its line count never exceeds it;
445 // 0 (a single long line cut mid-way) falls back to the generic label.
446 val moreLines = remember(detail, collapsed) {
447 if (detail.length > COLLAPSED_LIMIT) detail.lines().size - collapsed.lines().size else 0
453 keySuffix = "output",
454 expanded = expanded || detail.length <= COLLAPSED_LIMIT,
455 onToggleExpand = if (detail.length > COLLAPSED_LIMIT) {
456 { expanded = !expanded }
458 moreLines = moreLines,
459 expandedState = expandedState,
460 collapsedState = collapsedState,
462 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
463 copyToClipboard(context, context.getString(R.string.clip_label_output), detail)
465 diffSaveKey = part.id,
472 * A collapsible monospace detail block shared by tool input and output. [label] is an
473 * optional heading (e.g. "Input"); when null, no heading row is drawn (used for output
474 * which follows the tool name directly). Long content is truncated to [COLLAPSED_LIMIT]
475 * with an expand/collapse affordance when [onToggleExpand] is supplied.
478private fun CollapsibleDetail(
483 expanded: Boolean = false,
484 onToggleExpand: (() -> Unit)? = null,
486 expandedState: String = "",
487 collapsedState: String = "",
488 onCopy: (() -> Unit)? = null,
489 diffSaveKey: String? = null,
491 Column(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
493 var inputExpanded by rememberSaveable(label + keySuffix) { mutableStateOf(false) }
497 .defaultMinSize(minHeight = 48.dp)
498 .clickable(role = Role.Button) { inputExpanded = !inputExpanded }
499 .semantics { stateDescription = if (inputExpanded) expandedState else collapsedState },
500 verticalAlignment = Alignment.CenterVertically,
503 if (inputExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
504 contentDescription = null,
505 modifier = Modifier.size(16.dp),
506 tint = MaterialTheme.colorScheme.onSurfaceVariant,
510 style = MaterialTheme.typography.labelSmall,
511 color = MaterialTheme.colorScheme.onSurfaceVariant,
512 modifier = Modifier.padding(start = 4.dp),
515 if (!inputExpanded) return@Column
518 DiffView(detail, saveKey = diffSaveKey)
520 // SelectionContainer so the user can select a portion of the output (e.g.
521 // a single line of stdout) instead of only copy-all via the button below.
522 // Horizontal scroll + softWrap=false so wide stdout / tables scroll instead of
523 // soft-wrapping into an unreadable ragged block (mirrors the file viewer).
527 style = MaterialTheme.typography.bodySmall,
528 fontFamily = FontFamily.Monospace,
530 modifier = Modifier.horizontalScroll(rememberScrollState()),
534 if (onToggleExpand != null) {
536 onClick = onToggleExpand,
537 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
539 .defaultMinSize(minHeight = 48.dp)
540 .semantics { stateDescription = if (expanded) expandedState else collapsedState },
543 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
544 contentDescription = null,
545 modifier = Modifier.size(16.dp),
549 expanded -> stringResource(R.string.show_less)
550 moreLines > 0 -> pluralStringResource(R.plurals.show_more_lines, moreLines, moreLines)
551 else -> stringResource(R.string.show_more)
553 style = MaterialTheme.typography.labelSmall,
554 modifier = Modifier.padding(start = 4.dp),
558 if (onCopy != null) {
561 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
563 .defaultMinSize(minHeight = 48.dp)
564 .semantics(mergeDescendants = true) {},
566 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
567 Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
574private fun ToolStatusIcon(state: ToolState) {
577 // Queued, not yet executing: a static clock icon distinguishes it from an
578 // actively-running tool (spinner) so the user can tell what's waiting.
579 val label = stringResource(R.string.tool_queued)
581 Icons.Filled.Schedule,
582 contentDescription = label,
583 modifier = Modifier.size(16.dp),
584 tint = MaterialTheme.colorScheme.onSurfaceVariant,
588 val label = stringResource(R.string.tool_running)
589 CircularProgressIndicator(
590 modifier = Modifier.size(16.dp).semantics { contentDescription = label },
596 Icons.Filled.CheckCircle,
597 contentDescription = stringResource(R.string.tool_completed),
598 modifier = Modifier.size(16.dp),
599 tint = MaterialTheme.colorScheme.primary,
604 contentDescription = stringResource(R.string.tool_error),
605 modifier = Modifier.size(16.dp),
606 tint = MaterialTheme.colorScheme.error,
611 contentDescription = stringResource(R.string.tool_unknown),
612 modifier = Modifier.size(16.dp),
617@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
619private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -> Unit)?) {
620 val context = LocalContext.current
621 val haptics = LocalHapticFeedback.current
622 val path = part.sourcePath ?: part.url ?: part.filename
623 val copyLabel = stringResource(R.string.copy_path)
624 val openLabel = stringResource(R.string.open_file)
625 // When a source path is available and a navigation callback is wired, tapping the
626 // chip opens the file in the viewer (the action a user tapping a file reference
627 // most likely expects). Long-press still copies the path to the clipboard, so the
628 // copy affordance is preserved without being the default tap action. When no
629 // navigation is wired (e.g. tests), falls back to copy-path on tap.
630 val source = part.sourcePath
631 val opener = onOpenFile
632 val openPath: String? = if (opener != null && !source.isNullOrBlank()) source else null
633 val canOpen = openPath != null
634 val semanticsLabel = if (canOpen) openLabel else copyLabel
637 .clip(MaterialTheme.shapes.small)
638 .background(MaterialTheme.colorScheme.surfaceVariant)
642 val toOpen = openPath
643 if (toOpen != null) {
644 opener?.invoke(toOpen)
645 } else if (!path.isNullOrBlank()) {
646 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
647 copyToClipboard(context, context.getString(R.string.clip_label_path), path)
648 showCopyToast(context, context.getString(R.string.path_copied))
652 if (!path.isNullOrBlank()) {
653 haptics.performHapticFeedback(HapticFeedbackType.LongPress)
654 copyToClipboard(context, context.getString(R.string.clip_label_path), path)
655 showCopyToast(context, context.getString(R.string.path_copied))
659 .semantics { contentDescription = semanticsLabel }
660 .defaultMinSize(minHeight = 48.dp)
661 .padding(horizontal = 12.dp, vertical = 6.dp),
662 verticalAlignment = Alignment.CenterVertically,
665 Icons.Filled.Description,
666 contentDescription = null,
667 modifier = Modifier.size(16.dp),
668 tint = MaterialTheme.colorScheme.onSurfaceVariant,
671 part.filename ?: part.url ?: stringResource(R.string.file),
672 style = MaterialTheme.typography.bodyMedium,
673 modifier = Modifier.padding(start = 6.dp),
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