Misc Changes

This commit is contained in:
Joe Tretter
2026-05-06 20:44:34 -05:00
parent 5c7c7ad29d
commit 5727022baf
7 changed files with 451 additions and 110 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -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")
}
} }

View File

@@ -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")

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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")