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