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"
|
applicationId = "solutions.tretter.githugandroid"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 107
|
versionCode = 108
|
||||||
versionName = "0.1.106"
|
versionName = "0.1.107"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables.useSupportLibrary = true
|
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 historyDraft by remember { mutableStateOf("") }
|
||||||
var hasRestoredProgress by remember { mutableStateOf(false) }
|
var hasRestoredProgress by remember { mutableStateOf(false) }
|
||||||
var editorState by remember { mutableStateOf<TextEditorState?>(null) }
|
var editorState by remember { mutableStateOf<TextEditorState?>(null) }
|
||||||
|
var gitMessageEditorState by remember { mutableStateOf<GitMessageEditorState?>(null) }
|
||||||
var manPageState by remember { mutableStateOf<ManPageState?>(null) }
|
var manPageState by remember { mutableStateOf<ManPageState?>(null) }
|
||||||
val currentLevel = levels[currentLevelIndex]
|
val currentLevel = levels[currentLevelIndex]
|
||||||
|
|
||||||
@@ -173,6 +174,7 @@ fun GitHugApp() {
|
|||||||
historyIndex = -1
|
historyIndex = -1
|
||||||
historyDraft = ""
|
historyDraft = ""
|
||||||
editorState = null
|
editorState = null
|
||||||
|
gitMessageEditorState = null
|
||||||
manPageState = null
|
manPageState = null
|
||||||
applyRecommendedPaneWeights(persist = false)
|
applyRecommendedPaneWeights(persist = false)
|
||||||
}
|
}
|
||||||
@@ -190,6 +192,7 @@ fun GitHugApp() {
|
|||||||
historyIndex = -1
|
historyIndex = -1
|
||||||
historyDraft = ""
|
historyDraft = ""
|
||||||
editorState = null
|
editorState = null
|
||||||
|
gitMessageEditorState = null
|
||||||
manPageState = null
|
manPageState = null
|
||||||
paneLayout = paneLayout.copy(
|
paneLayout = paneLayout.copy(
|
||||||
weights = paneLayout.weights + recommendedPaneWeights(
|
weights = paneLayout.weights + recommendedPaneWeights(
|
||||||
@@ -341,6 +344,19 @@ fun GitHugApp() {
|
|||||||
applyRecommendedPaneWeights(persist = false)
|
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() {
|
fun saveEditor() {
|
||||||
val state = editorState ?: return
|
val state = editorState ?: return
|
||||||
val targetPath = state.saveAsPath.trim()
|
val targetPath = state.saveAsPath.trim()
|
||||||
@@ -350,6 +366,18 @@ fun GitHugApp() {
|
|||||||
applyCommandResult(state.originalCommand, newRepo, lines, echoCommand = false)
|
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() {
|
fun runCommand() {
|
||||||
val submittedText = commandInput.text
|
val submittedText = commandInput.text
|
||||||
val raw = submittedText.trim()
|
val raw = submittedText.trim()
|
||||||
@@ -376,6 +404,12 @@ fun GitHugApp() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val gitEditorInvocation = parseGitEditorInvocation(raw)
|
||||||
|
if (gitEditorInvocation != null) {
|
||||||
|
openGitMessageEditor(gitEditorInvocation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val (newRepo, lines) = runtime.execute(currentLevel, repo, raw)
|
val (newRepo, lines) = runtime.execute(currentLevel, repo, raw)
|
||||||
applyCommandResult(raw, newRepo, lines, echoCommand = true)
|
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 ->
|
manPageState?.let { state ->
|
||||||
ManPageDialog(
|
ManPageDialog(
|
||||||
state = state,
|
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")
|
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>> {
|
private fun executeHelperCommand(sandboxRoot: File, workingDir: File, currentRepo: RepoState, tokens: List<String>): Pair<RepoState, List<String>> {
|
||||||
return when (tokens.first()) {
|
return when (tokens.first()) {
|
||||||
"ls" -> currentRepo to workingDir.listFiles()
|
"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 {
|
private fun fallbackManPage(topic: String): String {
|
||||||
val body = when (topic) {
|
val body = when (topic) {
|
||||||
"tag" -> """
|
"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