From 4963123b3be55f5b2786896c8ab53a3844f74446 Mon Sep 17 00:00:00 2001 From: Joe Tretter Date: Sat, 2 May 2026 23:03:00 -0500 Subject: [PATCH] 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/GitEditorCommands.kt app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt app/src/main/java/solutions/tretter/githugandroid/GitMessageEditorDialog.kt app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt app/src/test/java/solutions/tretter/githugandroid/GitEditorCommandsTest.kt --- app/build.gradle.kts | 4 +- .../githugandroid/GitEditorCommands.kt | 58 ++++++++ .../tretter/githugandroid/GitHugApp.kt | 46 +++++++ .../githugandroid/GitMessageEditorDialog.kt | 129 ++++++++++++++++++ .../tretter/githugandroid/GitRuntime.kt | 40 ++++++ .../githugandroid/GitEditorCommandsTest.kt | 38 ++++++ 6 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/solutions/tretter/githugandroid/GitEditorCommands.kt create mode 100644 app/src/main/java/solutions/tretter/githugandroid/GitMessageEditorDialog.kt create mode 100644 app/src/test/java/solutions/tretter/githugandroid/GitEditorCommandsTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index be70b38..50f409a 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 = 107 - versionName = "0.1.106" + versionCode = 108 + versionName = "0.1.107" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitEditorCommands.kt b/app/src/main/java/solutions/tretter/githugandroid/GitEditorCommands.kt new file mode 100644 index 0000000..a627638 --- /dev/null +++ b/app/src/main/java/solutions/tretter/githugandroid/GitEditorCommands.kt @@ -0,0 +1,58 @@ +package solutions.tretter.githugandroid + +enum class GitEditorCommandKind { + COMMIT_MESSAGE, + TAG_MESSAGE, +} + +data class GitEditorInvocation( + val command: String, + val kind: GitEditorCommandKind, + val title: String, + val initialContent: String = "", +) + +fun parseGitEditorInvocation(command: String): GitEditorInvocation? { + val tokens = GitSandboxEngine.tokenizeCommand(command) + if (tokens.size < 2 || tokens[0] != "git") return null + + return when (tokens[1]) { + "commit" -> parseGitCommitEditor(command, tokens.drop(2)) + "tag" -> parseGitTagEditor(command, tokens.drop(2)) + else -> null + } +} + +private fun parseGitCommitEditor(command: String, arguments: List): GitEditorInvocation? { + if (arguments.any { it == "--no-edit" || it == "-F" || it == "--file" || it.startsWith("--file=") }) return null + if (arguments.containsMessageOption()) return null + if ("-C" in arguments || "--reuse-message" in arguments || arguments.any { it.startsWith("--reuse-message=") }) return null + + return GitEditorInvocation( + command = command, + kind = GitEditorCommandKind.COMMIT_MESSAGE, + title = "Edit Commit Message", + ) +} + +private fun parseGitTagEditor(command: String, arguments: List): GitEditorInvocation? { + val needsMessage = arguments.any { it == "-a" || it == "-s" || it == "--annotate" || it == "--sign" } + if (!needsMessage) return null + if (arguments.any { it == "-F" || it == "--file" || it.startsWith("--file=") }) return null + if (arguments.containsMessageOption()) return null + + return GitEditorInvocation( + command = command, + kind = GitEditorCommandKind.TAG_MESSAGE, + title = "Edit Tag Message", + ) +} + +private fun List.containsMessageOption(): Boolean { + return any { argument -> + argument == "-m" || + argument == "--message" || + argument.startsWith("--message=") || + (argument.startsWith("-") && !argument.startsWith("--") && argument.drop(1).contains('m')) + } +} diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt index 6f5c650..bb11740 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt @@ -61,6 +61,7 @@ fun GitHugApp() { var historyDraft by remember { mutableStateOf("") } var hasRestoredProgress by remember { mutableStateOf(false) } var editorState by remember { mutableStateOf(null) } + var gitMessageEditorState by remember { mutableStateOf(null) } var manPageState by remember { mutableStateOf(null) } val currentLevel = levels[currentLevelIndex] @@ -173,6 +174,7 @@ fun GitHugApp() { historyIndex = -1 historyDraft = "" editorState = null + gitMessageEditorState = null manPageState = null applyRecommendedPaneWeights(persist = false) } @@ -190,6 +192,7 @@ fun GitHugApp() { historyIndex = -1 historyDraft = "" editorState = null + gitMessageEditorState = null manPageState = null paneLayout = paneLayout.copy( weights = paneLayout.weights + recommendedPaneWeights( @@ -341,6 +344,19 @@ fun GitHugApp() { applyRecommendedPaneWeights(persist = false) } + fun openGitMessageEditor(invocation: GitEditorInvocation) { + output = buildList { + addAll(output) + add("$ ${invocation.command}") + add("Opened Git message editor") + } + gitMessageEditorState = GitMessageEditorState( + invocation = invocation, + content = invocation.initialContent, + ) + applyRecommendedPaneWeights(persist = false) + } + fun saveEditor() { val state = editorState ?: return val targetPath = state.saveAsPath.trim() @@ -350,6 +366,18 @@ fun GitHugApp() { applyCommandResult(state.originalCommand, newRepo, lines, echoCommand = false) } + fun saveGitMessageEditor() { + val state = gitMessageEditorState ?: return + val (newRepo, lines) = runtime.executeGitEditorCommand( + level = currentLevel, + currentRepo = repo, + invocation = state.invocation, + message = state.content, + ) + gitMessageEditorState = null + applyCommandResult(state.invocation.command, newRepo, lines, echoCommand = false) + } + fun runCommand() { val submittedText = commandInput.text val raw = submittedText.trim() @@ -376,6 +404,12 @@ fun GitHugApp() { return } + val gitEditorInvocation = parseGitEditorInvocation(raw) + if (gitEditorInvocation != null) { + openGitMessageEditor(gitEditorInvocation) + return + } + val (newRepo, lines) = runtime.execute(currentLevel, repo, raw) applyCommandResult(raw, newRepo, lines, echoCommand = true) } @@ -485,6 +519,18 @@ fun GitHugApp() { ) } + gitMessageEditorState?.let { state -> + GitMessageEditorDialog( + state = state, + onContentChange = { gitMessageEditorState = state.copy(content = it) }, + onClose = { + output = output + "Git editor closed without saving" + gitMessageEditorState = null + }, + onSave = { saveGitMessageEditor() }, + ) + } + manPageState?.let { state -> ManPageDialog( state = state, diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitMessageEditorDialog.kt b/app/src/main/java/solutions/tretter/githugandroid/GitMessageEditorDialog.kt new file mode 100644 index 0000000..d602e64 --- /dev/null +++ b/app/src/main/java/solutions/tretter/githugandroid/GitMessageEditorDialog.kt @@ -0,0 +1,129 @@ +package solutions.tretter.githugandroid + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +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.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog + +data class GitMessageEditorState( + val invocation: GitEditorInvocation, + val content: String, +) + +@Composable +fun GitMessageEditorDialog( + state: GitMessageEditorState, + onContentChange: (String) -> Unit, + onClose: () -> Unit, + onSave: () -> Unit, +) { + val horizontalScroll = rememberScrollState() + val verticalScroll = rememberScrollState() + + Dialog(onDismissRequest = onClose) { + Surface( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.72f), + color = PanelPrimary, + shape = RoundedCornerShape(8.dp), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = state.invocation.title, + color = TextPrimary, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + EditorButton(label = "Close", onClick = onClose) + EditorButton(label = "Save", enabled = state.content.isNotBlank(), onClick = onSave) + } + Text( + text = state.invocation.command, + color = TextSecondary, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + ) + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .heightIn(min = 220.dp) + .background(TerminalBackground, RoundedCornerShape(6.dp)) + .padding(10.dp) + .horizontalScroll(horizontalScroll) + .verticalScroll(verticalScroll), + ) { + BasicTextField( + value = state.content, + onValueChange = onContentChange, + modifier = Modifier.sizeIn(minWidth = 1000.dp, minHeight = 700.dp), + textStyle = TextStyle( + color = TextPrimary, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + ), + cursorBrush = SolidColor(Accent), + keyboardOptions = KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Ascii, + ), + ) + } + } + } + } +} + +@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) + } +} diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt index 3c7d471..2068299 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt @@ -154,6 +154,38 @@ class GitRepositoryRuntime(private val context: Context) { return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to listOf("Saved $path") } + fun executeGitEditorCommand( + level: Level, + currentRepo: RepoState, + invocation: GitEditorInvocation, + message: String, + ): Pair> { + val nativeGit = nativeGitBinary() + if (nativeGit == null) { + return GitSandboxEngine.execute(currentRepo, invocation.command.withFallbackMessage(invocation.kind, message)) + } + + val sandboxRoot = sandboxDir(level).canonicalFile + if (!sandboxRoot.exists()) { + prepareLevel(level) + } + val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile + .takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot + + val messageFile = File(sandboxRoot, ".git/GITHUG_ANDROID_EDITMSG").apply { + parentFile?.mkdirs() + writeText(message) + } + val arguments = GitSandboxEngine.tokenizeCommand(invocation.command) + .drop(1) + .toMutableList() + .apply { addAll(listOf("-F", messageFile.absolutePath)) } + val result = runGit(nativeGit, workingDir, arguments) + messageFile.delete() + + return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to result.outputLines + } + private fun executeHelperCommand(sandboxRoot: File, workingDir: File, currentRepo: RepoState, tokens: List): Pair> { return when (tokens.first()) { "ls" -> currentRepo to workingDir.listFiles() @@ -215,6 +247,14 @@ class GitRepositoryRuntime(private val context: Context) { } } + private fun String.withFallbackMessage(kind: GitEditorCommandKind, message: String): String { + val escaped = message.replace("\\", "\\\\").replace("\"", "\\\"").lineSequence().firstOrNull().orEmpty() + return when (kind) { + GitEditorCommandKind.COMMIT_MESSAGE -> "$this -m \"$escaped\"" + GitEditorCommandKind.TAG_MESSAGE -> "$this -m \"$escaped\"" + } + } + private fun fallbackManPage(topic: String): String { val body = when (topic) { "tag" -> """ diff --git a/app/src/test/java/solutions/tretter/githugandroid/GitEditorCommandsTest.kt b/app/src/test/java/solutions/tretter/githugandroid/GitEditorCommandsTest.kt new file mode 100644 index 0000000..79b60bc --- /dev/null +++ b/app/src/test/java/solutions/tretter/githugandroid/GitEditorCommandsTest.kt @@ -0,0 +1,38 @@ +package solutions.tretter.githugandroid + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class GitEditorCommandsTest { + @Test + fun commitWithoutMessageOpensEditor() { + val invocation = parseGitEditorInvocation("git commit") + + assertEquals(GitEditorCommandKind.COMMIT_MESSAGE, invocation?.kind) + assertEquals("Edit Commit Message", invocation?.title) + } + + @Test + fun commitWithMessageDoesNotOpenEditor() { + assertNull(parseGitEditorInvocation("git commit -m \"message\"")) + } + + @Test + fun amendNoEditDoesNotOpenEditor() { + assertNull(parseGitEditorInvocation("git commit --amend --no-edit")) + } + + @Test + fun annotatedTagWithoutMessageOpensEditor() { + val invocation = parseGitEditorInvocation("git tag -a v1.0") + + assertEquals(GitEditorCommandKind.TAG_MESSAGE, invocation?.kind) + assertEquals("Edit Tag Message", invocation?.title) + } + + @Test + fun lightweightTagDoesNotOpenEditor() { + assertNull(parseGitEditorInvocation("git tag v1.0")) + } +}