1package com.parseable.android.ui.screens.logviewer
3import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.animation.animateContentSize
5import androidx.compose.animation.core.*
6import androidx.compose.foundation.horizontalScroll
7import androidx.compose.foundation.layout.*
8import androidx.compose.foundation.lazy.LazyColumn
9import androidx.compose.foundation.lazy.itemsIndexed
10import androidx.compose.foundation.lazy.rememberLazyListState
11import androidx.compose.foundation.rememberScrollState
12import androidx.compose.foundation.text.KeyboardActions
13import androidx.compose.foundation.text.KeyboardOptions
14import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.automirrored.filled.ArrowBack
16import androidx.compose.material.icons.filled.*
17import androidx.compose.material3.*
18import androidx.compose.material3.MenuAnchorType
19import androidx.compose.material3.pulltorefresh.PullToRefreshBox
20import androidx.compose.runtime.*
21import androidx.compose.ui.Alignment
22import androidx.compose.ui.Modifier
23import androidx.compose.ui.draw.alpha
24import androidx.compose.ui.platform.LocalContext
25import androidx.compose.ui.platform.LocalSoftwareKeyboardController
26import androidx.compose.ui.text.font.FontFamily
27import androidx.compose.ui.text.font.FontWeight
28import androidx.compose.ui.text.input.ImeAction
29import androidx.compose.ui.text.style.TextOverflow
30import androidx.compose.ui.unit.dp
31import androidx.compose.ui.unit.sp
32import android.content.Intent
33import androidx.core.content.FileProvider
34import com.parseable.android.data.escapeIdentifier
35import com.parseable.android.data.formatTimestamp
36import androidx.hilt.navigation.compose.hiltViewModel
37import androidx.lifecycle.compose.collectAsStateWithLifecycle
38import kotlinx.coroutines.launch
39import kotlinx.coroutines.withContext
40import kotlinx.serialization.json.*
42private val prettyJson = Json { prettyPrint = true }
44@OptIn(ExperimentalMaterial3Api::class)
49 onStreamInfo: (String) -> Unit,
50 viewModel: LogViewerViewModel = hiltViewModel(),
52 val state by viewModel.state.collectAsStateWithLifecycle()
53 val context = LocalContext.current
54 val scope = rememberCoroutineScope()
55 val snackbarHostState = remember { SnackbarHostState() }
56 val keyboardController = LocalSoftwareKeyboardController.current
57 var showFilterSheet by remember { mutableStateOf(false) }
58 var showSqlSheet by remember { mutableStateOf(false) }
59 var expandedLogKey by remember { mutableStateOf<String?>(null) }
60 var showDateRangePicker by remember { mutableStateOf(false) }
61 var showShareWarning by remember { mutableStateOf(false) }
63 LaunchedEffect(streamName) {
64 viewModel.initialize(streamName)
68 snackbarHost = { SnackbarHost(snackbarHostState) },
75 fontWeight = FontWeight.Bold,
77 overflow = TextOverflow.Ellipsis,
80 text = if (state.isStreaming) {
81 "Live - ${state.logs.size} logs (+${state.streamingNewCount} new)"
83 "${state.logs.size} results"
85 style = MaterialTheme.typography.bodySmall,
86 color = if (state.isStreaming) {
87 MaterialTheme.colorScheme.primary
89 MaterialTheme.colorScheme.onSurfaceVariant
95 IconButton(onClick = onBack) {
96 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
100 StreamingToggleButton(
101 isStreaming = state.isStreaming,
102 onClick = viewModel::toggleStreaming,
104 IconButton(onClick = { onStreamInfo(streamName) }) {
105 Icon(Icons.Filled.Info, contentDescription = "Stream Info")
107 IconButton(onClick = { showFilterSheet = true }) {
108 Icon(Icons.Filled.FilterList, contentDescription = "Filters")
110 IconButton(onClick = { showSqlSheet = true }) {
111 Icon(Icons.Filled.Code, contentDescription = "SQL Query")
114 onClick = { showShareWarning = true },
115 enabled = state.logs.isNotEmpty(),
117 Icon(Icons.Filled.Share, contentDescription = "Share logs")
130 selectedRange = state.selectedTimeRange,
131 onRangeSelected = { range ->
132 if (range == TimeRange.CUSTOM) {
133 showDateRangePicker = true
135 viewModel.onTimeRangeChange(range)
142 value = state.searchQuery,
143 onValueChange = viewModel::onSearchQueryChange,
144 placeholder = { Text("Search logs...") },
145 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = "Search") },
147 if (state.isSearching) {
148 CircularProgressIndicator(
149 modifier = Modifier.size(20.dp),
152 } else if (state.searchQuery.isNotEmpty()) {
153 IconButton(onClick = { viewModel.onSearchQueryChange("") }) {
154 Icon(Icons.Filled.Clear, contentDescription = "Clear")
159 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
160 keyboardActions = KeyboardActions(onSearch = {
161 keyboardController?.hide()
166 .padding(horizontal = 16.dp, vertical = 4.dp),
167 textStyle = MaterialTheme.typography.bodyMedium,
170 // Active filters indicator
171 if (state.activeFilters.isNotEmpty()) {
174 .horizontalScroll(rememberScrollState())
175 .padding(horizontal = 16.dp, vertical = 4.dp)
176 .animateContentSize(),
177 horizontalArrangement = Arrangement.spacedBy(8.dp),
179 state.activeFilters.forEach { filter ->
182 onClick = { viewModel.removeFilter(filter) },
183 label = { Text(filter, maxLines = 1) },
187 contentDescription = "Remove filter",
188 modifier = Modifier.size(16.dp),
193 TextButton(onClick = viewModel::clearFilters) {
199 // Streaming indicator banner
200 AnimatedVisibility(visible = state.isStreaming) {
202 color = MaterialTheme.colorScheme.primaryContainer,
203 modifier = Modifier.fillMaxWidth(),
206 modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
207 verticalAlignment = Alignment.CenterVertically,
209 val infiniteTransition = rememberInfiniteTransition(label = "pulse")
210 val alpha by infiniteTransition.animateFloat(
213 animationSpec = infiniteRepeatable(
214 animation = tween(800, easing = EaseInOut),
215 repeatMode = RepeatMode.Reverse,
217 label = "pulse_alpha",
220 Icons.Filled.FiberManualRecord,
221 contentDescription = "Live streaming active",
222 tint = MaterialTheme.colorScheme.error,
227 Spacer(modifier = Modifier.width(8.dp))
229 text = "Live tail active - polling every ${state.currentIntervalMs / 1000}s",
230 style = MaterialTheme.typography.labelMedium,
231 color = MaterialTheme.colorScheme.onPrimaryContainer,
232 modifier = Modifier.weight(1f),
234 TextButton(onClick = viewModel::stopStreaming) {
241 // Streaming error banner
242 AnimatedVisibility(visible = state.streamingError != null) {
244 color = MaterialTheme.colorScheme.errorContainer,
245 modifier = Modifier.fillMaxWidth(),
248 modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
249 verticalAlignment = Alignment.CenterVertically,
252 Icons.Filled.ErrorOutline,
253 contentDescription = "Streaming error",
254 tint = MaterialTheme.colorScheme.onErrorContainer,
255 modifier = Modifier.size(16.dp),
257 Spacer(modifier = Modifier.width(8.dp))
259 text = state.streamingError ?: "",
260 style = MaterialTheme.typography.labelMedium,
261 color = MaterialTheme.colorScheme.onErrorContainer,
262 modifier = Modifier.weight(1f),
264 overflow = TextOverflow.Ellipsis,
266 TextButton(onClick = viewModel::dismissStreamingError) {
275 isRefreshing = state.isLoading,
276 onRefresh = viewModel::refresh,
277 modifier = Modifier.fillMaxSize(),
279 if (state.error != null && state.logs.isEmpty()) {
281 modifier = Modifier.fillMaxSize(),
282 contentAlignment = Alignment.Center,
284 Column(horizontalAlignment = Alignment.CenterHorizontally) {
286 Icons.Filled.ErrorOutline,
287 contentDescription = "Error loading logs",
288 modifier = Modifier.size(48.dp),
289 tint = MaterialTheme.colorScheme.error,
291 Spacer(modifier = Modifier.height(16.dp))
293 text = state.error.orEmpty(),
294 style = MaterialTheme.typography.bodyMedium,
295 color = MaterialTheme.colorScheme.error,
296 modifier = Modifier.padding(horizontal = 32.dp),
298 Spacer(modifier = Modifier.height(16.dp))
299 OutlinedButton(onClick = viewModel::refresh) {
304 } else if (state.logs.isEmpty() && state.isLoading) {
306 modifier = Modifier.fillMaxSize(),
307 contentAlignment = Alignment.Center,
309 CircularProgressIndicator()
311 } else if (state.logs.isEmpty() && !state.isLoading) {
312 val emptyMessage = when {
314 "Waiting for new logs\u2026"
315 state.searchQuery.isNotBlank() && state.activeFilters.isNotEmpty() ->
316 "No logs match \"${state.searchQuery}\" with the active filters"
317 state.searchQuery.isNotBlank() ->
318 "No logs match \"${state.searchQuery}\""
319 state.activeFilters.isNotEmpty() ->
320 "No logs match the active filters"
322 "No logs found for the selected time range"
325 modifier = Modifier.fillMaxSize(),
326 contentAlignment = Alignment.Center,
328 Column(horizontalAlignment = Alignment.CenterHorizontally) {
330 Icons.Filled.SearchOff,
331 contentDescription = "No logs found",
332 modifier = Modifier.size(48.dp),
333 tint = MaterialTheme.colorScheme.onSurfaceVariant,
335 Spacer(modifier = Modifier.height(16.dp))
338 style = MaterialTheme.typography.bodyLarge,
339 color = MaterialTheme.colorScheme.onSurfaceVariant,
340 modifier = Modifier.padding(horizontal = 32.dp),
345 val listState = rememberLazyListState()
346 val clipboard = remember {
347 context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
350 // Auto-load more when scrolling near the bottom
351 if (state.hasMore && !state.isLoading) {
352 val shouldLoadMore by remember {
354 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
355 lastVisible >= listState.layoutInfo.totalItemsCount - 5
358 LaunchedEffect(shouldLoadMore) {
359 if (shouldLoadMore) viewModel.loadMore()
363 // Pre-compute stable keys so each log is assigned exactly one key
364 val logKeys = remember(state.logs) {
365 stableLogKeys(state.logs)
370 contentPadding = PaddingValues(8.dp),
371 verticalArrangement = Arrangement.spacedBy(4.dp),
375 key = { index, _ -> logKeys.getOrElse(index) { "log_$index" } },
376 ) { index, logEntry ->
377 val key = logKeys.getOrElse(index) { "log_$index" }
380 isExpanded = expandedLogKey == key,
382 expandedLogKey = if (expandedLogKey == key) null else key
384 clipboardManager = clipboard,
387 snackbarHostState.showSnackbar(
388 message = "Log entry copied to clipboard",
389 duration = SnackbarDuration.Short,
402 contentAlignment = Alignment.Center,
404 CircularProgressIndicator(
405 modifier = Modifier.size(24.dp),
410 } else if (state.logs.isNotEmpty()) {
416 contentAlignment = Alignment.Center,
418 val atCap = state.currentLimit >= 5000 && state.logs.size >= 5000
421 "Showing max 5,000 results. Refine your query to see more."
425 style = MaterialTheme.typography.labelMedium,
427 MaterialTheme.colorScheme.primary
429 MaterialTheme.colorScheme.onSurfaceVariant
441 // Filter bottom sheet
442 if (showFilterSheet) {
444 columns = state.columns,
445 onDismiss = { showFilterSheet = false },
446 onApplyFilter = { column, operator, value ->
447 viewModel.addFilter(column, operator, value)
448 showFilterSheet = false
453 // SQL query bottom sheet
456 streamName = streamName,
457 currentSql = state.customSql,
458 onDismiss = { showSqlSheet = false },
460 viewModel.executeCustomSql(sql)
466 // Share confirmation dialog (logs may contain sensitive data)
467 if (showShareWarning) {
469 onDismissRequest = { showShareWarning = false },
470 title = { Text("Share Logs") },
472 Text("Logs may contain sensitive information (PII, secrets, tokens). " +
473 "They will be exported as unencrypted JSON. Continue?")
476 TextButton(onClick = {
477 showShareWarning = false
478 val logs = state.logs
479 if (logs.isNotEmpty()) {
482 val file = withContext(kotlinx.coroutines.Dispatchers.IO) {
483 val dir = java.io.File(context.cacheDir, "shared_logs")
484 if (!dir.mkdirs() && !dir.isDirectory) {
485 throw java.io.IOException("Failed to create shared_logs directory")
487 // Clean up previous exports
488 dir.listFiles()?.forEach { old -> old.delete() }
489 val safeFileName = streamName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
490 val file = java.io.File(dir, "logs_$safeFileName.json")
491 file.bufferedWriter().use { writer ->
492 // Use compact JSON for large exports to reduce memory/file size
493 val encoder = if (logs.size > 1000) Json else prettyJson
495 logs.forEachIndexed { index, log ->
496 if (index > 0) writer.write(",")
498 writer.write(encoder.encodeToString(JsonObject.serializer(), log))
505 val uri = FileProvider.getUriForFile(
507 "${context.packageName}.fileprovider",
510 val sendIntent = Intent().apply {
511 action = Intent.ACTION_SEND
512 putExtra(Intent.EXTRA_STREAM, uri)
513 putExtra(Intent.EXTRA_SUBJECT, "Logs: $streamName")
514 type = "application/json"
515 addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
517 context.startActivity(
518 Intent.createChooser(sendIntent, "Share logs")
520 } catch (e: Exception) {
521 snackbarHostState.showSnackbar(
522 message = "Failed to share logs: ${e.message}",
523 duration = SnackbarDuration.Short,
533 TextButton(onClick = { showShareWarning = false }) {
540 // Custom date range picker – state is created inside the block so it
541 // resets every time the dialog reopens (no stale selection carried over).
542 if (showDateRangePicker) {
543 val dateRangePickerState = rememberDateRangePickerState()
545 onDismissRequest = { showDateRangePicker = false },
549 val start = dateRangePickerState.selectedStartDateMillis
550 val end = dateRangePickerState.selectedEndDateMillis
551 if (start != null && end != null) {
552 // DateRangePicker returns midnight UTC for the selected date.
553 // Adjust to cover the full end day in the user's local timezone:
554 // Convert to local date, get end-of-day, convert back to UTC millis.
555 val localZone = java.time.ZoneId.systemDefault()
556 val endLocalDate = java.time.Instant.ofEpochMilli(end)
557 .atZone(localZone).toLocalDate()
558 val endOfDayUtc = endLocalDate.plusDays(1)
559 .atStartOfDay(localZone)
560 .toInstant().toEpochMilli() - 1
561 val startLocalDate = java.time.Instant.ofEpochMilli(start)
562 .atZone(localZone).toLocalDate()
563 val startUtc = startLocalDate
564 .atStartOfDay(localZone)
565 .toInstant().toEpochMilli()
566 viewModel.setCustomTimeRange(startUtc, endOfDayUtc)
568 showDateRangePicker = false
570 enabled = dateRangePickerState.selectedStartDateMillis != null &&
571 dateRangePickerState.selectedEndDateMillis != null,
577 TextButton(onClick = { showDateRangePicker = false }) {
583 state = dateRangePickerState,
584 modifier = Modifier.weight(1f),
591private fun TimeRangeBar(
592 selectedRange: TimeRange,
593 onRangeSelected: (TimeRange) -> Unit,
597 .horizontalScroll(rememberScrollState())
598 .padding(horizontal = 16.dp, vertical = 4.dp),
599 horizontalArrangement = Arrangement.spacedBy(8.dp),
601 TimeRange.entries.forEach { range ->
603 selected = selectedRange == range,
604 onClick = { onRangeSelected(range) },
605 label = { Text(range.label) },
612private fun StreamingToggleButton(
613 isStreaming: Boolean,
616 IconButton(onClick = onClick) {
618 val infiniteTransition = rememberInfiniteTransition(label = "stream_pulse")
619 val alpha by infiniteTransition.animateFloat(
622 animationSpec = infiniteRepeatable(
623 animation = tween(600, easing = EaseInOut),
624 repeatMode = RepeatMode.Reverse,
626 label = "stream_btn_alpha",
630 contentDescription = "Stop streaming",
631 tint = MaterialTheme.colorScheme.error,
632 modifier = Modifier.alpha(alpha),
636 Icons.Filled.PlayArrow,
637 contentDescription = "Start live tail",
638 tint = MaterialTheme.colorScheme.primary,
646 logEntry: JsonObject,
649 onCopied: () -> Unit = {},
650 clipboardManager: android.content.ClipboardManager,
652 val severity = remember(logEntry) { detectSeverity(logEntry) }
653 val severityTint = severityBackgroundColor(severity)
654 val containerColor = severityTint ?: MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
655 val cardColors = CardDefaults.cardColors(containerColor = containerColor)
658 modifier = Modifier.fillMaxWidth(),
661 Column(modifier = Modifier.padding(12.dp)) {
663 val rawTimestamp = remember(logEntry) {
665 logEntry["p_timestamp"]?.jsonPrimitive?.content
666 ?: logEntry["datetime"]?.jsonPrimitive?.content
668 } catch (_: Exception) {
672 val displayTimestamp = remember(rawTimestamp) {
673 if (rawTimestamp.isNotEmpty()) formatTimestamp(rawTimestamp) else ""
676 if (displayTimestamp.isNotEmpty()) {
678 text = displayTimestamp,
679 style = MaterialTheme.typography.labelSmall,
680 color = MaterialTheme.colorScheme.primary,
681 fontFamily = FontFamily.Monospace,
683 Spacer(modifier = Modifier.height(4.dp))
687 // Compact view: show first meaningful fields
688 val preview = remember(logEntry) {
690 .filter { !it.key.startsWith("p_") || it.key == "p_timestamp" }
692 .joinToString(" | ") { "${it.key}: ${formatJsonValue(it.value)}" }
695 text = preview.ifEmpty { logEntry.toString() },
696 style = MaterialTheme.typography.bodySmall,
697 fontFamily = FontFamily.Monospace,
699 overflow = TextOverflow.Ellipsis,
703 // Expanded view: show all fields + copy button
705 modifier = Modifier.fillMaxWidth(),
706 horizontalArrangement = Arrangement.End,
710 val text = prettyJson.encodeToString(JsonObject.serializer(), logEntry)
712 clipboardManager.setPrimaryClip(
713 android.content.ClipData.newPlainText("Log entry", text)
716 } catch (_: SecurityException) {
717 // Some devices/custom ROMs restrict clipboard access
722 Icons.Filled.ContentCopy,
723 contentDescription = "Copy log entry to clipboard",
724 tint = MaterialTheme.colorScheme.onSurfaceVariant,
728 HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
729 logEntry.entries.forEach { (key, value) ->
733 .padding(vertical = 2.dp),
737 style = MaterialTheme.typography.bodySmall,
738 fontWeight = FontWeight.Bold,
739 fontFamily = FontFamily.Monospace,
740 color = MaterialTheme.colorScheme.primary,
744 text = formatJsonValue(value),
745 style = MaterialTheme.typography.bodySmall,
746 fontFamily = FontFamily.Monospace,
757 * Produces deterministic keys for a list of log entries so that expanded state survives
758 * list mutations (e.g., new logs prepended during streaming). Keys are content-based
759 * using p_timestamp + p_metadata + p_tags; duplicate content gets a disambiguation suffix.
761private fun stableLogKeys(logs: List<JsonObject>): List<String> {
762 val seen = mutableMapOf<String, Int>()
763 return logs.map { log ->
764 val ts = log["p_timestamp"]?.toString()
765 val meta = log["p_metadata"]?.toString()
766 val tag = log["p_tags"]?.toString()
767 val base = if (ts != null) "$ts|${meta.orEmpty()}|${tag.orEmpty()}" else log.hashCode().toString()
768 val count = seen.getOrDefault(base, 0)
769 seen[base] = count + 1
770 if (count == 0) base else "$base|$count"
774private fun formatJsonValue(element: JsonElement): String {
775 return when (element) {
776 is JsonPrimitive -> element.content
777 is JsonNull -> "null"
778 else -> element.toString()
782@OptIn(ExperimentalMaterial3Api::class)
784fun FilterBottomSheet(
785 columns: List<String>,
786 onDismiss: () -> Unit,
787 onApplyFilter: (column: String, operator: String, value: String) -> Unit,
789 var selectedColumn by remember { mutableStateOf(columns.firstOrNull() ?: "") }
790 var selectedOperator by remember { mutableStateOf("=") }
791 var filterValue by remember { mutableStateOf("") }
792 var columnDropdownExpanded by remember { mutableStateOf(false) }
793 var operatorDropdownExpanded by remember { mutableStateOf(false) }
795 val operators = listOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
797 ModalBottomSheet(onDismissRequest = onDismiss) {
799 modifier = Modifier.padding(16.dp),
800 verticalArrangement = Arrangement.spacedBy(12.dp),
804 style = MaterialTheme.typography.titleLarge,
805 fontWeight = FontWeight.Bold,
809 ExposedDropdownMenuBox(
810 expanded = columnDropdownExpanded,
811 onExpandedChange = { columnDropdownExpanded = it },
814 value = selectedColumn,
817 label = { Text("Column") },
818 trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = columnDropdownExpanded) },
821 .menuAnchor(MenuAnchorType.PrimaryNotEditable),
824 expanded = columnDropdownExpanded,
825 onDismissRequest = { columnDropdownExpanded = false },
827 columns.forEach { column ->
829 text = { Text(column) },
831 selectedColumn = column
832 columnDropdownExpanded = false
840 ExposedDropdownMenuBox(
841 expanded = operatorDropdownExpanded,
842 onExpandedChange = { operatorDropdownExpanded = it },
845 value = selectedOperator,
848 label = { Text("Operator") },
849 trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = operatorDropdownExpanded) },
852 .menuAnchor(MenuAnchorType.PrimaryNotEditable),
855 expanded = operatorDropdownExpanded,
856 onDismissRequest = { operatorDropdownExpanded = false },
858 operators.forEach { op ->
862 selectedOperator = op
863 operatorDropdownExpanded = false
870 // Value input (hidden for IS NULL / IS NOT NULL)
871 if (selectedOperator !in listOf("IS NULL", "IS NOT NULL")) {
872 val valueHint = when (selectedOperator) {
873 "LIKE", "ILIKE" -> "Pattern (% added automatically)"
874 ">", "<", ">=", "<=" -> "Numeric or date value"
875 else -> "Exact value"
877 val valueError = when {
878 filterValue.isNotEmpty() && selectedOperator in listOf(">", "<", ">=", "<=") &&
879 filterValue.toDoubleOrNull() == null &&
880 !filterValue.matches(Regex("\\d{4}-\\d{2}-\\d{2}.*")) ->
881 "Expected a number or date"
886 onValueChange = { filterValue = it },
887 label = { Text("Value") },
888 placeholder = { Text(valueHint) },
889 modifier = Modifier.fillMaxWidth(),
891 isError = valueError != null,
892 supportingText = valueError?.let { { Text(it) } },
893 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
894 keyboardActions = KeyboardActions(onDone = {
895 if (selectedColumn.isNotBlank() && filterValue.isNotEmpty() && valueError == null) {
896 onApplyFilter(selectedColumn, selectedOperator, filterValue)
902 val hasValueError = selectedOperator in listOf(">", "<", ">=", "<=") &&
903 filterValue.isNotEmpty() &&
904 filterValue.toDoubleOrNull() == null &&
905 !filterValue.matches(Regex("\\d{4}-\\d{2}-\\d{2}.*"))
908 modifier = Modifier.fillMaxWidth(),
909 horizontalArrangement = Arrangement.End,
910 verticalAlignment = Alignment.CenterVertically,
912 TextButton(onClick = onDismiss) {
915 Spacer(modifier = Modifier.width(8.dp))
918 if (selectedColumn.isNotBlank()) {
919 onApplyFilter(selectedColumn, selectedOperator, filterValue)
922 enabled = selectedColumn.isNotBlank() &&
924 (selectedOperator in listOf("IS NULL", "IS NOT NULL") || filterValue.isNotEmpty()),
930 Spacer(modifier = Modifier.height(32.dp))
935@OptIn(ExperimentalMaterial3Api::class)
937fun SqlQueryBottomSheet(
940 onDismiss: () -> Unit,
941 onExecute: (String) -> Unit,
943 val safeName = remember(streamName) { escapeIdentifier(streamName) }
944 var sql by remember {
947 "SELECT * FROM \"$safeName\" ORDER BY p_timestamp DESC LIMIT 100"
952 ModalBottomSheet(onDismissRequest = onDismiss) {
954 modifier = Modifier.padding(16.dp),
955 verticalArrangement = Arrangement.spacedBy(12.dp),
959 style = MaterialTheme.typography.titleLarge,
960 fontWeight = FontWeight.Bold,
964 text = "Use PostgreSQL-compatible SQL to query your logs.",
965 style = MaterialTheme.typography.bodySmall,
966 color = MaterialTheme.colorScheme.onSurfaceVariant,
971 onValueChange = { sql = it },
972 label = { Text("SQL") },
975 .heightIn(min = 120.dp),
976 textStyle = MaterialTheme.typography.bodyMedium.copy(
977 fontFamily = FontFamily.Monospace,
983 modifier = Modifier.fillMaxWidth(),
984 horizontalArrangement = Arrangement.End,
986 TextButton(onClick = onDismiss) {
989 Spacer(modifier = Modifier.width(8.dp))
991 onClick = { onExecute(sql) },
992 enabled = sql.isNotBlank(),
994 Icon(Icons.Filled.PlayArrow, contentDescription = "Execute query")
995 Spacer(modifier = Modifier.width(4.dp))
1000 Spacer(modifier = Modifier.height(32.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