OXYGENHUB

A code browser: files at any bookmark or tag, highlighted, the README rendered, history and changes (refs #3) 98dbd1220929

A repository opens on its Code tab: the tree at its default bookmark (or
another bookmark or tag), folders first, a breadcrumb back up, and the
folder's README rendered below. A file shows with line numbers and
syntax highlighting (Markdown rendered or as source); its history and a
reference's are a page at a time; a change shows its message, where it
is, and the files it touched with their counts, each opening at the
change. Libraries Andy approved: multiplatform-markdown-renderer-m3
0.38.1 (the newest built for Kotlin 2.2) and highlights 1.1.0.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key babda8d4512a
When
· 2026-10-25 22:37 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
98dbd12209291f8ba0504237e29eb570ecf8fd49da9b8488f8be9aaaa1aa8acc
Depends on
0a2858c0dad3 144cbf7bf2b5 4aee5293461d 57c0824300db 5ae4af48ddd2 d1f635be9442 db00632a61d6 e0e4a1c48a77 e2df27a81e5d ff65ea456ff1
Depended on by
d98e5a147428 840c75813f86 6e2af63ae616 dce8ebd2482a 1dfa631436b6 2a15621eca66 af9f2e514c72 40ab4df121e0 db7727e5de19 aa21eb5e0e5b 62429abe5869 a49fd826e10b 0414607973cb dfbc3e972872 1c42a70fcba3 e833eb0a9e7f 31e4f3755627
In bookmarks
main
In tags
v0.1.0 v0.1.1 v0.1.2 v0.1.3 v0.1.4 v0.1.5 v0.1.6 v0.1.7 v0.1.8 v0.1.9
Edits
29 atoms, 7 tree operations, 13 files

Browse the tree at this change

added app/src/main/java/io/oxygenhub/android/ui/code/CodeBrowser.kt +187 −0
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 +
}
added app/src/main/java/io/oxygenhub/android/ui/code/CodeScreens.kt +184 −0
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 +
}
added app/src/main/java/io/oxygenhub/android/ui/code/CodeViewModel.kt +125 −0
1 +
package io.oxygenhub.android.ui.code
2 +
3 +
import androidx.lifecycle.ViewModel
4 +
import io.oxygenhub.android.api.HubApi
5 +
import io.oxygenhub.android.api.TreeEntry
6 +
import io.oxygenhub.android.ui.common.Load
7 +
import io.oxygenhub.android.ui.common.load
8 +
import kotlinx.coroutines.Job
9 +
import kotlinx.coroutines.flow.MutableStateFlow
10 +
import kotlinx.coroutines.flow.StateFlow
11 +
import kotlinx.coroutines.flow.asStateFlow
12 +
13 +
/**
14 +
 * A repository's code (#3): a directory of its tree at a bookmark, tag or
15 +
 * change, folders first, and the directory's README to show below it.
16 +
 */
17 +
class CodeViewModel(
18 +
    private val api: HubApi,
19 +
    val owner: String,
20 +
    val name: String,
21 +
    /** The bookmark to start at; the repository's default when not given. */
22 +
    defaultRef: String? = null,
23 +
) : ViewModel() {
24 +
    /** What a directory holds, and its README's text when it has one. */
25 +
    data class Dir(val ref: String, val path: String, val entries: List<TreeEntry>, val readme: Readme?)
26 +
27 +
    data class Readme(val path: String, val text: String)
28 +
29 +
    private val _ref = MutableStateFlow(defaultRef.orEmpty())
30 +
    val ref: StateFlow<String> = _ref.asStateFlow()
31 +
32 +
    private val _path = MutableStateFlow("")
33 +
    val path: StateFlow<String> = _path.asStateFlow()
34 +
35 +
    private val _state = MutableStateFlow<Load<Dir>>(Load.Loading)
36 +
    val state: StateFlow<Load<Dir>> = _state.asStateFlow()
37 +
38 +
    /** The bookmarks and tags to browse at, fetched once the picker opens. */
39 +
    private val _refs = MutableStateFlow<Load<Pair<List<String>, List<String>>>>(Load.Loading)
40 +
    val refs: StateFlow<Load<Pair<List<String>, List<String>>>> = _refs.asStateFlow()
41 +
42 +
    private var job: Job? = null
43 +
    private var refsJob: Job? = null
44 +
45 +
    init {
46 +
        refresh()
47 +
    }
48 +
49 +
    fun open(path: String) {
50 +
        if (path == _path.value) return
51 +
        _path.value = path
52 +
        _state.value = Load.Loading
53 +
        refresh()
54 +
    }
55 +
56 +
    /** One directory up; false at the top. */
57 +
    fun up(): Boolean {
58 +
        val p = _path.value
59 +
        if (p.isEmpty()) return false
60 +
        open(p.substringBeforeLast('/', ""))
61 +
        return true
62 +
    }
63 +
64 +
    fun setRef(ref: String) {
65 +
        if (ref == _ref.value) return
66 +
        _ref.value = ref
67 +
        _state.value = Load.Loading
68 +
        refresh()
69 +
    }
70 +
71 +
    fun loadRefs() {
72 +
        if (_refs.value is Load.Ready) return
73 +
        refsJob = load(_refs, refsJob) { api.refs(owner, name) }
74 +
    }
75 +
76 +
    fun refresh() {
77 +
        val path = _path.value
78 +
        job = load(_state, job) {
79 +
            val ref = _ref.value.ifEmpty { api.repository(owner, name).defaultBookmark.also { _ref.value = it } }
80 +
            val entries = order(api.tree(owner, name, ref, path))
81 +
            val readme = readmeOf(entries)?.let { e ->
82 +
                // A README that will not load is left out, not an error.
83 +
                runCatching { Readme(e.path, api.file(owner, name, ref, e.path)) }.getOrNull()
84 +
            }
85 +
            Dir(ref, path, entries, readme)
86 +
        }
87 +
    }
88 +
89 +
    companion object {
90 +
        /** Folders first, then files, each by name as a person reads them. */
91 +
        fun order(entries: List<TreeEntry>): List<TreeEntry> =
92 +
            entries.sortedWith(compareBy<TreeEntry> { !it.isDir }.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name })
93 +
94 +
        /** The README a directory shows: Markdown first. */
95 +
        fun readmeOf(entries: List<TreeEntry>): TreeEntry? {
96 +
            val files = entries.filter { !it.isDir }
97 +
            return listOf("README.md", "readme.md", "README.markdown", "README", "README.txt")
98 +
                .firstNotNullOfOrNull { n -> files.firstOrNull { it.name.equals(n, ignoreCase = false) } }
99 +
                ?: files.firstOrNull { it.name.lowercase().startsWith("readme") }
100 +
        }
101 +
    }
102 +
}
103 +
104 +
/** A file at a reference: its text, to show highlighted, or rendered when it is Markdown. */
105 +
class FileViewModel(
106 +
    private val api: HubApi,
107 +
    val owner: String,
108 +
    val name: String,
109 +
    val ref: String,
110 +
    val path: String,
111 +
) : ViewModel() {
112 +
    private val _state = MutableStateFlow<Load<String>>(Load.Loading)
113 +
    val state: StateFlow<Load<String>> = _state.asStateFlow()
114 +
    private var job: Job? = null
115 +
116 +
    init {
117 +
        refresh()
118 +
    }
119 +
120 +
    fun refresh() {
121 +
        job = load(_state, job) { api.file(owner, name, ref, path) }
122 +
    }
123 +
124 +
    val isMarkdown: Boolean get() = path.lowercase().let { it.endsWith(".md") || it.endsWith(".markdown") }
125 +
}
added app/src/main/java/io/oxygenhub/android/ui/code/Highlight.kt +68 −0
1 +
package io.oxygenhub.android.ui.code
2 +
3 +
import androidx.compose.ui.graphics.Color
4 +
import androidx.compose.ui.text.AnnotatedString
5 +
import androidx.compose.ui.text.SpanStyle
6 +
import androidx.compose.ui.text.font.FontWeight
7 +
import dev.snipme.highlights.Highlights
8 +
import dev.snipme.highlights.model.BoldHighlight
9 +
import dev.snipme.highlights.model.ColorHighlight
10 +
import dev.snipme.highlights.model.SyntaxLanguage
11 +
import dev.snipme.highlights.model.SyntaxThemes
12 +
13 +
/** The language to highlight a file as, from its name; plain text when unknown. */
14 +
fun languageOf(path: String): SyntaxLanguage {
15 +
    val name = path.substringAfterLast('/').lowercase()
16 +
    return when (name.substringAfterLast('.', "")) {
17 +
        "rs" -> SyntaxLanguage.RUST
18 +
        "kt", "kts" -> SyntaxLanguage.KOTLIN
19 +
        "java" -> SyntaxLanguage.JAVA
20 +
        "js", "mjs", "cjs", "jsx" -> SyntaxLanguage.JAVASCRIPT
21 +
        "ts", "tsx" -> SyntaxLanguage.TYPESCRIPT
22 +
        "py" -> SyntaxLanguage.PYTHON
23 +
        "sh", "bash", "zsh" -> SyntaxLanguage.SHELL
24 +
        "c", "h" -> SyntaxLanguage.C
25 +
        "cc", "cpp", "cxx", "hpp", "hh" -> SyntaxLanguage.CPP
26 +
        "go" -> SyntaxLanguage.GO
27 +
        "rb" -> SyntaxLanguage.RUBY
28 +
        "swift" -> SyntaxLanguage.SWIFT
29 +
        "dart" -> SyntaxLanguage.DART
30 +
        "cs" -> SyntaxLanguage.CSHARP
31 +
        "php" -> SyntaxLanguage.PHP
32 +
        "pl", "pm" -> SyntaxLanguage.PERL
33 +
        else -> if (name == "makefile" || name == "dockerfile") SyntaxLanguage.SHELL else SyntaxLanguage.DEFAULT
34 +
    }
35 +
}
36 +
37 +
/**
38 +
 * [code] highlighted for [path], one AnnotatedString per line, so a long
39 +
 * file is laid out a line at a time. Plain lines when the language is not
40 +
 * known or the file is too long to highlight quickly.
41 +
 */
42 +
fun highlightLines(code: String, path: String, dark: Boolean): List<AnnotatedString> {
43 +
    val lines = code.split('\n')
44 +
    val language = languageOf(path)
45 +
    if (language == SyntaxLanguage.DEFAULT || code.length > MAX_HIGHLIGHT) return lines.map { AnnotatedString(it) }
46 +
    val whole = AnnotatedString.Builder(code)
47 +
    val spans = runCatching {
48 +
        Highlights.Builder().code(code).language(language).theme(SyntaxThemes.default(dark)).build().getHighlights()
49 +
    }.getOrDefault(emptyList())
50 +
    for (h in spans) {
51 +
        val (start, end) = h.location.start to h.location.end
52 +
        if (start < 0 || end > code.length || start >= end) continue
53 +
        when (h) {
54 +
            is ColorHighlight -> whole.addStyle(SpanStyle(color = Color(0xFF000000.toInt() or h.rgb)), start, end)
55 +
            is BoldHighlight -> whole.addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end)
56 +
        }
57 +
    }
58 +
    val text = whole.toAnnotatedString()
59 +
    var at = 0
60 +
    return lines.map { line ->
61 +
        val piece = text.subSequence(at, at + line.length)
62 +
        at += line.length + 1
63 +
        piece
64 +
    }
65 +
}
66 +
67 +
/** Beyond this many characters a file is shown plain: highlighting it would stall the screen. */
68 +
const val MAX_HIGHLIGHT = 200_000
added app/src/main/java/io/oxygenhub/android/ui/code/HistoryViewModel.kt +84 −0
1 +
package io.oxygenhub.android.ui.code
2 +
3 +
import androidx.lifecycle.ViewModel
4 +
import androidx.lifecycle.viewModelScope
5 +
import io.oxygenhub.android.api.ChangeDetail
6 +
import io.oxygenhub.android.api.ChangeSummary
7 +
import io.oxygenhub.android.api.HubApi
8 +
import io.oxygenhub.android.api.userMessage
9 +
import io.oxygenhub.android.ui.common.Load
10 +
import io.oxygenhub.android.ui.common.load
11 +
import kotlinx.coroutines.CancellationException
12 +
import kotlinx.coroutines.Job
13 +
import kotlinx.coroutines.flow.MutableStateFlow
14 +
import kotlinx.coroutines.flow.StateFlow
15 +
import kotlinx.coroutines.flow.asStateFlow
16 +
import kotlinx.coroutines.launch
17 +
18 +
/** A reference's history, or a file's, newest first, a page at a time (#3). */
19 +
class HistoryViewModel(
20 +
    private val api: HubApi,
21 +
    val owner: String,
22 +
    val name: String,
23 +
    val ref: String,
24 +
    /** Only the changes to this path, when given. */
25 +
    val path: String?,
26 +
) : ViewModel() {
27 +
    /** The changes so far, and how many there are in all. */
28 +
    data class Page(val changes: List<ChangeSummary>, val total: Int, val loadingMore: Boolean = false, val moreFailed: String? = null) {
29 +
        val hasMore: Boolean get() = changes.size < total
30 +
    }
31 +
32 +
    private val _state = MutableStateFlow<Load<Page>>(Load.Loading)
33 +
    val state: StateFlow<Load<Page>> = _state.asStateFlow()
34 +
    private var job: Job? = null
35 +
36 +
    init {
37 +
        refresh()
38 +
    }
39 +
40 +
    fun refresh() {
41 +
        job = load(_state, job) { api.history(owner, name, ref, path, 0, PAGE).let { Page(it.changes, it.total) } }
42 +
    }
43 +
44 +
    /** The next page, added at the end. */
45 +
    fun more() {
46 +
        val page = (_state.value as? Load.Ready)?.value ?: return
47 +
        if (page.loadingMore || !page.hasMore) return
48 +
        _state.value = Load.Ready(page.copy(loadingMore = true, moreFailed = null))
49 +
        viewModelScope.launch {
50 +
            _state.value = try {
51 +
                val next = api.history(owner, name, ref, path, page.changes.size, PAGE)
52 +
                Load.Ready(Page(page.changes + next.changes, next.total))
53 +
            } catch (e: CancellationException) {
54 +
                throw e
55 +
            } catch (e: Exception) {
56 +
                Load.Ready(page.copy(loadingMore = false, moreFailed = e.userMessage()))
57 +
            }
58 +
        }
59 +
    }
60 +
61 +
    companion object {
62 +
        const val PAGE = 50
63 +
    }
64 +
}
65 +
66 +
/** One change: its message, where it is, and the files it touched. */
67 +
class ChangeViewModel(
68 +
    private val api: HubApi,
69 +
    val owner: String,
70 +
    val name: String,
71 +
    val hash: String,
72 +
) : ViewModel() {
73 +
    private val _state = MutableStateFlow<Load<ChangeDetail>>(Load.Loading)
74 +
    val state: StateFlow<Load<ChangeDetail>> = _state.asStateFlow()
75 +
    private var job: Job? = null
76 +
77 +
    init {
78 +
        refresh()
79 +
    }
80 +
81 +
    fun refresh() {
82 +
        job = load(_state, job) { api.change(owner, name, hash) }
83 +
    }
84 +
}
added app/src/test/java/io/oxygenhub/android/ui/CodeBrowserTest.kt +94 −0
1 +
package io.oxygenhub.android.ui
2 +
3 +
import io.oxygenhub.android.FakeHubApi
4 +
import io.oxygenhub.android.MainDispatcherRule
5 +
import io.oxygenhub.android.api.ChangeSummary
6 +
import io.oxygenhub.android.api.History
7 +
import io.oxygenhub.android.api.TreeEntry
8 +
import io.oxygenhub.android.ui.code.CodeViewModel
9 +
import io.oxygenhub.android.ui.code.HistoryViewModel
10 +
import io.oxygenhub.android.ui.code.languageOf
11 +
import io.oxygenhub.android.ui.common.Load
12 +
import dev.snipme.highlights.model.SyntaxLanguage
13 +
import org.junit.Assert.assertEquals
14 +
import org.junit.Assert.assertFalse
15 +
import org.junit.Assert.assertNull
16 +
import org.junit.Assert.assertTrue
17 +
import org.junit.Rule
18 +
import org.junit.Test
19 +
20 +
class CodeBrowserTest {
21 +
    @get:Rule val main = MainDispatcherRule()
22 +
23 +
    private val api = FakeHubApi()
24 +
25 +
    private fun <T> ready(load: Load<T>): T = (load as Load.Ready).value
26 +
27 +
    private val top = listOf(
28 +
        TreeEntry("src", "src", "dir"),
29 +
        TreeEntry("README.md", "README.md", "file", size = 12),
30 +
        TreeEntry("Cargo.toml", "Cargo.toml", "file"),
31 +
        TreeEntry("assets", "assets", "dir"),
32 +
    )
33 +
34 +
    @Test
35 +
    fun startsAtTheDefaultBookmarkFoldersFirstWithTheReadme() {
36 +
        api.repository = api.repository.copy(defaultBookmark = "trunk")
37 +
        api.tree = { _, path -> if (path.isEmpty()) top else listOf(TreeEntry("main.rs", "src/main.rs")) }
38 +
        api.files = mapOf("README.md" to "# Demo")
39 +
        val vm = CodeViewModel(api, "andymccall", "demo")
40 +
        val dir = ready(vm.state.value)
41 +
        assertEquals("trunk", vm.ref.value)
42 +
        assertEquals(listOf("assets", "src", "Cargo.toml", "README.md"), dir.entries.map { it.name })
43 +
        assertEquals("# Demo", dir.readme?.text)
44 +
        assertTrue("tree trunk:" in api.calls)
45 +
46 +
        vm.open("src")
47 +
        assertEquals(listOf("main.rs"), ready(vm.state.value).entries.map { it.name })
48 +
        assertNull("no README here", ready(vm.state.value).readme)
49 +
        assertTrue(vm.up())
50 +
        assertEquals("", vm.path.value)
51 +
        assertFalse("already at the top", vm.up())
52 +
    }
53 +
54 +
    @Test
55 +
    fun aReadmeThatWillNotLoadIsLeftOut() {
56 +
        api.tree = { _, _ -> top }
57 +
        val dir = ready(CodeViewModel(api, "andymccall", "demo", "main").state.value)
58 +
        assertNull(dir.readme)
59 +
        assertEquals(4, dir.entries.size)
60 +
    }
61 +
62 +
    @Test
63 +
    fun anotherBookmarkOrTagIsBrowsedFromItsTop() {
64 +
        api.tree = { ref, _ -> if (ref == "v1.0") listOf(TreeEntry("OLD", "OLD")) else top }
65 +
        api.refs = listOf("main", "develop") to listOf("v1.0")
66 +
        val vm = CodeViewModel(api, "andymccall", "demo", "main")
67 +
        vm.loadRefs()
68 +
        assertEquals(listOf("main", "develop") to listOf("v1.0"), ready(vm.refs.value))
69 +
        vm.setRef("v1.0")
70 +
        assertEquals(listOf("OLD"), ready(vm.state.value).entries.map { it.name })
71 +
    }
72 +
73 +
    @Test
74 +
    fun historyComesAPageAtATime() {
75 +
        val c = { n: Int -> ChangeSummary("%064x".format(n), "change $n\n\nbody") }
76 +
        api.history = History("main", total = 3, changes = listOf(c(1), c(2)))
77 +
        val vm = HistoryViewModel(api, "andymccall", "demo", "main", "src/main.rs")
78 +
        assertEquals(listOf("change 1", "change 2"), ready(vm.state.value).changes.map { it.subject })
79 +
        assertTrue(ready(vm.state.value).hasMore)
80 +
        assertTrue("history main:src/main.rs" in api.calls)
81 +
        api.history = History("main", total = 3, offset = 2, changes = listOf(c(3)))
82 +
        vm.more()
83 +
        assertEquals(3, ready(vm.state.value).changes.size)
84 +
        assertFalse(ready(vm.state.value).hasMore)
85 +
    }
86 +
87 +
    @Test
88 +
    fun filesAreHighlightedByTheirNames() {
89 +
        assertEquals(SyntaxLanguage.RUST, languageOf("crates/hub/src/main.rs"))
90 +
        assertEquals(SyntaxLanguage.KOTLIN, languageOf("app/build.gradle.kts"))
91 +
        assertEquals(SyntaxLanguage.SHELL, languageOf("Makefile"))
92 +
        assertEquals(SyntaxLanguage.DEFAULT, languageOf("pipeline.kdl"))
93 +
    }
94 +
}
modified app/build.gradle.kts +2 −0
…
113 unchanged lines
114 114
    implementation(libs.kotlinx.serialization.json)
115 115
    implementation(libs.kotlinx.coroutines.android)
116 116
    implementation(libs.okhttp)
117 +
    implementation(libs.markdown.renderer.m3)
118 +
    implementation(libs.highlights)
117 119
    testImplementation(libs.junit)
118 120
    testImplementation(libs.kotlinx.coroutines.test)
119 121
    testImplementation(libs.okhttp.mockwebserver)
…
10 unchanged lines
modified app/src/main/java/io/oxygenhub/android/api/HubClient.kt +46 −0
…
61 unchanged lines
62 62
    /** A one-time token registering this phone as a runner for the account's repositories (`repo:write`). */
63 63
    suspend fun runnerToken(): RunnerToken
64 64
65 +
    /** One repository (`GET /repos/{owner}/{name}`): its default bookmark, for the code browser. */
66 +
    suspend fun repository(owner: String, name: String): Repository
67 +
68 +
    /** A directory of the tree at [ref] (a bookmark, tag or change), [path] "" for the top. */
69 +
    suspend fun tree(owner: String, name: String, ref: String, path: String = ""): List<TreeEntry>
70 +
71 +
    /** A file's bytes at [ref], as text. */
72 +
    suspend fun file(owner: String, name: String, ref: String, path: String): String
73 +
74 +
    /** [ref]'s history, newest first; only changes to [path] when given. */
75 +
    suspend fun history(owner: String, name: String, ref: String, path: String? = null, offset: Int = 0, limit: Int = 50): History
76 +
77 +
    suspend fun change(owner: String, name: String, hash: String): ChangeDetail
78 +
79 +
    /** The repository's bookmarks and tags, to browse at. */
80 +
    suspend fun refs(owner: String, name: String): Pair<List<String>, List<String>>
81 +
65 82
    /** An account's picture (`/avatar/{name}`, public); null when the hub has none this app can draw. */
66 83
    suspend fun avatar(name: String): Avatar?
67 84
}
…
23 unchanged lines
91 108
        get(url("notifications") { addQueryParameter("limit", limit.coerceIn(1, 200).toString()) }, Inbox.serializer())
92 109
93 110
    override suspend fun runnerToken(): RunnerToken = send("POST", url("user", "runner-token"), RunnerToken.serializer())
111 +
112 +
    override suspend fun repository(owner: String, name: String): Repository = get(url("repos", owner, name), Repository.serializer())
113 +
114 +
    private fun parts(path: String): Array<String> = path.split('/').filter { it.isNotEmpty() }.toTypedArray()
115 +
116 +
    override suspend fun tree(owner: String, name: String, ref: String, path: String): List<TreeEntry> =
117 +
        get(url("repos", owner, name, "tree", ref, *parts(path)), ListSerializer(TreeEntry.serializer()))
118 +
119 +
    override suspend fun file(owner: String, name: String, ref: String, path: String): String =
120 +
        call(request(url("repos", owner, name, "file", ref, *parts(path))).get().build(), notifyUnauthorized = true)
121 +
122 +
    override suspend fun history(owner: String, name: String, ref: String, path: String?, offset: Int, limit: Int): History =
123 +
        get(
124 +
            url("repos", owner, name, "history", ref) {
125 +
                path?.let { addQueryParameter("path", it) }
126 +
                addQueryParameter("offset", offset.toString())
127 +
                addQueryParameter("limit", limit.toString())
128 +
            },
129 +
            History.serializer(),
130 +
        )
131 +
132 +
    override suspend fun change(owner: String, name: String, hash: String): ChangeDetail =
133 +
        get(url("repos", owner, name, "changes", hash), ChangeDetail.serializer())
134 +
135 +
    override suspend fun refs(owner: String, name: String): Pair<List<String>, List<String>> {
136 +
        val bookmarks = get(url("repos", owner, name, "bookmarks"), ListSerializer(NamedRef.serializer())).map { it.name }
137 +
        val tags = get(url("repos", owner, name, "tags"), ListSerializer(NamedRef.serializer())).map { it.name }
138 +
        return bookmarks to tags
139 +
    }
94 140
95 141
    override suspend fun userProposals(landable: Boolean): UserProposals =
96 142
        get(url("user", "proposals") { if (landable) addQueryParameter("landable", "true") }, UserProposals.serializer())
…
110 unchanged lines
modified app/src/main/java/io/oxygenhub/android/api/Models.kt +64 −0
…
160 unchanged lines
161 161
    val subject: String get() = message.lineSequence().firstOrNull().orEmpty()
162 162
}
163 163
164 +
/** A file or directory of a repository's tree at some reference (the code browser, #3). */
165 +
@Serializable
166 +
data class TreeEntry(
167 +
    val name: String,
168 +
    val path: String,
169 +
    /** `file`, `dir`, `symlink` or `subrepo`. */
170 +
    val kind: String = "file",
171 +
    val exec: Boolean = false,
172 +
    val size: Long? = null,
173 +
    val conflicted: Boolean = false,
174 +
) {
175 +
    val isDir: Boolean get() = kind == "dir"
176 +
}
177 +
178 +
/** Who made a change, as the hub knows them. */
179 +
@Serializable
180 +
data class ChangeAuthor(val name: String = "", val email: String = "", val account: String? = null)
181 +
182 +
/** A change in a history or a proposal. */
183 +
@Serializable
184 +
data class ChangeSummary(
185 +
    val hash: String,
186 +
    val message: String = "",
187 +
    val author: ChangeAuthor = ChangeAuthor(),
188 +
    @SerialName("when_ms") val whenMs: Long = 0,
189 +
) {
190 +
    val subject: String get() = message.lineSequence().firstOrNull().orEmpty()
191 +
    val short: String get() = hash.take(12)
192 +
}
193 +
194 +
/** A page of a reference's history (`GET …/history/{r}`), newest first. */
195 +
@Serializable
196 +
data class History(
197 +
    val reference: String = "",
198 +
    val total: Int = 0,
199 +
    val offset: Int = 0,
200 +
    val changes: List<ChangeSummary> = emptyList(),
201 +
)
202 +
203 +
/** A file a change touched, and how. */
204 +
@Serializable
205 +
data class FileChange(
206 +
    val path: String,
207 +
    /** `added`, `modified`, `removed` or `renamed`. */
208 +
    val kind: String = "modified",
209 +
    val from: String? = null,
210 +
    val added: Int = 0,
211 +
    val removed: Int = 0,
212 +
)
213 +
214 +
/** One change, whole (`GET …/changes/{hash}`). */
215 +
@Serializable
216 +
data class ChangeDetail(
217 +
    val change: ChangeSummary,
218 +
    val body: String = "",
219 +
    @SerialName("in_bookmarks") val inBookmarks: List<String> = emptyList(),
220 +
    @SerialName("in_tags") val inTags: List<String> = emptyList(),
221 +
    val files: List<FileChange> = emptyList(),
222 +
)
223 +
224 +
/** A bookmark or tag to browse at. */
225 +
@Serializable
226 +
data class NamedRef(val name: String)
227 +
164 228
/** A reviewer's approval; it counts only when [sealed] (a seal over every change, by a key linked to them). */
165 229
@Serializable
166 230
data class Approval(val account: String, val sealed: Boolean = false)
…
87 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/nav/AppNavigation.kt +51 −7
…
16 unchanged lines
17 17
import androidx.compose.runtime.Composable
18 18
import androidx.compose.runtime.LaunchedEffect
19 19
import androidx.compose.runtime.getValue
20 +
import androidx.compose.runtime.rememberCoroutineScope
20 21
import androidx.compose.ui.graphics.vector.ImageVector
21 22
import androidx.lifecycle.ViewModel
22 23
import androidx.lifecycle.compose.collectAsStateWithLifecycle
…
12 unchanged lines
35 36
import io.oxygenhub.android.api.HubApi
36 37
import io.oxygenhub.android.auth.Session
37 38
import io.oxygenhub.android.ui.account.AccountScreen
39 +
import io.oxygenhub.android.ui.code.ChangeScreen
40 +
import io.oxygenhub.android.ui.code.ChangeViewModel
41 +
import io.oxygenhub.android.ui.code.CodeViewModel
42 +
import io.oxygenhub.android.ui.code.FileScreen
43 +
import io.oxygenhub.android.ui.code.FileViewModel
44 +
import io.oxygenhub.android.ui.code.HistoryScreen
45 +
import io.oxygenhub.android.ui.code.HistoryViewModel
38 46
import io.oxygenhub.android.ui.common.rememberAvatar
39 -
import androidx.compose.runtime.rememberCoroutineScope
40 -
import io.oxygenhub.android.ui.proposals.ProposalScreen
41 -
import io.oxygenhub.android.ui.proposals.ProposalViewModel
42 -
import io.oxygenhub.android.ui.proposals.ProposalsViewModel
43 -
import io.oxygenhub.android.ui.repos.RepositoryScreen
44 -
import kotlinx.coroutines.launch
45 47
import io.oxygenhub.android.ui.dashboard.DashboardScreen
46 48
import io.oxygenhub.android.ui.dashboard.DashboardViewModel
47 49
import io.oxygenhub.android.ui.inbox.InboxScreen
48 50
import io.oxygenhub.android.ui.inbox.InboxViewModel
49 51
import io.oxygenhub.android.ui.issues.IssuesViewModel
52 +
import io.oxygenhub.android.ui.proposals.ProposalScreen
53 +
import io.oxygenhub.android.ui.proposals.ProposalViewModel
54 +
import io.oxygenhub.android.ui.proposals.ProposalsViewModel
50 55
import io.oxygenhub.android.ui.repos.RepositoriesScreen
51 56
import io.oxygenhub.android.ui.repos.RepositoriesViewModel
57 +
import io.oxygenhub.android.ui.repos.RepositoryScreen
58 +
import kotlin.reflect.KClass
59 +
import kotlinx.coroutines.launch
52 60
import kotlinx.serialization.Serializable
53 -
import kotlin.reflect.KClass
54 61
55 62
// The app's routes. Top-level ones are in the navigation bar (or rail, or
56 63
// drawer, as the window allows); the rest are pushed over them. Browse
…
13 unchanged lines
70 77
71 78
/** One proposal, pushed over the repository it belongs to. */
72 79
@Serializable data class ProposalRoute(val owner: String, val name: String, val number: Long)
80 +
81 +
/** A file at a reference, a history (of a file when [path] is given), and a change (#3). */
82 +
@Serializable data class FileRoute(val owner: String, val name: String, val ref: String, val path: String)
83 +
84 +
@Serializable data class HistoryRoute(val owner: String, val name: String, val ref: String, val path: String? = null)
85 +
86 +
@Serializable data class ChangeRoute(val owner: String, val name: String, val hash: String)
73 87
74 88
enum class TopLevel(val label: String, val icon: ImageVector, val selectedIcon: ImageVector, val route: Any, val routeClass: KClass<*>) {
75 89
    Dashboard("Dashboard", Icons.Outlined.Dashboard, Icons.Filled.Dashboard, DashboardRoute, DashboardRoute::class),
…
68 unchanged lines
144 158
                RepositoriesScreen(rememberViewModel { RepositoriesViewModel(api, session.username) }, selectedRepository) { slug, onBack ->
145 159
                    val (owner, name) = slug.split('/', limit = 2)
146 160
                    RepositoryScreen(
161 +
                        rememberViewModel(key = "code:$slug") { CodeViewModel(api, owner, name) },
147 162
                        rememberViewModel(key = "issues:$slug") { IssuesViewModel(api, owner, name) },
148 163
                        rememberViewModel(key = "proposals:$slug") { ProposalsViewModel(api, owner, name) },
149 164
                        onBack,
150 165
                        onOpenProposal = { nav.navigate(ProposalRoute(owner, name, it.number)) },
166 +
                        onOpenFile = { ref, path -> nav.navigate(FileRoute(owner, name, ref, path)) },
167 +
                        onOpenHistory = { ref -> nav.navigate(HistoryRoute(owner, name, ref)) },
151 168
                    )
152 169
                }
153 170
            }
154 171
            composable<IssuesRoute> { e ->
155 172
                val r = e.toRoute<IssuesRoute>()
156 173
                RepositoryScreen(
174 +
                    rememberViewModel { CodeViewModel(api, r.owner, r.name) },
157 175
                    rememberViewModel { IssuesViewModel(api, r.owner, r.name) },
158 176
                    rememberViewModel { ProposalsViewModel(api, r.owner, r.name) },
159 177
                    onBack = { nav.popBackStack() },
160 178
                    onOpenProposal = { nav.navigate(ProposalRoute(r.owner, r.name, it.number)) },
179 +
                    onOpenFile = { ref, path -> nav.navigate(FileRoute(r.owner, r.name, ref, path)) },
180 +
                    onOpenHistory = { ref -> nav.navigate(HistoryRoute(r.owner, r.name, ref)) },
181 +
                )
182 +
            }
183 +
            composable<FileRoute> { e ->
184 +
                val r = e.toRoute<FileRoute>()
185 +
                FileScreen(
186 +
                    rememberViewModel { FileViewModel(api, r.owner, r.name, r.ref, r.path) },
187 +
                    onBack = { nav.popBackStack() },
188 +
                    onOpenHistory = { nav.navigate(HistoryRoute(r.owner, r.name, r.ref, r.path)) },
189 +
                )
190 +
            }
191 +
            composable<HistoryRoute> { e ->
192 +
                val r = e.toRoute<HistoryRoute>()
193 +
                HistoryScreen(
194 +
                    rememberViewModel { HistoryViewModel(api, r.owner, r.name, r.ref, r.path) },
195 +
                    onBack = { nav.popBackStack() },
196 +
                    onOpenChange = { nav.navigate(ChangeRoute(r.owner, r.name, it.hash)) },
197 +
                )
198 +
            }
199 +
            composable<ChangeRoute> { e ->
200 +
                val r = e.toRoute<ChangeRoute>()
201 +
                ChangeScreen(
202 +
                    rememberViewModel { ChangeViewModel(api, r.owner, r.name, r.hash) },
203 +
                    onBack = { nav.popBackStack() },
204 +
                    onOpenFile = { path -> nav.navigate(FileRoute(r.owner, r.name, r.hash, path)) },
161 205
                )
162 206
            }
163 207
            composable<ProposalRoute> { e ->
…
30 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/repos/RepositoryScreen.kt +9 −3
…
11 unchanged lines
12 12
import androidx.compose.runtime.setValue
13 13
import androidx.compose.ui.Modifier
14 14
import io.oxygenhub.android.api.Proposal
15 +
import io.oxygenhub.android.ui.code.CodeBrowser
16 +
import io.oxygenhub.android.ui.code.CodeViewModel
15 17
import io.oxygenhub.android.ui.common.AppScreen
16 18
import io.oxygenhub.android.ui.issues.IssueList
17 19
import io.oxygenhub.android.ui.issues.IssuesViewModel
18 20
import io.oxygenhub.android.ui.proposals.ProposalList
19 21
import io.oxygenhub.android.ui.proposals.ProposalsViewModel
20 22
21 -
/** A repository's tabs: its issues and its proposals (the overview, code and history follow, #3). */
22 -
enum class RepositoryTab(val label: String) { Issues("Issues"), Proposals("Proposals") }
23 +
/** A repository's tabs: its code, its issues and its proposals (#3). */
24 +
enum class RepositoryTab(val label: String) { Code("Code"), Issues("Issues"), Proposals("Proposals") }
23 25
24 26
@Composable
25 27
fun RepositoryScreen(
28 +
    code: CodeViewModel,
26 29
    issues: IssuesViewModel,
27 30
    proposals: ProposalsViewModel,
28 31
    onBack: (() -> Unit)?,
29 32
    onOpenProposal: (Proposal) -> Unit,
30 -
    initialTab: RepositoryTab = RepositoryTab.Issues,
33 +
    onOpenFile: (ref: String, path: String) -> Unit,
34 +
    onOpenHistory: (ref: String) -> Unit,
35 +
    initialTab: RepositoryTab = RepositoryTab.Code,
31 36
) {
32 37
    var tab by rememberSaveable { mutableIntStateOf(initialTab.ordinal) }
33 38
    AppScreen(title = "${issues.owner}/${issues.name}", onBack = onBack) {
…
4 unchanged lines
38 43
                }
39 44
            }
40 45
            when (RepositoryTab.entries[tab]) {
46 +
                RepositoryTab.Code -> CodeBrowser(code, onOpenFile, onOpenHistory)
41 47
                RepositoryTab.Issues -> IssueList(issues)
42 48
                RepositoryTab.Proposals -> ProposalList(proposals, onOpenProposal)
43 49
            }
…
3 unchanged lines
modified app/src/test/java/io/oxygenhub/android/FakeHubApi.kt +17 −0
…
58 unchanged lines
59 59
    }
60 60
    var runnerToken: () -> io.oxygenhub.android.api.RunnerToken = { io.oxygenhub.android.api.RunnerToken("flowreg_test", "andymccall") }
61 61
    override suspend fun runnerToken() = runnerToken.invoke().also { calls += "runner token" }
62 +
    var repository: io.oxygenhub.android.api.Repository = io.oxygenhub.android.api.Repository("andymccall", "demo", defaultBookmark = "main")
63 +
    override suspend fun repository(owner: String, name: String) = repository.also { calls += "repository" }
64 +
    var tree: (String, String) -> List<io.oxygenhub.android.api.TreeEntry> = { _, _ -> emptyList() }
65 +
    override suspend fun tree(owner: String, name: String, ref: String, path: String) =
66 +
        tree.invoke(ref, path).also { calls += "tree $ref:$path" }
67 +
    var files: Map<String, String> = emptyMap()
68 +
    override suspend fun file(owner: String, name: String, ref: String, path: String): String {
69 +
        calls += "file $ref:$path"
70 +
        return files[path] ?: throw io.oxygenhub.android.api.HubException.Http(404, "no such file")
71 +
    }
72 +
    var history: io.oxygenhub.android.api.History = io.oxygenhub.android.api.History()
73 +
    override suspend fun history(owner: String, name: String, ref: String, path: String?, offset: Int, limit: Int) =
74 +
        history.also { calls += "history $ref:${path.orEmpty()}" }
75 +
    var change: (String) -> io.oxygenhub.android.api.ChangeDetail = { error("no change") }
76 +
    override suspend fun change(owner: String, name: String, hash: String) = change.invoke(hash).also { calls += "change $hash" }
77 +
    var refs: Pair<List<String>, List<String>> = listOf("main") to emptyList()
78 +
    override suspend fun refs(owner: String, name: String) = refs.also { calls += "refs" }
62 79
    override suspend fun userProposals(landable: Boolean) =
63 80
        userProposals.invoke().also { calls += if (landable) "user proposals, landable" else "user proposals" }
64 81
    override suspend fun proposals(owner: String, name: String, state: ProposalState): List<Proposal> =
…
19 unchanged lines
modified gradle/libs.versions.toml +5 −0
…
14 unchanged lines
15 15
okhttp = "4.12.0"
16 16
jna = "5.17.0"
17 17
screenshot = "0.0.1-alpha16"
18 +
# The code browser (#3): Markdown rendered in Compose, and syntax highlighting.
19 +
markdownRenderer = "0.38.1"
20 +
highlights = "1.1.0"
18 21
19 22
[libraries]
23 +
markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" }
24 +
highlights = { group = "dev.snipme", name = "highlights", version.ref = "highlights" }
20 25
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
21 26
junit = { group = "junit", name = "junit", version.ref = "junit" }
22 27
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
…
32 unchanged lines