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"
minSdk = 26
targetSdk = 35
versionCode = 121
versionName = "0.1.120"
versionCode = 122
versionName = "0.1.121"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true

View File

@@ -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) {

View File

@@ -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<Rect?>(null) }
var exerciseBounds by remember { mutableStateOf<Rect?>(null) }
var promptBounds by remember { mutableStateOf<Rect?>(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,

View File

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