OXYGENHUB
# bfa2eff05d35 oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / code / CodeScreens.kt
12.4 KiB · last changed Diffs on a change, natively 10 days ago source blame history raw
1
package io.oxygenhub.android.ui.code
2
3
import androidx.compose.foundation.background
4
import androidx.compose.foundation.clickable
5
import androidx.compose.foundation.horizontalScroll
6
import androidx.compose.foundation.isSystemInDarkTheme
7
import androidx.compose.foundation.layout.Arrangement
8
import androidx.compose.foundation.layout.Box
9
import androidx.compose.foundation.layout.Column
10
import androidx.compose.foundation.layout.Row
11
import androidx.compose.foundation.layout.fillMaxWidth
12
import androidx.compose.foundation.layout.padding
13
import androidx.compose.foundation.layout.width
14
import androidx.compose.foundation.lazy.LazyColumn
15
import androidx.compose.foundation.lazy.itemsIndexed
16
import androidx.compose.foundation.lazy.items
17
import androidx.compose.foundation.rememberScrollState
18
import androidx.compose.foundation.text.selection.SelectionContainer
19
import androidx.compose.material.icons.Icons
20
import androidx.compose.material.icons.automirrored.outlined.OpenInNew
21
import androidx.compose.material.icons.outlined.ExpandLess
22
import androidx.compose.material.icons.outlined.ExpandMore
23
import androidx.compose.material.icons.outlined.History
24
import androidx.compose.material3.FilterChip
25
import androidx.compose.material3.HorizontalDivider
26
import androidx.compose.material3.Icon
27
import androidx.compose.material3.IconButton
28
import androidx.compose.material3.ListItem
29
import androidx.compose.material3.MaterialTheme
30
import androidx.compose.material3.Text
31
import androidx.compose.material3.TextButton
32
import androidx.compose.runtime.Composable
33
import androidx.compose.runtime.getValue
34
import androidx.compose.runtime.mutableStateOf
35
import androidx.compose.runtime.remember
36
import androidx.compose.runtime.saveable.rememberSaveable
37
import androidx.compose.runtime.setValue
38
import androidx.compose.ui.Modifier
39
import androidx.compose.ui.graphics.Color
40
import androidx.compose.ui.text.font.FontFamily
41
import androidx.compose.ui.text.style.TextAlign
42
import androidx.compose.ui.unit.dp
43
import androidx.lifecycle.compose.collectAsStateWithLifecycle
44
import com.mikepenz.markdown.m3.Markdown
45
import io.oxygenhub.android.api.ChangeSummary
46
import io.oxygenhub.android.api.DiffLine
47
import io.oxygenhub.android.api.FileChange
48
import io.oxygenhub.android.ui.common.AppScreen
49
import io.oxygenhub.android.ui.common.LoadContent
50
import io.oxygenhub.android.ui.common.relativeTime
51
import io.oxygenhub.android.ui.theme.LocalStatusColors
52
53
/** A file at a reference: highlighted with line numbers, or rendered when it is Markdown. */
54
@Composable
55
fun FileScreen(vm: FileViewModel, onBack: () -> Unit, onOpenHistory: () -> Unit) {
56
    val state by vm.state.collectAsStateWithLifecycle()
57
    var rendered by rememberSaveable { mutableStateOf(vm.isMarkdown) }
58
    AppScreen(
59
        title = vm.path.substringAfterLast('/'),
60
        subtitle = "${vm.owner}/${vm.name} · ${vm.ref.take(24)} · ${vm.path}",
61
        onBack = onBack,
62
        actions = {
63
            IconButton(onClick = onOpenHistory) { Icon(Icons.Outlined.History, contentDescription = "This file's history") }
64
        },
65
    ) {
66
        LoadContent(state, onRefresh = vm::refresh) { text ->
67
            Column {
68
                if (vm.isMarkdown) {
69
                    Row(Modifier.padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
70
                        FilterChip(selected = rendered, onClick = { rendered = true }, label = { Text("Rendered") })
71
                        FilterChip(selected = !rendered, onClick = { rendered = false }, label = { Text("Source") })
72
                    }
73
                }
74
                if (vm.isMarkdown && rendered) {
75
                    LazyColumn { item { Box(Modifier.padding(16.dp)) { Markdown(text) } } }
76
                } else {
77
                    CodeView(text, vm.path)
78
                }
79
            }
80
        }
81
    }
82
}
83
84
/** Lines with their numbers, highlighted, scrolling sideways together. */
85
@Composable
86
fun CodeView(text: String, path: String) {
87
    val dark = isSystemInDarkTheme()
88
    val lines = remember(text, path, dark) { highlightLines(text.trimEnd('\n'), path, dark) }
89
    val numbers = lines.size.toString().length
90
    val sideways = rememberScrollState()
91
    SelectionContainer {
92
        LazyColumn(Modifier.fillMaxWidth().horizontalScroll(sideways)) {
93
            itemsIndexed(lines) { i, line ->
94
                Row {
95
                    Text(
96
                        (i + 1).toString(),
97
                        modifier = Modifier.width((numbers * 9 + 16).dp).padding(end = 8.dp),
98
                        textAlign = TextAlign.End,
99
                        fontFamily = FontFamily.Monospace,
100
                        style = MaterialTheme.typography.bodySmall,
101
                        color = MaterialTheme.colorScheme.outline,
102
                    )
103
                    Text(line, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall, softWrap = false)
104
                }
105
            }
106
        }
107
    }
108
}
109
110
/** A reference's history, or a file's, newest first. */
111
@Composable
112
fun HistoryScreen(vm: HistoryViewModel, onBack: () -> Unit, onOpenChange: (ChangeSummary) -> Unit) {
113
    val state by vm.state.collectAsStateWithLifecycle()
114
    AppScreen(title = "History", subtitle = listOfNotNull("${vm.owner}/${vm.name}", vm.ref.take(24), vm.path).joinToString(" · "), onBack = onBack) {
115
        LoadContent(state, onRefresh = vm::refresh) { page ->
116
            LazyColumn {
117
                items(page.changes, key = { it.hash }) { c -> ChangeRow(c) { onOpenChange(c) } }
118
                if (page.hasMore) {
119
                    item {
120
                        TextButton(onClick = vm::more, enabled = !page.loadingMore, modifier = Modifier.fillMaxWidth()) {
121
                            Text(if (page.loadingMore) "Loading…" else page.moreFailed ?: "Older changes (${page.total - page.changes.size} more)")
122
                        }
123
                    }
124
                }
125
            }
126
        }
127
    }
128
}
129
130
@Composable
131
private fun ChangeRow(c: ChangeSummary, onClick: () -> Unit) {
132
    ListItem(
133
        modifier = Modifier.clickable(onClick = onClick),
134
        headlineContent = { Text(c.subject.ifEmpty { "(no message)" }, maxLines = 2) },
135
        supportingContent = { Text("${c.author.account ?: c.author.name} · ${relativeTime(c.whenMs)}") },
136
        trailingContent = { Text(c.short.take(8), fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall) },
137
    )
138
    HorizontalDivider()
139
}
140
141
/** Files a change touched whose diffs open by default: all of them for a small change. */
142
fun openByDefault(files: List<FileChange>): Set<String> =
143
    if (files.size <= 5 && files.sumOf { it.lines.size } <= 400) files.map { it.path }.toSet() else emptySet()
144
145
/** One change: its message, where it is, and the files it touched, each with its diff and a way to the file at the change. */
146
@Composable
147
fun ChangeScreen(vm: ChangeViewModel, onBack: () -> Unit, onOpenFile: (path: String) -> Unit) {
148
    val state by vm.state.collectAsStateWithLifecycle()
149
    val status = LocalStatusColors.current
150
    AppScreen(title = vm.hash.take(12), subtitle = "${vm.owner}/${vm.name}", onBack = onBack) {
151
        LoadContent(state, onRefresh = vm::refresh) { d ->
152
            var open by rememberSaveable(d.change.hash) { mutableStateOf(openByDefault(d.files).toList()) }
153
            LazyColumn {
154
                item {
155
                    Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
156
                        Text(d.change.subject, style = MaterialTheme.typography.titleMedium)
157
                        Text(
158
                            "${d.change.author.account ?: d.change.author.name} · ${relativeTime(d.change.whenMs)}",
159
                            style = MaterialTheme.typography.bodySmall,
160
                            color = MaterialTheme.colorScheme.onSurfaceVariant,
161
                        )
162
                        val rest = d.change.message.lines().drop(1).joinToString("\n").trim()
163
                        if (rest.isNotEmpty()) Markdown(rest)
164
                        val where = d.inBookmarks.map { it } + d.inTags.map { "tag $it" }
165
                        if (where.isNotEmpty()) Text("In " + where.joinToString(", "), style = MaterialTheme.typography.bodySmall)
166
                    }
167
                    HorizontalDivider()
168
                }
169
                d.files.forEach { f ->
170
                    val expanded = f.path in open
171
                    item(key = f.path) {
172
                        FileRow(
173
                            f,
174
                            expanded,
175
                            status.ok,
176
                            status.bad,
177
                            onToggle = { open = if (expanded) open - f.path else open + f.path },
178
                            onOpen = if (f.kind != "removed") ({ onOpenFile(f.path) }) else null,
179
                        )
180
                    }
181
                    if (expanded) {
182
                        val numbers = f.lines.maxOfOrNull { maxOf(it.old ?: 0, it.new ?: 0) }?.toString()?.length ?: 1
183
                        itemsIndexed(f.lines, key = { i, _ -> "${f.path}\u0000$i" }) { _, l -> DiffRow(l, numbers, status.ok, status.bad) }
184
                        if (f.lines.isEmpty() || f.truncated) {
185
                            item(key = "${f.path}\u0000end") {
186
                                Text(
187
                                    if (f.truncated) "The diff goes on; open the file to see it whole." else "No lines changed (${f.kind}).",
188
                                    modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
189
                                    style = MaterialTheme.typography.bodySmall,
190
                                    color = MaterialTheme.colorScheme.onSurfaceVariant,
191
                                )
192
                            }
193
                        }
194
                        item(key = "${f.path}\u0000rule") { HorizontalDivider() }
195
                    }
196
                }
197
            }
198
        }
199
    }
200
}
201
202
@Composable
203
private fun FileRow(
204
    f: FileChange,
205
    expanded: Boolean,
206
    added: Color,
207
    removed: Color,
208
    onToggle: () -> Unit,
209
    onOpen: (() -> Unit)?,
210
) {
211
    ListItem(
212
        modifier = Modifier.clickable(onClick = onToggle),
213
        leadingContent = {
214
            Icon(
215
                if (expanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore,
216
                contentDescription = if (expanded) "Hide the diff" else "Show the diff",
217
            )
218
        },
219
        headlineContent = { Text(f.path) },
220
        supportingContent = {
221
            Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
222
                Text(if (f.kind == "renamed") "renamed from ${f.from}" else f.kind)
223
                Text("+${f.added}", color = added)
224
                Text("−${f.removed}", color = removed)
225
            }
226
        },
227
        trailingContent = onOpen?.let { open ->
228
            { IconButton(onClick = open) { Icon(Icons.AutoMirrored.Outlined.OpenInNew, contentDescription = "The file at this change") } }
229
        },
230
    )
231
}
232
233
/** A diff line: old and new numbers, then the text on a tint of its kind; long lines wrap. */
234
@Composable
235
private fun DiffRow(l: DiffLine, numbers: Int, added: Color, removed: Color) {
236
    val tint = when (l.kind) {
237
        "add" -> added.copy(alpha = 0.14f)
238
        "del" -> removed.copy(alpha = 0.14f)
239
        "gap" -> MaterialTheme.colorScheme.surfaceVariant
240
        else -> Color.Transparent
241
    }
242
    val gutter = Modifier.width((numbers * 8 + 8).dp).padding(end = 4.dp)
243
    val small = MaterialTheme.typography.bodySmall
244
    Row(Modifier.fillMaxWidth().background(tint).padding(horizontal = 8.dp)) {
245
        if (l.kind == "gap") {
246
            Text("⋯", modifier = Modifier.padding(vertical = 2.dp), style = small, color = MaterialTheme.colorScheme.onSurfaceVariant)
247
            return@Row
248
        }
249
        Text(l.old?.toString() ?: "", gutter, textAlign = TextAlign.End, fontFamily = FontFamily.Monospace, style = small, color = MaterialTheme.colorScheme.outline)
250
        Text(l.new?.toString() ?: "", gutter, textAlign = TextAlign.End, fontFamily = FontFamily.Monospace, style = small, color = MaterialTheme.colorScheme.outline)
251
        Text(
252
            when (l.kind) {
253
                "add" -> "+"
254
                "del" -> "−"
255
                else -> " "
256
            },
257
            Modifier.width(12.dp),
258
            fontFamily = FontFamily.Monospace,
259
            style = small,
260
            color = if (l.kind == "add") added else if (l.kind == "del") removed else MaterialTheme.colorScheme.outline,
261
        )
262
        Text(l.text, Modifier.weight(1f), fontFamily = FontFamily.Monospace, style = small)
263
    }
264
}