From 565028f64a0d9b67f83b9dd6d8da679b803c8755 Mon Sep 17 00:00:00 2001 From: Joe Tretter Date: Thu, 7 May 2026 09:57:50 -0500 Subject: [PATCH] Fix help callout anchoring and Push remote setup - Anchor startup help callouts inside the scrollable pane workspace so they scroll with the exercise and terminal content they point at. - Rebuild the Push level remote as a bare sibling repo with a temporary worktree for the remote-only commit, avoiding Android non-bare remote push failures. - Leave a visible local worktree edit in Push while enabling rebase autostash so status, diff, and pull --rebase all behave as expected. - Add native Push setup coverage for diverged status output, plain diff output, and clean pull --rebase behavior. --- app/build.gradle.kts | 4 +- .../tretter/githugandroid/Exercise.kt | 8 +- .../tretter/githugandroid/GitHugApp.kt | 229 ++++++++++-------- .../tretter/githugandroid/GitRuntime.kt | 33 ++- .../githugandroid/GitSandboxEngineTest.kt | 24 ++ 5 files changed, 184 insertions(+), 114 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ab8600e..fc0700a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,8 +19,8 @@ android { applicationId = "solutions.tretter.githugandroid" minSdk = 26 targetSdk = 35 - versionCode = 121 - versionName = "0.1.120" + versionCode = 122 + versionName = "0.1.121" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/app/src/main/java/solutions/tretter/githugandroid/Exercise.kt b/app/src/main/java/solutions/tretter/githugandroid/Exercise.kt index c40a46d..054c39d 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/Exercise.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/Exercise.kt @@ -20,8 +20,11 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -32,12 +35,15 @@ fun ExercisePane( visibleHint: String?, suggestionsExpanded: Boolean, showDescriptionHint: Boolean, + onBoundsChanged: (Rect) -> Unit = {}, onToggleSuggestions: () -> Unit, onHint: () -> Unit, onReset: () -> Unit, ) { Column( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .onGloballyPositioned { onBoundsChanged(it.boundsInRoot()) }, verticalArrangement = Arrangement.spacedBy(12.dp), ) { if (showDescriptionHint) { diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt index 624d0af..5f9587d 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt @@ -53,8 +53,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.sp +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlin.math.roundToInt @Composable fun GitHugApp() { @@ -95,6 +98,8 @@ fun GitHugApp() { var showHelpOverlay by remember { mutableStateOf(false) } var showHelpOnStart by remember { mutableStateOf(true) } var helpPreferenceInitialized by remember { mutableStateOf(false) } + var workspaceBounds by remember { mutableStateOf(null) } + var exerciseBounds by remember { mutableStateOf(null) } var promptBounds by remember { mutableStateOf(null) } val currentLevel = levels[currentLevelIndex] @@ -495,104 +500,112 @@ fun GitHugApp() { verticalArrangement = Arrangement.spacedBy(8.dp), ) { FixedHeader() - PaneWorkspace( + Box( modifier = Modifier - .fillMaxWidth(), - paneLayout = paneLayout, - onMovePane = { paneId, delta -> - updatePaneLayout(transform = { layout -> movePane(layout, paneId, delta) }) - }, - onTogglePane = { paneId -> - updatePaneLayout(transform = { layout -> - val collapsed = layout.collapsed.toMutableSet() - if (!collapsed.add(paneId)) collapsed.remove(paneId) - layout.copy(collapsed = collapsed) - }) - }, - levelsContent = { - LevelsPane(levels, currentLevelIndex, completedLevels) { index -> loadLevel(index) } - }, - visualContent = { VisualPane(repo) }, - exerciseContent = { - ExercisePane( - level = currentLevel, - visibleHint = if (activeExerciseDetail == ExerciseDetailPanel.HINT) visibleHint else null, - suggestionsExpanded = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS, - showDescriptionHint = false, - onToggleSuggestions = { - showExerciseDescriptionHint = false - showHelpOverlay = false - activeExerciseDetail = if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) null else ExerciseDetailPanel.SUGGESTIONS - if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) { - visibleHint = null - } - applyRecommendedPaneWeights(persist = false) - }, - onHint = { - showExerciseDescriptionHint = false - showHelpOverlay = false - val hint = currentLevel.hints.getOrNull(hintIndex) ?: "No more hints for this level." - visibleHint = hint - activeExerciseDetail = ExerciseDetailPanel.HINT - hintIndex = (hintIndex + 1).coerceAtMost(currentLevel.hints.size) - applyRecommendedPaneWeights(persist = false) - }, - onReset = { - showExerciseDescriptionHint = false - showHelpOverlay = false - resetCurrentLevel() - }, - ) - }, - terminalContent = { - TerminalPane( - output = output, - inputFieldVersion = inputFieldVersion, - commandInput = commandInput, - showInputHint = false, - onInputHintDismiss = { - showTerminalInputHint = false - showHelpOverlay = false - }, - onValueChange = { - val blockedEcho = suppressedImeEcho - if (blockedEcho != null && commandInput.text.isEmpty()) { - val blockedTrimmed = blockedEcho.trim() - if (it.text == blockedEcho || (blockedTrimmed.isNotEmpty() && it.text == blockedTrimmed)) { - return@TerminalPane - } - } - suppressedImeEcho = null - if (it.text.isNotEmpty()) { - showTerminalInputHint = false + .fillMaxWidth() + .onGloballyPositioned { workspaceBounds = it.boundsInRoot() }, + ) { + PaneWorkspace( + modifier = Modifier.fillMaxWidth(), + paneLayout = paneLayout, + onMovePane = { paneId, delta -> + updatePaneLayout(transform = { layout -> movePane(layout, paneId, delta) }) + }, + onTogglePane = { paneId -> + updatePaneLayout(transform = { layout -> + val collapsed = layout.collapsed.toMutableSet() + if (!collapsed.add(paneId)) collapsed.remove(paneId) + layout.copy(collapsed = collapsed) + }) + }, + levelsContent = { + LevelsPane(levels, currentLevelIndex, completedLevels) { index -> loadLevel(index) } + }, + visualContent = { VisualPane(repo) }, + exerciseContent = { + ExercisePane( + level = currentLevel, + visibleHint = if (activeExerciseDetail == ExerciseDetailPanel.HINT) visibleHint else null, + suggestionsExpanded = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS, + showDescriptionHint = false, + onBoundsChanged = { exerciseBounds = it }, + onToggleSuggestions = { showExerciseDescriptionHint = false showHelpOverlay = false - } - commandInput = it + activeExerciseDetail = if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) null else ExerciseDetailPanel.SUGGESTIONS + if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) { + visibleHint = null + } + applyRecommendedPaneWeights(persist = false) + }, + onHint = { + showExerciseDescriptionHint = false + showHelpOverlay = false + val hint = currentLevel.hints.getOrNull(hintIndex) ?: "No more hints for this level." + visibleHint = hint + activeExerciseDetail = ExerciseDetailPanel.HINT + hintIndex = (hintIndex + 1).coerceAtMost(currentLevel.hints.size) + applyRecommendedPaneWeights(persist = false) + }, + onReset = { + showExerciseDescriptionHint = false + showHelpOverlay = false + resetCurrentLevel() + }, + ) + }, + terminalContent = { + TerminalPane( + output = output, + inputFieldVersion = inputFieldVersion, + commandInput = commandInput, + showInputHint = false, + onInputHintDismiss = { + showTerminalInputHint = false + showHelpOverlay = false + }, + onValueChange = { + val blockedEcho = suppressedImeEcho + if (blockedEcho != null && commandInput.text.isEmpty()) { + val blockedTrimmed = blockedEcho.trim() + if (it.text == blockedEcho || (blockedTrimmed.isNotEmpty() && it.text == blockedTrimmed)) { + return@TerminalPane + } + } + suppressedImeEcho = null + if (it.text.isNotEmpty()) { + showTerminalInputHint = false + showExerciseDescriptionHint = false + showHelpOverlay = false + } + commandInput = it + }, + onRun = { runCommand() }, + onTab = { tabComplete() }, + onHelp = { showCommandHelp() }, + onCursorLeft = { moveCursor(-1) }, + onCursorRight = { moveCursor(1) }, + onHistoryUp = { historyUp() }, + onHistoryDown = { historyDown() }, + onPromptBoundsChanged = { promptBounds = it }, + ) + }, + ) + if (showHelpOverlay) { + HelpCalloutOverlay( + showHelpOnStart = showHelpOnStart, + onShowHelpOnStartChange = { showHelpOnStart = it }, + onOk = { + showHelpOverlay = false + scope.launch { gameProgressStore.saveShowHelpOnStart(showHelpOnStart) } }, - onRun = { runCommand() }, - onTab = { tabComplete() }, - onHelp = { showCommandHelp() }, - onCursorLeft = { moveCursor(-1) }, - onCursorRight = { moveCursor(1) }, - onHistoryUp = { historyUp() }, - onHistoryDown = { historyDown() }, - onPromptBoundsChanged = { promptBounds = it }, + onClose = { showHelpOverlay = false }, + workspaceBounds = workspaceBounds, + exerciseBounds = exerciseBounds, + promptBounds = promptBounds, ) - }, - ) - } - if (showHelpOverlay) { - HelpCalloutOverlay( - showHelpOnStart = showHelpOnStart, - onShowHelpOnStartChange = { showHelpOnStart = it }, - onOk = { - showHelpOverlay = false - scope.launch { gameProgressStore.saveShowHelpOnStart(showHelpOnStart) } - }, - onClose = { showHelpOverlay = false }, - promptBounds = promptBounds, - ) + } + } } } } @@ -670,25 +683,41 @@ private fun HelpCalloutOverlay( onShowHelpOnStartChange: (Boolean) -> Unit, onOk: () -> Unit, onClose: () -> Unit, + workspaceBounds: Rect?, + exerciseBounds: Rect?, promptBounds: Rect?, ) { val density = LocalDensity.current + val workspaceTop = workspaceBounds?.top ?: 0f + val insetPx = with(density) { 14.dp.roundToPx() } Box( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp, vertical = 12.dp), + modifier = Modifier.fillMaxSize(), ) { HelpBubble( text = "Read the exercise description, then solve it by entering commands below.", - modifier = Modifier.align(Alignment.TopStart), + modifier = exerciseBounds?.let { bounds -> + Modifier.offset { + IntOffset( + x = insetPx, + y = (bounds.top - workspaceTop - insetPx).roundToInt().coerceAtLeast(0), + ) + } + } ?: Modifier + .align(Alignment.TopStart) + .padding(horizontal = 14.dp, vertical = 10.dp), ) HelpBubbleWithControls( modifier = promptBounds?.let { bounds -> - val calloutTop = with(density) { (bounds.top.toDp() - 148.dp).coerceAtLeast(12.dp) } - Modifier.offset { IntOffset(0, with(density) { calloutTop.roundToPx() }) } + val bubbleHeight = with(density) { 190.dp.roundToPx() } + Modifier.offset { + IntOffset( + x = insetPx, + y = (bounds.top - workspaceTop - bubbleHeight).roundToInt().coerceAtLeast(0), + ) + } } ?: Modifier .align(Alignment.BottomStart) - .padding(bottom = 96.dp), + .padding(start = 14.dp, bottom = 96.dp), text = "Tap the prompt to enter a command.", showHelpOnStart = showHelpOnStart, onShowHelpOnStartChange = onShowHelpOnStartChange, diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt index 6e6cf3a..5c30161 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt @@ -498,27 +498,38 @@ class GitRepositoryRuntime private constructor( writeFile("file2") commitIn(sandbox, "Second commit", "file2") - val remoteWorkTree = File(sandbox.parentFile ?: sandbox, "${sandbox.name}-origin") + val parent = sandbox.parentFile ?: sandbox + val remoteDir = File(parent, "${sandbox.name}-origin.git") + val remoteWorkTree = File(parent, "${sandbox.name}-origin-work") + remoteDir.deleteRecursively() remoteWorkTree.deleteRecursively() + remoteDir.mkdirs() remoteWorkTree.mkdirs() - val initResult = runGit(nativeGit, remoteWorkTree, listOf("init", "-b", "master")) - if (initResult.exitCode != 0) { - runGit(nativeGit, remoteWorkTree, listOf("init")) - runGit(nativeGit, remoteWorkTree, listOf("checkout", "-B", "master")) - } - runGit(nativeGit, remoteWorkTree, listOf("config", "receive.denyCurrentBranch", "ignore")) - runGit(nativeGit, sandbox, listOf("remote", "add", "push-setup-origin", File(remoteWorkTree, ".git").absolutePath)) + runGit(nativeGit, remoteDir, listOf("init", "--bare", "-b", "master")) + runGit(nativeGit, sandbox, listOf("remote", "add", "push-setup-origin", remoteDir.absolutePath)) runGit(nativeGit, sandbox, listOf("push", "push-setup-origin", "master")) runGit(nativeGit, sandbox, listOf("remote", "remove", "push-setup-origin")) - runGit(nativeGit, remoteWorkTree, listOf("checkout", "-f", "master")) + + fun runRemoteGit(arguments: List): ProcessExecutionResult { + return runGit( + nativeGit, + sandbox, + listOf("--git-dir", remoteDir.absolutePath, "--work-tree", remoteWorkTree.absolutePath) + arguments, + ) + } + + runRemoteGit(listOf("checkout", "-f", "master")) File(remoteWorkTree, "file4").writeText("file4\n") - commitIn(remoteWorkTree, "Fourth commit", "file4") + runRemoteGit(listOf("add", "file4")) + runRemoteGit(listOf("commit", "-m", "Fourth commit")) writeFile("file3") commitIn(sandbox, "Third commit", "file3") - runGit(nativeGit, sandbox, listOf("remote", "add", "origin", File(remoteWorkTree, ".git").absolutePath)) + runGit(nativeGit, sandbox, listOf("remote", "add", "origin", remoteDir.absolutePath)) runGit(nativeGit, sandbox, listOf("fetch", "origin")) runGit(nativeGit, sandbox, listOf("branch", "--set-upstream-to=origin/master", "master")) + runGit(nativeGit, sandbox, listOf("config", "rebase.autoStash", "true")) + File(sandbox, "file3").appendText("local worktree change\n") } private fun filesForSetupCommit(files: List, index: Int, commitCount: Int): List { diff --git a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt index aa67dde..fe190d2 100644 --- a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt +++ b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt @@ -175,6 +175,30 @@ class GitSandboxEngineTest { } } + @Test + fun nativePushLevelHasDivergedStatusAndRebasesCleanly() { + val git = File("/usr/bin/git") + assumeTrue(git.exists() && git.canExecute()) + val root = Files.createTempDirectory("githug-push-level").toFile() + try { + val runtime = GitRepositoryRuntime(root, git) + val level = pushLevel() + val repo = runtime.prepareLevel(level) + + val (_, statusOutput) = runtime.execute(level, repo, "git status") + assertTrue(statusOutput.any { it.contains("diverged", ignoreCase = true) }) + + val (_, diffOutput) = runtime.execute(level, repo, "git diff") + assertTrue(diffOutput.isNotEmpty()) + + val (rebasedRepo, pullOutput) = runtime.execute(level, repo, "git pull --rebase origin master") + assertFalse(pullOutput.any { it.contains("fatal:", ignoreCase = true) || it.contains("error:", ignoreCase = true) }) + assertTrue(rebasedRepo.files.any { it.name == "file4" && it.tracked }) + } finally { + root.deleteRecursively() + } + } + @Test fun cdDotDotShortcutMovesToParentDirectory() { val repo = RepoState(initialized = true, currentDir = "src/main")