1package soy.iko.opencode.ui.settings
3import android.content.Intent
5import androidx.compose.foundation.background
6import androidx.compose.foundation.clickable
7import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Box
9import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.Spacer
12import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.heightIn
15import androidx.compose.foundation.layout.padding
16import androidx.compose.foundation.layout.size
17import androidx.compose.foundation.lazy.LazyColumn
18import androidx.compose.foundation.lazy.items
19import androidx.compose.foundation.rememberScrollState
20import androidx.compose.foundation.verticalScroll
21import androidx.compose.material.icons.Icons
22import androidx.compose.material.icons.automirrored.filled.ArrowBack
23import androidx.compose.material.icons.filled.BugReport
24import androidx.compose.material.icons.filled.DeleteSweep
25import androidx.compose.material.icons.filled.Share
26import androidx.compose.material3.AlertDialog
27import androidx.compose.material3.CircularProgressIndicator
28import androidx.compose.material3.ExperimentalMaterial3Api
29import androidx.compose.material3.HorizontalDivider
30import androidx.compose.material3.Icon
31import androidx.compose.material3.IconButton
32import androidx.compose.material3.MaterialTheme
33import androidx.compose.material3.Scaffold
34import androidx.compose.material3.SnackbarDuration
35import androidx.compose.material3.SnackbarHost
36import androidx.compose.material3.SnackbarHostState
37import androidx.compose.material3.SnackbarResult
38import androidx.compose.material3.SwipeToDismissBox
39import androidx.compose.material3.SwipeToDismissBoxValue
40import androidx.compose.material3.Text
41import androidx.compose.material3.TextButton
42import androidx.compose.material3.TopAppBar
43import androidx.compose.material3.rememberSwipeToDismissBoxState
44import androidx.compose.runtime.Composable
45import androidx.compose.runtime.CompositionLocalProvider
46import androidx.compose.runtime.LaunchedEffect
47import androidx.compose.runtime.getValue
48import androidx.compose.runtime.mutableStateOf
49import androidx.compose.runtime.remember
50import androidx.compose.runtime.rememberCoroutineScope
51import androidx.compose.runtime.saveable.rememberSaveable
52import androidx.compose.runtime.setValue
53import kotlinx.coroutines.Dispatchers
54import kotlinx.coroutines.channels.BufferOverflow
55import kotlinx.coroutines.coroutineScope
56import kotlinx.coroutines.delay
57import kotlinx.coroutines.flow.MutableSharedFlow
58import kotlinx.coroutines.flow.collectLatest
59import kotlinx.coroutines.launch
60import kotlinx.coroutines.withContext
61import soy.iko.opencode.util.runCatchingCancellable
62import soy.iko.opencode.ui.components.showToast
63import androidx.compose.ui.Alignment
64import androidx.compose.ui.Modifier
65import androidx.compose.ui.draw.clip
66import androidx.compose.ui.platform.LocalContext
67import androidx.compose.ui.platform.LocalHapticFeedback
68import androidx.compose.ui.res.stringResource
69import androidx.compose.ui.semantics.Role
70import androidx.compose.ui.semantics.contentDescription
71import androidx.compose.ui.semantics.heading
72import androidx.compose.ui.semantics.semantics
73import androidx.compose.ui.unit.dp
74import androidx.lifecycle.compose.collectAsStateWithLifecycle
75import soy.iko.opencode.R
76import soy.iko.opencode.data.network.NetworkConfig
77import soy.iko.opencode.data.repo.CrashLogger
78import soy.iko.opencode.ui.components.LocalRelativeTimeTick
79import soy.iko.opencode.ui.components.rememberRelativeTime
80import soy.iko.opencode.ui.components.rememberRelativeTimeTick
82@OptIn(ExperimentalMaterial3Api::class)
84fun DiagnosticsScreen(onBack: () -> Unit) {
85 val context = LocalContext.current
86 val logger = remember { CrashLogger.get(context) }
87 val reports by logger.reports.collectAsStateWithLifecycle()
88 var viewing by rememberSaveable { mutableStateOf<String?>(null) }
89 var showClearAll by rememberSaveable { mutableStateOf(false) }
90 var pendingReportDelete by rememberSaveable { mutableStateOf<String?>(null) }
91 val shareScope = rememberCoroutineScope()
92 val haptics = LocalHapticFeedback.current
93 val snackbar = remember { SnackbarHostState() }
94 val undoLabel = stringResource(R.string.undo)
95 val reportDeletedLabel = stringResource(R.string.report_deleted)
96 // Deferred report deletions are owned by the CrashLogger (its process-lived scope),
97 // so they survive both rotation and navigating away from this screen. The screen only
98 // shows the Undo snackbar and asks the logger to cancel a pending delete on undo.
99 val shareLabel = stringResource(R.string.share)
100 val shareSubject = stringResource(R.string.crash_report_share_subject)
101 val timeTick = rememberRelativeTimeTick()
103 // Report-delete undo events, shown via a single collectLatest collector rather than a
104 // per-confirm launch. A serialized showSnackbar would queue each report's undo behind
105 // the previous one's full window, so under rapid deletes a later report's delete timer
106 // (started at confirm time) fires before its snackbar is ever shown — a dead Undo
107 // button. collectLatest cancels the current snackbar and shows the newest immediately,
108 // keeping each Undo window aligned with its own timer. Mirrors ServerListScreen.
109 val reportDeleteEvents = remember {
110 MutableSharedFlow<String>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
112 LaunchedEffect(Unit) {
113 reportDeleteEvents.collectLatest { name ->
115 val dismisser = launch {
116 delay(NetworkConfig.undoReportDeleteDelayMs)
117 snackbar.currentSnackbarData?.dismiss()
119 val result = snackbar.showSnackbar(
120 message = reportDeletedLabel,
121 actionLabel = undoLabel,
122 duration = SnackbarDuration.Indefinite,
125 if (result == SnackbarResult.ActionPerformed) {
126 logger.cancelScheduledDelete(name)
135 title = { Text(stringResource(R.string.diagnostics)) },
137 IconButton(onClick = onBack) {
138 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
142 if (reports.isNotEmpty()) {
143 IconButton(onClick = {
146 Icon(Icons.Filled.DeleteSweep, contentDescription = stringResource(R.string.clear_all))
152 snackbarHost = { SnackbarHost(snackbar) },
154 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
155 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
157 stringResource(R.string.diagnostics_desc),
158 style = MaterialTheme.typography.bodySmall,
159 color = MaterialTheme.colorScheme.onSurfaceVariant,
160 modifier = Modifier.padding(16.dp),
163 stringResource(R.string.crash_reports),
164 style = MaterialTheme.typography.titleSmall,
165 color = MaterialTheme.colorScheme.primary,
167 .padding(start = 16.dp, bottom = 8.dp)
168 .semantics { heading() },
170 if (reports.isEmpty()) {
172 modifier = Modifier.fillMaxWidth().padding(24.dp),
175 LazyColumn(modifier = Modifier.fillMaxSize()) {
176 items(reports, key = { it.fileName }) { report ->
177 // Swipe end-to-start reveals a delete affordance. The delete is
178 // deferred for an undo window (matching the session/server lists);
179 // confirmValueChange snaps back so the row remains while the undo
180 // snackbar is shown, and is removed only when the window expires.
181 val swipeState = rememberSwipeToDismissBoxState(
182 confirmValueChange = {
183 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
184 pendingReportDelete = report.fileName
190 enableDismissFromStartToEnd = false,
191 modifier = Modifier.animateItem(),
192 backgroundContent = {
196 .clip(MaterialTheme.shapes.medium)
197 .background(MaterialTheme.colorScheme.errorContainer)
198 .padding(horizontal = 20.dp),
199 contentAlignment = Alignment.CenterEnd,
202 Icons.Filled.DeleteSweep,
203 contentDescription = stringResource(R.string.delete),
204 tint = MaterialTheme.colorScheme.onErrorContainer,
212 .clickable(role = Role.Button) { viewing = report.fileName }
213 .padding(horizontal = 16.dp, vertical = 12.dp),
214 verticalAlignment = Alignment.CenterVertically,
215 horizontalArrangement = Arrangement.spacedBy(12.dp),
218 Icons.Filled.BugReport,
219 contentDescription = null,
220 tint = MaterialTheme.colorScheme.error,
222 Column(modifier = Modifier.weight(1f)) {
223 Text(report.preview, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
225 rememberRelativeTime(report.timestamp),
226 style = MaterialTheme.typography.bodySmall,
227 color = MaterialTheme.colorScheme.onSurfaceVariant,
230 IconButton(onClick = {
232 val content = withContext(Dispatchers.IO) {
233 logger.readReport(report.fileName).orEmpty()
235 val send = Intent(Intent.ACTION_SEND).apply {
237 putExtra(Intent.EXTRA_SUBJECT, shareSubject)
238 putExtra(Intent.EXTRA_TEXT, content)
240 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
242 Log.w("Diagnostics", "Failed to share crash report", it)
243 showToast(context, context.getString(R.string.no_share_app))
247 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
259 val reportName = viewing
260 if (reportName != null) {
261 var reportContent by remember(reportName) { mutableStateOf<String?>(null) }
262 var loadFailed by remember(reportName) { mutableStateOf(false) }
263 LaunchedEffect(reportName) {
264 val loaded = withContext(Dispatchers.IO) {
265 // runCatchingCancellable (not runCatching) so dismissing the dialog or
266 // switching reports mid-read lets the CancellationException propagate
267 // instead of being swallowed and setting state on a torn-down effect.
268 runCatchingCancellable { logger.readReport(reportName) }.getOrNull()
270 if (loaded != null) reportContent = loaded else loadFailed = true
272 val content = reportContent
274 onDismissRequest = { viewing = null },
276 TextButton(onClick = { viewing = null }) { Text(stringResource(R.string.close)) }
281 // Disable Share until the report has loaded — otherwise tapping it
282 // while the async read is still in flight (content == null) shares an
283 // empty body instead of the report.
284 enabled = content != null,
286 val send = Intent(Intent.ACTION_SEND).apply {
288 putExtra(Intent.EXTRA_SUBJECT, shareSubject)
289 putExtra(Intent.EXTRA_TEXT, content.orEmpty())
291 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
293 Log.w("Diagnostics", "Failed to share crash report", it)
294 showToast(context, context.getString(R.string.no_share_app))
297 ) { Text(stringResource(R.string.share)) }
298 Spacer(Modifier.size(8.dp))
299 TextButton(onClick = {
300 // Confirm before deleting a single report, matching clear-all.
301 pendingReportDelete = reportName
302 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
305 title = { Text(stringResource(R.string.crash_report)) },
308 content != null -> Box(
311 .heightIn(max = 400.dp)
312 .verticalScroll(rememberScrollState()),
316 style = MaterialTheme.typography.bodySmall,
317 modifier = Modifier.fillMaxWidth(),
321 stringResource(R.string.report_load_failed),
322 color = MaterialTheme.colorScheme.error,
323 modifier = Modifier.padding(vertical = 24.dp),
326 val loadingLabel = stringResource(R.string.loading)
328 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
329 contentAlignment = Alignment.Center,
331 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
339 pendingReportDelete?.let { name ->
341 onDismissRequest = { pendingReportDelete = null },
342 title = { Text(stringResource(R.string.delete_report_title)) },
343 text = { Text(stringResource(R.string.delete_report_text)) },
345 TextButton(onClick = {
346 pendingReportDelete = null
348 // Defer the actual delete so the Undo snackbar can cancel it. The
349 // deletion is owned by the CrashLogger's own scope, so it commits even
350 // if the user navigates away during the undo window. The Undo snackbar
351 // is shown by the single collectLatest collector above (not a per-confirm
352 // launch) so rapid consecutive deletes don't produce a dead Undo button.
353 logger.scheduleDelete(name, NetworkConfig.undoReportDeleteDelayMs)
354 reportDeleteEvents.tryEmit(name)
355 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
358 TextButton(onClick = { pendingReportDelete = null }) { Text(stringResource(R.string.cancel)) }
365 onDismissRequest = { showClearAll = false },
366 title = { Text(stringResource(R.string.clear_all)) },
367 text = { Text(stringResource(R.string.clear_all_confirm)) },
369 TextButton(onClick = {
372 }) { Text(stringResource(R.string.clear_all), color = MaterialTheme.colorScheme.error) }
375 TextButton(onClick = { showClearAll = false }) { Text(stringResource(R.string.cancel)) }
381/** Empty state for the crash reports list. Rendered with an icon + text to match the
382 * empty-state pattern used by the session and server lists (the prior version was a
383 * bare `Text` line that read as a status message rather than an intentional state). */
385private fun EmptyCrashReports(modifier: Modifier = Modifier) {
388 horizontalAlignment = Alignment.CenterHorizontally,
391 Icons.Filled.BugReport,
392 contentDescription = null,
393 modifier = Modifier.size(48.dp),
394 tint = MaterialTheme.colorScheme.onSurfaceVariant,
396 Spacer(Modifier.size(12.dp))
398 stringResource(R.string.no_crash_reports),
399 style = MaterialTheme.typography.bodyMedium,
400 color = MaterialTheme.colorScheme.onSurfaceVariant,
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