OXYGENHUB
# 1c42a70fcba3 oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / code / CodeScreens.kt
8.4 KiB · last changed A code browser: files at any bookmark or tag, highlighted, the README rendered, history and changes (refs #3) 10 days ago source blame history raw
1
package io.oxygenhub.android.ui.code
2
3
import androidx.compose.foundation.clickable
4
import androidx.compose.foundation.horizontalScroll
5
import androidx.compose.foundation.isSystemInDarkTheme
6
import androidx.compose.foundation.layout.Arrangement
7
import androidx.compose.foundation.layout.Box
8
import androidx.compose.foundation.layout.Column
9
import androidx.compose.foundation.layout.Row
10
import androidx.compose.foundation.layout.fillMaxWidth
11
import androidx.compose.foundation.layout.padding
12
import androidx.compose.foundation.layout.width
13
import androidx.compose.foundation.lazy.LazyColumn
14
import androidx.compose.foundation.lazy.itemsIndexed
15
import androidx.compose.foundation.lazy.items
16
import androidx.compose.foundation.rememberScrollState
17
import androidx.compose.foundation.text.selection.SelectionContainer
18
import androidx.compose.material.icons.Icons
19
import androidx.compose.material.icons.outlined.History
20
import androidx.compose.material3.FilterChip
21
import androidx.compose.material3.HorizontalDivider
22
import androidx.compose.material3.Icon
23
import androidx.compose.material3.IconButton
24
import androidx.compose.material3.ListItem
25
import androidx.compose.material3.MaterialTheme
26
import androidx.compose.material3.Text
27
import androidx.compose.material3.TextButton
28
import androidx.compose.runtime.Composable
29
import androidx.compose.runtime.getValue
30
import androidx.compose.runtime.mutableStateOf
31
import androidx.compose.runtime.remember
32
import androidx.compose.runtime.saveable.rememberSaveable
33
import androidx.compose.runtime.setValue
34
import androidx.compose.ui.Modifier
35
import androidx.compose.ui.text.font.FontFamily
36
import androidx.compose.ui.text.style.TextAlign
37
import androidx.compose.ui.unit.dp
38
import androidx.lifecycle.compose.collectAsStateWithLifecycle
39
import com.mikepenz.markdown.m3.Markdown
40
import io.oxygenhub.android.api.ChangeSummary
41
import io.oxygenhub.android.api.FileChange
42
import io.oxygenhub.android.ui.common.AppScreen
43
import io.oxygenhub.android.ui.common.LoadContent
44
import io.oxygenhub.android.ui.common.relativeTime
45
import io.oxygenhub.android.ui.theme.LocalStatusColors
46
47
/** A file at a reference: highlighted with line numbers, or rendered when it is Markdown. */
48
@Composable
49
fun FileScreen(vm: FileViewModel, onBack: () -> Unit, onOpenHistory: () -> Unit) {
50
    val state by vm.state.collectAsStateWithLifecycle()
51
    var rendered by rememberSaveable { mutableStateOf(vm.isMarkdown) }
52
    AppScreen(
53
        title = vm.path.substringAfterLast('/'),
54
        subtitle = "${vm.owner}/${vm.name} · ${vm.ref.take(24)} · ${vm.path}",
55
        onBack = onBack,
56
        actions = {
57
            IconButton(onClick = onOpenHistory) { Icon(Icons.Outlined.History, contentDescription = "This file's history") }
58
        },
59
    ) {
60
        LoadContent(state, onRefresh = vm::refresh) { text ->
61
            Column {
62
                if (vm.isMarkdown) {
63
                    Row(Modifier.padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
64
                        FilterChip(selected = rendered, onClick = { rendered = true }, label = { Text("Rendered") })
65
                        FilterChip(selected = !rendered, onClick = { rendered = false }, label = { Text("Source") })
66
                    }
67
                }
68
                if (vm.isMarkdown && rendered) {
69
                    LazyColumn { item { Box(Modifier.padding(16.dp)) { Markdown(text) } } }
70
                } else {
71
                    CodeView(text, vm.path)
72
                }
73
            }
74
        }
75
    }
76
}
77
78
/** Lines with their numbers, highlighted, scrolling sideways together. */
79
@Composable
80
fun CodeView(text: String, path: String) {
81
    val dark = isSystemInDarkTheme()
82
    val lines = remember(text, path, dark) { highlightLines(text.trimEnd('\n'), path, dark) }
83
    val numbers = lines.size.toString().length
84
    val sideways = rememberScrollState()
85
    SelectionContainer {
86
        LazyColumn(Modifier.fillMaxWidth().horizontalScroll(sideways)) {
87
            itemsIndexed(lines) { i, line ->
88
                Row {
89
                    Text(
90
                        (i + 1).toString(),
91
                        modifier = Modifier.width((numbers * 9 + 16).dp).padding(end = 8.dp),
92
                        textAlign = TextAlign.End,
93
                        fontFamily = FontFamily.Monospace,
94
                        style = MaterialTheme.typography.bodySmall,
95
                        color = MaterialTheme.colorScheme.outline,
96
                    )
97
                    Text(line, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall, softWrap = false)
98
                }
99
            }
100
        }
101
    }
102
}
103
104
/** A reference's history, or a file's, newest first. */
105
@Composable
106
fun HistoryScreen(vm: HistoryViewModel, onBack: () -> Unit, onOpenChange: (ChangeSummary) -> Unit) {
107
    val state by vm.state.collectAsStateWithLifecycle()
108
    AppScreen(title = "History", subtitle = listOfNotNull("${vm.owner}/${vm.name}", vm.ref.take(24), vm.path).joinToString(" · "), onBack = onBack) {
109
        LoadContent(state, onRefresh = vm::refresh) { page ->
110
            LazyColumn {
111
                items(page.changes, key = { it.hash }) { c -> ChangeRow(c) { onOpenChange(c) } }
112
                if (page.hasMore) {
113
                    item {
114
                        TextButton(onClick = vm::more, enabled = !page.loadingMore, modifier = Modifier.fillMaxWidth()) {
115
                            Text(if (page.loadingMore) "Loading…" else page.moreFailed ?: "Older changes (${page.total - page.changes.size} more)")
116
                        }
117
                    }
118
                }
119
            }
120
        }
121
    }
122
}
123
124
@Composable
125
private fun ChangeRow(c: ChangeSummary, onClick: () -> Unit) {
126
    ListItem(
127
        modifier = Modifier.clickable(onClick = onClick),
128
        headlineContent = { Text(c.subject.ifEmpty { "(no message)" }, maxLines = 2) },
129
        supportingContent = { Text("${c.author.account ?: c.author.name} · ${relativeTime(c.whenMs)}") },
130
        trailingContent = { Text(c.short.take(8), fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall) },
131
    )
132
    HorizontalDivider()
133
}
134
135
/** One change: its message, where it is, and the files it touched, each opening at the change. */
136
@Composable
137
fun ChangeScreen(vm: ChangeViewModel, onBack: () -> Unit, onOpenFile: (path: String) -> Unit) {
138
    val state by vm.state.collectAsStateWithLifecycle()
139
    val status = LocalStatusColors.current
140
    AppScreen(title = vm.hash.take(12), subtitle = "${vm.owner}/${vm.name}", onBack = onBack) {
141
        LoadContent(state, onRefresh = vm::refresh) { d ->
142
            LazyColumn {
143
                item {
144
                    Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
145
                        Text(d.change.subject, style = MaterialTheme.typography.titleMedium)
146
                        Text(
147
                            "${d.change.author.account ?: d.change.author.name} · ${relativeTime(d.change.whenMs)}",
148
                            style = MaterialTheme.typography.bodySmall,
149
                            color = MaterialTheme.colorScheme.onSurfaceVariant,
150
                        )
151
                        val rest = d.change.message.lines().drop(1).joinToString("\n").trim()
152
                        if (rest.isNotEmpty()) Markdown(rest)
153
                        val where = d.inBookmarks.map { it } + d.inTags.map { "tag $it" }
154
                        if (where.isNotEmpty()) Text("In " + where.joinToString(", "), style = MaterialTheme.typography.bodySmall)
155
                    }
156
                    HorizontalDivider()
157
                }
158
                items(d.files, key = { it.path }) { f -> FileRow(f, status.ok, status.bad) { if (f.kind != "removed") onOpenFile(f.path) } }
159
            }
160
        }
161
    }
162
}
163
164
@Composable
165
private fun FileRow(f: FileChange, added: androidx.compose.ui.graphics.Color, removed: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {
166
    ListItem(
167
        modifier = Modifier.clickable(onClick = onClick),
168
        headlineContent = { Text(f.path) },
169
        supportingContent = {
170
            Text(
171
                when (f.kind) {
172
                    "renamed" -> "renamed from ${f.from}"
173
                    else -> f.kind
174
                },
175
            )
176
        },
177
        trailingContent = {
178
            Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
179
                Text("+${f.added}", color = added, style = MaterialTheme.typography.labelMedium)
180
                Text("−${f.removed}", color = removed, style = MaterialTheme.typography.labelMedium)
181
            }
182
        },
183
    )
184
}