Add exercise description onboarding bubble

Remove duplicate IME padding that caused keyboard gap
Add md alias for mkdir helper command
Add dir alias for ls helper command
Add cd.. shortcut for cd .. helper command
This commit is contained in:
Joe Tretter
2026-05-05 20:08:51 -05:00
parent 344ff99f0a
commit b8c4096123
6 changed files with 112 additions and 14 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 = 114 versionCode = 115
versionName = "0.1.113" versionName = "0.1.114"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true

View File

@@ -1,9 +1,16 @@
package solutions.tretter.githugandroid package solutions.tretter.githugandroid
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@@ -12,7 +19,9 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton 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.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
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
@@ -22,6 +31,7 @@ fun ExercisePane(
level: Level, level: Level,
visibleHint: String?, visibleHint: String?,
suggestionsExpanded: Boolean, suggestionsExpanded: Boolean,
showDescriptionHint: Boolean,
onToggleSuggestions: () -> Unit, onToggleSuggestions: () -> Unit,
onHint: () -> Unit, onHint: () -> Unit,
onReset: () -> Unit, onReset: () -> Unit,
@@ -30,6 +40,9 @@ fun ExercisePane(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
if (showDescriptionHint) {
ExerciseDescriptionHintBubble()
}
SelectionContainer { SelectionContainer {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(level.title, style = MaterialTheme.typography.titleLarge, color = TextPrimary) Text(level.title, style = MaterialTheme.typography.titleLarge, color = TextPrimary)
@@ -81,3 +94,40 @@ fun ExercisePane(
} }
} }
} }
@Composable
private fun ExerciseDescriptionHintBubble() {
val bubbleColor = Color(0xFFFFF1A8)
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
Box(
modifier = Modifier
.background(bubbleColor, RoundedCornerShape(18.dp))
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Text(
text = "Read the exercise description, then solve it by entering commands below.",
color = Color(0xFF161000),
fontWeight = FontWeight.Bold,
)
}
Canvas(
modifier = Modifier
.padding(start = 28.dp)
.width(26.dp)
.height(13.dp),
) {
drawPath(
path = Path().apply {
moveTo(0f, 0f)
lineTo(size.width, 0f)
lineTo(size.width * 0.25f, size.height)
close()
},
color = bubbleColor,
)
}
}
}

View File

@@ -95,14 +95,15 @@ object GitSandboxEngine {
fun commandReferenceLines(): List<String> = listOf( fun commandReferenceLines(): List<String> = listOf(
"Available sandbox commands:", "Available sandbox commands:",
" git ", " git ",
" ls", " ls|dir",
" touch <file>", " touch <file>",
" help", " help",
" pwd ", " pwd ",
" cat <file>", " cat <file>",
" touch <file>", " touch <file>",
" mkdir <directory>", " mkdir|md <directory>",
" cd <directory>", " cd <directory>",
" cd..",
" rm <file>", " rm <file>",
" echo <message>", " echo <message>",
) )
@@ -117,7 +118,7 @@ object GitSandboxEngine {
if (repo.files.any { it.name == name && !it.deleted }) repo to listOf("$name already exists") if (repo.files.any { it.name == name && !it.deleted }) repo to listOf("$name already exists")
else repo.copy(files = repo.files + GitFile(name = name)) to emptyList() else repo.copy(files = repo.files + GitFile(name = name)) to emptyList()
} }
parts[0] == "mkdir" && parts.size >= 2 -> repo to emptyList() (parts[0] == "mkdir" || parts[0] == "md") && parts.size >= 2 -> repo to emptyList()
parts[0] == "rm" && parts.size >= 2 -> { parts[0] == "rm" && parts.size >= 2 -> {
val target = parts[1] val target = parts[1]
repo.copy(files = repo.files.mapNotNull { file -> repo.copy(files = repo.files.mapNotNull { file ->
@@ -129,7 +130,8 @@ object GitSandboxEngine {
}) to emptyList() }) to emptyList()
} }
parts[0] == "echo" -> writeEcho(repo, parts) parts[0] == "echo" -> writeEcho(repo, parts)
parts[0] == "ls" -> repo to repo.files.filterNot { it.deleted }.map { it.name }.ifEmpty { listOf() } parts[0] == "ls" || parts[0] == "dir" -> repo to repo.files.filterNot { it.deleted }.map { it.name }.ifEmpty { listOf() }
parts[0] == "cd.." -> repo.copy(currentDir = parentDirectory(repo.currentDir)) to emptyList()
parts[0] != "git" -> repo to listOf("Command not supported in sandbox. Try a git command or 'touch'.") parts[0] != "git" -> repo to listOf("Command not supported in sandbox. Try a git command or 'touch'.")
parts.size >= 2 && parts[1] == "init" -> repo.copy(initialized = true, branches = mapOf("master" to repo.commits.size)) to listOf("Initialized empty Git repository") parts.size >= 2 && parts[1] == "init" -> repo.copy(initialized = true, branches = mapOf("master" to repo.commits.size)) to listOf("Initialized empty Git repository")
!repo.initialized -> repo to listOf("fatal: not a git repository") !repo.initialized -> repo to listOf("fatal: not a git repository")
@@ -258,6 +260,11 @@ object GitSandboxEngine {
return repo.copy(files = updatedFiles) to emptyList() return repo.copy(files = updatedFiles) to emptyList()
} }
private fun parentDirectory(currentDir: String): String {
if (currentDir == ".") return "."
return currentDir.substringBeforeLast('/', missingDelimiterValue = ".").ifBlank { "." }
}
fun expandPathspecs(repo: RepoState, arguments: List<String>): List<String> { fun expandPathspecs(repo: RepoState, arguments: List<String>): List<String> {
return arguments.flatMap { argument -> return arguments.flatMap { argument ->
if (!argument.hasGlob()) { if (!argument.hasGlob()) {

View File

@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -78,6 +77,7 @@ fun GitHugApp() {
var manPageState by remember { mutableStateOf<ManPageState?>(null) } var manPageState by remember { mutableStateOf<ManPageState?>(null) }
var solvedCelebrationTitle by remember { mutableStateOf<String?>(null) } var solvedCelebrationTitle by remember { mutableStateOf<String?>(null) }
var showTerminalInputHint by remember { mutableStateOf(true) } var showTerminalInputHint by remember { mutableStateOf(true) }
var showExerciseDescriptionHint by remember { mutableStateOf(true) }
val currentLevel = levels[currentLevelIndex] val currentLevel = levels[currentLevelIndex]
LaunchedEffect(solvedCelebrationTitle) { LaunchedEffect(solvedCelebrationTitle) {
@@ -414,6 +414,7 @@ fun GitHugApp() {
if (raw.isBlank()) return if (raw.isBlank()) return
showTerminalInputHint = false showTerminalInputHint = false
showExerciseDescriptionHint = false
suppressedImeEcho = submittedText suppressedImeEcho = submittedText
clearCommandInput(recreateField = true) clearCommandInput(recreateField = true)
@@ -460,7 +461,6 @@ fun GitHugApp() {
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(AppBackground) .background(AppBackground)
.imePadding()
.verticalScroll(screenScrollState) .verticalScroll(screenScrollState)
.padding(horizontal = 10.dp, vertical = 8.dp), .padding(horizontal = 10.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -489,7 +489,9 @@ fun GitHugApp() {
level = currentLevel, level = currentLevel,
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 = showExerciseDescriptionHint,
onToggleSuggestions = { onToggleSuggestions = {
showExerciseDescriptionHint = false
activeExerciseDetail = if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) null else ExerciseDetailPanel.SUGGESTIONS activeExerciseDetail = if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) null else ExerciseDetailPanel.SUGGESTIONS
if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) { if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) {
visibleHint = null visibleHint = null
@@ -497,13 +499,17 @@ fun GitHugApp() {
applyRecommendedPaneWeights(persist = false) applyRecommendedPaneWeights(persist = false)
}, },
onHint = { onHint = {
showExerciseDescriptionHint = false
val hint = currentLevel.hints.getOrNull(hintIndex) ?: "No more hints for this level." val hint = currentLevel.hints.getOrNull(hintIndex) ?: "No more hints for this level."
visibleHint = hint visibleHint = hint
activeExerciseDetail = ExerciseDetailPanel.HINT activeExerciseDetail = ExerciseDetailPanel.HINT
hintIndex = (hintIndex + 1).coerceAtMost(currentLevel.hints.size) hintIndex = (hintIndex + 1).coerceAtMost(currentLevel.hints.size)
applyRecommendedPaneWeights(persist = false) applyRecommendedPaneWeights(persist = false)
}, },
onReset = { resetCurrentLevel() }, onReset = {
showExerciseDescriptionHint = false
resetCurrentLevel()
},
) )
}, },
terminalContent = { terminalContent = {
@@ -524,6 +530,7 @@ fun GitHugApp() {
suppressedImeEcho = null suppressedImeEcho = null
if (it.text.isNotEmpty()) { if (it.text.isNotEmpty()) {
showTerminalInputHint = false showTerminalInputHint = false
showExerciseDescriptionHint = false
} }
commandInput = it commandInput = it
}, },

View File

@@ -84,7 +84,7 @@ class GitRepositoryRuntime(private val context: Context) {
add("Native Git prototype:") add("Native Git prototype:")
add(" binary path: nativeLibraryDir/libgit.so") add(" binary path: nativeLibraryDir/libgit.so")
add(" selected ABI: ${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"}") add(" selected ABI: ${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"}")
add(" helper commands: ls, pwd, cat, touch, mkdir, rm, echo") add(" helper commands: ls/dir, pwd, cat, touch, mkdir/md, cd.., rm, echo")
add(" visual editors: vi, nano, emacs, ed, ex") add(" visual editors: vi, nano, emacs, ed, ex")
add(" git help <command>") add(" git help <command>")
} }
@@ -193,7 +193,7 @@ class GitRepositoryRuntime(private val context: Context) {
private fun executeHelperCommand(sandboxRoot: File, workingDir: File, currentRepo: RepoState, tokens: List<String>): Pair<RepoState, List<String>> { private fun executeHelperCommand(sandboxRoot: File, workingDir: File, currentRepo: RepoState, tokens: List<String>): Pair<RepoState, List<String>> {
return when (tokens.first()) { return when (tokens.first()) {
"ls" -> currentRepo to workingDir.listFiles() "ls", "dir" -> currentRepo to workingDir.listFiles()
?.filterNot { it.name == ".git" } ?.filterNot { it.name == ".git" }
?.sortedBy { it.name } ?.sortedBy { it.name }
?.map { it.name } ?.map { it.name }
@@ -218,7 +218,7 @@ class GitRepositoryRuntime(private val context: Context) {
currentRepo to emptyList() currentRepo to emptyList()
} }
} }
"mkdir" -> { "mkdir", "md" -> {
val target = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: mkdir <dir>") val target = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: mkdir <dir>")
val dir = File(workingDir, target) val dir = File(workingDir, target)
if (dir.exists()) currentRepo to listOf("mkdir: $target: File exists") else { if (dir.exists()) currentRepo to listOf("mkdir: $target: File exists") else {
@@ -226,8 +226,9 @@ class GitRepositoryRuntime(private val context: Context) {
currentRepo to emptyList() currentRepo to emptyList()
} }
} }
"cd" -> { "cd", "cd.." -> {
val target = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: cd <dir>") val target = if (tokens.first() == "cd..") ".." else tokens.getOrNull(1)
?: return currentRepo to listOf("usage: cd <dir>")
val dir = File(workingDir, target).canonicalFile val dir = File(workingDir, target).canonicalFile
when { when {
!dir.exists() -> currentRepo to listOf("cd: $target: does not exist") !dir.exists() -> currentRepo to listOf("cd: $target: does not exist")

View File

@@ -1,5 +1,6 @@
package solutions.tretter.githugandroid package solutions.tretter.githugandroid
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@@ -64,4 +65,36 @@ class GitSandboxEngineTest {
assertTrue(commitHashAnswer("0000001")(repo, "abc1234fedcba9876543210fedcba9876543210")) assertTrue(commitHashAnswer("0000001")(repo, "abc1234fedcba9876543210fedcba9876543210"))
} }
@Test
fun mdIsAcceptedAsMkdirAlias() {
val repo = RepoState(initialized = true)
val (_, output) = GitSandboxEngine.execute(repo, "md src")
assertTrue(output.isEmpty())
}
@Test
fun dirIsAcceptedAsLsAlias() {
val repo = RepoState(
initialized = true,
files = listOf(GitFile("README"), GitFile("src/main.kt")),
)
val (_, output) = GitSandboxEngine.execute(repo, "dir")
assertTrue("README" in output)
assertTrue("src/main.kt" in output)
}
@Test
fun cdDotDotShortcutMovesToParentDirectory() {
val repo = RepoState(initialized = true, currentDir = "src/main")
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "cd..")
assertTrue(output.isEmpty())
assertEquals("src", updatedRepo.currentDir)
}
} }