git.iko.soy/opencode-android
on 066cded help sign in
609 lines 27 kB history raw
1package soy.iko.opencode.ui.file
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.horizontalScroll
5import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.Spacer
10import androidx.compose.foundation.layout.defaultMinSize
11import androidx.compose.foundation.layout.fillMaxSize
12import androidx.compose.foundation.layout.fillMaxWidth
13import androidx.compose.foundation.layout.padding
14import androidx.compose.foundation.layout.size
15import androidx.compose.foundation.lazy.LazyColumn
16import androidx.compose.foundation.lazy.items
17import androidx.compose.foundation.lazy.itemsIndexed
18import androidx.compose.foundation.rememberScrollState
19import androidx.compose.foundation.text.KeyboardActions
20import androidx.compose.foundation.text.KeyboardOptions
21import androidx.compose.material.icons.Icons
22import androidx.compose.material.icons.automirrored.filled.ArrowBack
23import androidx.compose.material.icons.filled.ChevronRight
24import androidx.compose.material.icons.filled.Close
25import androidx.compose.material.icons.filled.Description
26import androidx.compose.material.icons.filled.Folder
27import androidx.compose.material.icons.filled.Home
28import androidx.compose.material.icons.filled.Search
29import androidx.compose.material3.CircularProgressIndicator
30import androidx.compose.material3.ExperimentalMaterial3Api
31import androidx.compose.material3.FilterChip
32import androidx.compose.material3.HorizontalDivider
33import androidx.compose.material3.Icon
34import androidx.compose.material3.IconButton
35import androidx.compose.material3.LinearProgressIndicator
36import androidx.compose.material3.MaterialTheme
37import androidx.compose.material3.OutlinedTextField
38import androidx.compose.material3.Scaffold
39import androidx.compose.material3.SnackbarHost
40import androidx.compose.material3.SnackbarHostState
41import androidx.compose.material3.Text
42import androidx.compose.material3.TopAppBar
43import androidx.compose.runtime.Composable
44import androidx.compose.runtime.LaunchedEffect
45import androidx.compose.runtime.getValue
46import androidx.compose.runtime.remember
47import androidx.compose.ui.Alignment
48import androidx.compose.ui.Modifier
49import androidx.compose.ui.focus.FocusRequester
50import androidx.compose.ui.focus.focusRequester
51import androidx.compose.ui.graphics.Color
52import androidx.compose.ui.platform.LocalSoftwareKeyboardController
53import androidx.compose.ui.platform.testTag
54import androidx.compose.ui.semantics.Role
55import androidx.compose.ui.semantics.contentDescription
56import androidx.compose.ui.semantics.role
57import androidx.compose.ui.semantics.semantics
58import androidx.compose.ui.text.SpanStyle
59import androidx.compose.ui.text.buildAnnotatedString
60import androidx.compose.ui.text.font.FontFamily
61import androidx.compose.ui.text.font.FontWeight
62import androidx.compose.ui.text.input.ImeAction
63import androidx.compose.ui.text.style.TextOverflow
64import androidx.compose.ui.text.withStyle
65import androidx.compose.ui.unit.dp
66import androidx.compose.ui.res.pluralStringResource
67import androidx.compose.ui.res.stringResource
68import androidx.lifecycle.compose.collectAsStateWithLifecycle
69import androidx.lifecycle.viewmodel.compose.viewModel
70import soy.iko.opencode.data.model.FileStatusEntry
71import soy.iko.opencode.data.model.FindMatch
72import soy.iko.opencode.data.model.SymbolResult
73import soy.iko.opencode.data.model.symbolKindLabel
74import soy.iko.opencode.di.AppContainer
75import soy.iko.opencode.R
76import soy.iko.opencode.ui.vmFactory
77import soy.iko.opencode.util.runCatchingCancellable
79@OptIn(ExperimentalMaterial3Api::class)
80@Composable
81fun FileBrowserScreen(
82 container: AppContainer,
83 onOpenFile: (path: String, line: Int?) -> Unit,
84 onBack: () -> Unit,
85) {
86 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
87 val state by vm.state.collectAsStateWithLifecycle()
88 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
89 val snackbar = remember { SnackbarHostState() }
91 LaunchedEffect(Unit) {
92 vm.transientErrors.collect { msg ->
93 snackbar.showSnackbar(msg)
94 }
95 }
97 Scaffold(
98 topBar = {
99 TopAppBar(
100 title = { Text(stringResource(R.string.files), maxLines = 1, overflow = TextOverflow.Ellipsis) },
101 navigationIcon = {
102 IconButton(onClick = onBack) {
103 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
104 }
105 },
106 )
107 },
108 snackbarHost = { SnackbarHost(snackbar) },
109 ) { padding ->
110 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
111 // Tappable breadcrumb trail so deep paths are navigable.
112 Breadcrumbs(
113 path = state.path,
114 onNavigate = vm::open,
115 modifier = Modifier.fillMaxWidth(),
116 )
117 val keyboardController = LocalSoftwareKeyboardController.current
118 // Search is the primary reason to open this screen, so focus the field on first
119 // composition. Focus only (no forced keyboard) so it doesn't fight the breadcrumb/listing.
120 val searchFocus = remember { FocusRequester() }
121 LaunchedEffect(Unit) { runCatchingCancellable { searchFocus.requestFocus() } }
122 OutlinedTextField(
123 value = state.query,
124 onValueChange = vm::setQuery,
125 modifier = Modifier
126 .fillMaxWidth()
127 .padding(start = 12.dp, end = 12.dp, top = 12.dp)
128 .focusRequester(searchFocus)
129 .testTag("file_search"),
130 label = {
131 Text(
132 stringResource(
133 when (state.mode) {
134 SearchMode.FILES -> R.string.search_files
135 SearchMode.TEXT -> R.string.search_in_files
136 SearchMode.SYMBOL -> R.string.search_symbols_label
137 },
138 ),
139 )
140 },
141 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
142 trailingIcon = if (state.query.isNotEmpty()) {
143 {
144 IconButton(onClick = { vm.setQuery("") }) {
145 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
146 }
147 }
148 } else null,
149 singleLine = true,
150 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
151 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
152 )
153 // Mode selector: file names / contents / symbols.
154 Row(
155 modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp),
156 horizontalArrangement = Arrangement.spacedBy(8.dp),
157 ) {
158 SearchModeChip(stringResource(R.string.search_mode_files), state.mode == SearchMode.FILES) { vm.setMode(SearchMode.FILES) }
159 SearchModeChip(stringResource(R.string.search_mode_text), state.mode == SearchMode.TEXT) { vm.setMode(SearchMode.TEXT) }
160 SearchModeChip(stringResource(R.string.search_mode_symbols), state.mode == SearchMode.SYMBOL) { vm.setMode(SearchMode.SYMBOL) }
161 }
162 Spacer(Modifier.size(8.dp))
164 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
165 isRefreshing = refreshing,
166 onRefresh = { vm.refresh() },
167 modifier = Modifier.fillMaxSize(),
168 ) {
169 Box(modifier = Modifier.fillMaxSize()) {
170 val loadingLabel = stringResource(R.string.loading)
171 when {
172 // Full-screen spinner only for the initial directory load. An in-flight
173 // search keeps the previous results visible (a slim top bar below shows the
174 // progress) instead of blanking the list behind a spinner on every keystroke.
175 state.loading -> CircularProgressIndicator(
176 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
177 )
178 state.error != null -> Column(
179 modifier = Modifier.align(Alignment.Center).padding(24.dp),
180 horizontalAlignment = Alignment.CenterHorizontally,
181 ) {
182 Text(
183 state.error ?: "",
184 color = MaterialTheme.colorScheme.error,
185 )
186 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
187 // Retry the operation that actually failed: re-run the search
188 // when one was active (open() would reset the query and drop the
189 // user back into the directory listing), otherwise reload the dir.
190 androidx.compose.material3.TextButton(
191 onClick = { if (state.isSearching) vm.setQuery(state.query) else vm.open(state.path) },
192 ) {
193 Text(stringResource(R.string.retry))
194 }
195 }
196 state.mode == SearchMode.TEXT ->
197 TextResults(
198 state.textResults,
199 searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_contents_hint),
200 ) { path, line -> onOpenFile(path, line) }
201 state.mode == SearchMode.SYMBOL ->
202 SymbolResults(
203 state.symbolResults,
204 searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_symbols_hint),
205 ) { path, line -> onOpenFile(path, line) }
206 state.isSearching -> SearchResults(state.results) { onOpenFile(it, null) }
207 else -> DirectoryListing(
208 state = state,
209 onOpenDir = vm::open,
210 onUp = vm::up,
211 onOpenFile = { onOpenFile(it, null) },
212 )
213 }
214 // Slim progress bar for an in-flight search; the results list stays visible
215 // underneath so incremental typing doesn't flash an empty screen each keystroke.
216 if (state.searching) {
217 LinearProgressIndicator(
218 modifier = Modifier
219 .align(Alignment.TopCenter)
220 .fillMaxWidth()
221 .semantics { contentDescription = loadingLabel },
222 )
223 }
224 }
225 }
226 }
227 }
230@Composable
231private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
232 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
233 val scrollState = rememberScrollState()
234 // Scroll to the deepest segment when the path changes so the current directory stays in
235 // view instead of hiding off the right edge behind the earlier segments on a deep path.
236 LaunchedEffect(path) { scrollState.animateScrollTo(scrollState.maxValue) }
237 Row(
238 modifier = modifier
239 .horizontalScroll(scrollState)
240 .padding(horizontal = 16.dp, vertical = 8.dp),
241 verticalAlignment = Alignment.CenterVertically,
242 ) {
243 IconButton(onClick = { onNavigate("") }) {
244 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
245 }
246 var acc = ""
247 segments.forEachIndexed { index, segment ->
248 acc = if (acc.isEmpty()) segment else "$acc/$segment"
249 Icon(
250 Icons.Filled.ChevronRight,
251 contentDescription = null,
252 modifier = Modifier.size(16.dp),
253 tint = MaterialTheme.colorScheme.onSurfaceVariant,
254 )
255 val target = acc
256 Text(
257 segment,
258 style = MaterialTheme.typography.bodyMedium,
259 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
260 modifier = Modifier
261 .clickable(role = Role.Button) { onNavigate(target) }
262 .defaultMinSize(minHeight = 48.dp)
263 .padding(horizontal = 6.dp, vertical = 14.dp),
264 )
265 }
266 }
269@Composable
270private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
271 if (results.isEmpty()) {
272 EmptyFileState(
273 icon = Icons.Filled.Search,
274 message = stringResource(R.string.no_matches),
275 modifier = Modifier.fillMaxWidth().padding(24.dp),
276 )
277 return
278 }
279 LazyColumn(modifier = Modifier.fillMaxSize()) {
280 // Key on path + index (not the bare path): a duplicate path from findFiles would
281 // otherwise crash LazyColumn with "Key already used". Mirrors TextResults/SymbolResults.
282 itemsIndexed(results, key = { i, p -> "$p:$i" }) { _, path ->
283 // Split the path into directory (muted) + filename (emphasized) so results
284 // are scannable instead of a wall of identical-looking full paths.
285 val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
286 val name = path.substringAfterLast('/')
287 FileRow(
288 icon = false,
289 label = name,
290 sublabel = dir.takeIf { it.isNotEmpty() },
291 onClick = { onOpenFile(path) },
292 modifier = Modifier.animateItem(),
293 )
294 HorizontalDivider()
295 }
296 }
299@OptIn(ExperimentalMaterial3Api::class)
300@Composable
301private fun SearchModeChip(label: String, selected: Boolean, onClick: () -> Unit) {
302 FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
305/** Empty-state message for a content/symbol search: a "type to search" hint before anything
306 * is typed, nothing while a search is in flight (the top progress bar covers that, so the
307 * first keystroke doesn't flash a false "No matches"), and "No matches" only once a real
308 * query genuinely returned nothing. */
309@Composable
310private fun searchEmptyMessage(searching: Boolean, queryBlank: Boolean, hint: Int): String? = when {
311 searching -> null
312 queryBlank -> stringResource(hint)
313 else -> stringResource(R.string.no_matches)
316/** Content (ripgrep) search results: file + line number, with the matched line highlighted. */
317@Composable
318private fun TextResults(results: List<FindMatch>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
319 if (results.isEmpty()) {
320 if (emptyMessage != null) {
321 EmptyFileState(
322 icon = Icons.Filled.Search,
323 message = emptyMessage,
324 modifier = Modifier.fillMaxWidth().padding(24.dp),
325 )
326 }
327 return
328 }
329 LazyColumn(modifier = Modifier.fillMaxSize()) {
330 item(key = "__count") {
331 Text(
332 pluralStringResource(R.plurals.file_search_results, results.size, results.size),
333 style = MaterialTheme.typography.labelSmall,
334 color = MaterialTheme.colorScheme.onSurfaceVariant,
335 modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
336 )
337 }
338 itemsIndexed(results, key = { i, m -> "${m.filePath}:${m.lineNumber}:$i" }) { _, match ->
339 val name = match.filePath.substringAfterLast('/')
340 val dir = match.filePath.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
341 val highlighted = remember(match) { highlightMatchLine(match) }
342 Column(
343 modifier = Modifier
344 .fillMaxWidth()
345 .clickable(role = Role.Button) { onOpen(match.filePath, match.lineNumber) }
346 .padding(horizontal = 16.dp, vertical = 10.dp),
347 ) {
348 Row(verticalAlignment = Alignment.CenterVertically) {
349 Text(
350 name,
351 style = MaterialTheme.typography.bodyMedium,
352 maxLines = 1,
353 overflow = TextOverflow.Ellipsis,
354 modifier = Modifier.weight(1f, fill = false),
355 )
356 Text(
357 ":${match.lineNumber}",
358 style = MaterialTheme.typography.labelSmall,
359 color = MaterialTheme.colorScheme.onSurfaceVariant,
360 fontFamily = FontFamily.Monospace,
361 modifier = Modifier.padding(start = 4.dp),
362 )
363 }
364 if (dir.isNotEmpty()) {
365 Text(
366 dir,
367 style = MaterialTheme.typography.labelSmall,
368 color = MaterialTheme.colorScheme.onSurfaceVariant,
369 maxLines = 1,
370 overflow = TextOverflow.Ellipsis,
371 )
372 }
373 Text(
374 highlighted,
375 style = MaterialTheme.typography.bodySmall,
376 fontFamily = FontFamily.Monospace,
377 maxLines = 2,
378 overflow = TextOverflow.Ellipsis,
379 modifier = Modifier.padding(top = 2.dp),
380 )
381 }
382 HorizontalDivider()
383 }
384 }
387/** Workspace symbol results: symbol name + kind, and the file:line it's defined at. */
388@Composable
389private fun SymbolResults(results: List<SymbolResult>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
390 if (results.isEmpty()) {
391 if (emptyMessage != null) {
392 EmptyFileState(
393 icon = Icons.Filled.Search,
394 message = emptyMessage,
395 modifier = Modifier.fillMaxWidth().padding(24.dp),
396 )
397 }
398 return
399 }
400 LazyColumn(modifier = Modifier.fillMaxSize()) {
401 item(key = "__count") {
402 Text(
403 pluralStringResource(R.plurals.file_search_results, results.size, results.size),
404 style = MaterialTheme.typography.labelSmall,
405 color = MaterialTheme.colorScheme.onSurfaceVariant,
406 modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
407 )
408 }
409 itemsIndexed(results, key = { i, s -> "${s.filePath}:${s.name}:$i" }) { _, symbol ->
410 val fileName = symbol.filePath.substringAfterLast('/')
411 Column(
412 modifier = Modifier
413 .fillMaxWidth()
414 .clickable(role = Role.Button) { onOpen(symbol.filePath, symbol.displayLine) }
415 .padding(horizontal = 16.dp, vertical = 10.dp),
416 ) {
417 Row(verticalAlignment = Alignment.CenterVertically) {
418 Text(
419 symbol.name,
420 style = MaterialTheme.typography.bodyMedium,
421 maxLines = 1,
422 overflow = TextOverflow.Ellipsis,
423 modifier = Modifier.weight(1f, fill = false),
424 )
425 Text(
426 symbolKindLabel(symbol.kind),
427 style = MaterialTheme.typography.labelSmall,
428 color = MaterialTheme.colorScheme.primary,
429 modifier = Modifier.padding(start = 8.dp),
430 )
431 }
432 Text(
433 "$fileName:${symbol.displayLine}",
434 style = MaterialTheme.typography.labelSmall,
435 color = MaterialTheme.colorScheme.onSurfaceVariant,
436 fontFamily = FontFamily.Monospace,
437 maxLines = 1,
438 overflow = TextOverflow.Ellipsis,
439 )
440 }
441 HorizontalDivider()
442 }
443 }
446/** Build the matched line with each ripgrep submatch bolded. Offsets are byte columns
447 * within the line; clamped to the (trimmed) line length so a multibyte line can't crash. */
448private fun highlightMatchLine(match: FindMatch): androidx.compose.ui.text.AnnotatedString {
449 val line = match.lineText
450 return buildAnnotatedString {
451 var cursor = 0
452 for (sub in match.submatches.sortedBy { it.start }) {
453 val start = sub.start.coerceIn(0, line.length)
454 val end = sub.end.coerceIn(start, line.length)
455 if (start > cursor) append(line.substring(cursor, start))
456 withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(line.substring(start, end)) }
457 cursor = end
458 }
459 if (cursor < line.length) append(line.substring(cursor))
460 }
463@Composable
464private fun DirectoryListing(
465 state: FileBrowserState,
466 onOpenDir: (String) -> Unit,
467 onUp: () -> Unit,
468 onOpenFile: (String) -> Unit,
469) {
470 if (state.entries.isEmpty()) {
471 EmptyFileState(
472 icon = Icons.Filled.Folder,
473 message = stringResource(R.string.empty_folder),
474 modifier = Modifier.fillMaxWidth().padding(24.dp),
475 )
476 return
477 }
478 LazyColumn(modifier = Modifier.fillMaxSize()) {
479 if (state.path.isNotBlank()) {
480 item(key = "__up") {
481 FileRow(icon = true, label = "..", onClick = onUp)
482 HorizontalDivider()
483 }
484 }
485 items(state.entries, key = { it.path + "_" + it.name }) { node ->
486 FileRow(
487 icon = node.isDirectory,
488 label = node.name,
489 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
490 status = state.statusMap[node.path],
491 modifier = Modifier.animateItem(),
492 )
493 HorizontalDivider()
494 }
495 }
498/** Shared empty-state for the file browser: icon + message, matching the icon+text
499 * pattern used by the session and server lists (the prior versions were bare Text
500 * lines that read as status messages rather than intentional empty states). */
501@Composable
502private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
503 Column(
504 modifier = modifier,
505 horizontalAlignment = Alignment.CenterHorizontally,
506 ) {
507 Icon(
508 icon,
509 contentDescription = null,
510 modifier = Modifier.size(48.dp),
511 tint = MaterialTheme.colorScheme.onSurfaceVariant,
512 )
513 Spacer(Modifier.size(12.dp))
514 Text(
515 message,
516 style = MaterialTheme.typography.bodyMedium,
517 color = MaterialTheme.colorScheme.onSurfaceVariant,
518 )
519 }
522@Composable
523private fun FileRow(
524 icon: Boolean,
525 label: String,
526 onClick: () -> Unit,
527 status: FileStatusEntry? = null,
528 sublabel: String? = null,
529 modifier: Modifier = Modifier,
530) {
531 val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
532 else if (icon) stringResource(R.string.folder, label)
533 else stringResource(R.string.file_label, label)
534 // Build a combined description so TalkBack announces both the file name and its git
535 // status. The parent sets an explicit contentDescription (mergeDescendants), which
536 // would otherwise suppress the StatusBadge's own description.
537 val statusDesc = status?.let {
538 when (it.status) {
539 "added" -> stringResource(R.string.git_added)
540 "modified" -> stringResource(R.string.git_modified)
541 "deleted" -> stringResource(R.string.git_deleted)
542 else -> ""
543 }
544 }.orEmpty()
545 val fullDesc = if (statusDesc.isNotEmpty()) "$fileDesc, $statusDesc" else fileDesc
546 Row(
547 modifier = modifier
548 .fillMaxWidth()
549 .defaultMinSize(minHeight = 48.dp)
550 .clickable(role = Role.Button, onClick = onClick)
551 .semantics(mergeDescendants = true) { contentDescription = fullDesc }
552 .padding(horizontal = 16.dp, vertical = 14.dp),
553 verticalAlignment = Alignment.CenterVertically,
554 ) {
555 Icon(
556 if (icon) Icons.Filled.Folder else Icons.Filled.Description,
557 contentDescription = null,
558 modifier = Modifier.size(20.dp),
559 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
560 )
561 Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
562 Text(
563 label,
564 style = MaterialTheme.typography.bodyLarge,
565 maxLines = 1,
566 overflow = TextOverflow.Ellipsis,
567 )
568 if (sublabel != null) {
569 Text(
570 sublabel,
571 style = MaterialTheme.typography.labelSmall,
572 color = MaterialTheme.colorScheme.onSurfaceVariant,
573 maxLines = 1,
574 overflow = TextOverflow.Ellipsis,
575 )
576 }
577 }
578 if (status != null) StatusBadge(status)
579 }
582@Composable
583private fun StatusBadge(status: FileStatusEntry) {
584 val (letter, color) = when (status.status) {
585 "added" -> "A" to MaterialTheme.colorScheme.primary
586 "modified" -> "M" to MaterialTheme.colorScheme.tertiary
587 "deleted" -> "D" to MaterialTheme.colorScheme.error
588 else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
589 }
590 Row(
591 verticalAlignment = Alignment.CenterVertically,
592 ) {
593 Text(
594 letter,
595 style = MaterialTheme.typography.labelMedium,
596 fontFamily = FontFamily.Monospace,
597 color = color,
598 )
599 if (status.added > 0 || status.removed > 0) {
600 Text(
601 "+${status.added} −${status.removed}",
602 style = MaterialTheme.typography.labelSmall,
603 fontFamily = FontFamily.Monospace,
604 color = MaterialTheme.colorScheme.onSurfaceVariant,
605 modifier = Modifier.padding(start = 4.dp),
606 )
607 }
608 }