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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<String>): 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<String>): 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<String>.containsMessageOption(): Boolean {
|
||||
return any { argument ->
|
||||
argument == "-m" ||
|
||||
argument == "--message" ||
|
||||
argument.startsWith("--message=") ||
|
||||
(argument.startsWith("-") && !argument.startsWith("--") && argument.drop(1).contains('m'))
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ fun GitHugApp() {
|
||||
var historyDraft by remember { mutableStateOf("") }
|
||||
var hasRestoredProgress by remember { mutableStateOf(false) }
|
||||
var editorState by remember { mutableStateOf<TextEditorState?>(null) }
|
||||
var gitMessageEditorState by remember { mutableStateOf<GitMessageEditorState?>(null) }
|
||||
var manPageState by remember { mutableStateOf<ManPageState?>(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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<RepoState, List<String>> {
|
||||
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<String>): Pair<RepoState, List<String>> {
|
||||
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" -> """
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user