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 |