Auto-commit after successful build: update app gameplay/UI, improve build setup

Changed files:\napp/build.gradle.kts
app/src/main/java/solutions/tretter/githugandroid/EditorCommands.kt
app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt
app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt
app/src/main/java/solutions/tretter/githugandroid/TextEditorDialog.kt
app/src/test/java/solutions/tretter/githugandroid/EditorCommandsTest.kt
This commit is contained in:
Joe Tretter
2026-05-02 21:50:34 -05:00
parent d6c4c4a2df
commit 17f19ca0ba
6 changed files with 327 additions and 42 deletions

View File

@@ -0,0 +1,19 @@
package solutions.tretter.githugandroid
private val VisualEditorCommands = setOf("vi", "nano", "emacs", "ed", "ex")
data class VisualEditorInvocation(
val editor: String,
val path: String?,
val command: String,
)
fun parseVisualEditorInvocation(command: String): VisualEditorInvocation? {
val tokens = GitSandboxEngine.tokenizeCommand(command)
val editor = tokens.firstOrNull()?.takeIf { it in VisualEditorCommands } ?: return null
return VisualEditorInvocation(
editor = editor,
path = tokens.drop(1).firstOrNull { !it.startsWith("-") },
command = command,
)
}

View File

@@ -60,6 +60,7 @@ fun GitHugApp() {
var historyIndex by remember { mutableStateOf(-1) }
var historyDraft by remember { mutableStateOf("") }
var hasRestoredProgress by remember { mutableStateOf(false) }
var editorState by remember { mutableStateOf<TextEditorState?>(null) }
val currentLevel = levels[currentLevelIndex]
LaunchedEffect(persistedPaneLayout) {
@@ -166,6 +167,7 @@ fun GitHugApp() {
visibleHint = null
historyIndex = -1
historyDraft = ""
editorState = null
applyRecommendedPaneWeights(persist = false)
}
@@ -181,6 +183,7 @@ fun GitHugApp() {
visibleHint = null
historyIndex = -1
historyDraft = ""
editorState = null
paneLayout = paneLayout.copy(
weights = paneLayout.weights + recommendedPaneWeights(
heights = recommendedPaneHeights(
@@ -247,6 +250,87 @@ fun GitHugApp() {
commandInput = TextFieldValue(newText, selection = TextRange(newCursor))
}
fun applyCommandResult(raw: String, newRepo: RepoState, lines: List<String>, echoCommand: Boolean) {
val levelForResult = currentLevel
val solvedAfterCommand = levelForResult.validator(newRepo, raw)
val wasAlreadyCompleted = currentLevel.id in completedLevels
AppLog.d(
"GitHugApp",
"Command='$raw' level=${levelForResult.id} solved=$solvedAfterCommand alreadyCompleted=$wasAlreadyCompleted completedBefore=${completedLevels.sorted()}",
)
val newOutput = buildList {
addAll(output)
if (echoCommand) {
add("$ $raw")
}
addAll(lines)
if (solvedAfterCommand && !wasAlreadyCompleted) {
add("✔ Level solved: ${levelForResult.title}")
}
}
if (solvedAfterCommand && !wasAlreadyCompleted) {
val updatedCompletedLevels = completedLevels + levelForResult.id
completedLevels = updatedCompletedLevels
val nextLevelIndex = levels.indexOfFirst { it.id !in updatedCompletedLevels }
if (nextLevelIndex >= 0) {
val nextLevelId = levels[nextLevelIndex].id
AppLog.d(
"GitHugApp",
"Level solved ${levelForResult.id}; advancing to next incomplete index=$nextLevelIndex id=$nextLevelId",
)
scope.launch { persistProgress(updatedCompletedLevels, nextLevelId) }
loadLevel(nextLevelIndex)
} else {
AppLog.d("GitHugApp", "All levels completed")
scope.launch { persistProgress(updatedCompletedLevels, levels.lastOrNull()?.id) }
repo = newRepo
output = listOf("🏁 All Githug levels completed.")
clearCommandInput()
suppressedImeEcho = null
}
} else {
AppLog.d("GitHugApp", "Staying on level=${levelForResult.id}")
repo = newRepo
output = newOutput
applyRecommendedPaneWeights(persist = false)
}
}
fun openEditor(invocation: VisualEditorInvocation) {
val path = invocation.path.orEmpty()
val (content, lines) = if (path.isBlank()) {
"" to emptyList()
} else {
runtime.readEditorFile(currentLevel, repo, path)
}
output = buildList {
addAll(output)
add("$ ${invocation.command}")
addAll(lines)
if (lines.isEmpty()) {
add("Opened ${invocation.editor} editor${if (path.isBlank()) "" else " for $path"}")
}
}
if (lines.isEmpty()) {
editorState = TextEditorState(
editor = invocation.editor,
originalCommand = invocation.command,
path = path,
content = content,
saveAsPath = path,
)
}
applyRecommendedPaneWeights(persist = false)
}
fun saveEditor(targetPath: String) {
val state = editorState ?: return
if (targetPath.isBlank()) return
val (newRepo, lines) = runtime.writeEditorFile(currentLevel, repo, targetPath, state.content)
editorState = null
applyCommandResult(state.originalCommand, newRepo, lines, echoCommand = false)
}
fun runCommand() {
val submittedText = commandInput.text
val raw = submittedText.trim()
@@ -261,47 +345,14 @@ fun GitHugApp() {
historyIndex = -1
historyDraft = ""
val editorInvocation = parseVisualEditorInvocation(raw)
if (editorInvocation != null) {
openEditor(editorInvocation)
return
}
val (newRepo, lines) = runtime.execute(currentLevel, repo, raw)
val solvedAfterCommand = currentLevel.validator(newRepo, raw)
val wasAlreadyCompleted = currentLevel.id in completedLevels
AppLog.d(
"GitHugApp",
"Command='$raw' level=${currentLevel.id} solved=$solvedAfterCommand alreadyCompleted=$wasAlreadyCompleted completedBefore=${completedLevels.sorted()}",
)
val newOutput = buildList {
addAll(output)
add("$ $raw")
addAll(lines)
if (solvedAfterCommand && !wasAlreadyCompleted) {
add("✔ Level solved: ${currentLevel.title}")
}
}
if (solvedAfterCommand && !wasAlreadyCompleted) {
val updatedCompletedLevels = completedLevels + currentLevel.id
completedLevels = updatedCompletedLevels
val nextLevelIndex = levels.indexOfFirst { it.id !in updatedCompletedLevels }
if (nextLevelIndex >= 0) {
val nextLevelId = levels[nextLevelIndex].id
AppLog.d(
"GitHugApp",
"Level solved ${currentLevel.id}; advancing to next incomplete index=$nextLevelIndex id=$nextLevelId",
)
scope.launch { persistProgress(updatedCompletedLevels, nextLevelId) }
loadLevel(nextLevelIndex)
} else {
AppLog.d("GitHugApp", "All levels completed")
scope.launch { persistProgress(updatedCompletedLevels, levels.lastOrNull()?.id) }
repo = newRepo
output = listOf("🏁 All Githug levels completed.")
clearCommandInput()
suppressedImeEcho = null
}
} else {
AppLog.d("GitHugApp", "Staying on level=${currentLevel.id}")
repo = newRepo
output = newOutput
applyRecommendedPaneWeights(persist = false)
}
applyCommandResult(raw, newRepo, lines, echoCommand = true)
}
fun showCommandHelp() {
@@ -395,5 +446,25 @@ fun GitHugApp() {
}
}
}
editorState?.let { state ->
TextEditorDialog(
state = state,
onContentChange = { editorState = state.copy(content = it) },
onSaveAsPathChange = { editorState = state.copy(saveAsPath = it) },
onCancel = {
output = output + "Editor closed without saving"
editorState = null
},
onSave = { saveEditor(state.path) },
onSaveAs = {
val targetPath = state.saveAsPath.trim()
if (targetPath.isNotEmpty()) {
editorState = state.copy(path = targetPath, saveAsPath = targetPath)
saveEditor(targetPath)
}
},
)
}
}
}

View File

@@ -84,9 +84,59 @@ class GitRepositoryRuntime(private val context: Context) {
add(" binary path: nativeLibraryDir/libgit.so")
add(" selected ABI: ${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"}")
add(" helper commands: ls, pwd, cat, touch, mkdir, rm, echo")
add(" visual editors: vi, nano, emacs, ed, ex")
}
}
fun readEditorFile(level: Level, currentRepo: RepoState, path: String): Pair<String, List<String>> {
val nativeGit = nativeGitBinary()
if (nativeGit == null) {
return currentRepo.files.find { it.name == path }?.content.orEmpty() to emptyList()
}
val sandboxRoot = sandboxDir(level).canonicalFile
val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile
.takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot
val file = File(workingDir, path).canonicalFile
if (!file.path.startsWith(sandboxRoot.path)) {
return "" to listOf("editor: $path: Permission denied")
}
if (file.exists() && file.isDirectory) {
return "" to listOf("editor: $path: Is a directory")
}
return if (file.exists()) file.readText() to emptyList() else "" to emptyList()
}
fun writeEditorFile(level: Level, currentRepo: RepoState, path: String, content: String): Pair<RepoState, List<String>> {
val nativeGit = nativeGitBinary()
if (nativeGit == null) {
val updatedFiles = currentRepo.files.toMutableList()
val index = updatedFiles.indexOfFirst { it.name == path }
if (index == -1) {
updatedFiles += GitFile(name = path, content = content)
} else {
updatedFiles[index] = updatedFiles[index].copy(content = content)
}
return currentRepo.copy(files = updatedFiles) to listOf("Saved $path")
}
val sandboxRoot = sandboxDir(level).canonicalFile
val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile
.takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot
val file = File(workingDir, path).canonicalFile
if (!file.path.startsWith(sandboxRoot.path)) {
return currentRepo to listOf("editor: $path: Permission denied")
}
if (file.exists() && file.isDirectory) {
return currentRepo to listOf("editor: $path: Is a directory")
}
file.parentFile?.mkdirs()
file.writeText(content)
return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to listOf("Saved $path")
}
private fun executeHelperCommand(sandboxRoot: File, workingDir: File, currentRepo: RepoState, tokens: List<String>): Pair<RepoState, List<String>> {
return when (tokens.first()) {
"ls" -> currentRepo to workingDir.listFiles()

View File

@@ -0,0 +1,116 @@
package solutions.tretter.githugandroid
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
data class TextEditorState(
val editor: String,
val originalCommand: String,
val path: String,
val content: String,
val saveAsPath: String,
)
@Composable
fun TextEditorDialog(
state: TextEditorState,
onContentChange: (String) -> Unit,
onSaveAsPathChange: (String) -> Unit,
onCancel: () -> Unit,
onSave: () -> Unit,
onSaveAs: () -> Unit,
) {
Dialog(onDismissRequest = onCancel) {
Surface(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.86f),
color = PanelPrimary,
shape = RoundedCornerShape(8.dp),
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
EditorButton(label = "Cancel", onClick = onCancel)
EditorButton(label = "Save", enabled = state.path.isNotBlank(), onClick = onSave)
EditorButton(label = "Save As", enabled = state.saveAsPath.isNotBlank(), onClick = onSaveAs)
}
Text(
text = "${state.editor} ${state.path.ifBlank { "<new file>" }}",
color = TextPrimary,
fontWeight = FontWeight.Bold,
)
OutlinedTextField(
value = state.saveAsPath,
onValueChange = onSaveAsPathChange,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Save As") },
)
BasicTextField(
value = state.content,
onValueChange = onContentChange,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.heightIn(min = 260.dp)
.background(TerminalBackground, RoundedCornerShape(6.dp))
.padding(10.dp),
textStyle = TextStyle(
color = TextPrimary,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
),
cursorBrush = SolidColor(Accent),
)
}
}
}
}
@Composable
private fun EditorButton(
label: String,
enabled: Boolean = true,
onClick: () -> Unit,
) {
Button(
onClick = onClick,
enabled = enabled,
colors = ButtonDefaults.buttonColors(
containerColor = Accent,
contentColor = AppBackground,
disabledContainerColor = PanelTertiary,
disabledContentColor = TextMuted,
),
) {
Text(label)
}
}