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