OXYGENHUB

A proposal's files and their diffs: Files changed from its Changes section, the change screen's diff list shared (refs #4) 40ab4df121e0

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 5ef1fb74a204
When
· 2026-10-27 21:56 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
40ab4df121e0710d2490326666952cd953fb6b9bcce7777f63cef106d34c382c
Depends on
0414607973cb 144cbf7bf2b5 57c0824300db 5ae4af48ddd2 98dbd1220929 aa21eb5e0e5b db00632a61d6 db7727e5de19 e2df27a81e5d e833eb0a9e7f
Depended on by
840c75813f86 af9f2e514c72
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
21 atoms, 2 tree operations, 8 files

Browse the tree at this change

added app/src/main/java/io/oxygenhub/android/ui/proposals/ProposalFiles.kt +67 −0
1 +
package io.oxygenhub.android.ui.proposals
2 +
3 +
import androidx.compose.foundation.lazy.LazyColumn
4 +
import androidx.compose.material.icons.Icons
5 +
import androidx.compose.material.icons.outlined.Description
6 +
import androidx.compose.runtime.Composable
7 +
import androidx.compose.runtime.getValue
8 +
import androidx.compose.runtime.mutableStateOf
9 +
import androidx.compose.runtime.saveable.rememberSaveable
10 +
import androidx.compose.runtime.setValue
11 +
import androidx.lifecycle.ViewModel
12 +
import androidx.lifecycle.compose.collectAsStateWithLifecycle
13 +
import io.oxygenhub.android.api.FileChange
14 +
import io.oxygenhub.android.api.HubApi
15 +
import io.oxygenhub.android.ui.code.fileDiffs
16 +
import io.oxygenhub.android.ui.code.openByDefault
17 +
import io.oxygenhub.android.ui.common.AppScreen
18 +
import io.oxygenhub.android.ui.common.EmptyState
19 +
import io.oxygenhub.android.ui.common.Load
20 +
import io.oxygenhub.android.ui.common.LoadContent
21 +
import io.oxygenhub.android.ui.common.load
22 +
import io.oxygenhub.android.ui.theme.LocalStatusColors
23 +
import kotlinx.coroutines.Job
24 +
import kotlinx.coroutines.flow.MutableStateFlow
25 +
import kotlinx.coroutines.flow.StateFlow
26 +
import kotlinx.coroutines.flow.asStateFlow
27 +
28 +
/** The files a proposal changes, each with its diff (#4). */
29 +
class ProposalFilesViewModel(private val api: HubApi, val owner: String, val name: String, val number: Long) : ViewModel() {
30 +
    private val _state = MutableStateFlow<Load<List<FileChange>>>(Load.Loading)
31 +
    val state: StateFlow<Load<List<FileChange>>> = _state.asStateFlow()
32 +
    private var job: Job? = null
33 +
34 +
    init {
35 +
        refresh()
36 +
    }
37 +
38 +
    fun refresh() {
39 +
        job = load(_state, job) { api.proposalDiff(owner, name, number) }
40 +
    }
41 +
}
42 +
43 +
/** A proposal's files, as its page on the hub shows them: each folds open to its diff. */
44 +
@Composable
45 +
fun ProposalFilesScreen(vm: ProposalFilesViewModel, onBack: () -> Unit) {
46 +
    val state by vm.state.collectAsStateWithLifecycle()
47 +
    val status = LocalStatusColors.current
48 +
    AppScreen(title = "Files · #${vm.number}", subtitle = "${vm.owner}/${vm.name}", onBack = onBack) {
49 +
        LoadContent(state, onRefresh = vm::refresh) { files ->
50 +
            if (files.isEmpty()) {
51 +
                EmptyState(Icons.Outlined.Description, "No files changed", "This proposal's changes are already on its target, or change nothing.")
52 +
                return@LoadContent
53 +
            }
54 +
            var open by rememberSaveable(vm.number) { mutableStateOf(openByDefault(files).toList()) }
55 +
            LazyColumn {
56 +
                fileDiffs(
57 +
                    files,
58 +
                    open,
59 +
                    status.ok,
60 +
                    status.bad,
61 +
                    onToggle = { path -> open = if (path in open) open - path else open + path },
62 +
                    onOpen = { null },
63 +
                )
64 +
            }
65 +
        }
66 +
    }
67 +
}
added app/src/test/java/io/oxygenhub/android/ui/ProposalFilesTest.kt +36 −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.DiffLine
6 +
import io.oxygenhub.android.api.FileChange
7 +
import io.oxygenhub.android.api.HubException
8 +
import io.oxygenhub.android.ui.common.Load
9 +
import io.oxygenhub.android.ui.proposals.ProposalFilesViewModel
10 +
import org.junit.Assert.assertEquals
11 +
import org.junit.Assert.assertTrue
12 +
import org.junit.Rule
13 +
import org.junit.Test
14 +
15 +
class ProposalFilesTest {
16 +
    @get:Rule val main = MainDispatcherRule()
17 +
18 +
    private val api = FakeHubApi()
19 +
20 +
    @Test
21 +
    fun aProposalsFilesComeWithTheirDiffs() {
22 +
        api.proposalDiff = {
23 +
            listOf(FileChange(path = "a.txt", kind = "modified", added = 1, lines = listOf(DiffLine("context", 1, 1, "a"), DiffLine("add", null, 2, "A"))))
24 +
        }
25 +
        val files = (ProposalFilesViewModel(api, "andy", "demo", 2).state.value as Load.Ready).value
26 +
        assertEquals(listOf("a.txt"), files.map { it.path })
27 +
        assertEquals(listOf("context", "add"), files[0].lines.map { it.kind })
28 +
        assertTrue("proposalDiff" in api.calls)
29 +
    }
30 +
31 +
    @Test
32 +
    fun aHubWithoutTheDiffSaysSo() {
33 +
        api.proposalDiff = { throw HubException.Http(404, "no such route") }
34 +
        assertTrue(ProposalFilesViewModel(api, "andy", "demo", 2).state.value is Load.Failed)
35 +
    }
36 +
}
modified CHANGELOG.md +1 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **A proposal's files and their diffs (#4):** a proposal's Changes section opens Files changed: each file it changes, folding open to its diff (old and new line numbers, added and removed lines tinted), as its page on the hub shows them, from the hub's `GET …/proposals/N/diff` (OxygenHub 0.9.10). The change screen and this one share the same diff list.
5 6
- **Clone a repository from the phone (#3):** the repository screen's Clone action shows its `oxy clone` command, selectable, with a button that copies it.
6 7
7 8
## 0.1.0 (2026-10-27)
…
18 unchanged lines
modified app/src/main/java/io/oxygenhub/android/api/HubClient.kt +6 −0
…
128 unchanged lines
129 129
130 130
    suspend fun change(owner: String, name: String, hash: String): ChangeDetail
131 131
132 +
    /** The files a proposal changes, each with its diff, as the proposal page shows them (#4). */
133 +
    suspend fun proposalDiff(owner: String, name: String, number: Long): List<FileChange>
134 +
132 135
    /** The repository's bookmarks and tags, to browse at. */
133 136
    suspend fun refs(owner: String, name: String): Pair<List<String>, List<String>>
134 137
…
122 unchanged lines
257 260
            },
258 261
            History.serializer(),
259 262
        )
263 +
264 +
    override suspend fun proposalDiff(owner: String, name: String, number: Long): List<FileChange> =
265 +
        get(url("repos", owner, name, "proposals", number.toString(), "diff"), ListSerializer(FileChange.serializer()))
260 266
261 267
    override suspend fun change(owner: String, name: String, hash: String): ChangeDetail =
262 268
        get(url("repos", owner, name, "changes", hash), ChangeDetail.serializer())
…
135 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/code/CodeScreens.kt +42 −27
…
11 unchanged lines
12 12
import androidx.compose.foundation.layout.padding
13 13
import androidx.compose.foundation.layout.width
14 14
import androidx.compose.foundation.lazy.LazyColumn
15 +
import androidx.compose.foundation.lazy.LazyListScope
15 16
import androidx.compose.foundation.lazy.itemsIndexed
16 17
import androidx.compose.foundation.lazy.items
17 18
import androidx.compose.foundation.rememberScrollState
…
148 unchanged lines
166 167
                    }
167 168
                    HorizontalDivider()
168 169
                }
169 -
                d.files.forEach { f ->
170 -
                    val expanded = f.path in open
171 -
                    item(key = f.path) {
172 -
                        FileRow(
173 -
                            f,
174 -
                            expanded,
175 -
                            status.ok,
176 -
                            status.bad,
177 -
                            onToggle = { open = if (expanded) open - f.path else open + f.path },
178 -
                            onOpen = if (f.kind != "removed") ({ onOpenFile(f.path) }) else null,
179 -
                        )
180 -
                    }
181 -
                    if (expanded) {
182 -
                        val numbers = f.lines.maxOfOrNull { maxOf(it.old ?: 0, it.new ?: 0) }?.toString()?.length ?: 1
183 -
                        itemsIndexed(f.lines, key = { i, _ -> "${f.path}\u0000$i" }) { _, l -> DiffRow(l, numbers, status.ok, status.bad) }
184 -
                        if (f.lines.isEmpty() || f.truncated) {
185 -
                            item(key = "${f.path}\u0000end") {
186 -
                                Text(
187 -
                                    if (f.truncated) "The diff goes on; open the file to see it whole." else "No lines changed (${f.kind}).",
188 -
                                    modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
189 -
                                    style = MaterialTheme.typography.bodySmall,
190 -
                                    color = MaterialTheme.colorScheme.onSurfaceVariant,
191 -
                                )
192 -
                            }
193 -
                        }
194 -
                        item(key = "${f.path}\u0000rule") { HorizontalDivider() }
195 -
                    }
170 +
                fileDiffs(
171 +
                    d.files,
172 +
                    open,
173 +
                    status.ok,
174 +
                    status.bad,
175 +
                    onToggle = { path -> open = if (path in open) open - path else open + path },
176 +
                    onOpen = { f -> if (f.kind != "removed") ({ onOpenFile(f.path) }) else null },
177 +
                )
178 +
            }
179 +
        }
180 +
    }
181 +
}
182 +
183 +
/**
184 +
 * Files with their diffs, each folding open on a tap (a change's, or a
185 +
 * proposal's, #4): [open] holds the paths shown open; [onOpen] gives a
186 +
 * file's way to open it, if it has one.
187 +
 */
188 +
fun LazyListScope.fileDiffs(
189 +
    files: List<FileChange>,
190 +
    open: List<String>,
191 +
    added: Color,
192 +
    removed: Color,
193 +
    onToggle: (String) -> Unit,
194 +
    onOpen: (FileChange) -> (() -> Unit)?,
195 +
) {
196 +
    files.forEach { f ->
197 +
        val expanded = f.path in open
198 +
        item(key = f.path) { FileRow(f, expanded, added, removed, onToggle = { onToggle(f.path) }, onOpen = onOpen(f)) }
199 +
        if (expanded) {
200 +
            val numbers = f.lines.maxOfOrNull { maxOf(it.old ?: 0, it.new ?: 0) }?.toString()?.length ?: 1
201 +
            itemsIndexed(f.lines, key = { i, _ -> "${f.path}\u0000$i" }) { _, l -> DiffRow(l, numbers, added, removed) }
202 +
            if (f.lines.isEmpty() || f.truncated) {
203 +
                item(key = "${f.path}\u0000end") {
204 +
                    Text(
205 +
                        if (f.truncated) "The diff goes on; open the file to see it whole." else "No lines changed (${f.kind}).",
206 +
                        modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
207 +
                        style = MaterialTheme.typography.bodySmall,
208 +
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
209 +
                    )
196 210
                }
197 211
            }
212 +
            item(key = "${f.path}\u0000rule") { HorizontalDivider() }
198 213
        }
199 214
    }
200 215
}
…
64 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/nav/AppNavigation.kt +10 −0
…
66 unchanged lines
67 67
import io.oxygenhub.android.ui.projects.BoardViewModel
68 68
import io.oxygenhub.android.ui.projects.ProjectsScreen
69 69
import io.oxygenhub.android.ui.projects.ProjectsViewModel
70 +
import io.oxygenhub.android.ui.proposals.ProposalFilesScreen
71 +
import io.oxygenhub.android.ui.proposals.ProposalFilesViewModel
70 72
import io.oxygenhub.android.ui.proposals.ProposalScreen
71 73
import io.oxygenhub.android.ui.proposals.ProposalViewModel
72 74
import io.oxygenhub.android.ui.proposals.ProposalsViewModel
…
28 unchanged lines
101 103
/** One proposal, pushed over the repository it belongs to. */
102 104
@Serializable data class ProposalRoute(val owner: String, val name: String, val number: Long)
103 105
106 +
/** A proposal's files with their diffs (#4). */
107 +
@Serializable data class ProposalFilesRoute(val owner: String, val name: String, val number: Long)
108 +
104 109
/** A file at a reference, a history (of a file when [path] is given), and a change (#3). */
105 110
@Serializable data class FileRoute(val owner: String, val name: String, val ref: String, val path: String)
106 111
…
208 unchanged lines
315 320
                    onBack = { nav.popBackStack() },
316 321
                    // Signing out revokes this token; the new sign-in asks for repo:write.
317 322
                    onSignInAgain = { scope.launch { onSignOut() } },
323 +
                    onOpenFiles = { nav.navigate(ProposalFilesRoute(r.owner, r.name, r.number)) },
318 324
                )
325 +
            }
326 +
            composable<ProposalFilesRoute> { e ->
327 +
                val r = e.toRoute<ProposalFilesRoute>()
328 +
                ProposalFilesScreen(rememberViewModel { ProposalFilesViewModel(api, r.owner, r.name, r.number) }, onBack = { nav.popBackStack() })
319 329
            }
320 330
            composable<InboxRoute> { InboxScreen(inbox, onOpen = openLink) }
321 331
            composable<AccountRoute> { AccountScreen(session, rememberAvatar(api, session.username), api, nativeCore, onSignOut) }
…
18 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/proposals/ProposalScreen.kt +12 −3
…
14 unchanged lines
15 15
import androidx.compose.foundation.rememberScrollState
16 16
import androidx.compose.foundation.verticalScroll
17 17
import androidx.compose.material.icons.Icons
18 +
import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight
18 19
import androidx.compose.material.icons.automirrored.outlined.OpenInNew
19 20
import androidx.compose.material.icons.outlined.Cancel
20 21
import androidx.compose.material.icons.outlined.CheckCircle
…
44 unchanged lines
65 66
 * its checks, approvals, changes and description.
66 67
 */
67 68
@Composable
68 -
fun ProposalScreen(vm: ProposalViewModel, canWrite: Boolean, onBack: () -> Unit, onSignInAgain: () -> Unit) {
69 +
fun ProposalScreen(vm: ProposalViewModel, canWrite: Boolean, onBack: () -> Unit, onSignInAgain: () -> Unit, onOpenFiles: () -> Unit = {}) {
69 70
    val state by vm.state.collectAsStateWithLifecycle()
70 71
    val action by vm.action.collectAsStateWithLifecycle()
71 72
    val snackbar = remember { SnackbarHostState() }
…
29 unchanged lines
101 102
                    Landing(page.proposal, action, canWrite, vm::land, vm::setAutoLand, onSignInAgain, vm::dismiss)
102 103
                    Checks(page.checks) { if (it.url.isNotBlank()) uri.openUri(it.url) }
103 104
                    Reviews(page.proposal)
104 -
                    Changes(page.proposal)
105 +
                    Changes(page.proposal, onOpenFiles)
105 106
                    if (page.proposal.body.isNotBlank()) {
106 107
                        Section("Description") {
107 108
                            Text(page.proposal.body, modifier = Modifier.padding(16.dp), style = MaterialTheme.typography.bodyMedium)
…
181 unchanged lines
289 290
}
290 291
291 292
@Composable
292 -
private fun Changes(p: Proposal) {
293 +
private fun Changes(p: Proposal, onOpenFiles: () -> Unit) {
293 294
    if (p.changes.isEmpty()) return
294 295
    Section("Changes · ${p.changes.size}") {
295 296
        Column {
297 +
            // Its files and their diffs, as the proposal page shows them (#4).
298 +
            ListItem(
299 +
                modifier = Modifier.clickable(onClick = onOpenFiles),
300 +
                headlineContent = { Text("Files changed") },
301 +
                supportingContent = { Text("Each file with its diff") },
302 +
                trailingContent = { Icon(Icons.AutoMirrored.Outlined.KeyboardArrowRight, contentDescription = null) },
303 +
            )
304 +
            HorizontalDivider()
296 305
            p.changes.asReversed().forEachIndexed { i, c ->
297 306
                if (i > 0) HorizontalDivider()
298 307
                ListItem(
…
15 unchanged lines
modified app/src/test/java/io/oxygenhub/android/FakeHubApi.kt +4 −0
1 1
package io.oxygenhub.android
2 2
3 +
import io.oxygenhub.android.api.FileChange
3 4
import io.oxygenhub.android.api.Avatar
4 5
import io.oxygenhub.android.api.HubException
5 6
import io.oxygenhub.android.api.Check
…
45 unchanged lines
51 52
        issues.invoke(state).also { calls += "issues $owner/$name ${state.query}" }
52 53
    override suspend fun inbox(limit: Int): Inbox = inbox.invoke().also { calls += "inbox" }
53 54
    override suspend fun registerPush(fcmToken: String, publicKey: String): Boolean = true.also { calls += "push" }
55 +
    var proposalDiff: () -> List<FileChange> = { emptyList() }
56 +
    override suspend fun proposalDiff(owner: String, name: String, number: Long): List<FileChange> =
57 +
        proposalDiff.invoke().also { calls += "proposalDiff" }
54 58
    override suspend fun markRead(id: Long) {
55 59
        calls += "read $id"
56 60
        markRead.invoke(id)
…
114 unchanged lines