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

@@ -92,6 +92,11 @@ val RepoStateSaver = listSaver<RepoState, Any>(
)
object GitSandboxEngine {
data class ShellToken(
val value: String,
val quoted: Boolean = false,
)
fun commandReferenceLines(): List<String> = listOf(
"Available sandbox commands:",
" git ",
@@ -109,7 +114,8 @@ object GitSandboxEngine {
)
fun execute(repo: RepoState, command: String): Pair<RepoState, List<String>> {
val parts = tokenizeCommand(command)
val shellParts = tokenizeShellCommand(command)
val parts = shellParts.map { it.value }
if (parts.isEmpty()) return repo to emptyList()
return when {
parts[0] == "help" || parts[0] == "?" -> repo to commandReferenceLines()
@@ -129,7 +135,7 @@ object GitSandboxEngine {
}
}) to emptyList()
}
parts[0] == "echo" -> writeEcho(repo, parts)
parts[0] == "echo" -> writeEcho(repo, shellParts)
parts[0] == "ls" || parts[0] == "dir" -> repo to repo.files.filterNot { it.deleted }.map { it.name }.ifEmpty { listOf() }
parts[0] == "cd.." -> repo.copy(currentDir = parentDirectory(repo.currentDir)) to emptyList()
parts[0] != "git" -> repo to listOf("Command not supported in sandbox. Try a git command or 'touch'.")
@@ -157,7 +163,7 @@ object GitSandboxEngine {
}
}
parts.size >= 3 && parts[1] == "rm" -> removeGitPath(repo, parts.drop(2))
parts.size >= 4 && parts[1] == "mv" -> moveGitPath(repo, expandPathspecs(repo, parts.drop(2)))
parts.size >= 4 && parts[1] == "mv" -> moveGitPath(repo, expandPathspecTokens(repo, shellParts.drop(2)))
parts.size >= 2 && parts[1] == "commit" -> commit(repo, parts.drop(2))
parts.size >= 2 && parts[1] == "log" -> {
repo to if (repo.commits.isEmpty()) listOf("fatal: your current branch '${repo.headBranch}' does not have any commits yet")
@@ -194,12 +200,30 @@ object GitSandboxEngine {
}
fun tokenizeCommand(command: String): List<String> {
val result = mutableListOf<String>()
return tokenizeShellCommand(command).map { it.value }
}
fun tokenizeShellCommand(command: String): List<ShellToken> {
val result = mutableListOf<ShellToken>()
val current = StringBuilder()
var quoteChar: Char? = null
var escaping = false
var currentQuoted = false
var skipNext = false
command.forEach { char ->
fun emitCurrent(force: Boolean = false) {
if (current.isNotEmpty() || force && currentQuoted) {
result += ShellToken(value = current.toString(), quoted = currentQuoted)
current.clear()
currentQuoted = false
}
}
command.forEachIndexed { index, char ->
if (skipNext) {
skipNext = false
return@forEachIndexed
}
when {
escaping -> {
current.append(char)
@@ -217,11 +241,18 @@ object GitSandboxEngine {
}
char == '"' || char == '\'' -> {
quoteChar = char
currentQuoted = true
}
char.isWhitespace() -> {
if (current.isNotEmpty()) {
result += current.toString()
current.clear()
emitCurrent()
}
char == '>' -> {
emitCurrent()
if (command.getOrNull(index + 1) == '>') {
result += ShellToken(">>")
skipNext = true
} else if (command.getOrNull(index - 1) != '>') {
result += ShellToken(">")
}
}
else -> current.append(char)
@@ -231,14 +262,13 @@ object GitSandboxEngine {
if (escaping) {
current.append('\\')
}
if (current.isNotEmpty()) {
result += current.toString()
}
emitCurrent()
return result
}
private fun writeEcho(repo: RepoState, parts: List<String>): Pair<RepoState, List<String>> {
private fun writeEcho(repo: RepoState, shellParts: List<ShellToken>): Pair<RepoState, List<String>> {
val parts = shellParts.map { it.value }
val redirectIndex = parts.indexOfFirst { it == ">" || it == ">>" }
if (redirectIndex == -1 || redirectIndex == parts.lastIndex) {
return repo to listOf(parts.drop(1).joinToString(" "))
@@ -266,8 +296,13 @@ object GitSandboxEngine {
}
fun expandPathspecs(repo: RepoState, arguments: List<String>): List<String> {
return arguments.flatMap { argument ->
if (!argument.hasGlob()) {
return expandPathspecTokens(repo, arguments.map { ShellToken(it) })
}
fun expandPathspecTokens(repo: RepoState, arguments: List<ShellToken>): List<String> {
return arguments.flatMap { token ->
val argument = token.value
if (token.quoted || !argument.hasGlob()) {
listOf(argument)
} else {
val regex = argument.globToRegex()

View File

@@ -2,6 +2,7 @@ package solutions.tretter.githugandroid
import android.content.Context
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey
@@ -22,10 +23,12 @@ class GameProgressStore(private val context: Context) {
private val completedLevelsKey = stringPreferencesKey("completed_levels")
private val activeLevelIdKey = stringPreferencesKey("active_level_id")
private val showHelpOnStartKey = booleanPreferencesKey("show_help_on_start")
data class GameProgress(
val completedLevels: Set<String> = emptySet(),
val activeLevelId: String? = null,
val showHelpOnStart: Boolean = true,
)
val progressFlow: Flow<GameProgress> = dataStore.data
@@ -45,13 +48,15 @@ class GameProgressStore(private val context: Context) {
?.toSet()
?: emptySet()
val activeLevelId = preferences[activeLevelIdKey]?.takeIf { it.isNotBlank() }
val showHelpOnStart = preferences[showHelpOnStartKey] ?: true
AppLog.d(
"ProgressStore",
"Loaded progress completed=${completedLevels.sorted()} activeLevelId=$activeLevelId",
"Loaded progress completed=${completedLevels.sorted()} activeLevelId=$activeLevelId showHelpOnStart=$showHelpOnStart",
)
GameProgress(
completedLevels = completedLevels,
activeLevelId = activeLevelId,
showHelpOnStart = showHelpOnStart,
)
}
@@ -69,4 +74,11 @@ class GameProgressStore(private val context: Context) {
"Saved progress completed=${completedLevels.sorted()} activeLevelId=$activeLevelId",
)
}
}
suspend fun saveShowHelpOnStart(showHelpOnStart: Boolean) {
dataStore.edit { preferences ->
preferences[showHelpOnStartKey] = showHelpOnStart
}
AppLog.d("ProgressStore", "Saved showHelpOnStart=$showHelpOnStart")
}
}

View File

@@ -11,13 +11,21 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -33,10 +41,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
@@ -78,6 +88,9 @@ fun GitHugApp() {
var solvedCelebrationTitle by remember { mutableStateOf<String?>(null) }
var showTerminalInputHint by remember { mutableStateOf(true) }
var showExerciseDescriptionHint by remember { mutableStateOf(true) }
var showHelpOverlay by remember { mutableStateOf(false) }
var showHelpOnStart by remember { mutableStateOf(true) }
var helpPreferenceInitialized by remember { mutableStateOf(false) }
val currentLevel = levels[currentLevelIndex]
LaunchedEffect(solvedCelebrationTitle) {
@@ -119,6 +132,11 @@ fun GitHugApp() {
val restoredProgress = persistedProgress ?: return@LaunchedEffect
val restoredCompletedLevels = restoredProgress.completedLevels
completedLevels = restoredCompletedLevels
showHelpOnStart = restoredProgress.showHelpOnStart
if (!helpPreferenceInitialized) {
helpPreferenceInitialized = true
showHelpOverlay = restoredProgress.showHelpOnStart
}
AppLog.d(
"GitHugApp",
"Observed persisted progress completed=${restoredCompletedLevels.sorted()} activeLevelId=${restoredProgress.activeLevelId} restored=$hasRestoredProgress",
@@ -271,9 +289,12 @@ fun GitHugApp() {
val beforeCursor = commandInput.text.substring(0, cursor)
val tokenStart = beforeCursor.lastIndexOf(' ').let { if (it == -1) 0 else it + 1 }
val token = commandInput.text.substring(tokenStart, cursor)
if (token.isBlank()) return
val leadingCommand = beforeCursor.trimStart().substringBefore(' ')
val isCdCompletion = leadingCommand == "cd"
if (token.isBlank() && !isCdCompletion) return
val matches = repo.files.map { it.name }.sorted().filter { it.startsWith(token) }
val candidates = if (isCdCompletion) directoryCompletionCandidates(repo) else fileCompletionCandidates(repo)
val matches = candidates.sorted().filter { it.startsWith(token) }
if (matches.isEmpty()) return
val replacement = if (matches.size == 1) matches.first() else commonPrefix(matches)
@@ -413,6 +434,7 @@ fun GitHugApp() {
val raw = submittedText.trim()
if (raw.isBlank()) return
showHelpOverlay = false
showTerminalInputHint = false
showExerciseDescriptionHint = false
suppressedImeEcho = submittedText
@@ -457,93 +479,114 @@ fun GitHugApp() {
.padding(padding),
color = AppBackground,
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(AppBackground)
.verticalScroll(screenScrollState)
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
FixedHeader()
PaneWorkspace(
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxWidth(),
paneLayout = paneLayout,
onMovePane = { paneId, delta ->
updatePaneLayout(transform = { layout -> movePane(layout, paneId, delta) })
},
onTogglePane = { paneId ->
updatePaneLayout(transform = { layout ->
val collapsed = layout.collapsed.toMutableSet()
if (!collapsed.add(paneId)) collapsed.remove(paneId)
layout.copy(collapsed = collapsed)
})
},
levelsContent = {
LevelsPane(levels, currentLevelIndex, completedLevels) { index -> loadLevel(index) }
},
visualContent = { VisualPane(repo) },
exerciseContent = {
ExercisePane(
level = currentLevel,
visibleHint = if (activeExerciseDetail == ExerciseDetailPanel.HINT) visibleHint else null,
suggestionsExpanded = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS,
showDescriptionHint = showExerciseDescriptionHint,
onToggleSuggestions = {
showExerciseDescriptionHint = false
activeExerciseDetail = if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) null else ExerciseDetailPanel.SUGGESTIONS
if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) {
visibleHint = null
}
applyRecommendedPaneWeights(persist = false)
},
onHint = {
showExerciseDescriptionHint = false
val hint = currentLevel.hints.getOrNull(hintIndex) ?: "No more hints for this level."
visibleHint = hint
activeExerciseDetail = ExerciseDetailPanel.HINT
hintIndex = (hintIndex + 1).coerceAtMost(currentLevel.hints.size)
applyRecommendedPaneWeights(persist = false)
},
onReset = {
showExerciseDescriptionHint = false
resetCurrentLevel()
},
)
},
terminalContent = {
TerminalPane(
output = output,
inputFieldVersion = inputFieldVersion,
commandInput = commandInput,
showInputHint = showTerminalInputHint,
onInputHintDismiss = { showTerminalInputHint = false },
onValueChange = {
val blockedEcho = suppressedImeEcho
if (blockedEcho != null && commandInput.text.isEmpty()) {
val blockedTrimmed = blockedEcho.trim()
if (it.text == blockedEcho || (blockedTrimmed.isNotEmpty() && it.text == blockedTrimmed)) {
return@TerminalPane
}
}
suppressedImeEcho = null
if (it.text.isNotEmpty()) {
showTerminalInputHint = false
.fillMaxSize()
.background(AppBackground)
.imePadding()
.verticalScroll(screenScrollState)
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
FixedHeader()
PaneWorkspace(
modifier = Modifier
.fillMaxWidth(),
paneLayout = paneLayout,
onMovePane = { paneId, delta ->
updatePaneLayout(transform = { layout -> movePane(layout, paneId, delta) })
},
onTogglePane = { paneId ->
updatePaneLayout(transform = { layout ->
val collapsed = layout.collapsed.toMutableSet()
if (!collapsed.add(paneId)) collapsed.remove(paneId)
layout.copy(collapsed = collapsed)
})
},
levelsContent = {
LevelsPane(levels, currentLevelIndex, completedLevels) { index -> loadLevel(index) }
},
visualContent = { VisualPane(repo) },
exerciseContent = {
ExercisePane(
level = currentLevel,
visibleHint = if (activeExerciseDetail == ExerciseDetailPanel.HINT) visibleHint else null,
suggestionsExpanded = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS,
showDescriptionHint = false,
onToggleSuggestions = {
showExerciseDescriptionHint = false
}
commandInput = it
},
onRun = { runCommand() },
onTab = { tabComplete() },
onHelp = { showCommandHelp() },
onCursorLeft = { moveCursor(-1) },
onCursorRight = { moveCursor(1) },
onHistoryUp = { historyUp() },
onHistoryDown = { historyDown() },
)
},
)
showHelpOverlay = false
activeExerciseDetail = if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) null else ExerciseDetailPanel.SUGGESTIONS
if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) {
visibleHint = null
}
applyRecommendedPaneWeights(persist = false)
},
onHint = {
showExerciseDescriptionHint = false
showHelpOverlay = false
val hint = currentLevel.hints.getOrNull(hintIndex) ?: "No more hints for this level."
visibleHint = hint
activeExerciseDetail = ExerciseDetailPanel.HINT
hintIndex = (hintIndex + 1).coerceAtMost(currentLevel.hints.size)
applyRecommendedPaneWeights(persist = false)
},
onReset = {
showExerciseDescriptionHint = false
showHelpOverlay = false
resetCurrentLevel()
},
)
},
terminalContent = {
TerminalPane(
output = output,
inputFieldVersion = inputFieldVersion,
commandInput = commandInput,
showInputHint = false,
onInputHintDismiss = {
showTerminalInputHint = false
showHelpOverlay = false
},
onValueChange = {
val blockedEcho = suppressedImeEcho
if (blockedEcho != null && commandInput.text.isEmpty()) {
val blockedTrimmed = blockedEcho.trim()
if (it.text == blockedEcho || (blockedTrimmed.isNotEmpty() && it.text == blockedTrimmed)) {
return@TerminalPane
}
}
suppressedImeEcho = null
if (it.text.isNotEmpty()) {
showTerminalInputHint = false
showExerciseDescriptionHint = false
showHelpOverlay = false
}
commandInput = it
},
onRun = { runCommand() },
onTab = { tabComplete() },
onHelp = { showCommandHelp() },
onCursorLeft = { moveCursor(-1) },
onCursorRight = { moveCursor(1) },
onHistoryUp = { historyUp() },
onHistoryDown = { historyDown() },
)
},
)
}
if (showHelpOverlay) {
HelpCalloutOverlay(
showHelpOnStart = showHelpOnStart,
onShowHelpOnStartChange = { showHelpOnStart = it },
onOk = {
showHelpOverlay = false
scope.launch { gameProgressStore.saveShowHelpOnStart(showHelpOnStart) }
},
onClose = { showHelpOverlay = false },
)
}
}
}
}
@@ -586,6 +629,135 @@ fun GitHugApp() {
}
}
private fun fileCompletionCandidates(repo: RepoState): List<String> {
return repo.files
.filterNot { it.deleted }
.map { it.name }
}
private fun directoryCompletionCandidates(repo: RepoState): List<String> {
return repo.files
.filterNot { it.deleted }
.flatMap { file ->
val parts = file.name.split('/').dropLast(1)
parts.indices.map { index -> parts.take(index + 1).joinToString("/") + "/" }
}
.distinct()
}
@Composable
private fun HelpCalloutOverlay(
showHelpOnStart: Boolean,
onShowHelpOnStartChange: (Boolean) -> Unit,
onOk: () -> Unit,
onClose: () -> Unit,
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
HelpBubble(
text = "Read the exercise description, then solve it by entering commands below.",
modifier = Modifier.align(Alignment.TopStart),
)
HelpBubble(
text = "Tap the prompt to enter a command.",
modifier = Modifier.align(Alignment.BottomStart),
)
Surface(
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth(),
color = PanelPrimary.copy(alpha = 0.97f),
shape = RoundedCornerShape(8.dp),
shadowElevation = 8.dp,
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Help",
color = TextPrimary,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
)
TextButton(onClick = onClose) {
Text("Close", color = Accent)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Checkbox(
checked = showHelpOnStart,
onCheckedChange = onShowHelpOnStartChange,
colors = CheckboxDefaults.colors(
checkedColor = Accent,
uncheckedColor = TextSecondary,
checkmarkColor = AppBackground,
),
)
Text("Show help on start", color = TextPrimary)
}
Button(
onClick = onOk,
colors = ButtonDefaults.buttonColors(
containerColor = Accent,
contentColor = AppBackground,
),
) {
Text("OK")
}
}
}
}
}
@Composable
private fun HelpBubble(
text: String,
modifier: Modifier = Modifier,
) {
val bubbleColor = Color(0xFFFFF1A8)
Column(modifier = modifier.fillMaxWidth(0.86f)) {
Box(
modifier = Modifier
.background(bubbleColor, RoundedCornerShape(18.dp))
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Text(
text = text,
color = Color(0xFF161000),
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
)
}
Canvas(
modifier = Modifier
.padding(start = 28.dp)
.size(width = 26.dp, height = 13.dp),
) {
drawPath(
path = Path().apply {
moveTo(0f, 0f)
lineTo(size.width, 0f)
lineTo(size.width * 0.25f, size.height)
close()
},
color = bubbleColor,
)
}
}
}
@Composable
private fun SolvedCelebrationOverlay(title: String) {
val transition = rememberInfiniteTransition(label = "solved-celebration")

View File

@@ -65,9 +65,14 @@ class GitRepositoryRuntime(private val context: Context) {
val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile
.takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot
val tokens = GitSandboxEngine.tokenizeCommand(command)
val shellTokens = GitSandboxEngine.tokenizeShellCommand(command)
val tokens = shellTokens.map { it.value }
if (tokens.isEmpty()) return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to emptyList()
val expandedTokens = expandShellPathspecs(currentRepo, tokens)
val expandedTokens = if (tokens.firstOrNull() == "echo") {
tokens
} else {
expandShellPathspecs(currentRepo, shellTokens)
}
val result = when (expandedTokens.first()) {
"git" -> currentRepo to runGit(nativeGit, workingDir, expandedTokens.drop(1)).outputLines
@@ -536,9 +541,9 @@ class GitRepositoryRuntime(private val context: Context) {
private fun sandboxDir(level: Level): File = File(sandboxesRoot, level.id)
private fun expandShellPathspecs(repo: RepoState, tokens: List<String>): List<String> {
if (tokens.size <= 1) return tokens
return listOf(tokens.first()) + GitSandboxEngine.expandPathspecs(repo, tokens.drop(1))
private fun expandShellPathspecs(repo: RepoState, tokens: List<GitSandboxEngine.ShellToken>): List<String> {
if (tokens.size <= 1) return tokens.map { it.value }
return listOf(tokens.first().value) + GitSandboxEngine.expandPathspecTokens(repo, tokens.drop(1))
}
private fun runGit(binary: File, workingDir: File, arguments: List<String>): ProcessExecutionResult {

View File

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

View File

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