From b8c4096123e5bab436fdfd240b76d63870d0fe33 Mon Sep 17 00:00:00 2001 From: Joe Tretter Date: Tue, 5 May 2026 20:08:51 -0500 Subject: [PATCH] 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 --- app/build.gradle.kts | 4 +- .../tretter/githugandroid/Exercise.kt | 50 +++++++++++++++++++ .../tretter/githugandroid/GameModels.kt | 15 ++++-- .../tretter/githugandroid/GitHugApp.kt | 13 +++-- .../tretter/githugandroid/GitRuntime.kt | 11 ++-- .../githugandroid/GitSandboxEngineTest.kt | 33 ++++++++++++ 6 files changed, 112 insertions(+), 14 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7642e3f..4bb1fb9 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 = 114 - versionName = "0.1.113" + versionCode = 115 + versionName = "0.1.114" 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 516204b..c40a46d 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/Exercise.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/Exercise.kt @@ -1,9 +1,16 @@ package solutions.tretter.githugandroid +import androidx.compose.foundation.background 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.Row 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.material3.Button import androidx.compose.material3.ButtonDefaults @@ -12,7 +19,9 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment 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.FontWeight import androidx.compose.ui.unit.dp @@ -22,6 +31,7 @@ fun ExercisePane( level: Level, visibleHint: String?, suggestionsExpanded: Boolean, + showDescriptionHint: Boolean, onToggleSuggestions: () -> Unit, onHint: () -> Unit, onReset: () -> Unit, @@ -30,6 +40,9 @@ fun ExercisePane( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + if (showDescriptionHint) { + ExerciseDescriptionHintBubble() + } SelectionContainer { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { 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, + ) + } + } +} diff --git a/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt b/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt index 2ecdb38..4c09c87 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt @@ -95,14 +95,15 @@ object GitSandboxEngine { fun commandReferenceLines(): List = listOf( "Available sandbox commands:", " git ", - " ls", + " ls|dir", " touch ", " help", " pwd ", " cat ", " touch ", - " mkdir ", + " mkdir|md ", " cd ", + " cd..", " rm ", " echo ", ) @@ -117,7 +118,7 @@ object GitSandboxEngine { 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() } - 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 -> { val target = parts[1] repo.copy(files = repo.files.mapNotNull { file -> @@ -129,7 +130,8 @@ object GitSandboxEngine { }) to emptyList() } 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.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") @@ -258,6 +260,11 @@ object GitSandboxEngine { 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): List { return arguments.flatMap { argument -> if (!argument.hasGlob()) { diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt index b0dcf64..5a9d864 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -78,6 +77,7 @@ fun GitHugApp() { var manPageState by remember { mutableStateOf(null) } var solvedCelebrationTitle by remember { mutableStateOf(null) } var showTerminalInputHint by remember { mutableStateOf(true) } + var showExerciseDescriptionHint by remember { mutableStateOf(true) } val currentLevel = levels[currentLevelIndex] LaunchedEffect(solvedCelebrationTitle) { @@ -414,6 +414,7 @@ fun GitHugApp() { if (raw.isBlank()) return showTerminalInputHint = false + showExerciseDescriptionHint = false suppressedImeEcho = submittedText clearCommandInput(recreateField = true) @@ -460,7 +461,6 @@ fun GitHugApp() { modifier = Modifier .fillMaxSize() .background(AppBackground) - .imePadding() .verticalScroll(screenScrollState) .padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), @@ -489,7 +489,9 @@ fun GitHugApp() { level = currentLevel, visibleHint = if (activeExerciseDetail == ExerciseDetailPanel.HINT) visibleHint else null, suggestionsExpanded = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS, + showDescriptionHint = showExerciseDescriptionHint, onToggleSuggestions = { + showExerciseDescriptionHint = false activeExerciseDetail = if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) null else ExerciseDetailPanel.SUGGESTIONS if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) { visibleHint = null @@ -497,13 +499,17 @@ fun GitHugApp() { applyRecommendedPaneWeights(persist = false) }, onHint = { + showExerciseDescriptionHint = 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 = { resetCurrentLevel() }, + onReset = { + showExerciseDescriptionHint = false + resetCurrentLevel() + }, ) }, terminalContent = { @@ -524,6 +530,7 @@ fun GitHugApp() { suppressedImeEcho = null if (it.text.isNotEmpty()) { showTerminalInputHint = false + showExerciseDescriptionHint = false } commandInput = it }, diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt index c02e21a..2bb35d1 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt @@ -84,7 +84,7 @@ class GitRepositoryRuntime(private val context: Context) { add("Native Git prototype:") add(" binary path: nativeLibraryDir/libgit.so") 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(" git help ") } @@ -193,7 +193,7 @@ class GitRepositoryRuntime(private val context: Context) { private fun executeHelperCommand(sandboxRoot: File, workingDir: File, currentRepo: RepoState, tokens: List): Pair> { return when (tokens.first()) { - "ls" -> currentRepo to workingDir.listFiles() + "ls", "dir" -> currentRepo to workingDir.listFiles() ?.filterNot { it.name == ".git" } ?.sortedBy { it.name } ?.map { it.name } @@ -218,7 +218,7 @@ class GitRepositoryRuntime(private val context: Context) { currentRepo to emptyList() } } - "mkdir" -> { + "mkdir", "md" -> { val target = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: mkdir ") val dir = File(workingDir, target) if (dir.exists()) currentRepo to listOf("mkdir: $target: File exists") else { @@ -226,8 +226,9 @@ class GitRepositoryRuntime(private val context: Context) { currentRepo to emptyList() } } - "cd" -> { - val target = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: cd ") + "cd", "cd.." -> { + val target = if (tokens.first() == "cd..") ".." else tokens.getOrNull(1) + ?: return currentRepo to listOf("usage: cd ") val dir = File(workingDir, target).canonicalFile when { !dir.exists() -> currentRepo to listOf("cd: $target: does not exist") diff --git a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt index a28bd7f..6c1c404 100644 --- a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt +++ b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt @@ -1,5 +1,6 @@ package solutions.tretter.githugandroid +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -64,4 +65,36 @@ class GitSandboxEngineTest { 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) + } }