OXYGENHUB
# a8e798f6ccae oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / code / CodeBrowser.kt
7.8 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.layout.Arrangement
6
import androidx.compose.foundation.layout.Box
7
import androidx.compose.foundation.layout.Row
8
import androidx.compose.foundation.layout.fillMaxWidth
9
import androidx.compose.foundation.layout.padding
10
import androidx.compose.foundation.lazy.LazyColumn
11
import androidx.compose.foundation.lazy.items
12
import androidx.compose.foundation.rememberScrollState
13
import androidx.compose.material.icons.Icons
14
import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile
15
import androidx.compose.material.icons.outlined.AccountTree
16
import androidx.compose.material.icons.outlined.Folder
17
import androidx.compose.material.icons.outlined.History
18
import androidx.compose.material.icons.outlined.Link
19
import androidx.compose.material3.AssistChip
20
import androidx.compose.material3.DropdownMenu
21
import androidx.compose.material3.DropdownMenuItem
22
import androidx.compose.material3.ElevatedCard
23
import androidx.compose.material3.HorizontalDivider
24
import androidx.compose.material3.Icon
25
import androidx.compose.material3.ListItem
26
import androidx.compose.material3.MaterialTheme
27
import androidx.compose.material3.Text
28
import androidx.compose.material3.TextButton
29
import androidx.compose.runtime.Composable
30
import androidx.compose.runtime.getValue
31
import androidx.compose.runtime.mutableStateOf
32
import androidx.compose.runtime.remember
33
import androidx.compose.runtime.setValue
34
import androidx.compose.ui.Alignment
35
import androidx.compose.ui.Modifier
36
import androidx.compose.ui.unit.dp
37
import androidx.lifecycle.compose.collectAsStateWithLifecycle
38
import com.mikepenz.markdown.m3.Markdown
39
import io.oxygenhub.android.api.TreeEntry
40
import io.oxygenhub.android.ui.common.EmptyState
41
import io.oxygenhub.android.ui.common.Load
42
import io.oxygenhub.android.ui.common.LoadContent
43
44
/**
45
 * A repository's code (#3): where you are (the reference, then each folder
46
 * of the path, tappable), the folder's entries, and its README rendered.
47
 */
48
@Composable
49
fun CodeBrowser(
50
    vm: CodeViewModel,
51
    onOpenFile: (ref: String, path: String) -> Unit,
52
    onOpenHistory: (ref: String) -> Unit,
53
) {
54
    val state by vm.state.collectAsStateWithLifecycle()
55
    val ref by vm.ref.collectAsStateWithLifecycle()
56
    val path by vm.path.collectAsStateWithLifecycle()
57
    LoadContent(state, onRefresh = vm::refresh) { dir ->
58
        LazyColumn(Modifier.fillMaxWidth()) {
59
            item {
60
                Where(vm, ref, path, onOpenHistory)
61
                HorizontalDivider()
62
            }
63
            if (path.isNotEmpty()) {
64
                item {
65
                    ListItem(
66
                        modifier = Modifier.clickable { vm.up() },
67
                        leadingContent = { Icon(Icons.Outlined.Folder, contentDescription = null) },
68
                        headlineContent = { Text("..") },
69
                    )
70
                }
71
            }
72
            if (dir.entries.isEmpty()) {
73
                item { EmptyState(Icons.Outlined.Folder, "Nothing here", "This folder is empty at $ref.") }
74
            }
75
            items(dir.entries, key = { it.path }) { e ->
76
                Entry(e) { if (e.isDir) vm.open(e.path) else onOpenFile(ref, e.path) }
77
            }
78
            dir.readme?.let { readme ->
79
                item {
80
                    ElevatedCard(Modifier.fillMaxWidth().padding(12.dp)) {
81
                        Text(
82
                            readme.path.substringAfterLast('/'),
83
                            style = MaterialTheme.typography.titleSmall,
84
                            modifier = Modifier.padding(start = 16.dp, top = 12.dp, end = 16.dp),
85
                        )
86
                        Box(Modifier.padding(16.dp)) {
87
                            if (readme.path.lowercase().let { it.endsWith(".md") || it.endsWith(".markdown") }) {
88
                                Markdown(readme.text)
89
                            } else {
90
                                Text(readme.text, style = MaterialTheme.typography.bodyMedium)
91
                            }
92
                        }
93
                    }
94
                }
95
            }
96
        }
97
    }
98
}
99
100
/** The reference (a menu of bookmarks and tags) and the path, each folder a step back. */
101
@Composable
102
private fun Where(vm: CodeViewModel, ref: String, path: String, onOpenHistory: (String) -> Unit) {
103
    var picking by remember { mutableStateOf(false) }
104
    val refs by vm.refs.collectAsStateWithLifecycle()
105
    Row(
106
        Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp, vertical = 6.dp),
107
        verticalAlignment = Alignment.CenterVertically,
108
        horizontalArrangement = Arrangement.spacedBy(4.dp),
109
    ) {
110
        Box {
111
            AssistChip(
112
                onClick = {
113
                    vm.loadRefs()
114
                    picking = true
115
                },
116
                label = { Text(ref) },
117
                leadingIcon = { Icon(Icons.Outlined.AccountTree, contentDescription = "Bookmark or tag") },
118
            )
119
            DropdownMenu(expanded = picking, onDismissRequest = { picking = false }) {
120
                when (val r = refs) {
121
                    is Load.Ready -> {
122
                        r.value.first.forEach { b ->
123
                            DropdownMenuItem(text = { Text(b) }, onClick = {
124
                                picking = false
125
                                vm.setRef(b)
126
                            })
127
                        }
128
                        r.value.second.forEach { t ->
129
                            DropdownMenuItem(
130
                                text = { Text(t) },
131
                                leadingIcon = { Icon(Icons.Outlined.Link, contentDescription = "Tag") },
132
                                onClick = {
133
                                    picking = false
134
                                    vm.setRef(t)
135
                                },
136
                            )
137
                        }
138
                    }
139
                    is Load.Failed -> DropdownMenuItem(text = { Text(r.message) }, onClick = { picking = false })
140
                    Load.Loading -> DropdownMenuItem(text = { Text("Loading…") }, onClick = {})
141
                }
142
            }
143
        }
144
        TextButton(onClick = { vm.open("") }) { Text(vm.name) }
145
        val parts = path.split('/').filter { it.isNotEmpty() }
146
        parts.forEachIndexed { i, part ->
147
            Text("/", color = MaterialTheme.colorScheme.onSurfaceVariant)
148
            TextButton(onClick = { vm.open(parts.take(i + 1).joinToString("/")) }) { Text(part) }
149
        }
150
        TextButton(onClick = { onOpenHistory(ref) }) {
151
            Icon(Icons.Outlined.History, contentDescription = null)
152
            Text(" History")
153
        }
154
    }
155
}
156
157
@Composable
158
private fun Entry(e: TreeEntry, onClick: () -> Unit) {
159
    ListItem(
160
        modifier = Modifier.clickable(onClick = onClick),
161
        leadingContent = {
162
            Icon(
163
                if (e.isDir) Icons.Outlined.Folder else Icons.AutoMirrored.Outlined.InsertDriveFile,
164
                contentDescription = if (e.isDir) "Folder" else "File",
165
                tint = if (e.isDir) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
166
            )
167
        },
168
        headlineContent = { Text(e.name) },
169
        supportingContent = when {
170
            e.conflicted -> {
171
                { Text("conflicted", color = MaterialTheme.colorScheme.error) }
172
            }
173
            e.kind == "subrepo" -> {
174
                { Text("subrepo") }
175
            }
176
            else -> null
177
        },
178
        trailingContent = e.size?.takeIf { !e.isDir }?.let { { Text(bytes(it), style = MaterialTheme.typography.labelSmall) } },
179
    )
180
}
181
182
/** 1.2 KB, 3.4 MB. */
183
fun bytes(n: Long): String = when {
184
    n < 1024 -> "$n B"
185
    n < 1024 * 1024 -> "%.1f KB".format(n / 1024.0)
186
    else -> "%.1f MB".format(n / (1024.0 * 1024.0))
187
}