OXYGENHUB
# 0414607973cb oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / code / CodeViewModel.kt
4.3 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.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
}