Misc Changes
This commit is contained in:
@@ -19,8 +19,8 @@ android {
|
||||
applicationId = "solutions.tretter.githugandroid"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 116
|
||||
versionName = "0.1.115"
|
||||
versionCode = 117
|
||||
versionName = "0.1.116"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
||||
@@ -92,6 +92,11 @@ val RepoStateSaver = listSaver<RepoState, Any>(
|
||||
)
|
||||
|
||||
object GitSandboxEngine {
|
||||
data class ShellToken(
|
||||
val value: String,
|
||||
val quoted: Boolean = false,
|
||||
)
|
||||
|
||||
fun commandReferenceLines(): List<String> = listOf(
|
||||
"Available sandbox commands:",
|
||||
" git ",
|
||||
@@ -109,7 +114,8 @@ object GitSandboxEngine {
|
||||
)
|
||||
|
||||
fun execute(repo: RepoState, command: String): Pair<RepoState, List<String>> {
|
||||
val parts = tokenizeCommand(command)
|
||||
val shellParts = tokenizeShellCommand(command)
|
||||
val parts = shellParts.map { it.value }
|
||||
if (parts.isEmpty()) return repo to emptyList()
|
||||
return when {
|
||||
parts[0] == "help" || parts[0] == "?" -> repo to commandReferenceLines()
|
||||
@@ -129,7 +135,7 @@ object GitSandboxEngine {
|
||||
}
|
||||
}) to emptyList()
|
||||
}
|
||||
parts[0] == "echo" -> writeEcho(repo, parts)
|
||||
parts[0] == "echo" -> writeEcho(repo, shellParts)
|
||||
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'.")
|
||||
@@ -157,7 +163,7 @@ object GitSandboxEngine {
|
||||
}
|
||||
}
|
||||
parts.size >= 3 && parts[1] == "rm" -> removeGitPath(repo, parts.drop(2))
|
||||
parts.size >= 4 && parts[1] == "mv" -> moveGitPath(repo, expandPathspecs(repo, parts.drop(2)))
|
||||
parts.size >= 4 && parts[1] == "mv" -> moveGitPath(repo, expandPathspecTokens(repo, shellParts.drop(2)))
|
||||
parts.size >= 2 && parts[1] == "commit" -> commit(repo, parts.drop(2))
|
||||
parts.size >= 2 && parts[1] == "log" -> {
|
||||
repo to if (repo.commits.isEmpty()) listOf("fatal: your current branch '${repo.headBranch}' does not have any commits yet")
|
||||
@@ -194,12 +200,30 @@ object GitSandboxEngine {
|
||||
}
|
||||
|
||||
fun tokenizeCommand(command: String): List<String> {
|
||||
val result = mutableListOf<String>()
|
||||
return tokenizeShellCommand(command).map { it.value }
|
||||
}
|
||||
|
||||
fun tokenizeShellCommand(command: String): List<ShellToken> {
|
||||
val result = mutableListOf<ShellToken>()
|
||||
val current = StringBuilder()
|
||||
var quoteChar: Char? = null
|
||||
var escaping = false
|
||||
var currentQuoted = false
|
||||
var skipNext = false
|
||||
|
||||
command.forEach { char ->
|
||||
fun emitCurrent(force: Boolean = false) {
|
||||
if (current.isNotEmpty() || force && currentQuoted) {
|
||||
result += ShellToken(value = current.toString(), quoted = currentQuoted)
|
||||
current.clear()
|
||||
currentQuoted = false
|
||||
}
|
||||
}
|
||||
|
||||
command.forEachIndexed { index, char ->
|
||||
if (skipNext) {
|
||||
skipNext = false
|
||||
return@forEachIndexed
|
||||
}
|
||||
when {
|
||||
escaping -> {
|
||||
current.append(char)
|
||||
@@ -217,11 +241,18 @@ object GitSandboxEngine {
|
||||
}
|
||||
char == '"' || char == '\'' -> {
|
||||
quoteChar = char
|
||||
currentQuoted = true
|
||||
}
|
||||
char.isWhitespace() -> {
|
||||
if (current.isNotEmpty()) {
|
||||
result += current.toString()
|
||||
current.clear()
|
||||
emitCurrent()
|
||||
}
|
||||
char == '>' -> {
|
||||
emitCurrent()
|
||||
if (command.getOrNull(index + 1) == '>') {
|
||||
result += ShellToken(">>")
|
||||
skipNext = true
|
||||
} else if (command.getOrNull(index - 1) != '>') {
|
||||
result += ShellToken(">")
|
||||
}
|
||||
}
|
||||
else -> current.append(char)
|
||||
@@ -231,14 +262,13 @@ object GitSandboxEngine {
|
||||
if (escaping) {
|
||||
current.append('\\')
|
||||
}
|
||||
if (current.isNotEmpty()) {
|
||||
result += current.toString()
|
||||
}
|
||||
emitCurrent()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private fun writeEcho(repo: RepoState, parts: List<String>): Pair<RepoState, List<String>> {
|
||||
private fun writeEcho(repo: RepoState, shellParts: List<ShellToken>): Pair<RepoState, List<String>> {
|
||||
val parts = shellParts.map { it.value }
|
||||
val redirectIndex = parts.indexOfFirst { it == ">" || it == ">>" }
|
||||
if (redirectIndex == -1 || redirectIndex == parts.lastIndex) {
|
||||
return repo to listOf(parts.drop(1).joinToString(" "))
|
||||
@@ -266,8 +296,13 @@ object GitSandboxEngine {
|
||||
}
|
||||
|
||||
fun expandPathspecs(repo: RepoState, arguments: List<String>): List<String> {
|
||||
return arguments.flatMap { argument ->
|
||||
if (!argument.hasGlob()) {
|
||||
return expandPathspecTokens(repo, arguments.map { ShellToken(it) })
|
||||
}
|
||||
|
||||
fun expandPathspecTokens(repo: RepoState, arguments: List<ShellToken>): List<String> {
|
||||
return arguments.flatMap { token ->
|
||||
val argument = token.value
|
||||
if (token.quoted || !argument.hasGlob()) {
|
||||
listOf(argument)
|
||||
} else {
|
||||
val regex = argument.globToRegex()
|
||||
|
||||
@@ -2,6 +2,7 @@ package solutions.tretter.githugandroid
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
@@ -22,10 +23,12 @@ class GameProgressStore(private val context: Context) {
|
||||
|
||||
private val completedLevelsKey = stringPreferencesKey("completed_levels")
|
||||
private val activeLevelIdKey = stringPreferencesKey("active_level_id")
|
||||
private val showHelpOnStartKey = booleanPreferencesKey("show_help_on_start")
|
||||
|
||||
data class GameProgress(
|
||||
val completedLevels: Set<String> = emptySet(),
|
||||
val activeLevelId: String? = null,
|
||||
val showHelpOnStart: Boolean = true,
|
||||
)
|
||||
|
||||
val progressFlow: Flow<GameProgress> = dataStore.data
|
||||
@@ -45,13 +48,15 @@ class GameProgressStore(private val context: Context) {
|
||||
?.toSet()
|
||||
?: emptySet()
|
||||
val activeLevelId = preferences[activeLevelIdKey]?.takeIf { it.isNotBlank() }
|
||||
val showHelpOnStart = preferences[showHelpOnStartKey] ?: true
|
||||
AppLog.d(
|
||||
"ProgressStore",
|
||||
"Loaded progress completed=${completedLevels.sorted()} activeLevelId=$activeLevelId",
|
||||
"Loaded progress completed=${completedLevels.sorted()} activeLevelId=$activeLevelId showHelpOnStart=$showHelpOnStart",
|
||||
)
|
||||
GameProgress(
|
||||
completedLevels = completedLevels,
|
||||
activeLevelId = activeLevelId,
|
||||
showHelpOnStart = showHelpOnStart,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -69,4 +74,11 @@ class GameProgressStore(private val context: Context) {
|
||||
"Saved progress completed=${completedLevels.sorted()} activeLevelId=$activeLevelId",
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun saveShowHelpOnStart(showHelpOnStart: Boolean) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[showHelpOnStartKey] = showHelpOnStart
|
||||
}
|
||||
AppLog.d("ProgressStore", "Saved showHelpOnStart=$showHelpOnStart")
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,21 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -33,10 +41,12 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -78,6 +88,9 @@ fun GitHugApp() {
|
||||
var solvedCelebrationTitle by remember { mutableStateOf<String?>(null) }
|
||||
var showTerminalInputHint by remember { mutableStateOf(true) }
|
||||
var showExerciseDescriptionHint by remember { mutableStateOf(true) }
|
||||
var showHelpOverlay by remember { mutableStateOf(false) }
|
||||
var showHelpOnStart by remember { mutableStateOf(true) }
|
||||
var helpPreferenceInitialized by remember { mutableStateOf(false) }
|
||||
val currentLevel = levels[currentLevelIndex]
|
||||
|
||||
LaunchedEffect(solvedCelebrationTitle) {
|
||||
@@ -119,6 +132,11 @@ fun GitHugApp() {
|
||||
val restoredProgress = persistedProgress ?: return@LaunchedEffect
|
||||
val restoredCompletedLevels = restoredProgress.completedLevels
|
||||
completedLevels = restoredCompletedLevels
|
||||
showHelpOnStart = restoredProgress.showHelpOnStart
|
||||
if (!helpPreferenceInitialized) {
|
||||
helpPreferenceInitialized = true
|
||||
showHelpOverlay = restoredProgress.showHelpOnStart
|
||||
}
|
||||
AppLog.d(
|
||||
"GitHugApp",
|
||||
"Observed persisted progress completed=${restoredCompletedLevels.sorted()} activeLevelId=${restoredProgress.activeLevelId} restored=$hasRestoredProgress",
|
||||
@@ -271,9 +289,12 @@ fun GitHugApp() {
|
||||
val beforeCursor = commandInput.text.substring(0, cursor)
|
||||
val tokenStart = beforeCursor.lastIndexOf(' ').let { if (it == -1) 0 else it + 1 }
|
||||
val token = commandInput.text.substring(tokenStart, cursor)
|
||||
if (token.isBlank()) return
|
||||
val leadingCommand = beforeCursor.trimStart().substringBefore(' ')
|
||||
val isCdCompletion = leadingCommand == "cd"
|
||||
if (token.isBlank() && !isCdCompletion) return
|
||||
|
||||
val matches = repo.files.map { it.name }.sorted().filter { it.startsWith(token) }
|
||||
val candidates = if (isCdCompletion) directoryCompletionCandidates(repo) else fileCompletionCandidates(repo)
|
||||
val matches = candidates.sorted().filter { it.startsWith(token) }
|
||||
if (matches.isEmpty()) return
|
||||
|
||||
val replacement = if (matches.size == 1) matches.first() else commonPrefix(matches)
|
||||
@@ -413,6 +434,7 @@ fun GitHugApp() {
|
||||
val raw = submittedText.trim()
|
||||
if (raw.isBlank()) return
|
||||
|
||||
showHelpOverlay = false
|
||||
showTerminalInputHint = false
|
||||
showExerciseDescriptionHint = false
|
||||
suppressedImeEcho = submittedText
|
||||
@@ -457,93 +479,114 @@ fun GitHugApp() {
|
||||
.padding(padding),
|
||||
color = AppBackground,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(AppBackground)
|
||||
.verticalScroll(screenScrollState)
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FixedHeader()
|
||||
PaneWorkspace(
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
paneLayout = paneLayout,
|
||||
onMovePane = { paneId, delta ->
|
||||
updatePaneLayout(transform = { layout -> movePane(layout, paneId, delta) })
|
||||
},
|
||||
onTogglePane = { paneId ->
|
||||
updatePaneLayout(transform = { layout ->
|
||||
val collapsed = layout.collapsed.toMutableSet()
|
||||
if (!collapsed.add(paneId)) collapsed.remove(paneId)
|
||||
layout.copy(collapsed = collapsed)
|
||||
})
|
||||
},
|
||||
levelsContent = {
|
||||
LevelsPane(levels, currentLevelIndex, completedLevels) { index -> loadLevel(index) }
|
||||
},
|
||||
visualContent = { VisualPane(repo) },
|
||||
exerciseContent = {
|
||||
ExercisePane(
|
||||
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
|
||||
}
|
||||
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 = {
|
||||
showExerciseDescriptionHint = false
|
||||
resetCurrentLevel()
|
||||
},
|
||||
)
|
||||
},
|
||||
terminalContent = {
|
||||
TerminalPane(
|
||||
output = output,
|
||||
inputFieldVersion = inputFieldVersion,
|
||||
commandInput = commandInput,
|
||||
showInputHint = showTerminalInputHint,
|
||||
onInputHintDismiss = { showTerminalInputHint = false },
|
||||
onValueChange = {
|
||||
val blockedEcho = suppressedImeEcho
|
||||
if (blockedEcho != null && commandInput.text.isEmpty()) {
|
||||
val blockedTrimmed = blockedEcho.trim()
|
||||
if (it.text == blockedEcho || (blockedTrimmed.isNotEmpty() && it.text == blockedTrimmed)) {
|
||||
return@TerminalPane
|
||||
}
|
||||
}
|
||||
suppressedImeEcho = null
|
||||
if (it.text.isNotEmpty()) {
|
||||
showTerminalInputHint = false
|
||||
.fillMaxSize()
|
||||
.background(AppBackground)
|
||||
.imePadding()
|
||||
.verticalScroll(screenScrollState)
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FixedHeader()
|
||||
PaneWorkspace(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
paneLayout = paneLayout,
|
||||
onMovePane = { paneId, delta ->
|
||||
updatePaneLayout(transform = { layout -> movePane(layout, paneId, delta) })
|
||||
},
|
||||
onTogglePane = { paneId ->
|
||||
updatePaneLayout(transform = { layout ->
|
||||
val collapsed = layout.collapsed.toMutableSet()
|
||||
if (!collapsed.add(paneId)) collapsed.remove(paneId)
|
||||
layout.copy(collapsed = collapsed)
|
||||
})
|
||||
},
|
||||
levelsContent = {
|
||||
LevelsPane(levels, currentLevelIndex, completedLevels) { index -> loadLevel(index) }
|
||||
},
|
||||
visualContent = { VisualPane(repo) },
|
||||
exerciseContent = {
|
||||
ExercisePane(
|
||||
level = currentLevel,
|
||||
visibleHint = if (activeExerciseDetail == ExerciseDetailPanel.HINT) visibleHint else null,
|
||||
suggestionsExpanded = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS,
|
||||
showDescriptionHint = false,
|
||||
onToggleSuggestions = {
|
||||
showExerciseDescriptionHint = false
|
||||
}
|
||||
commandInput = it
|
||||
},
|
||||
onRun = { runCommand() },
|
||||
onTab = { tabComplete() },
|
||||
onHelp = { showCommandHelp() },
|
||||
onCursorLeft = { moveCursor(-1) },
|
||||
onCursorRight = { moveCursor(1) },
|
||||
onHistoryUp = { historyUp() },
|
||||
onHistoryDown = { historyDown() },
|
||||
)
|
||||
},
|
||||
)
|
||||
showHelpOverlay = false
|
||||
activeExerciseDetail = if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) null else ExerciseDetailPanel.SUGGESTIONS
|
||||
if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) {
|
||||
visibleHint = null
|
||||
}
|
||||
applyRecommendedPaneWeights(persist = false)
|
||||
},
|
||||
onHint = {
|
||||
showExerciseDescriptionHint = false
|
||||
showHelpOverlay = 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 = {
|
||||
showExerciseDescriptionHint = false
|
||||
showHelpOverlay = false
|
||||
resetCurrentLevel()
|
||||
},
|
||||
)
|
||||
},
|
||||
terminalContent = {
|
||||
TerminalPane(
|
||||
output = output,
|
||||
inputFieldVersion = inputFieldVersion,
|
||||
commandInput = commandInput,
|
||||
showInputHint = false,
|
||||
onInputHintDismiss = {
|
||||
showTerminalInputHint = false
|
||||
showHelpOverlay = false
|
||||
},
|
||||
onValueChange = {
|
||||
val blockedEcho = suppressedImeEcho
|
||||
if (blockedEcho != null && commandInput.text.isEmpty()) {
|
||||
val blockedTrimmed = blockedEcho.trim()
|
||||
if (it.text == blockedEcho || (blockedTrimmed.isNotEmpty() && it.text == blockedTrimmed)) {
|
||||
return@TerminalPane
|
||||
}
|
||||
}
|
||||
suppressedImeEcho = null
|
||||
if (it.text.isNotEmpty()) {
|
||||
showTerminalInputHint = false
|
||||
showExerciseDescriptionHint = false
|
||||
showHelpOverlay = false
|
||||
}
|
||||
commandInput = it
|
||||
},
|
||||
onRun = { runCommand() },
|
||||
onTab = { tabComplete() },
|
||||
onHelp = { showCommandHelp() },
|
||||
onCursorLeft = { moveCursor(-1) },
|
||||
onCursorRight = { moveCursor(1) },
|
||||
onHistoryUp = { historyUp() },
|
||||
onHistoryDown = { historyDown() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showHelpOverlay) {
|
||||
HelpCalloutOverlay(
|
||||
showHelpOnStart = showHelpOnStart,
|
||||
onShowHelpOnStartChange = { showHelpOnStart = it },
|
||||
onOk = {
|
||||
showHelpOverlay = false
|
||||
scope.launch { gameProgressStore.saveShowHelpOnStart(showHelpOnStart) }
|
||||
},
|
||||
onClose = { showHelpOverlay = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -586,6 +629,135 @@ fun GitHugApp() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun fileCompletionCandidates(repo: RepoState): List<String> {
|
||||
return repo.files
|
||||
.filterNot { it.deleted }
|
||||
.map { it.name }
|
||||
}
|
||||
|
||||
private fun directoryCompletionCandidates(repo: RepoState): List<String> {
|
||||
return repo.files
|
||||
.filterNot { it.deleted }
|
||||
.flatMap { file ->
|
||||
val parts = file.name.split('/').dropLast(1)
|
||||
parts.indices.map { index -> parts.take(index + 1).joinToString("/") + "/" }
|
||||
}
|
||||
.distinct()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HelpCalloutOverlay(
|
||||
showHelpOnStart: Boolean,
|
||||
onShowHelpOnStartChange: (Boolean) -> Unit,
|
||||
onOk: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) {
|
||||
HelpBubble(
|
||||
text = "Read the exercise description, then solve it by entering commands below.",
|
||||
modifier = Modifier.align(Alignment.TopStart),
|
||||
)
|
||||
HelpBubble(
|
||||
text = "Tap the prompt to enter a command.",
|
||||
modifier = Modifier.align(Alignment.BottomStart),
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.fillMaxWidth(),
|
||||
color = PanelPrimary.copy(alpha = 0.97f),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
shadowElevation = 8.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Help",
|
||||
color = TextPrimary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
TextButton(onClick = onClose) {
|
||||
Text("Close", color = Accent)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Checkbox(
|
||||
checked = showHelpOnStart,
|
||||
onCheckedChange = onShowHelpOnStartChange,
|
||||
colors = CheckboxDefaults.colors(
|
||||
checkedColor = Accent,
|
||||
uncheckedColor = TextSecondary,
|
||||
checkmarkColor = AppBackground,
|
||||
),
|
||||
)
|
||||
Text("Show help on start", color = TextPrimary)
|
||||
}
|
||||
Button(
|
||||
onClick = onOk,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Accent,
|
||||
contentColor = AppBackground,
|
||||
),
|
||||
) {
|
||||
Text("OK")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HelpBubble(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val bubbleColor = Color(0xFFFFF1A8)
|
||||
Column(modifier = modifier.fillMaxWidth(0.86f)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(bubbleColor, RoundedCornerShape(18.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
color = Color(0xFF161000),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.padding(start = 28.dp)
|
||||
.size(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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SolvedCelebrationOverlay(title: String) {
|
||||
val transition = rememberInfiniteTransition(label = "solved-celebration")
|
||||
|
||||
@@ -65,9 +65,14 @@ class GitRepositoryRuntime(private val context: Context) {
|
||||
val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile
|
||||
.takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot
|
||||
|
||||
val tokens = GitSandboxEngine.tokenizeCommand(command)
|
||||
val shellTokens = GitSandboxEngine.tokenizeShellCommand(command)
|
||||
val tokens = shellTokens.map { it.value }
|
||||
if (tokens.isEmpty()) return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to emptyList()
|
||||
val expandedTokens = expandShellPathspecs(currentRepo, tokens)
|
||||
val expandedTokens = if (tokens.firstOrNull() == "echo") {
|
||||
tokens
|
||||
} else {
|
||||
expandShellPathspecs(currentRepo, shellTokens)
|
||||
}
|
||||
|
||||
val result = when (expandedTokens.first()) {
|
||||
"git" -> currentRepo to runGit(nativeGit, workingDir, expandedTokens.drop(1)).outputLines
|
||||
@@ -536,9 +541,9 @@ class GitRepositoryRuntime(private val context: Context) {
|
||||
|
||||
private fun sandboxDir(level: Level): File = File(sandboxesRoot, level.id)
|
||||
|
||||
private fun expandShellPathspecs(repo: RepoState, tokens: List<String>): List<String> {
|
||||
if (tokens.size <= 1) return tokens
|
||||
return listOf(tokens.first()) + GitSandboxEngine.expandPathspecs(repo, tokens.drop(1))
|
||||
private fun expandShellPathspecs(repo: RepoState, tokens: List<GitSandboxEngine.ShellToken>): List<String> {
|
||||
if (tokens.size <= 1) return tokens.map { it.value }
|
||||
return listOf(tokens.first().value) + GitSandboxEngine.expandPathspecTokens(repo, tokens.drop(1))
|
||||
}
|
||||
|
||||
private fun runGit(binary: File, workingDir: File, arguments: List<String>): ProcessExecutionResult {
|
||||
|
||||
@@ -16,9 +16,16 @@ import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -38,6 +45,29 @@ fun ManPageDialog(
|
||||
) {
|
||||
val horizontalScrollState = rememberScrollState()
|
||||
val verticalScrollState = rememberScrollState()
|
||||
var searchQuery by remember(state.content) { mutableStateOf("") }
|
||||
var selectedMatch by remember(state.content) { mutableStateOf(0) }
|
||||
val contentLines = remember(state.content) { state.content.lines() }
|
||||
val matches = remember(searchQuery, contentLines) {
|
||||
if (searchQuery.isBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
contentLines.mapIndexedNotNull { index, line ->
|
||||
index.takeIf { line.contains(searchQuery, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
val matchCount = matches.size
|
||||
val currentMatchNumber = if (matchCount == 0) 0 else selectedMatch + 1
|
||||
|
||||
LaunchedEffect(searchQuery, matchCount) {
|
||||
selectedMatch = selectedMatch.coerceIn(0, (matchCount - 1).coerceAtLeast(0))
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedMatch, matches) {
|
||||
val lineIndex = matches.getOrNull(selectedMatch) ?: return@LaunchedEffect
|
||||
verticalScrollState.animateScrollTo((lineIndex * 18).coerceAtMost(verticalScrollState.maxValue))
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = onClose) {
|
||||
Surface(
|
||||
@@ -71,6 +101,59 @@ fun ManPageDialog(
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextField(
|
||||
value = searchQuery,
|
||||
onValueChange = {
|
||||
searchQuery = it
|
||||
selectedMatch = 0
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
placeholder = { Text("Search") },
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedTextColor = TextPrimary,
|
||||
unfocusedTextColor = TextPrimary,
|
||||
focusedContainerColor = PanelSecondary,
|
||||
unfocusedContainerColor = PanelSecondary,
|
||||
focusedIndicatorColor = Accent,
|
||||
unfocusedIndicatorColor = TextMuted,
|
||||
cursorColor = Accent,
|
||||
),
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
if (matchCount > 0) selectedMatch = (selectedMatch - 1 + matchCount) % matchCount
|
||||
},
|
||||
enabled = matchCount > 0,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PanelSecondary,
|
||||
contentColor = TextPrimary,
|
||||
),
|
||||
) {
|
||||
Text("Prev")
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
if (matchCount > 0) selectedMatch = (selectedMatch + 1) % matchCount
|
||||
},
|
||||
enabled = matchCount > 0,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PanelSecondary,
|
||||
contentColor = TextPrimary,
|
||||
),
|
||||
) {
|
||||
Text("Next")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "$currentMatchNumber/$matchCount",
|
||||
color = TextSecondary,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
Text(
|
||||
text = "git ${state.topic}",
|
||||
color = TextPrimary,
|
||||
|
||||
@@ -55,6 +55,28 @@ class GitSandboxEngineTest {
|
||||
assertTrue(updatedRepo.files.any { it.name == "README" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun singleQuotedWildcardIsNotExpanded() {
|
||||
val repo = RepoState(
|
||||
initialized = true,
|
||||
files = listOf(GitFile("README"), GitFile("main.kt")),
|
||||
)
|
||||
|
||||
val (_, output) = GitSandboxEngine.execute(repo, "echo '*'")
|
||||
|
||||
assertEquals(listOf("*"), output)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun echoRedirectAcceptsNoSurroundingSpaces() {
|
||||
val repo = RepoState(initialized = true)
|
||||
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "echo '*.swp'>.gitignore")
|
||||
|
||||
assertTrue(output.isEmpty())
|
||||
assertEquals("*.swp", updatedRepo.files.single { it.name == ".gitignore" }.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun commitHashAnswerAcceptsFullHashThatStartsWithDisplayedShortHash() {
|
||||
val repo = RepoState(
|
||||
@@ -88,6 +110,18 @@ class GitSandboxEngineTest {
|
||||
assertTrue("src/main.kt" in output)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lsShowsDotfiles() {
|
||||
val repo = RepoState(
|
||||
initialized = true,
|
||||
files = listOf(GitFile(".gitignore"), GitFile("README")),
|
||||
)
|
||||
|
||||
val (_, output) = GitSandboxEngine.execute(repo, "ls")
|
||||
|
||||
assertTrue(".gitignore" in output)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cdDotDotShortcutMovesToParentDirectory() {
|
||||
val repo = RepoState(initialized = true, currentDir = "src/main")
|
||||
|
||||
Reference in New Issue
Block a user