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:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,14 +95,15 @@ object GitSandboxEngine {
|
||||
fun commandReferenceLines(): List<String> = listOf(
|
||||
"Available sandbox commands:",
|
||||
" git ",
|
||||
" ls",
|
||||
" ls|dir",
|
||||
" touch <file>",
|
||||
" help",
|
||||
" pwd ",
|
||||
" cat <file>",
|
||||
" touch <file>",
|
||||
" mkdir <directory>",
|
||||
" mkdir|md <directory>",
|
||||
" cd <directory>",
|
||||
" cd..",
|
||||
" rm <file>",
|
||||
" echo <message>",
|
||||
)
|
||||
@@ -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<String>): List<String> {
|
||||
return arguments.flatMap { argument ->
|
||||
if (!argument.hasGlob()) {
|
||||
|
||||
@@ -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<ManPageState?>(null) }
|
||||
var solvedCelebrationTitle by remember { mutableStateOf<String?>(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
|
||||
},
|
||||
|
||||
@@ -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 <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>> {
|
||||
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 <dir>")
|
||||
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 <dir>")
|
||||
"cd", "cd.." -> {
|
||||
val target = if (tokens.first() == "cd..") ".." else tokens.getOrNull(1)
|
||||
?: return currentRepo to listOf("usage: cd <dir>")
|
||||
val dir = File(workingDir, target).canonicalFile
|
||||
when {
|
||||
!dir.exists() -> currentRepo to listOf("cd: $target: does not exist")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user