Misc Changes
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 = 116
|
versionCode = 117
|
||||||
versionName = "0.1.115"
|
versionName = "0.1.116"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ val RepoStateSaver = listSaver<RepoState, Any>(
|
|||||||
)
|
)
|
||||||
|
|
||||||
object GitSandboxEngine {
|
object GitSandboxEngine {
|
||||||
|
data class ShellToken(
|
||||||
|
val value: String,
|
||||||
|
val quoted: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
fun commandReferenceLines(): List<String> = listOf(
|
fun commandReferenceLines(): List<String> = listOf(
|
||||||
"Available sandbox commands:",
|
"Available sandbox commands:",
|
||||||
" git ",
|
" git ",
|
||||||
@@ -109,7 +114,8 @@ object GitSandboxEngine {
|
|||||||
)
|
)
|
||||||
|
|
||||||
fun execute(repo: RepoState, command: String): Pair<RepoState, List<String>> {
|
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()
|
if (parts.isEmpty()) return repo to emptyList()
|
||||||
return when {
|
return when {
|
||||||
parts[0] == "help" || parts[0] == "?" -> repo to commandReferenceLines()
|
parts[0] == "help" || parts[0] == "?" -> repo to commandReferenceLines()
|
||||||
@@ -129,7 +135,7 @@ object GitSandboxEngine {
|
|||||||
}
|
}
|
||||||
}) to emptyList()
|
}) 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] == "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] == "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'.")
|
||||||
@@ -157,7 +163,7 @@ object GitSandboxEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
parts.size >= 3 && parts[1] == "rm" -> removeGitPath(repo, parts.drop(2))
|
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] == "commit" -> commit(repo, parts.drop(2))
|
||||||
parts.size >= 2 && parts[1] == "log" -> {
|
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")
|
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> {
|
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()
|
val current = StringBuilder()
|
||||||
var quoteChar: Char? = null
|
var quoteChar: Char? = null
|
||||||
var escaping = false
|
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 {
|
when {
|
||||||
escaping -> {
|
escaping -> {
|
||||||
current.append(char)
|
current.append(char)
|
||||||
@@ -217,11 +241,18 @@ object GitSandboxEngine {
|
|||||||
}
|
}
|
||||||
char == '"' || char == '\'' -> {
|
char == '"' || char == '\'' -> {
|
||||||
quoteChar = char
|
quoteChar = char
|
||||||
|
currentQuoted = true
|
||||||
}
|
}
|
||||||
char.isWhitespace() -> {
|
char.isWhitespace() -> {
|
||||||
if (current.isNotEmpty()) {
|
emitCurrent()
|
||||||
result += current.toString()
|
}
|
||||||
current.clear()
|
char == '>' -> {
|
||||||
|
emitCurrent()
|
||||||
|
if (command.getOrNull(index + 1) == '>') {
|
||||||
|
result += ShellToken(">>")
|
||||||
|
skipNext = true
|
||||||
|
} else if (command.getOrNull(index - 1) != '>') {
|
||||||
|
result += ShellToken(">")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> current.append(char)
|
else -> current.append(char)
|
||||||
@@ -231,14 +262,13 @@ object GitSandboxEngine {
|
|||||||
if (escaping) {
|
if (escaping) {
|
||||||
current.append('\\')
|
current.append('\\')
|
||||||
}
|
}
|
||||||
if (current.isNotEmpty()) {
|
emitCurrent()
|
||||||
result += current.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
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 == ">>" }
|
val redirectIndex = parts.indexOfFirst { it == ">" || it == ">>" }
|
||||||
if (redirectIndex == -1 || redirectIndex == parts.lastIndex) {
|
if (redirectIndex == -1 || redirectIndex == parts.lastIndex) {
|
||||||
return repo to listOf(parts.drop(1).joinToString(" "))
|
return repo to listOf(parts.drop(1).joinToString(" "))
|
||||||
@@ -266,8 +296,13 @@ object GitSandboxEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun expandPathspecs(repo: RepoState, arguments: List<String>): List<String> {
|
fun expandPathspecs(repo: RepoState, arguments: List<String>): List<String> {
|
||||||
return arguments.flatMap { argument ->
|
return expandPathspecTokens(repo, arguments.map { ShellToken(it) })
|
||||||
if (!argument.hasGlob()) {
|
}
|
||||||
|
|
||||||
|
fun expandPathspecTokens(repo: RepoState, arguments: List<ShellToken>): List<String> {
|
||||||
|
return arguments.flatMap { token ->
|
||||||
|
val argument = token.value
|
||||||
|
if (token.quoted || !argument.hasGlob()) {
|
||||||
listOf(argument)
|
listOf(argument)
|
||||||
} else {
|
} else {
|
||||||
val regex = argument.globToRegex()
|
val regex = argument.globToRegex()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package solutions.tretter.githugandroid
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.emptyPreferences
|
import androidx.datastore.preferences.core.emptyPreferences
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
@@ -22,10 +23,12 @@ class GameProgressStore(private val context: Context) {
|
|||||||
|
|
||||||
private val completedLevelsKey = stringPreferencesKey("completed_levels")
|
private val completedLevelsKey = stringPreferencesKey("completed_levels")
|
||||||
private val activeLevelIdKey = stringPreferencesKey("active_level_id")
|
private val activeLevelIdKey = stringPreferencesKey("active_level_id")
|
||||||
|
private val showHelpOnStartKey = booleanPreferencesKey("show_help_on_start")
|
||||||
|
|
||||||
data class GameProgress(
|
data class GameProgress(
|
||||||
val completedLevels: Set<String> = emptySet(),
|
val completedLevels: Set<String> = emptySet(),
|
||||||
val activeLevelId: String? = null,
|
val activeLevelId: String? = null,
|
||||||
|
val showHelpOnStart: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
val progressFlow: Flow<GameProgress> = dataStore.data
|
val progressFlow: Flow<GameProgress> = dataStore.data
|
||||||
@@ -45,13 +48,15 @@ class GameProgressStore(private val context: Context) {
|
|||||||
?.toSet()
|
?.toSet()
|
||||||
?: emptySet()
|
?: emptySet()
|
||||||
val activeLevelId = preferences[activeLevelIdKey]?.takeIf { it.isNotBlank() }
|
val activeLevelId = preferences[activeLevelIdKey]?.takeIf { it.isNotBlank() }
|
||||||
|
val showHelpOnStart = preferences[showHelpOnStartKey] ?: true
|
||||||
AppLog.d(
|
AppLog.d(
|
||||||
"ProgressStore",
|
"ProgressStore",
|
||||||
"Loaded progress completed=${completedLevels.sorted()} activeLevelId=$activeLevelId",
|
"Loaded progress completed=${completedLevels.sorted()} activeLevelId=$activeLevelId showHelpOnStart=$showHelpOnStart",
|
||||||
)
|
)
|
||||||
GameProgress(
|
GameProgress(
|
||||||
completedLevels = completedLevels,
|
completedLevels = completedLevels,
|
||||||
activeLevelId = activeLevelId,
|
activeLevelId = activeLevelId,
|
||||||
|
showHelpOnStart = showHelpOnStart,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,4 +74,11 @@ class GameProgressStore(private val context: Context) {
|
|||||||
"Saved progress completed=${completedLevels.sorted()} activeLevelId=$activeLevelId",
|
"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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
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.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
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.MaterialTheme
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -33,10 +41,12 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.text.TextRange
|
import androidx.compose.ui.text.TextRange
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -78,6 +88,9 @@ fun GitHugApp() {
|
|||||||
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) }
|
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]
|
val currentLevel = levels[currentLevelIndex]
|
||||||
|
|
||||||
LaunchedEffect(solvedCelebrationTitle) {
|
LaunchedEffect(solvedCelebrationTitle) {
|
||||||
@@ -119,6 +132,11 @@ fun GitHugApp() {
|
|||||||
val restoredProgress = persistedProgress ?: return@LaunchedEffect
|
val restoredProgress = persistedProgress ?: return@LaunchedEffect
|
||||||
val restoredCompletedLevels = restoredProgress.completedLevels
|
val restoredCompletedLevels = restoredProgress.completedLevels
|
||||||
completedLevels = restoredCompletedLevels
|
completedLevels = restoredCompletedLevels
|
||||||
|
showHelpOnStart = restoredProgress.showHelpOnStart
|
||||||
|
if (!helpPreferenceInitialized) {
|
||||||
|
helpPreferenceInitialized = true
|
||||||
|
showHelpOverlay = restoredProgress.showHelpOnStart
|
||||||
|
}
|
||||||
AppLog.d(
|
AppLog.d(
|
||||||
"GitHugApp",
|
"GitHugApp",
|
||||||
"Observed persisted progress completed=${restoredCompletedLevels.sorted()} activeLevelId=${restoredProgress.activeLevelId} restored=$hasRestoredProgress",
|
"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 beforeCursor = commandInput.text.substring(0, cursor)
|
||||||
val tokenStart = beforeCursor.lastIndexOf(' ').let { if (it == -1) 0 else it + 1 }
|
val tokenStart = beforeCursor.lastIndexOf(' ').let { if (it == -1) 0 else it + 1 }
|
||||||
val token = commandInput.text.substring(tokenStart, cursor)
|
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
|
if (matches.isEmpty()) return
|
||||||
|
|
||||||
val replacement = if (matches.size == 1) matches.first() else commonPrefix(matches)
|
val replacement = if (matches.size == 1) matches.first() else commonPrefix(matches)
|
||||||
@@ -413,6 +434,7 @@ fun GitHugApp() {
|
|||||||
val raw = submittedText.trim()
|
val raw = submittedText.trim()
|
||||||
if (raw.isBlank()) return
|
if (raw.isBlank()) return
|
||||||
|
|
||||||
|
showHelpOverlay = false
|
||||||
showTerminalInputHint = false
|
showTerminalInputHint = false
|
||||||
showExerciseDescriptionHint = false
|
showExerciseDescriptionHint = false
|
||||||
suppressedImeEcho = submittedText
|
suppressedImeEcho = submittedText
|
||||||
@@ -457,93 +479,114 @@ fun GitHugApp() {
|
|||||||
.padding(padding),
|
.padding(padding),
|
||||||
color = AppBackground,
|
color = AppBackground,
|
||||||
) {
|
) {
|
||||||
Column(
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
modifier = Modifier
|
Column(
|
||||||
.fillMaxSize()
|
|
||||||
.background(AppBackground)
|
|
||||||
.verticalScroll(screenScrollState)
|
|
||||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
FixedHeader()
|
|
||||||
PaneWorkspace(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(),
|
.fillMaxSize()
|
||||||
paneLayout = paneLayout,
|
.background(AppBackground)
|
||||||
onMovePane = { paneId, delta ->
|
.imePadding()
|
||||||
updatePaneLayout(transform = { layout -> movePane(layout, paneId, delta) })
|
.verticalScroll(screenScrollState)
|
||||||
},
|
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
onTogglePane = { paneId ->
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
updatePaneLayout(transform = { layout ->
|
) {
|
||||||
val collapsed = layout.collapsed.toMutableSet()
|
FixedHeader()
|
||||||
if (!collapsed.add(paneId)) collapsed.remove(paneId)
|
PaneWorkspace(
|
||||||
layout.copy(collapsed = collapsed)
|
modifier = Modifier
|
||||||
})
|
.fillMaxWidth(),
|
||||||
},
|
paneLayout = paneLayout,
|
||||||
levelsContent = {
|
onMovePane = { paneId, delta ->
|
||||||
LevelsPane(levels, currentLevelIndex, completedLevels) { index -> loadLevel(index) }
|
updatePaneLayout(transform = { layout -> movePane(layout, paneId, delta) })
|
||||||
},
|
},
|
||||||
visualContent = { VisualPane(repo) },
|
onTogglePane = { paneId ->
|
||||||
exerciseContent = {
|
updatePaneLayout(transform = { layout ->
|
||||||
ExercisePane(
|
val collapsed = layout.collapsed.toMutableSet()
|
||||||
level = currentLevel,
|
if (!collapsed.add(paneId)) collapsed.remove(paneId)
|
||||||
visibleHint = if (activeExerciseDetail == ExerciseDetailPanel.HINT) visibleHint else null,
|
layout.copy(collapsed = collapsed)
|
||||||
suggestionsExpanded = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS,
|
})
|
||||||
showDescriptionHint = showExerciseDescriptionHint,
|
},
|
||||||
onToggleSuggestions = {
|
levelsContent = {
|
||||||
showExerciseDescriptionHint = false
|
LevelsPane(levels, currentLevelIndex, completedLevels) { index -> loadLevel(index) }
|
||||||
activeExerciseDetail = if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) null else ExerciseDetailPanel.SUGGESTIONS
|
},
|
||||||
if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) {
|
visualContent = { VisualPane(repo) },
|
||||||
visibleHint = null
|
exerciseContent = {
|
||||||
}
|
ExercisePane(
|
||||||
applyRecommendedPaneWeights(persist = false)
|
level = currentLevel,
|
||||||
},
|
visibleHint = if (activeExerciseDetail == ExerciseDetailPanel.HINT) visibleHint else null,
|
||||||
onHint = {
|
suggestionsExpanded = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS,
|
||||||
showExerciseDescriptionHint = false
|
showDescriptionHint = false,
|
||||||
val hint = currentLevel.hints.getOrNull(hintIndex) ?: "No more hints for this level."
|
onToggleSuggestions = {
|
||||||
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
|
|
||||||
showExerciseDescriptionHint = false
|
showExerciseDescriptionHint = false
|
||||||
}
|
showHelpOverlay = false
|
||||||
commandInput = it
|
activeExerciseDetail = if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) null else ExerciseDetailPanel.SUGGESTIONS
|
||||||
},
|
if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) {
|
||||||
onRun = { runCommand() },
|
visibleHint = null
|
||||||
onTab = { tabComplete() },
|
}
|
||||||
onHelp = { showCommandHelp() },
|
applyRecommendedPaneWeights(persist = false)
|
||||||
onCursorLeft = { moveCursor(-1) },
|
},
|
||||||
onCursorRight = { moveCursor(1) },
|
onHint = {
|
||||||
onHistoryUp = { historyUp() },
|
showExerciseDescriptionHint = false
|
||||||
onHistoryDown = { historyDown() },
|
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
|
@Composable
|
||||||
private fun SolvedCelebrationOverlay(title: String) {
|
private fun SolvedCelebrationOverlay(title: String) {
|
||||||
val transition = rememberInfiniteTransition(label = "solved-celebration")
|
val transition = rememberInfiniteTransition(label = "solved-celebration")
|
||||||
|
|||||||
@@ -65,9 +65,14 @@ class GitRepositoryRuntime(private val context: Context) {
|
|||||||
val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile
|
val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile
|
||||||
.takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot
|
.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()
|
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()) {
|
val result = when (expandedTokens.first()) {
|
||||||
"git" -> currentRepo to runGit(nativeGit, workingDir, expandedTokens.drop(1)).outputLines
|
"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 sandboxDir(level: Level): File = File(sandboxesRoot, level.id)
|
||||||
|
|
||||||
private fun expandShellPathspecs(repo: RepoState, tokens: List<String>): List<String> {
|
private fun expandShellPathspecs(repo: RepoState, tokens: List<GitSandboxEngine.ShellToken>): List<String> {
|
||||||
if (tokens.size <= 1) return tokens
|
if (tokens.size <= 1) return tokens.map { it.value }
|
||||||
return listOf(tokens.first()) + GitSandboxEngine.expandPathspecs(repo, tokens.drop(1))
|
return listOf(tokens.first().value) + GitSandboxEngine.expandPathspecTokens(repo, tokens.drop(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun runGit(binary: File, workingDir: File, arguments: List<String>): ProcessExecutionResult {
|
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.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.Modifier
|
||||||
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
|
||||||
@@ -38,6 +45,29 @@ fun ManPageDialog(
|
|||||||
) {
|
) {
|
||||||
val horizontalScrollState = rememberScrollState()
|
val horizontalScrollState = rememberScrollState()
|
||||||
val verticalScrollState = 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) {
|
Dialog(onDismissRequest = onClose) {
|
||||||
Surface(
|
Surface(
|
||||||
@@ -71,6 +101,59 @@ fun ManPageDialog(
|
|||||||
Text("Close")
|
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(
|
||||||
text = "git ${state.topic}",
|
text = "git ${state.topic}",
|
||||||
color = TextPrimary,
|
color = TextPrimary,
|
||||||
|
|||||||
@@ -55,6 +55,28 @@ class GitSandboxEngineTest {
|
|||||||
assertTrue(updatedRepo.files.any { it.name == "README" })
|
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
|
@Test
|
||||||
fun commitHashAnswerAcceptsFullHashThatStartsWithDisplayedShortHash() {
|
fun commitHashAnswerAcceptsFullHashThatStartsWithDisplayedShortHash() {
|
||||||
val repo = RepoState(
|
val repo = RepoState(
|
||||||
@@ -88,6 +110,18 @@ class GitSandboxEngineTest {
|
|||||||
assertTrue("src/main.kt" in output)
|
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
|
@Test
|
||||||
fun cdDotDotShortcutMovesToParentDirectory() {
|
fun cdDotDotShortcutMovesToParentDirectory() {
|
||||||
val repo = RepoState(initialized = true, currentDir = "src/main")
|
val repo = RepoState(initialized = true, currentDir = "src/main")
|
||||||
|
|||||||
Reference in New Issue
Block a user