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) + } }