From 5727022baf927e66e12de0c7372f7aba02f09918 Mon Sep 17 00:00:00 2001 From: Joe Tretter Date: Wed, 6 May 2026 20:44:34 -0500 Subject: [PATCH] Misc Changes --- app/build.gradle.kts | 4 +- .../tretter/githugandroid/GameModels.kt | 63 +++- .../githugandroid/GameProgressStore.kt | 16 +- .../tretter/githugandroid/GitHugApp.kt | 346 +++++++++++++----- .../tretter/githugandroid/GitRuntime.kt | 15 +- .../tretter/githugandroid/ManPageDialog.kt | 83 +++++ .../githugandroid/GitSandboxEngineTest.kt | 34 ++ 7 files changed, 451 insertions(+), 110 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9e9ff7b..c0da005 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 diff --git a/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt b/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt index 77572cc..153a67d 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt @@ -92,6 +92,11 @@ val RepoStateSaver = listSaver( ) object GitSandboxEngine { + data class ShellToken( + val value: String, + val quoted: Boolean = false, + ) + fun commandReferenceLines(): List = listOf( "Available sandbox commands:", " git ", @@ -109,7 +114,8 @@ object GitSandboxEngine { ) fun execute(repo: RepoState, command: String): Pair> { - 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 { - val result = mutableListOf() + return tokenizeShellCommand(command).map { it.value } + } + + fun tokenizeShellCommand(command: String): List { + val result = mutableListOf() 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): Pair> { + private fun writeEcho(repo: RepoState, shellParts: List): Pair> { + 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): List { - return arguments.flatMap { argument -> - if (!argument.hasGlob()) { + return expandPathspecTokens(repo, arguments.map { ShellToken(it) }) + } + + fun expandPathspecTokens(repo: RepoState, arguments: List): List { + return arguments.flatMap { token -> + val argument = token.value + if (token.quoted || !argument.hasGlob()) { listOf(argument) } else { val regex = argument.globToRegex() diff --git a/app/src/main/java/solutions/tretter/githugandroid/GameProgressStore.kt b/app/src/main/java/solutions/tretter/githugandroid/GameProgressStore.kt index 7ce2e26..965bd13 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GameProgressStore.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GameProgressStore.kt @@ -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 = emptySet(), val activeLevelId: String? = null, + val showHelpOnStart: Boolean = true, ) val progressFlow: Flow = 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", ) } -} \ No newline at end of file + + suspend fun saveShowHelpOnStart(showHelpOnStart: Boolean) { + dataStore.edit { preferences -> + preferences[showHelpOnStartKey] = showHelpOnStart + } + AppLog.d("ProgressStore", "Saved showHelpOnStart=$showHelpOnStart") + } +} diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt index 5a9d864..d6b9a38 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt @@ -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(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 { + return repo.files + .filterNot { it.deleted } + .map { it.name } +} + +private fun directoryCompletionCandidates(repo: RepoState): List { + 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") diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt index 072ee2f..aff32fb 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt @@ -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): List { - if (tokens.size <= 1) return tokens - return listOf(tokens.first()) + GitSandboxEngine.expandPathspecs(repo, tokens.drop(1)) + private fun expandShellPathspecs(repo: RepoState, tokens: List): List { + 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): ProcessExecutionResult { diff --git a/app/src/main/java/solutions/tretter/githugandroid/ManPageDialog.kt b/app/src/main/java/solutions/tretter/githugandroid/ManPageDialog.kt index 228076d..3b4d2b4 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/ManPageDialog.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/ManPageDialog.kt @@ -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, diff --git a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt index a95f147..c82f922 100644 --- a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt +++ b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt @@ -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")