| 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 |
} |