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