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

Changed files:\napp/build.gradle.kts
app/src/main/java/com/kawomi/githugandroid/GameModels.kt
app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt
This commit is contained in:
Joe Tretter
2026-04-22 13:42:59 -05:00
parent 75462e784b
commit 44bd87d536
3 changed files with 178 additions and 24 deletions

View File

@@ -11,8 +11,8 @@ android {
applicationId = "com.kawomi.githugandroid" applicationId = "com.kawomi.githugandroid"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 6 versionCode = 7
versionName = "0.1.5" versionName = "0.1.6"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true

View File

@@ -11,6 +11,7 @@ data class GitFile(
val name: String, val name: String,
val content: String = "", val content: String = "",
val staged: Boolean = false, val staged: Boolean = false,
val tracked: Boolean = false,
) )
data class CommitNode( data class CommitNode(
@@ -71,7 +72,7 @@ val RepoStateSaver = listSaver<RepoState, Any>(
listOf( listOf(
state.initialized, state.initialized,
state.headBranch, state.headBranch,
state.files.flatMap { listOf(it.name, it.content, it.staged.toString()) }, state.files.flatMap { listOf(it.name, it.content, it.staged.toString(), it.tracked.toString()) },
state.commits.flatMap { listOf(it.id, it.message) }, state.commits.flatMap { listOf(it.id, it.message) },
state.branches.flatMap { listOf(it.key, it.value.toString()) }, state.branches.flatMap { listOf(it.key, it.value.toString()) },
) )
@@ -85,8 +86,13 @@ val RepoStateSaver = listSaver<RepoState, Any>(
RepoState( RepoState(
initialized = initialized, initialized = initialized,
headBranch = headBranch, headBranch = headBranch,
files = fileParts.chunked(3).map { files = fileParts.chunked(4).map {
GitFile(it[0] as String, it[1] as String, (it[2] as String).toBoolean()) GitFile(
name = it[0] as String,
content = it[1] as String,
staged = (it[2] as String).toBoolean(),
tracked = (it[3] as String).toBoolean(),
)
}, },
commits = commitParts.chunked(2).map { commits = commitParts.chunked(2).map {
CommitNode(it[0] as String, it[1] as String) CommitNode(it[0] as String, it[1] as String)
@@ -97,10 +103,28 @@ val RepoStateSaver = listSaver<RepoState, Any>(
) )
object GitSandboxEngine { object GitSandboxEngine {
fun commandReferenceLines(): List<String> = listOf(
"Available sandbox commands:",
" git init",
" git status",
" git add <file>",
" git add .",
" git commit -m \"message\"",
" git commit -am \"message\"",
" git log",
" git branch <name>",
" git checkout <name>",
" ls",
" touch <file>",
" help",
" git help",
)
fun execute(repo: RepoState, command: String): Pair<RepoState, List<String>> { fun execute(repo: RepoState, command: String): Pair<RepoState, List<String>> {
val parts = command.split(" ").filter { it.isNotBlank() } val parts = tokenizeCommand(command)
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] == "touch" && parts.size >= 2 -> { parts[0] == "touch" && parts.size >= 2 -> {
val name = parts[1] val name = parts[1]
if (repo.files.any { it.name == name }) repo to listOf("$name already exists") if (repo.files.any { it.name == name }) repo to listOf("$name already exists")
@@ -110,6 +134,7 @@ object GitSandboxEngine {
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'.")
parts.size >= 2 && parts[1] == "init" -> repo.copy(initialized = true, branches = mapOf("master" to repo.commits.size)) to listOf("Initialized empty Git repository") parts.size >= 2 && parts[1] == "init" -> repo.copy(initialized = true, branches = mapOf("master" to repo.commits.size)) to listOf("Initialized empty Git repository")
!repo.initialized -> repo to listOf("fatal: not a git repository") !repo.initialized -> repo to listOf("fatal: not a git repository")
parts.size >= 2 && parts[1] == "help" -> repo to commandReferenceLines()
parts.size >= 2 && parts[1] == "status" -> repo to statusLines(repo) parts.size >= 2 && parts[1] == "status" -> repo to statusLines(repo)
parts.size >= 3 && parts[1] == "add" -> { parts.size >= 3 && parts[1] == "add" -> {
val target = parts[2] val target = parts[2]
@@ -120,7 +145,7 @@ object GitSandboxEngine {
repo.copy(files = updated) to listOf("staged ${if (target == ".") "all files" else target}") 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] == "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")
else repo.commits.reversed().flatMap { listOf("commit ${it.id}", " ${it.message}") } else repo.commits.reversed().flatMap { listOf("commit ${it.id}", " ${it.message}") }
@@ -139,26 +164,94 @@ object GitSandboxEngine {
} }
} }
private fun commit(repo: RepoState, parts: List<String>): Pair<RepoState, List<String>> { private fun commit(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
val messageIndex = parts.indexOf("-m") val parsed = parseCommitArguments(arguments)
if (messageIndex == -1 || messageIndex == parts.lastIndex) { if (parsed.error != null) {
return repo to listOf("error: commit message required. Use git commit -m \"message\"") return repo to listOf(parsed.error)
} }
val message = parts.drop(messageIndex + 1).joinToString(" ").trim('"')
val staged = repo.files.filter { it.staged } val repoForCommit = if (parsed.stageAllTracked) {
repo.copy(files = repo.files.map { file -> if (file.tracked) file.copy(staged = true) else file })
} else {
repo
}
val message = parsed.message.orEmpty()
val staged = repoForCommit.files.filter { it.staged }
if (staged.isEmpty()) return repo to listOf("nothing to commit") if (staged.isEmpty()) return repo to listOf("nothing to commit")
val nextId = "${repo.commits.size + 1}".padStart(7, '0') val nextId = "${repo.commits.size + 1}".padStart(7, '0')
val cleanedFiles = repo.files.map { it.copy(staged = false) } val cleanedFiles = repoForCommit.files.map { file ->
return repo.copy( if (file.staged) file.copy(staged = false, tracked = true) else file
}
return repoForCommit.copy(
files = cleanedFiles, files = cleanedFiles,
commits = repo.commits + CommitNode(nextId, message), commits = repo.commits + CommitNode(nextId, message),
branches = repo.branches + (repo.headBranch to (repo.commits.size + 1)), branches = repo.branches + (repo.headBranch to (repo.commits.size + 1)),
) to listOf("[$nextId] $message") ) to listOf("[$nextId] $message")
} }
private fun parseCommitArguments(arguments: List<String>): ParsedCommitArguments {
var message: String? = null
var stageAllTracked = false
var index = 0
while (index < arguments.size) {
val argument = arguments[index]
when {
argument == "-a" || argument == "--all" -> {
stageAllTracked = true
}
argument == "-m" || argument == "--message" -> {
val next = arguments.getOrNull(index + 1)
?: return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
message = next
index += 1
}
argument.startsWith("--message=") -> {
message = argument.substringAfter('=')
}
argument.startsWith("-") && !argument.startsWith("--") && argument.length > 2 -> {
val shortFlags = argument.drop(1)
var shortIndex = 0
while (shortIndex < shortFlags.length) {
when (val flag = shortFlags[shortIndex]) {
'a' -> stageAllTracked = true
'm' -> {
val attachedValue = shortFlags.substring(shortIndex + 1)
if (attachedValue.isNotEmpty()) {
message = attachedValue
} else {
val next = arguments.getOrNull(index + 1)
?: return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
message = next
index += 1
}
break
}
else -> return ParsedCommitArguments(error = "error: unsupported commit option '-$flag'")
}
shortIndex += 1
}
}
else -> return ParsedCommitArguments(error = "error: unsupported commit argument '$argument'")
}
index += 1
}
if (message.isNullOrBlank()) {
return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
}
return ParsedCommitArguments(message = message, stageAllTracked = stageAllTracked)
}
private fun statusLines(repo: RepoState): List<String> { private fun statusLines(repo: RepoState): List<String> {
val staged = repo.files.filter { it.staged }.map { "new file: ${it.name}" } val staged = repo.files.filter { it.staged }.map {
val unstaged = repo.files.filterNot { it.staged }.map { "untracked: ${it.name}" } if (it.tracked) "modified: ${it.name}" else "new file: ${it.name}"
}
val unstaged = repo.files.filterNot { it.staged || it.tracked }.map { "untracked: ${it.name}" }
return buildList { return buildList {
add("On branch ${repo.headBranch}") add("On branch ${repo.headBranch}")
if (staged.isEmpty() && unstaged.isEmpty()) { if (staged.isEmpty() && unstaged.isEmpty()) {
@@ -175,4 +268,55 @@ object GitSandboxEngine {
} }
} }
} }
private fun tokenizeCommand(command: String): List<String> {
val result = mutableListOf<String>()
val current = StringBuilder()
var quoteChar: Char? = null
var escaping = false
command.forEach { char ->
when {
escaping -> {
current.append(char)
escaping = false
}
char == '\\' && quoteChar != '\'' -> {
escaping = true
}
quoteChar != null -> {
if (char == quoteChar) {
quoteChar = null
} else {
current.append(char)
}
}
char == '"' || char == '\'' -> {
quoteChar = char
}
char.isWhitespace() -> {
if (current.isNotEmpty()) {
result += current.toString()
current.clear()
}
}
else -> current.append(char)
}
}
if (escaping) {
current.append('\\')
}
if (current.isNotEmpty()) {
result += current.toString()
}
return result
}
private data class ParsedCommitArguments(
val message: String? = null,
val stageAllTracked: Boolean = false,
val error: String? = null,
)
} }

View File

@@ -211,6 +211,10 @@ fun GitHugApp() {
} }
} }
fun showCommandHelp() {
output = output + GitSandboxEngine.commandReferenceLines()
}
Scaffold { padding -> Scaffold { padding ->
Surface( Surface(
modifier = Modifier modifier = Modifier
@@ -265,6 +269,7 @@ fun GitHugApp() {
}, },
onRun = { runCommand() }, onRun = { runCommand() },
onTab = { tabComplete() }, onTab = { tabComplete() },
onHelp = { showCommandHelp() },
onCursorLeft = { moveCursor(-1) }, onCursorLeft = { moveCursor(-1) },
onCursorRight = { moveCursor(1) }, onCursorRight = { moveCursor(1) },
onHistoryUp = { historyUp() }, onHistoryUp = { historyUp() },
@@ -383,6 +388,7 @@ private fun TerminalPanel(
onValueChange: (TextFieldValue) -> Unit, onValueChange: (TextFieldValue) -> Unit,
onRun: () -> Unit, onRun: () -> Unit,
onTab: () -> Unit, onTab: () -> Unit,
onHelp: () -> Unit,
onCursorLeft: () -> Unit, onCursorLeft: () -> Unit,
onCursorRight: () -> Unit, onCursorRight: () -> Unit,
onHistoryUp: () -> Unit, onHistoryUp: () -> Unit,
@@ -419,6 +425,7 @@ private fun TerminalPanel(
} }
SpecialKeyBar( SpecialKeyBar(
onTab = onTab, onTab = onTab,
onHelp = onHelp,
onCursorLeft = onCursorLeft, onCursorLeft = onCursorLeft,
onCursorRight = onCursorRight, onCursorRight = onCursorRight,
onHistoryUp = onHistoryUp, onHistoryUp = onHistoryUp,
@@ -464,6 +471,7 @@ private fun TerminalPanel(
@Composable @Composable
private fun SpecialKeyBar( private fun SpecialKeyBar(
onTab: () -> Unit, onTab: () -> Unit,
onHelp: () -> Unit,
onCursorLeft: () -> Unit, onCursorLeft: () -> Unit,
onCursorRight: () -> Unit, onCursorRight: () -> Unit,
onHistoryUp: () -> Unit, onHistoryUp: () -> Unit,
@@ -471,13 +479,14 @@ private fun SpecialKeyBar(
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
TerminalKeyButton(label = "TAB", onClick = onTab, modifier = Modifier.width(68.dp)) TerminalKeyButton(label = "", onClick = onTab, modifier = Modifier.width(56.dp), fontFamily = FontFamily.Default)
TerminalKeyButton(label = "", onClick = onCursorLeft, modifier = Modifier.width(48.dp)) TerminalKeyButton(label = "?", onClick = onHelp, modifier = Modifier.width(40.dp), fontFamily = FontFamily.Default)
TerminalKeyButton(label = "", onClick = onCursorRight, modifier = Modifier.width(48.dp)) TerminalKeyButton(label = "", onClick = onCursorLeft, modifier = Modifier.width(40.dp))
TerminalKeyButton(label = "", onClick = onHistoryUp, modifier = Modifier.width(48.dp)) TerminalKeyButton(label = "", onClick = onCursorRight, modifier = Modifier.width(40.dp))
TerminalKeyButton(label = "", onClick = onHistoryDown, modifier = Modifier.width(48.dp)) TerminalKeyButton(label = "", onClick = onHistoryUp, modifier = Modifier.width(40.dp))
TerminalKeyButton(label = "", onClick = onHistoryDown, modifier = Modifier.width(40.dp))
} }
} }
@@ -486,6 +495,7 @@ private fun TerminalKeyButton(
label: String, label: String,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
fontFamily: FontFamily = FontFamily.Monospace,
) { ) {
Button( Button(
onClick = onClick, onClick = onClick,
@@ -498,7 +508,7 @@ private fun TerminalKeyButton(
) { ) {
Text( Text(
text = label, text = label,
fontFamily = FontFamily.Monospace, fontFamily = fontFamily,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 13.sp fontSize = 13.sp
) )