From 8e573429f233477a70935fee07089b3c1f866ed9 Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Wed, 17 Jun 2026 16:22:08 -0700 Subject: [PATCH 1/4] fix(git): wire JvmGitRepository on desktop and keep OAuth code visible during polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs prevented git sync from being usable on desktop: 1. Main.kt omitted `gitRepository`, keeping `gitSyncService` and `gitConfigRepository` null, so `canShowGitSetup` was always false and the Git Setup wizard never appeared. 2. `OAuthDialogState.Polling` was a data object with no fields; transitioning to it hid the user code ~5 seconds into the OAuth flow, before most users could copy or enter it. Fixes: - Add `remember { JvmGitRepository() }` to `StelekitApp(...)` call in `Main.kt` so the Git Setup wizard opens on the first click. - Promote `OAuthDialogState.Polling` to a data class carrying `userCode`, `verificationUri`, and `expiresAt`. - Update `startOAuthFlow` to pass those values into `Polling`. - Update the `Polling` UI branch to show the code, countdown, copy/open buttons, and a demoted "Waiting…" spinner — identical to `ShowCode` but with a polling indicator. - Add a state-model test asserting `Polling` carries its fields. Co-Authored-By: Claude Sonnet 4.6 --- .../ui/screens/git/GitHubOAuthDialog.kt | 41 +++++++++++++++---- .../stelekit/ui/screens/git/GitSetupScreen.kt | 11 +++-- .../dev/stapler/stelekit/desktop/Main.kt | 3 ++ .../git/GitHubDeviceFlowClientTest.kt | 18 ++++++++ 4 files changed, 61 insertions(+), 12 deletions(-) diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitHubOAuthDialog.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitHubOAuthDialog.kt index 77e8796d..c1925f38 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitHubOAuthDialog.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitHubOAuthDialog.kt @@ -47,7 +47,11 @@ sealed class OAuthDialogState { val verificationUri: String, val expiresAt: Long, ) : OAuthDialogState() - data object Polling : OAuthDialogState() + data class Polling( + val userCode: String, + val verificationUri: String, + val expiresAt: Long, + ) : OAuthDialogState() data class Success(val username: String) : OAuthDialogState() data class Error(val message: String) : OAuthDialogState() } @@ -113,19 +117,38 @@ fun GitHubOAuthDialog( } is OAuthDialogState.Polling -> { + Text("Open github.com/login/device and enter this code:", style = MaterialTheme.typography.bodyMedium) + Text( + text = state.userCode, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary, + ) + CountdownText(expiresAt = state.expiresAt) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { onCopyCode(state.userCode) }, + modifier = Modifier.weight(1f), + ) { Text("Copy code") } + Button( + onClick = { onOpenBrowser(state.verificationUri) }, + modifier = Modifier.weight(1f), + ) { Text("Open GitHub") } + } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) - Text("Waiting for GitHub authorization…") + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Text( + "Waiting for authorization…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - Text( - "Once you approve in the browser, this screen will update automatically.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) } is OAuthDialogState.Success -> { diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitSetupScreen.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitSetupScreen.kt index ba1f8c33..aef26b9f 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitSetupScreen.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitSetupScreen.kt @@ -498,9 +498,14 @@ private suspend fun startOAuthFlow( deviceCode = response.deviceCode, expiresIn = response.expiresIn, initialInterval = response.interval, - onStateChange = { pollState -> - // Once user has opened browser (we're polling), transition to Polling state - onDialogStateChange(OAuthDialogState.Polling) + onStateChange = { _ -> + onDialogStateChange( + OAuthDialogState.Polling( + userCode = response.userCode, + verificationUri = response.verificationUri, + expiresAt = expiresAt, + ) + ) }, ) diff --git a/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/desktop/Main.kt b/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/desktop/Main.kt index aad3ec74..efcad440 100644 --- a/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/desktop/Main.kt +++ b/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/desktop/Main.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.rememberWindowState import dev.stapler.stelekit.domain.UrlFetcherJvm import dev.stapler.stelekit.service.JvmMediaAttachmentService +import dev.stapler.stelekit.git.JvmGitRepository import dev.stapler.stelekit.ui.StelekitApp import dev.stapler.stelekit.ui.theme.setSystemDarkTheme import dev.stapler.stelekit.platform.PlatformFileSystem @@ -119,6 +120,7 @@ fun main() { } val attachmentService = remember { JvmMediaAttachmentService() } + val gitRepository = remember { JvmGitRepository() } StelekitApp( fileSystem = fileSystem, graphPath = graphPath, @@ -126,6 +128,7 @@ fun main() { spanRecorder = spanRecorder, cryptoEngine = dev.stapler.stelekit.vault.JvmCryptoEngine(), attachmentService = attachmentService, + gitRepository = gitRepository, ) } } diff --git a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/git/GitHubDeviceFlowClientTest.kt b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/git/GitHubDeviceFlowClientTest.kt index e718a5d1..6028a469 100644 --- a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/git/GitHubDeviceFlowClientTest.kt +++ b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/git/GitHubDeviceFlowClientTest.kt @@ -21,6 +21,7 @@ import io.ktor.utils.io.ByteReadChannel import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json import java.io.IOException +import dev.stapler.stelekit.ui.screens.git.OAuthDialogState import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -283,4 +284,21 @@ class GitHubDeviceFlowClientTest { assertEquals(null, username) } + + // ------------------------------------------------------------------------- + // OAuthDialogState.Polling — state model + // ------------------------------------------------------------------------- + + @Test + fun `OAuthDialogState_Polling_carries_code_and_expiry_fields`() { + val state = OAuthDialogState.Polling( + userCode = "ABCD-1234", + verificationUri = "https://github.com/login/device", + expiresAt = 9_999_999L, + ) + assertIs(state) + assertEquals("ABCD-1234", state.userCode) + assertEquals("https://github.com/login/device", state.verificationUri) + assertEquals(9_999_999L, state.expiresAt) + } } From d472d13cb330e7ef82843503b891e0bf37380484 Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Wed, 17 Jun 2026 16:28:04 -0700 Subject: [PATCH 2/4] refactor(git): extract CodeDisplayContent composable to deduplicate ShowCode/Polling UI Co-Authored-By: Claude Sonnet 4.6 --- .../ui/screens/git/GitHubOAuthDialog.kt | 83 ++++++++++--------- 1 file changed, 43 insertions(+), 40 deletions(-) diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitHubOAuthDialog.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitHubOAuthDialog.kt index c1925f38..af0275a6 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitHubOAuthDialog.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitHubOAuthDialog.kt @@ -93,51 +93,23 @@ fun GitHubOAuthDialog( } is OAuthDialogState.ShowCode -> { - Text("Open github.com/login/device and enter this code:", style = MaterialTheme.typography.bodyMedium) - Text( - text = state.userCode, - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.primary, + CodeDisplayContent( + userCode = state.userCode, + verificationUri = state.verificationUri, + expiresAt = state.expiresAt, + onCopyCode = onCopyCode, + onOpenBrowser = onOpenBrowser, ) - CountdownText(expiresAt = state.expiresAt) - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - OutlinedButton( - onClick = { onCopyCode(state.userCode) }, - modifier = Modifier.weight(1f), - ) { Text("Copy code") } - Button( - onClick = { onOpenBrowser(state.verificationUri) }, - modifier = Modifier.weight(1f), - ) { Text("Open GitHub") } - } } is OAuthDialogState.Polling -> { - Text("Open github.com/login/device and enter this code:", style = MaterialTheme.typography.bodyMedium) - Text( - text = state.userCode, - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.primary, + CodeDisplayContent( + userCode = state.userCode, + verificationUri = state.verificationUri, + expiresAt = state.expiresAt, + onCopyCode = onCopyCode, + onOpenBrowser = onOpenBrowser, ) - CountdownText(expiresAt = state.expiresAt) - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - OutlinedButton( - onClick = { onCopyCode(state.userCode) }, - modifier = Modifier.weight(1f), - ) { Text("Copy code") } - Button( - onClick = { onOpenBrowser(state.verificationUri) }, - modifier = Modifier.weight(1f), - ) { Text("Open GitHub") } - } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -206,6 +178,37 @@ fun GitHubOAuthDialog( ) } +@Composable +private fun CodeDisplayContent( + userCode: String, + verificationUri: String, + expiresAt: Long, + onCopyCode: (String) -> Unit, + onOpenBrowser: (String) -> Unit, +) { + Text("Open github.com/login/device and enter this code:", style = MaterialTheme.typography.bodyMedium) + Text( + text = userCode, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary, + ) + CountdownText(expiresAt = expiresAt) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { onCopyCode(userCode) }, + modifier = Modifier.weight(1f), + ) { Text("Copy code") } + Button( + onClick = { onOpenBrowser(verificationUri) }, + modifier = Modifier.weight(1f), + ) { Text("Open GitHub") } + } +} + @Composable private fun CountdownText(expiresAt: Long) { var secondsLeft by remember { mutableLongStateOf(maxOf(0L, (expiresAt - Clock.System.now().toEpochMilliseconds()) / 1000L)) } From b241a17fbe4caa5cb3aefb7de7e777366bd9cb2f Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Wed, 17 Jun 2026 16:30:30 -0700 Subject: [PATCH 3/4] test(git): add missing coverage for authorization_pending and 5xx retry poll paths Co-Authored-By: Claude Sonnet 4.6 --- .../git/GitHubDeviceFlowClientTest.kt | 64 ++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/git/GitHubDeviceFlowClientTest.kt b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/git/GitHubDeviceFlowClientTest.kt index 6028a469..c0963d73 100644 --- a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/git/GitHubDeviceFlowClientTest.kt +++ b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/git/GitHubDeviceFlowClientTest.kt @@ -7,6 +7,7 @@ import arrow.core.Either import dev.stapler.stelekit.error.DomainError import dev.stapler.stelekit.git.model.DeviceCodeResponse import dev.stapler.stelekit.git.model.DeviceFlowPollState +import dev.stapler.stelekit.ui.screens.git.OAuthDialogState import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond @@ -18,10 +19,9 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json import io.ktor.utils.io.ByteReadChannel +import java.io.IOException import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json -import java.io.IOException -import dev.stapler.stelekit.ui.screens.git.OAuthDialogState import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -121,6 +121,66 @@ class GitHubDeviceFlowClientTest { assertTrue(states.isEmpty()) } + @Test + fun `pollForToken_continuesPolling_onAuthorizationPending`() = runTest { + var callCount = 0 + val engine = MockEngine { _ -> + callCount++ + val body = when (callCount) { + 1 -> """{"error":"authorization_pending"}""" + else -> """{"access_token":"gho_pending123","token_type":"bearer","scope":"repo"}""" + } + respond(content = ByteReadChannel(body), status = HttpStatusCode.OK, headers = jsonHeaders()) + } + val client = GitHubDeviceFlowClient(buildClient(engine)) + val states = mutableListOf() + + val result = client.pollForToken( + deviceCode = "dev123", + expiresIn = 300, + initialInterval = 0, + onStateChange = { states.add(it) }, + ) + + assertIs>(result) + assertEquals("gho_pending123", result.value) + assertEquals(1, states.size) + assertIs(states.first()) + assertEquals(2, callCount) + } + + @Test + fun `pollForToken_retriesAndContinues_on5xxServerError`() = runTest { + var callCount = 0 + val engine = MockEngine { _ -> + callCount++ + if (callCount == 1) { + respond(content = ByteReadChannel(""), status = HttpStatusCode.InternalServerError, headers = jsonHeaders()) + } else { + respond( + content = ByteReadChannel("""{"access_token":"gho_srv","token_type":"bearer","scope":"repo"}"""), + status = HttpStatusCode.OK, + headers = jsonHeaders(), + ) + } + } + val client = GitHubDeviceFlowClient(buildClient(engine)) + val states = mutableListOf() + + val result = client.pollForToken( + deviceCode = "dev123", + expiresIn = 300, + initialInterval = 0, + onStateChange = { states.add(it) }, + ) + + assertIs>(result) + assertEquals("gho_srv", result.value) + assertEquals(1, states.size) + assertIs(states.first()) + assertEquals(2, callCount) + } + @Test fun `pollForToken_incrementsInterval_cumulativelyOnSlowDown`() = runTest { // Returns slow_down twice then a token. Verify onStateChange receives Pending From 9f692d6ec682f0b2df296f36575f86a4dae2e268 Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Wed, 17 Jun 2026 17:25:00 -0700 Subject: [PATCH 4/4] fix(git): wrap CodeDisplayContent in Column to satisfy Detekt MultipleEmitters rule Co-Authored-By: Claude Sonnet 4.6 --- .../ui/screens/git/GitHubOAuthDialog.kt | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitHubOAuthDialog.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitHubOAuthDialog.kt index af0275a6..97c12d0a 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitHubOAuthDialog.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitHubOAuthDialog.kt @@ -186,26 +186,28 @@ private fun CodeDisplayContent( onCopyCode: (String) -> Unit, onOpenBrowser: (String) -> Unit, ) { - Text("Open github.com/login/device and enter this code:", style = MaterialTheme.typography.bodyMedium) - Text( - text = userCode, - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.primary, - ) - CountdownText(expiresAt = expiresAt) - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - OutlinedButton( - onClick = { onCopyCode(userCode) }, - modifier = Modifier.weight(1f), - ) { Text("Copy code") } - Button( - onClick = { onOpenBrowser(verificationUri) }, - modifier = Modifier.weight(1f), - ) { Text("Open GitHub") } + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Open github.com/login/device and enter this code:", style = MaterialTheme.typography.bodyMedium) + Text( + text = userCode, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary, + ) + CountdownText(expiresAt = expiresAt) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { onCopyCode(userCode) }, + modifier = Modifier.weight(1f), + ) { Text("Copy code") } + Button( + onClick = { onOpenBrowser(verificationUri) }, + modifier = Modifier.weight(1f), + ) { Text("Open GitHub") } + } } }