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:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user