OXYGENHUB

Clone from the phone: the repository screen's Clone action shows the oxy clone command with a copy button (refs #3) db7727e5de19

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key a175be3024ed
When
· 2026-10-27 21:27 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
db7727e5de1928ae5356df4b2b7f9200b5935237b1d00f3268588278eaddfb60
Depends on
144cbf7bf2b5 57c0824300db 62429abe5869 98dbd1220929 bfa2eff05d35 e2df27a81e5d
Depended on by
6e2af63ae616 40ab4df121e0
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
11 atoms, 3 tree operations, 5 files

Browse the tree at this change

added app/src/main/java/io/oxygenhub/android/ui/repos/CloneDialog.kt +72 −0
1 +
package io.oxygenhub.android.ui.repos
2 +
3 +
import android.content.ClipData
4 +
import androidx.compose.foundation.layout.Column
5 +
import androidx.compose.foundation.layout.Arrangement
6 +
import androidx.compose.foundation.text.selection.SelectionContainer
7 +
import androidx.compose.material3.AlertDialog
8 +
import androidx.compose.material3.MaterialTheme
9 +
import androidx.compose.material3.Text
10 +
import androidx.compose.material3.TextButton
11 +
import androidx.compose.runtime.Composable
12 +
import androidx.compose.runtime.getValue
13 +
import androidx.compose.runtime.mutableStateOf
14 +
import androidx.compose.runtime.produceState
15 +
import androidx.compose.runtime.remember
16 +
import androidx.compose.runtime.rememberCoroutineScope
17 +
import androidx.compose.runtime.setValue
18 +
import androidx.compose.ui.platform.ClipEntry
19 +
import androidx.compose.ui.platform.LocalClipboard
20 +
import androidx.compose.ui.text.font.FontFamily
21 +
import androidx.compose.ui.unit.dp
22 +
import io.oxygenhub.android.api.userMessage
23 +
import kotlinx.coroutines.launch
24 +
25 +
/** The command that clones a repository at [url] with oxy. */
26 +
fun cloneCommand(url: String): String = "oxy clone $url"
27 +
28 +
/**
29 +
 * How to clone the repository (#3): its `oxy clone` command, selectable,
30 +
 * and a button that copies it. [cloneUrl] fetches the address.
31 +
 */
32 +
@Composable
33 +
fun CloneDialog(cloneUrl: suspend () -> String, onDismiss: () -> Unit) {
34 +
    val url by produceState<Result<String>?>(null) { value = runCatching { cloneUrl() } }
35 +
    val clipboard = LocalClipboard.current
36 +
    val scope = rememberCoroutineScope()
37 +
    var copied by remember { mutableStateOf(false) }
38 +
    val command = url?.getOrNull()?.takeIf { it.isNotEmpty() }?.let(::cloneCommand)
39 +
    AlertDialog(
40 +
        onDismissRequest = onDismiss,
41 +
        title = { Text("Clone") },
42 +
        text = {
43 +
            Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
44 +
                when {
45 +
                    url == null -> Text("Finding its address…")
46 +
                    command == null -> Text(url?.exceptionOrNull()?.userMessage() ?: "The hub gave no clone address.")
47 +
                    else -> {
48 +
                        SelectionContainer { Text(command, fontFamily = FontFamily.Monospace) }
49 +
                        Text(
50 +
                            "In a terminal with oxy installed. A private repository asks for your sign-in.",
51 +
                            style = MaterialTheme.typography.bodySmall,
52 +
                            color = MaterialTheme.colorScheme.onSurfaceVariant,
53 +
                        )
54 +
                    }
55 +
                }
56 +
            }
57 +
        },
58 +
        confirmButton = {
59 +
            TextButton(
60 +
                enabled = command != null,
61 +
                onClick = {
62 +
                    val text = command ?: return@TextButton
63 +
                    scope.launch {
64 +
                        clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("oxy clone", text)))
65 +
                        copied = true
66 +
                    }
67 +
                },
68 +
            ) { Text(if (copied) "Copied" else "Copy") }
69 +
        },
70 +
        dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } },
71 +
    )
72 +
}
added app/src/test/java/io/oxygenhub/android/ui/repos/CloneDialogTest.kt +11 −0
1 +
package io.oxygenhub.android.ui.repos
2 +
3 +
import org.junit.Assert.assertEquals
4 +
import org.junit.Test
5 +
6 +
class CloneDialogTest {
7 +
    @Test
8 +
    fun theCommandClonesTheHubsAddress() {
9 +
        assertEquals("oxy clone https://oxygenhub.io/andymccall/oxygen", cloneCommand("https://oxygenhub.io/andymccall/oxygen"))
10 +
    }
11 +
}
modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **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 +
5 7
## 0.1.0 (2026-10-27)
6 8
7 9
- **Releases built by OxygenFlow (#7):** the app's version lives in `gradle.properties` (`oxygenhub.version`; the version code follows from it), and when it changes the pipeline's `release` job builds a release APK signed with the app's own key (a PKCS12 keystore and its password, the repository's `android-keystore` and `android-keystore-password` secrets, mounted as files), checks it with apksigner and keeps it with its `SHA256SUMS` for a release on oxygenhub.io. `scripts/release.sh` does the same locally with the key's files. The key's SHA-256 certificate fingerprint is `B0:99:57:36:44:42:AF:B2:27:28:B7:EF:93:F8:23:2D:F1:41:17:DA:50:28:6D:45:93:04:0F:9C:9F:A2:2A:48`; a release-signed app replaces a debug build only after uninstalling it.
…
14 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/code/CodeViewModel.kt +6 −1
…
72 unchanged lines
73 73
        refsJob = load(_refs, refsJob) { api.refs(owner, name) }
74 74
    }
75 75
76 +
    private var cloneUrl: String? = null
77 +
78 +
    /** The repository's clone address (#3), fetched once. */
79 +
    suspend fun cloneUrl(): String = cloneUrl ?: api.repository(owner, name).cloneUrl.also { cloneUrl = it }
80 +
76 81
    fun refresh() {
77 82
        val path = _path.value
78 83
        job = load(_state, job) {
79 -
            val ref = _ref.value.ifEmpty { api.repository(owner, name).defaultBookmark.also { _ref.value = it } }
84 +
            val ref = _ref.value.ifEmpty { api.repository(owner, name).also { cloneUrl = it.cloneUrl }.defaultBookmark.also { _ref.value = it } }
80 85
            val entries = order(api.tree(owner, name, ref, path))
81 86
            val readme = readmeOf(entries)?.let { e ->
82 87
                // A README that will not load is left out, not an error.
…
43 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/repos/RepositoryScreen.kt +7 −0
…
8 unchanged lines
9 9
import androidx.compose.material3.Icon
10 10
import androidx.compose.material.icons.Icons
11 11
import androidx.compose.material.icons.outlined.AccountCircle
12 +
import androidx.compose.material.icons.outlined.ContentCopy
12 13
import androidx.compose.runtime.Composable
13 14
import androidx.compose.runtime.getValue
14 15
import androidx.compose.runtime.mutableIntStateOf
16 +
import androidx.compose.runtime.mutableStateOf
15 17
import androidx.compose.runtime.saveable.rememberSaveable
16 18
import androidx.compose.runtime.setValue
17 19
import androidx.compose.ui.Modifier
…
29 unchanged lines
47 49
    initialTab: RepositoryTab = RepositoryTab.Code,
48 50
) {
49 51
    var tab by rememberSaveable { mutableIntStateOf(initialTab.ordinal) }
52 +
    var cloning by rememberSaveable { mutableStateOf(false) }
50 53
    AppScreen(
51 54
        title = "${issues.owner}/${issues.name}",
52 55
        onBack = onBack,
53 56
        actions = {
57 +
            IconButton(onClick = { cloning = true }) { Icon(Icons.Outlined.ContentCopy, contentDescription = "Clone") }
54 58
            onOpenOwner?.let { open ->
55 59
                IconButton(onClick = open) { Icon(Icons.Outlined.AccountCircle, contentDescription = "${issues.owner}'s page") }
56 60
            }
…
13 unchanged lines
70 74
            }
71 75
        }
72 76
    }
77 +
    if (cloning) {
78 +
        CloneDialog(cloneUrl = code::cloneUrl, onDismiss = { cloning = false })
79 +
    }
73 80
}