1package soy.iko.opencode.ui.settings
3import android.content.ActivityNotFoundException
4import android.content.Intent
6import androidx.compose.animation.Crossfade
7import androidx.compose.animation.core.tween
8import androidx.compose.foundation.background
9import androidx.compose.foundation.clickable
10import androidx.compose.foundation.layout.Arrangement
11import androidx.compose.foundation.layout.Box
12import androidx.compose.foundation.layout.Column
13import androidx.compose.foundation.layout.Row
14import androidx.compose.foundation.layout.fillMaxSize
15import androidx.compose.foundation.layout.fillMaxWidth
16import androidx.compose.foundation.layout.widthIn
17import androidx.compose.foundation.layout.wrapContentWidth
18import androidx.compose.foundation.layout.imePadding
19import androidx.compose.foundation.layout.padding
20import androidx.compose.foundation.lazy.LazyColumn
21import androidx.compose.foundation.lazy.items
22import androidx.compose.foundation.rememberScrollState
23import androidx.compose.foundation.text.selection.SelectionContainer
24import androidx.compose.foundation.verticalScroll
25import androidx.compose.material.icons.Icons
26import androidx.compose.material.icons.filled.BugReport
27import androidx.compose.material.icons.filled.Close
28import androidx.compose.material.icons.filled.ContentCopy
29import androidx.activity.compose.BackHandler
30import androidx.activity.compose.rememberLauncherForActivityResult
31import androidx.compose.material.icons.filled.Delete
32import androidx.compose.material.icons.filled.DeleteSweep
33import androidx.compose.material.icons.filled.IosShare
34import androidx.compose.material.icons.filled.MoreVert
35import androidx.compose.material.icons.filled.Search
36import androidx.compose.material.icons.filled.Share
37import androidx.compose.material3.AlertDialog
38import androidx.compose.material3.CircularProgressIndicator
39import androidx.compose.material3.DropdownMenu
40import androidx.compose.material3.DropdownMenuItem
41import androidx.compose.material3.ExperimentalMaterial3Api
42import androidx.compose.material3.HorizontalDivider
43import androidx.compose.material3.Icon
44import androidx.compose.material3.IconButton
45import androidx.compose.material3.MaterialTheme
46import androidx.compose.material3.OutlinedTextField
47import androidx.compose.material3.Scaffold
48import androidx.compose.material3.SnackbarDuration
49import androidx.compose.material3.SnackbarHost
50import androidx.compose.material3.SnackbarHostState
51import androidx.compose.material3.SnackbarResult
52import androidx.compose.material3.Surface
53import androidx.compose.material3.SwipeToDismissBox
54import androidx.compose.material3.SwipeToDismissBoxValue
55import androidx.compose.material3.Text
56import androidx.compose.material3.TextButton
57import androidx.compose.material3.TopAppBar
58import androidx.compose.material3.TopAppBarDefaults
59import androidx.compose.material3.rememberSwipeToDismissBoxState
60import androidx.compose.runtime.Composable
61import androidx.compose.runtime.CompositionLocalProvider
62import androidx.compose.runtime.LaunchedEffect
63import androidx.compose.runtime.getValue
64import androidx.compose.runtime.mutableIntStateOf
65import androidx.compose.runtime.mutableStateOf
66import androidx.compose.runtime.remember
67import androidx.compose.runtime.rememberCoroutineScope
68import androidx.compose.runtime.saveable.rememberSaveable
69import androidx.compose.runtime.setValue
70import kotlinx.coroutines.Dispatchers
71import kotlinx.coroutines.channels.BufferOverflow
72import kotlinx.coroutines.coroutineScope
73import kotlinx.coroutines.delay
74import kotlinx.coroutines.flow.MutableSharedFlow
75import kotlinx.coroutines.flow.collectLatest
76import kotlinx.coroutines.launch
77import kotlinx.coroutines.withContext
78import soy.iko.opencode.util.runCatchingCancellable
79import soy.iko.opencode.ui.components.AppTopBar
80import soy.iko.opencode.ui.components.EmptyState
81import soy.iko.opencode.ui.components.copyToClipboard
82import soy.iko.opencode.ui.components.reducedMotionAnimateItem
83import androidx.compose.ui.Alignment
84import androidx.compose.ui.Modifier
85import androidx.compose.ui.draw.clip
86import androidx.compose.ui.input.nestedscroll.nestedScroll
87import androidx.compose.ui.platform.LocalContext
88import androidx.compose.ui.platform.LocalHapticFeedback
89import androidx.compose.ui.res.stringResource
90import androidx.compose.ui.text.font.FontFamily
91import androidx.compose.ui.semantics.Role
92import androidx.compose.ui.semantics.contentDescription
93import androidx.compose.ui.semantics.heading
94import androidx.compose.ui.semantics.semantics
95import androidx.compose.ui.unit.dp
96import androidx.compose.ui.window.Dialog
97import androidx.compose.ui.window.DialogProperties
98import androidx.lifecycle.compose.collectAsStateWithLifecycle
99import soy.iko.opencode.R
100import soy.iko.opencode.data.network.NetworkConfig
101import soy.iko.opencode.data.repo.CrashLogger
102import soy.iko.opencode.ui.components.LocalRelativeTimeTick
103import soy.iko.opencode.ui.components.rememberRelativeTime
104import soy.iko.opencode.ui.components.rememberRelativeTimeTick
106@OptIn(ExperimentalMaterial3Api::class)
108fun DiagnosticsScreen(onBack: () -> Unit) {
109 val context = LocalContext.current
110 val logger = remember { CrashLogger.get(context) }
111 val reports by logger.reports.collectAsStateWithLifecycle()
112 var viewing by rememberSaveable { mutableStateOf<String?>(null) }
113 var showClearAll by rememberSaveable { mutableStateOf(false) }
114 var pendingReportDelete by rememberSaveable { mutableStateOf<String?>(null) }
115 // Optional text filter over the crash report previews/file names. Only surfaced once there
116 // are enough reports that scrolling is slower than typing.
117 var crashQuery by rememberSaveable { mutableStateOf("") }
118 val shareScope = rememberCoroutineScope()
119 val haptics = LocalHapticFeedback.current
120 val snackbar = remember { SnackbarHostState() }
121 // Separate host for transient error messages (share failures, etc.) so a transient error
122 // can't preempt an undo snackbar's full window — the undo and error channels don't share
123 // a queue, so a share error can't cancel an in-flight undo and strand a pending delete.
124 val errorSnackbar = remember { SnackbarHostState() }
125 val undoLabel = stringResource(R.string.undo)
126 val reportDeletedLabel = stringResource(R.string.report_deleted)
127 val reportsClearedLabel = stringResource(R.string.reports_cleared)
128 // Deferred report deletions are owned by the CrashLogger (its process-lived scope),
129 // so they survive both rotation and navigating away from this screen. The screen only
130 // shows the Undo snackbar and asks the logger to cancel a pending delete on undo.
131 val shareLabel = stringResource(R.string.share)
132 val shareSubject = stringResource(R.string.crash_report_share_subject)
133 val timeTick = rememberRelativeTimeTick()
135 // "Export all": let the user save every crash report into a single text file they pick via
136 // SAF (CreateDocument). Concatenating avoids needing a zip dependency; each report is
137 // delimited so a single file stays readable and shareable for support/debugging.
138 val exportAllLauncher = rememberLauncherForActivityResult(
139 androidx.activity.result.contract.ActivityResultContracts.CreateDocument("text/plain"),
141 if (uri == null) return@rememberLauncherForActivityResult
143 val ok = runCatchingCancellable {
144 withContext(Dispatchers.IO) {
145 val all = reports.joinToString("\n\n${"=".repeat(60)}\n\n") { report ->
146 "### ${report.fileName}\n\n" + logger.readReport(report.fileName).orEmpty()
148 context.contentResolver.openOutputStream(uri)?.use { it.write(all.toByteArray()) }
149 ?: error("no output stream")
152 if (ok) snackbar.showSnackbar(context.getString(R.string.export_all))
156 // Report-delete undo events, shown via a single collectLatest collector rather than a
157 // per-confirm launch: a serialized showSnackbar would queue each report's undo behind the
158 // previous one's full window. collectLatest instead cancels the current snackbar and shows
159 // the newest immediately. Because the actual delete is scheduled at confirm time (on the
160 // CrashLogger's own scope, so it still commits if the user navigates away during the undo
161 // window), a superseded report would otherwise be deleted with no reachable Undo; the
162 // collector withdraws the previously-pending report's scheduled delete when a newer one
163 // supersedes its snackbar, keeping every delete undoable for its whole window.
164 val reportDeleteEvents = remember {
165 // Buffer capacity matches the snackbar event buffer (NetworkConfig.snackbarEventBufferCapacity).
166 // With extraBufferCapacity = 1 + DROP_OLDEST, a rapid 3x delete can drop the MIDDLE
167 // event's snackbar while its scheduleDelete has already fired — silently deleting a
168 // report with no Undo. A larger buffer lets all pending deletes queue behind the
169 // collectLatest collector (which cancels the prior snackbar and withdraws its pending
170 // delete), so every delete stays undoable for its whole window.
171 MutableSharedFlow<String>(
172 extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
173 onBufferOverflow = BufferOverflow.DROP_OLDEST,
176 LaunchedEffect(Unit) {
177 var pending: String? = null
178 reportDeleteEvents.collectLatest { name ->
179 // Only reached when a newer delete arrives (collectLatest supersedes the prior
180 // snackbar) — navigating away tears down this collector without a new emission, so
181 // that report's delete still commits. cancelScheduledDelete no-ops if it already fired.
182 pending?.let { logger.cancelScheduledDelete(it) }
185 val dismisser = launch {
186 delay(NetworkConfig.undoReportDeleteDelayMs)
187 snackbar.currentSnackbarData?.dismiss()
189 val result = snackbar.showSnackbar(
190 message = reportDeletedLabel,
191 actionLabel = undoLabel,
192 duration = SnackbarDuration.Indefinite,
195 if (result == SnackbarResult.ActionPerformed) {
196 logger.cancelScheduledDelete(name)
203 // Clear-all undo: defers the bulk delete for the same window as a single-report delete,
204 // so the previously-irreversible "Clear all" is now recoverable.
205 val clearAllEvents = remember {
206 MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
208 LaunchedEffect(Unit) {
209 clearAllEvents.collectLatest {
210 logger.scheduleClearAll(NetworkConfig.undoReportDeleteDelayMs)
212 val dismisser = launch {
213 delay(NetworkConfig.undoReportDeleteDelayMs)
214 snackbar.currentSnackbarData?.dismiss()
216 val result = snackbar.showSnackbar(
217 message = reportsClearedLabel,
218 actionLabel = undoLabel,
219 duration = SnackbarDuration.Indefinite,
222 if (result == SnackbarResult.ActionPerformed) logger.cancelScheduledClearAll()
227 // TopAppBar scroll behavior: collapse/raise the app bar as the report list scrolls.
228 val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
232 title = stringResource(R.string.diagnostics),
234 scrollBehavior = scrollBehavior,
236 if (reports.isNotEmpty()) {
237 IconButton(onClick = {
239 exportAllLauncher.launch("opencode-crash-reports.txt")
242 Icon(Icons.Filled.IosShare, contentDescription = stringResource(R.string.export_all))
244 IconButton(onClick = {
247 Icon(Icons.Filled.DeleteSweep, contentDescription = stringResource(R.string.clear_all))
254 // Stack both hosts; the undo snackbar takes priority (rendered first), the error
255 // host renders below it so both can be visible simultaneously without preempting.
257 SnackbarHost(snackbar)
258 SnackbarHost(errorSnackbar)
262 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
263 Column(modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.CenterHorizontally).widthIn(max = soy.iko.opencode.data.network.NetworkConfig.listContentMaxWidthDp.dp).imePadding().padding(padding).nestedScroll(scrollBehavior.nestedScrollConnection)) {
265 stringResource(R.string.diagnostics_desc),
266 style = MaterialTheme.typography.bodySmall,
267 color = MaterialTheme.colorScheme.onSurfaceVariant,
268 modifier = Modifier.padding(16.dp),
271 stringResource(R.string.crash_reports),
272 style = MaterialTheme.typography.titleSmall,
273 color = MaterialTheme.colorScheme.primary,
275 .padding(start = 16.dp, bottom = 8.dp)
276 .semantics { heading() },
278 // Crossfade between empty and list states so the transition reads as a smooth fade
279 // instead of an instant snap. Matches the session list's Crossfade pattern; reduced
280 // motion is honored by Crossfade's default spec.
281 val stateKey = if (reports.isEmpty()) "empty" else "list"
282 @Suppress("UnusedCrossfadeTargetStateParameter")
284 targetState = stateKey,
285 animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),
286 label = "diagnostics_state",
288 if (reports.isEmpty()) {
290 icon = Icons.Filled.BugReport,
291 title = stringResource(R.string.no_crash_reports),
292 description = stringResource(R.string.no_crash_reports_desc),
293 actionLabel = stringResource(R.string.report_issue),
296 context.startActivity(
297 Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_report_issue))),
301 modifier = Modifier.fillMaxWidth().padding(24.dp),
304 val filtered = remember(reports, crashQuery) { filterCrashReports(reports, crashQuery) }
305 // Windowed render: compose only the first `renderCap` rows, growing as the
306 // user scrolls near the bottom. Bounds composition work on a device that has
307 // accumulated many crash reports (e.g. a flaky build used for weeks), matching
308 // the session/file lists' pattern.
309 var renderCap by remember {
310 mutableStateOf(NetworkConfig.diagnosticsListInitialPage)
312 val listState = androidx.compose.foundation.lazy.rememberLazyListState()
313 androidx.compose.runtime.LaunchedEffect(listState, filtered.size) {
314 androidx.compose.runtime.snapshotFlow {
315 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
316 lastVisible >= renderCap - NetworkConfig.diagnosticsListPageStep / 2
317 }.collect { nearEnd ->
318 if (nearEnd && renderCap < filtered.size) {
319 renderCap = (renderCap + NetworkConfig.diagnosticsListPageStep).coerceAtMost(filtered.size)
323 val capped = remember(filtered, renderCap) {
324 if (filtered.size <= renderCap) filtered else filtered.take(renderCap)
326 LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
327 crashSearchHeader(reports = reports, filtered = filtered, crashQuery = crashQuery) {
330 items(capped, key = { it.fileName }) { report ->
331 // rememberSaveable so an open row menu survives a rotation; BackHandler
332 // so system back closes it instead of navigating away, matching the
333 // session/server/file list dropdown back-handling.
334 var menuExpanded by rememberSaveable(report.fileName) { mutableStateOf(false) }
335 BackHandler(enabled = menuExpanded) { menuExpanded = false }
336 // Swipe end-to-start reveals a delete affordance. The delete is
337 // deferred for an undo window (matching the session/server lists);
338 // confirmValueChange snaps back so the row remains while the undo
339 // snackbar is shown, and is removed only when the window expires.
340 val swipeState = rememberSwipeToDismissBoxState(
341 confirmValueChange = {
342 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
343 pendingReportDelete = report.fileName
349 enableDismissFromStartToEnd = false,
350 modifier = reducedMotionAnimateItem(),
351 backgroundContent = {
355 .clip(MaterialTheme.shapes.medium)
356 .background(MaterialTheme.colorScheme.errorContainer)
357 .padding(horizontal = 20.dp),
358 contentAlignment = Alignment.CenterEnd,
361 Icons.Filled.DeleteSweep,
362 contentDescription = stringResource(R.string.delete),
363 tint = MaterialTheme.colorScheme.onErrorContainer,
371 .clickable(role = Role.Button) { viewing = report.fileName }
372 .padding(horizontal = 16.dp, vertical = 12.dp),
373 verticalAlignment = Alignment.CenterVertically,
374 horizontalArrangement = Arrangement.spacedBy(12.dp),
377 Icons.Filled.BugReport,
378 contentDescription = null,
379 tint = MaterialTheme.colorScheme.error,
381 Column(modifier = Modifier.weight(1f)) {
382 Text(report.preview, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
384 rememberRelativeTime(report.timestamp),
385 style = MaterialTheme.typography.bodySmall,
386 color = MaterialTheme.colorScheme.onSurfaceVariant,
388 // The filename often encodes the exception class (the
389 // CrashLogger names reports "<ExceptionClass>_<ts>.txt"),
390 // so showing it muted under the relative time lets a user
391 // scan for a specific exception type without opening each.
394 style = MaterialTheme.typography.labelSmall,
395 color = MaterialTheme.colorScheme.onSurfaceVariant,
397 overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
400 IconButton(onClick = {
402 val content = withContext(Dispatchers.IO) {
403 logger.readReport(report.fileName).orEmpty()
405 val send = Intent(Intent.ACTION_SEND).apply {
407 putExtra(Intent.EXTRA_SUBJECT, shareSubject)
408 putExtra(Intent.EXTRA_TEXT, content)
410 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
412 Log.w("Diagnostics", "Failed to share crash report", it)
413 // FIX 20: "no app to share" only fits ActivityNotFoundException; other
414 // failures (e.g. an oversized report exceeding the Binder limit →
415 // TransactionTooLargeException) get a generic toast, not that misleading one.
416 val msg = if (it is ActivityNotFoundException) R.string.no_share_app else R.string.error_generic
417 shareScope.launch { errorSnackbar.showSnackbar(context.getString(msg)) }
421 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
424 IconButton(onClick = { menuExpanded = true }) {
426 Icons.Filled.MoreVert,
427 contentDescription = stringResource(R.string.more_options_for, report.fileName),
430 DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
432 leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) },
435 stringResource(R.string.delete),
436 color = MaterialTheme.colorScheme.error,
441 pendingReportDelete = report.fileName
457 val reportName = viewing
458 if (reportName != null) {
459 var reportContent by remember(reportName) { mutableStateOf<String?>(null) }
460 var loadFailed by remember(reportName) { mutableStateOf(false) }
461 // A retry trigger: bumping this re-runs the load LaunchedEffect (keyed on it) and
462 // clears the failure state, so a transient read error is recoverable without closing
463 // and reopening the report — matching the file viewer's error-retry pattern.
464 var retryKey by remember(reportName) { mutableIntStateOf(0) }
465 LaunchedEffect(reportName, retryKey) {
466 if (retryKey > 0) { reportContent = null; loadFailed = false }
467 val loaded = withContext(Dispatchers.IO) {
468 // runCatchingCancellable (not runCatching) so dismissing the dialog or
469 // switching reports mid-read lets the CancellationException propagate
470 // instead of being swallowed and setting state on a torn-down effect.
471 runCatchingCancellable { logger.readReport(reportName) }.getOrNull()
473 if (loaded != null) reportContent = loaded else loadFailed = true
475 val content = reportContent
477 onDismissRequest = { viewing = null },
478 properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
480 Surface(modifier = Modifier.fillMaxSize()) {
484 title = { Text(stringResource(R.string.crash_report)) },
486 IconButton(onClick = { viewing = null }) {
487 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
492 // Disable Copy until the report has loaded so it can't copy an empty body.
493 enabled = content != null,
495 copyToClipboard(context, context.getString(R.string.crash_report), content.orEmpty())
497 ) { Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy)) }
499 // Disable Share until the report has loaded — otherwise tapping it
500 // while the async read is still in flight (content == null) shares an
501 // empty body instead of the report.
502 enabled = content != null,
504 val send = Intent(Intent.ACTION_SEND).apply {
506 putExtra(Intent.EXTRA_SUBJECT, shareSubject)
507 putExtra(Intent.EXTRA_TEXT, content.orEmpty())
509 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
511 Log.w("Diagnostics", "Failed to share crash report", it)
512 // FIX 20: "no app to share" only fits ActivityNotFoundException; other failures
513 // (e.g. an oversized report exceeding the Binder limit → TransactionTooLargeException)
514 // get a generic toast, not that misleading one.
515 val msg = if (it is ActivityNotFoundException) R.string.no_share_app else R.string.error_generic
516 shareScope.launch { errorSnackbar.showSnackbar(context.getString(msg)) }
519 ) { Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share)) }
520 IconButton(onClick = {
521 // Confirm before deleting a single report, matching clear-all.
522 pendingReportDelete = reportName
523 }, enabled = content != null) {
524 Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
531 content != null -> SelectionContainer {
532 // Wrap in SelectionContainer so a user can select/copy just the
533 // relevant stack snippet instead of the whole-report Copy button.
536 style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
539 .verticalScroll(rememberScrollState())
545 modifier = Modifier.fillMaxSize().padding(padding),
546 contentAlignment = Alignment.Center,
549 horizontalAlignment = Alignment.CenterHorizontally,
550 verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
553 stringResource(R.string.report_load_failed),
554 color = MaterialTheme.colorScheme.error,
556 TextButton(onClick = { retryKey++ }) {
557 Text(stringResource(R.string.retry))
562 val loadingLabel = stringResource(R.string.loading)
564 modifier = Modifier.fillMaxSize().padding(padding),
565 contentAlignment = Alignment.Center,
567 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
576 pendingReportDelete?.let { name ->
578 onDismissRequest = { pendingReportDelete = null },
579 title = { Text(stringResource(R.string.delete_report_title)) },
580 text = { Text(stringResource(R.string.delete_report_text)) },
582 TextButton(onClick = {
583 pendingReportDelete = null
585 // Defer the actual delete so the Undo snackbar can cancel it. The
586 // deletion is owned by the CrashLogger's own scope, so it commits even
587 // if the user navigates away during the undo window. The Undo snackbar
588 // is shown by the single collectLatest collector above (not a per-confirm
589 // launch) so rapid consecutive deletes don't produce a dead Undo button.
590 logger.scheduleDelete(name, NetworkConfig.undoReportDeleteDelayMs)
591 reportDeleteEvents.tryEmit(name)
592 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
595 TextButton(onClick = { pendingReportDelete = null }) { Text(stringResource(R.string.cancel)) }
602 onDismissRequest = { showClearAll = false },
603 title = { Text(stringResource(R.string.clear_all)) },
604 text = { Text(stringResource(R.string.clear_all_confirm)) },
606 TextButton(onClick = {
608 shareScope.launch { clearAllEvents.emit(Unit) }
609 }) { Text(stringResource(R.string.clear_all), color = MaterialTheme.colorScheme.error) }
612 TextButton(onClick = { showClearAll = false }) { Text(stringResource(R.string.cancel)) }
618/** Filter crash reports by preview/file name. Extracted from [DiagnosticsScreen] to keep its
619 * cyclomatic complexity under the detekt threshold. Empty query returns all reports. */
620private fun filterCrashReports(
621 reports: List<CrashLogger.CrashReport>,
623): List<CrashLogger.CrashReport> {
625 if (q.isEmpty()) return reports
626 return reports.filter {
627 it.preview.contains(q, ignoreCase = true) || it.fileName.contains(q, ignoreCase = true)
631/** The diagnostics filter field with a clear button. Extracted from [DiagnosticsScreen] to keep
632 * its cyclomatic complexity under the detekt threshold. */
634private fun CrashQueryField(query: String, onQueryChange: (String) -> Unit) {
635 val keyboard = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
638 onValueChange = onQueryChange,
639 modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
640 placeholder = { Text(stringResource(R.string.search_crash_reports)) },
642 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
644 if (query.isNotEmpty()) {
645 IconButton(onClick = { onQueryChange("") }) {
646 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
650 // Match the session/file/server search fields: ImeAction.Search dismisses the keyboard
651 // on submit so the IME doesn't keep covering the crash list after filtering.
652 keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
653 imeAction = androidx.compose.ui.text.input.ImeAction.Search,
655 keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { keyboard?.hide() }),
659/** Conditionally emits the search field and the no-match placeholder into the crash list.
660 * Extracted from [DiagnosticsScreen] to keep its cyclomatic complexity under the threshold. */
661private fun androidx.compose.foundation.lazy.LazyListScope.crashSearchHeader(
662 reports: List<CrashLogger.CrashReport>,
663 filtered: List<CrashLogger.CrashReport>,
665 onQueryChange: (String) -> Unit,
667 if (reports.size >= NetworkConfig.diagnosticsSearchThreshold) {
668 item(key = "__search") {
669 CrashQueryField(query = crashQuery, onQueryChange = onQueryChange)
672 if (crashQuery.isNotBlank() && filtered.isEmpty()) {
673 item(key = "__no_match") {
675 icon = Icons.Filled.Search,
676 title = stringResource(R.string.no_crash_matches),
677 modifier = Modifier.fillMaxWidth().padding(24.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