diff --git a/app/src/main/java/com/kawomi/githugandroid/GameModels.kt b/app/src/main/java/com/kawomi/githugandroid/GameModels.kt index b3e0253..c2a2af8 100644 --- a/app/src/main/java/com/kawomi/githugandroid/GameModels.kt +++ b/app/src/main/java/com/kawomi/githugandroid/GameModels.kt @@ -113,8 +113,12 @@ object GitSandboxEngine { parts.size >= 2 && parts[1] == "status" -> repo to statusLines(repo) parts.size >= 3 && parts[1] == "add" -> { val target = parts[2] - val updated = repo.files.map { if (target == "." || it.name == target) it.copy(staged = true) else it } - repo.copy(files = updated) to listOf("staged ${if (target == ".") "all files" else target}") + if (target != "." && repo.files.none { it.name == target }) { + repo to listOf("fatal: pathspec '$target' did not match any files") + } else { + val updated = repo.files.map { if (target == "." || it.name == target) it.copy(staged = true) else it } + repo.copy(files = updated) to listOf("staged ${if (target == ".") "all files" else target}") + } } parts.size >= 2 && parts[1] == "commit" -> commit(repo, parts) parts.size >= 2 && parts[1] == "log" -> { diff --git a/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt b/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt index da38303..b9007ea 100644 --- a/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt +++ b/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt @@ -31,11 +31,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp private val AppBackground = Color(0xFF000000) @@ -71,10 +73,13 @@ fun GitHugApp() { var currentLevelIndex by remember { mutableStateOf(0) } var mode by remember { mutableStateOf(PlayMode.CLI_ONLY) } var repo by remember { mutableStateOf(levels.first().setup()) } - var commandInput by remember { mutableStateOf("") } + var commandInput by remember { mutableStateOf(TextFieldValue("")) } var output by remember { mutableStateOf(listOf("Welcome to GitHug Android.")) } var hintIndex by remember { mutableStateOf(0) } var completedLevels by remember { mutableStateOf(setOf()) } + var commandHistory by remember { mutableStateOf(listOf()) } + var historyIndex by remember { mutableStateOf(-1) } + var historyDraft by remember { mutableStateOf("") } val currentLevel = levels[currentLevelIndex] val solved = currentLevel.validator(repo) @@ -85,14 +90,75 @@ fun GitHugApp() { fun resetCurrentLevel(message: String = "Level reset.") { repo = currentLevel.setup() - commandInput = "" + commandInput = TextFieldValue("") output = listOf(message) hintIndex = 0 + historyIndex = -1 + historyDraft = "" + } + + fun setCommandText(text: String) { + commandInput = TextFieldValue(text = text, selection = TextRange(text.length)) + } + + fun moveCursor(delta: Int) { + val next = (commandInput.selection.start + delta).coerceIn(0, commandInput.text.length) + commandInput = commandInput.copy(selection = TextRange(next)) + } + + fun historyUp() { + if (commandHistory.isEmpty()) return + if (historyIndex == -1) { + historyDraft = commandInput.text + historyIndex = commandHistory.lastIndex + } else { + historyIndex = (historyIndex - 1).coerceAtLeast(0) + } + setCommandText(commandHistory[historyIndex]) + } + + fun historyDown() { + if (commandHistory.isEmpty() || historyIndex == -1) return + if (historyIndex >= commandHistory.lastIndex) { + historyIndex = -1 + setCommandText(historyDraft) + } else { + historyIndex += 1 + setCommandText(commandHistory[historyIndex]) + } + } + + fun tabComplete() { + val cursor = commandInput.selection.start.coerceIn(0, commandInput.text.length) + 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 matches = repo.files.map { it.name }.sorted().filter { it.startsWith(token) } + if (matches.isEmpty()) return + + val replacement = if (matches.size == 1) matches.first() else commonPrefix(matches) + if (replacement == token && matches.size > 1) { + output = output + "completion> ${matches.joinToString(" ")}" + return + } + + val newText = commandInput.text.replaceRange(tokenStart, cursor, replacement) + val newCursor = tokenStart + replacement.length + commandInput = TextFieldValue(newText, selection = TextRange(newCursor)) } fun runCommand() { - val raw = commandInput.trim() + val raw = commandInput.text.trim() if (raw.isBlank()) return + + if (commandHistory.lastOrNull() != raw) { + commandHistory = commandHistory + raw + } + historyIndex = -1 + historyDraft = "" + val (newRepo, lines) = GitSandboxEngine.execute(repo, raw) val solvedAfterCommand = currentLevel.validator(newRepo) val wasAlreadyCompleted = currentLevel.id in completedLevels @@ -113,17 +179,17 @@ fun GitHugApp() { currentLevelIndex = nextLevelIndex repo = nextLevel.setup() output = listOf("Loaded level: ${nextLevel.title}") - commandInput = "" + commandInput = TextFieldValue("") hintIndex = 0 } else { repo = newRepo output = listOf("🏁 All available MVP levels completed.") - commandInput = "" + commandInput = TextFieldValue("") } } else { repo = newRepo output = newOutput - commandInput = "" + commandInput = TextFieldValue("") } } @@ -145,8 +211,10 @@ fun GitHugApp() { currentLevelIndex = index repo = levels[index].setup() output = listOf("Loaded level: ${levels[index].title}") - commandInput = "" + commandInput = TextFieldValue("") hintIndex = 0 + historyIndex = -1 + historyDraft = "" } ModeBar(mode = mode, onModeSelected = { mode = it }) LevelCard( @@ -166,6 +234,11 @@ fun GitHugApp() { commandInput = commandInput, onValueChange = { commandInput = it }, onRun = { runCommand() }, + onTab = { tabComplete() }, + onCursorLeft = { moveCursor(-1) }, + onCursorRight = { moveCursor(1) }, + onHistoryUp = { historyUp() }, + onHistoryDown = { historyDown() }, modifier = Modifier .fillMaxWidth() .heightIn(min = 260.dp) @@ -275,9 +348,14 @@ private fun InfoCard(title: String, content: String, modifier: Modifier = Modifi @Composable private fun TerminalPanel( output: List, - commandInput: String, - onValueChange: (String) -> Unit, + commandInput: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, onRun: () -> Unit, + onTab: () -> Unit, + onCursorLeft: () -> Unit, + onCursorRight: () -> Unit, + onHistoryUp: () -> Unit, + onHistoryDown: () -> Unit, modifier: Modifier = Modifier, ) { Card(modifier = modifier, colors = CardDefaults.cardColors(containerColor = TerminalBackground)) { @@ -291,6 +369,13 @@ private fun TerminalPanel( fontFamily = FontFamily.Monospace ) } + SpecialKeyBar( + onTab = onTab, + onCursorLeft = onCursorLeft, + onCursorRight = onCursorRight, + onHistoryUp = onHistoryUp, + onHistoryDown = onHistoryDown, + ) Row( modifier = Modifier .fillMaxWidth() @@ -322,4 +407,53 @@ private fun TerminalPanel( } } } +} + +@Composable +private fun SpecialKeyBar( + onTab: () -> Unit, + onCursorLeft: () -> Unit, + onCursorRight: () -> Unit, + onHistoryUp: () -> Unit, + onHistoryDown: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + TerminalKeyButton(label = "TAB", onClick = onTab, modifier = Modifier.weight(1.3f)) + TerminalKeyButton(label = "←", onClick = onCursorLeft) + TerminalKeyButton(label = "→", onClick = onCursorRight) + TerminalKeyButton(label = "↑", onClick = onHistoryUp) + TerminalKeyButton(label = "↓", onClick = onHistoryDown) + } +} + +@Composable +private fun TerminalKeyButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Button( + onClick = onClick, + modifier = modifier, + colors = ButtonDefaults.buttonColors( + containerColor = PanelSecondary, + contentColor = TextPrimary + ) + ) { + Text(label, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) + } +} + +private fun commonPrefix(values: List): String { + if (values.isEmpty()) return "" + var prefix = values.first() + values.drop(1).forEach { value -> + while (!value.startsWith(prefix) && prefix.isNotEmpty()) { + prefix = prefix.dropLast(1) + } + } + return prefix } \ No newline at end of file