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