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