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.
This commit is contained in:
Joe Tretter
2026-05-07 09:57:50 -05:00
parent 91e2df3ffe
commit 565028f64a
5 changed files with 184 additions and 114 deletions

View File

@@ -19,8 +19,8 @@ android {
applicationId = "solutions.tretter.githugandroid" applicationId = "solutions.tretter.githugandroid"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 121 versionCode = 122
versionName = "0.1.120" versionName = "0.1.121"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true

View File

@@ -20,8 +20,11 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path 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.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -32,12 +35,15 @@ fun ExercisePane(
visibleHint: String?, visibleHint: String?,
suggestionsExpanded: Boolean, suggestionsExpanded: Boolean,
showDescriptionHint: Boolean, showDescriptionHint: Boolean,
onBoundsChanged: (Rect) -> Unit = {},
onToggleSuggestions: () -> Unit, onToggleSuggestions: () -> Unit,
onHint: () -> Unit, onHint: () -> Unit,
onReset: () -> Unit, onReset: () -> Unit,
) { ) {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.onGloballyPositioned { onBoundsChanged(it.boundsInRoot()) },
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
if (showDescriptionHint) { if (showDescriptionHint) {

View File

@@ -53,8 +53,11 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.sp 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.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@Composable @Composable
fun GitHugApp() { fun GitHugApp() {
@@ -95,6 +98,8 @@ fun GitHugApp() {
var showHelpOverlay by remember { mutableStateOf(false) } var showHelpOverlay by remember { mutableStateOf(false) }
var showHelpOnStart by remember { mutableStateOf(true) } var showHelpOnStart by remember { mutableStateOf(true) }
var helpPreferenceInitialized by remember { mutableStateOf(false) } var helpPreferenceInitialized by remember { mutableStateOf(false) }
var workspaceBounds by remember { mutableStateOf<Rect?>(null) }
var exerciseBounds by remember { mutableStateOf<Rect?>(null) }
var promptBounds by remember { mutableStateOf<Rect?>(null) } var promptBounds by remember { mutableStateOf<Rect?>(null) }
val currentLevel = levels[currentLevelIndex] val currentLevel = levels[currentLevelIndex]
@@ -495,9 +500,13 @@ fun GitHugApp() {
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
FixedHeader() FixedHeader()
PaneWorkspace( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth(), .fillMaxWidth()
.onGloballyPositioned { workspaceBounds = it.boundsInRoot() },
) {
PaneWorkspace(
modifier = Modifier.fillMaxWidth(),
paneLayout = paneLayout, paneLayout = paneLayout,
onMovePane = { paneId, delta -> onMovePane = { paneId, delta ->
updatePaneLayout(transform = { layout -> movePane(layout, paneId, delta) }) updatePaneLayout(transform = { layout -> movePane(layout, paneId, delta) })
@@ -519,6 +528,7 @@ fun GitHugApp() {
visibleHint = if (activeExerciseDetail == ExerciseDetailPanel.HINT) visibleHint else null, visibleHint = if (activeExerciseDetail == ExerciseDetailPanel.HINT) visibleHint else null,
suggestionsExpanded = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS, suggestionsExpanded = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS,
showDescriptionHint = false, showDescriptionHint = false,
onBoundsChanged = { exerciseBounds = it },
onToggleSuggestions = { onToggleSuggestions = {
showExerciseDescriptionHint = false showExerciseDescriptionHint = false
showHelpOverlay = false showHelpOverlay = false
@@ -581,7 +591,6 @@ fun GitHugApp() {
) )
}, },
) )
}
if (showHelpOverlay) { if (showHelpOverlay) {
HelpCalloutOverlay( HelpCalloutOverlay(
showHelpOnStart = showHelpOnStart, showHelpOnStart = showHelpOnStart,
@@ -591,12 +600,16 @@ fun GitHugApp() {
scope.launch { gameProgressStore.saveShowHelpOnStart(showHelpOnStart) } scope.launch { gameProgressStore.saveShowHelpOnStart(showHelpOnStart) }
}, },
onClose = { showHelpOverlay = false }, onClose = { showHelpOverlay = false },
workspaceBounds = workspaceBounds,
exerciseBounds = exerciseBounds,
promptBounds = promptBounds, promptBounds = promptBounds,
) )
} }
} }
} }
} }
}
}
editorState?.let { state -> editorState?.let { state ->
TextEditorDialog( TextEditorDialog(
@@ -670,25 +683,41 @@ private fun HelpCalloutOverlay(
onShowHelpOnStartChange: (Boolean) -> Unit, onShowHelpOnStartChange: (Boolean) -> Unit,
onOk: () -> Unit, onOk: () -> Unit,
onClose: () -> Unit, onClose: () -> Unit,
workspaceBounds: Rect?,
exerciseBounds: Rect?,
promptBounds: Rect?, promptBounds: Rect?,
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
val workspaceTop = workspaceBounds?.top ?: 0f
val insetPx = with(density) { 14.dp.roundToPx() }
Box( Box(
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 12.dp),
) { ) {
HelpBubble( HelpBubble(
text = "Read the exercise description, then solve it by entering commands below.", 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( HelpBubbleWithControls(
modifier = promptBounds?.let { bounds -> modifier = promptBounds?.let { bounds ->
val calloutTop = with(density) { (bounds.top.toDp() - 148.dp).coerceAtLeast(12.dp) } val bubbleHeight = with(density) { 190.dp.roundToPx() }
Modifier.offset { IntOffset(0, with(density) { calloutTop.roundToPx() }) } Modifier.offset {
IntOffset(
x = insetPx,
y = (bounds.top - workspaceTop - bubbleHeight).roundToInt().coerceAtLeast(0),
)
}
} ?: Modifier } ?: Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.padding(bottom = 96.dp), .padding(start = 14.dp, bottom = 96.dp),
text = "Tap the prompt to enter a command.", text = "Tap the prompt to enter a command.",
showHelpOnStart = showHelpOnStart, showHelpOnStart = showHelpOnStart,
onShowHelpOnStartChange = onShowHelpOnStartChange, onShowHelpOnStartChange = onShowHelpOnStartChange,

View File

@@ -498,27 +498,38 @@ class GitRepositoryRuntime private constructor(
writeFile("file2") writeFile("file2")
commitIn(sandbox, "Second commit", "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() remoteWorkTree.deleteRecursively()
remoteDir.mkdirs()
remoteWorkTree.mkdirs() remoteWorkTree.mkdirs()
val initResult = runGit(nativeGit, remoteWorkTree, listOf("init", "-b", "master")) runGit(nativeGit, remoteDir, listOf("init", "--bare", "-b", "master"))
if (initResult.exitCode != 0) { runGit(nativeGit, sandbox, listOf("remote", "add", "push-setup-origin", remoteDir.absolutePath))
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, sandbox, listOf("push", "push-setup-origin", "master")) runGit(nativeGit, sandbox, listOf("push", "push-setup-origin", "master"))
runGit(nativeGit, sandbox, listOf("remote", "remove", "push-setup-origin")) runGit(nativeGit, sandbox, listOf("remote", "remove", "push-setup-origin"))
runGit(nativeGit, remoteWorkTree, listOf("checkout", "-f", "master"))
fun runRemoteGit(arguments: List<String>): 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") File(remoteWorkTree, "file4").writeText("file4\n")
commitIn(remoteWorkTree, "Fourth commit", "file4") runRemoteGit(listOf("add", "file4"))
runRemoteGit(listOf("commit", "-m", "Fourth commit"))
writeFile("file3") writeFile("file3")
commitIn(sandbox, "Third commit", "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("fetch", "origin"))
runGit(nativeGit, sandbox, listOf("branch", "--set-upstream-to=origin/master", "master")) 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<GitFile>, index: Int, commitCount: Int): List<GitFile> { private fun filesForSetupCommit(files: List<GitFile>, index: Int, commitCount: Int): List<GitFile> {

View File

@@ -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 @Test
fun cdDotDotShortcutMovesToParentDirectory() { fun cdDotDotShortcutMovesToParentDirectory() {
val repo = RepoState(initialized = true, currentDir = "src/main") val repo = RepoState(initialized = true, currentDir = "src/main")