Add low-output tooling mode

This commit is contained in:
Joe Tretter
2026-06-26 16:24:45 -05:00
parent 91913a9de8
commit 4b94173ae5
6 changed files with 224 additions and 207 deletions

View File

@@ -37,11 +37,66 @@ ANDROID_CMAKE_DIR="$SDK_DIR/cmake/3.22.1"
ANDROID_TOOLBIN="$ANDROID_NDK_DIR/toolchains/llvm/prebuilt/linux-x86_64/bin"
STATE_TTL_SECONDS=86400
HIDE_EMULATOR_WINDOW="false"
QUIET_LOG_DIR="$PROJECT_DIR/build/reports/android-project-tooling"
log() {
printf '\n[%s] %s\n' "tooling" "$1"
}
quiet_log_name() {
local mode="setup"
local arg
for arg in "$@"; do
case "$arg" in
--build|--build-release-aab|--test|--clean-test|--test-emulator|--compile-git)
mode="${arg#--}"
;;
esac
done
printf '%s-%s.log\n' "$(date +%Y%m%d-%H%M%S)" "$mode"
}
run_quiet_if_requested() {
if [ "${ANDROID_PROJECT_TOOLING_QUIET_CHILD:-}" = "1" ]; then
return
fi
local quiet="false"
local filtered_args=()
local arg
for arg in "$@"; do
if [ "$arg" = "--quiet" ]; then
quiet="true"
else
filtered_args+=("$arg")
fi
done
if [ "$quiet" != "true" ]; then
return
fi
mkdir -p "$QUIET_LOG_DIR"
local log_file="$QUIET_LOG_DIR/$(quiet_log_name "${filtered_args[@]}")"
printf '[tooling] Running quietly; full log: %s\n' "${log_file#$PROJECT_DIR/}"
set +e
ANDROID_PROJECT_TOOLING_QUIET_CHILD=1 bash "$0" "${filtered_args[@]}" >"$log_file" 2>&1 &
local child_pid=$!
wait "$child_pid"
local status=$?
set -e
if [ "$status" -eq 0 ]; then
printf '[tooling] Completed successfully; full log: %s\n' "${log_file#$PROJECT_DIR/}"
else
printf '[tooling] Failed with exit code %s; full log: %s\n' "$status" "${log_file#$PROJECT_DIR/}" >&2
printf '[tooling] Last 120 log lines:\n' >&2
tail -n 120 "$log_file" >&2 || true
fi
exit "$status"
}
require_tool() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required tool: $1" >&2
@@ -795,7 +850,7 @@ maybe_run_operation() {
print_usage() {
cat <<EOF_USAGE
Usage: bash ./AndroidProjectTooling.sh [--build | --build-release-aab | --test | --clean-test | --test-emulator [--hide-emulator-window] | --compile-git]
Usage: bash ./AndroidProjectTooling.sh [--quiet] [--build | --build-release-aab | --test | --clean-test | --test-emulator [--hide-emulator-window] | --compile-git]
--build Set up the environment and build the debug APK
--build-release-aab Set up the environment and build a release Android App Bundle (AAB)
@@ -804,11 +859,16 @@ Usage: bash ./AndroidProjectTooling.sh [--build | --build-release-aab | --test |
--test-emulator Set up a visible Android emulator and run debug instrumentation tests on it
--hide-emulator-window Run --test-emulator with the emulator window hidden
--compile-git Compile Git for the development host and all Android target ABIs
--quiet Write full output to build/reports/android-project-tooling/ and print only a concise result
EOF_USAGE
}
validate_args() {
local mode="${1:-}"
if [ "$mode" = "--quiet" ]; then
mode="${2:-}"
shift || true
fi
shift || true
case "$mode" in
@@ -823,6 +883,8 @@ validate_args() {
while [ "$#" -gt 0 ]; do
case "$1" in
--quiet)
;;
--hide-emulator-window)
if [ "$mode" != "--test-emulator" ]; then
echo "--hide-emulator-window can only be used with --test-emulator" >&2
@@ -842,6 +904,7 @@ validate_args() {
}
main() {
run_quiet_if_requested "$@"
validate_args "$@"
require_tool curl

View File

@@ -54,6 +54,14 @@ Available commands:
| `bash ./AndroidProjectTooling.sh --build-release-aab` | Build the release Android App Bundle. | `app/build/outputs/bundle/release/githug-android-release-v<versionCode>.aab` |
| `bash ./AndroidProjectTooling.sh --compile-git` | Compile Git for the development host and all Android target ABIs. | Host and Android `libgit.so` binaries |
Add `--quiet` before any tooling command when running through an AI or other log-sensitive automation:
```bash
bash ./AndroidProjectTooling.sh --quiet --test
```
Quiet mode runs the same command in a child process, writes the full output to `build/reports/android-project-tooling/`, waits for completion, and prints only a concise success/failure result. On failure it prints the last 120 log lines so the immediate error is visible without repeatedly streaming the full Gradle or emulator log.
To run the JVM unit test suite after ensuring the local toolchain is ready:
```bash

View File

@@ -20,8 +20,8 @@ android {
applicationId = "solutions.tretter.githugandroid"
minSdk = 26
targetSdk = 35
versionCode = 180
versionName = "0.1.179"
versionCode = 181
versionName = "0.1.180"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true

View File

@@ -117,6 +117,15 @@ class GitRepositoryRuntimeInstrumentedTest {
}
}
@Test
fun visualFileEditorStartsWithActionsVisible() {
launchFreshApp().use {
submitTerminalCommand("edit notes.txt")
waitForTextEditorActions()
}
}
@Test
fun selectingLevelFromLevelsPaneUpdatesExerciseImmediately() {
launchFreshApp().use {
@@ -200,6 +209,7 @@ class GitRepositoryRuntimeInstrumentedTest {
composeRule.onAllNodes(hasText(path, substring = true)).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("git-message-editor-path").assertIsDisplayed()
waitForGitEditorActions()
}
private fun waitForGitMessageEditor() {
@@ -207,6 +217,20 @@ class GitRepositoryRuntimeInstrumentedTest {
composeRule.onAllNodesWithTag("git-message-editor-content").fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("git-message-editor-content").assertIsDisplayed()
waitForGitEditorActions()
}
private fun waitForGitEditorActions() {
composeRule.onNodeWithTag("git-message-editor-save").assertIsDisplayed()
composeRule.onNodeWithTag("git-message-editor-dismiss").assertIsDisplayed()
}
private fun waitForTextEditorActions() {
composeRule.waitUntil(timeoutMillis = 30_000) {
composeRule.onAllNodesWithTag("text-editor-content").fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("text-editor-save").assertIsDisplayed()
composeRule.onNodeWithTag("text-editor-dismiss").assertIsDisplayed()
}
private fun currentGitEditorContent(): String {

View File

@@ -1,173 +0,0 @@
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
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.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
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
import androidx.compose.ui.window.DialogProperties
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()
val dialogScroll = rememberScrollState()
val contentFocusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(state.invocation.command) {
runCatching { contentFocusRequester.requestFocus() }
keyboardController?.show()
}
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(
modifier = Modifier
.fillMaxSize()
.imePadding()
.verticalScroll(dialogScroll)
.padding(12.dp),
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 360.dp, max = 720.dp),
color = PanelPrimary,
shape = RoundedCornerShape(8.dp),
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = state.invocation.title,
modifier = Modifier.testTag("git-message-editor-title"),
color = TextPrimary,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
EditorButton(
label = "Save",
enabled = state.content.isNotBlank(),
modifier = Modifier.testTag("git-message-editor-save"),
onClick = onSave,
)
EditorButton(
label = "Dismiss",
modifier = Modifier.testTag("git-message-editor-dismiss"),
onClick = onClose,
)
}
Text(
text = state.invocation.displayPath,
modifier = Modifier.testTag("git-message-editor-path"),
color = TextSecondary,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.heightIn(min = 260.dp)
.background(TerminalBackground, RoundedCornerShape(6.dp))
.padding(10.dp)
.horizontalScroll(horizontalScroll)
.verticalScroll(verticalScroll),
) {
BasicTextField(
value = state.content,
onValueChange = onContentChange,
modifier = Modifier
.sizeIn(minWidth = 1200.dp, minHeight = 1200.dp)
.focusRequester(contentFocusRequester)
.testTag("git-message-editor-content"),
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,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier,
colors = ButtonDefaults.buttonColors(
containerColor = Accent,
contentColor = AppBackground,
disabledContainerColor = PanelTertiary,
disabledContentColor = TextMuted,
),
) {
Text(label)
}
}

View File

@@ -7,13 +7,11 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
@@ -26,17 +24,16 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
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.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
@@ -50,6 +47,11 @@ data class TextEditorState(
val saveAsPath: String,
)
data class GitMessageEditorState(
val invocation: GitEditorInvocation,
val content: String,
)
@Composable
fun TextEditorDialog(
state: TextEditorState,
@@ -57,16 +59,75 @@ fun TextEditorDialog(
onSaveAsPathChange: (String) -> Unit,
onClose: () -> Unit,
onSave: () -> Unit,
) {
ParameterizedTextEditorDialog(
title = "Edit File",
titleTag = null,
metadata = "${state.editor} ${state.path.ifBlank { "<new file>" }}",
metadataTag = null,
metadataStyle = EditorMetadataStyle.Prominent,
content = state.content,
saveAsPath = state.saveAsPath,
saveEnabled = state.saveAsPath.isNotBlank(),
testTagPrefix = "text-editor",
resetKey = "${state.originalCommand}\n${state.path}",
onContentChange = onContentChange,
onSaveAsPathChange = onSaveAsPathChange,
onClose = onClose,
onSave = onSave,
)
}
@Composable
fun GitMessageEditorDialog(
state: GitMessageEditorState,
onContentChange: (String) -> Unit,
onClose: () -> Unit,
onSave: () -> Unit,
) {
ParameterizedTextEditorDialog(
title = state.invocation.title,
titleTag = "git-message-editor-title",
metadata = state.invocation.displayPath,
metadataTag = "git-message-editor-path",
metadataStyle = EditorMetadataStyle.SecondaryMonospace,
content = state.content,
saveAsPath = null,
saveEnabled = state.content.isNotBlank(),
testTagPrefix = "git-message-editor",
resetKey = "${state.invocation.command}\n${state.invocation.displayPath}",
onContentChange = onContentChange,
onSaveAsPathChange = {},
onClose = onClose,
onSave = onSave,
)
}
@Composable
private fun ParameterizedTextEditorDialog(
title: String,
titleTag: String?,
metadata: String,
metadataTag: String?,
metadataStyle: EditorMetadataStyle,
content: String,
saveAsPath: String?,
saveEnabled: Boolean,
testTagPrefix: String,
resetKey: String,
onContentChange: (String) -> Unit,
onSaveAsPathChange: (String) -> Unit,
onClose: () -> Unit,
onSave: () -> Unit,
) {
val editorHorizontalScroll = rememberScrollState()
val editorVerticalScroll = rememberScrollState()
val dialogScroll = rememberScrollState()
val contentFocusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(state.originalCommand, state.path) {
runCatching { contentFocusRequester.requestFocus() }
keyboardController?.show()
LaunchedEffect(resetKey) {
dialogScroll.scrollTo(0)
editorVerticalScroll.scrollTo(0)
editorHorizontalScroll.scrollTo(0)
}
Dialog(
@@ -92,7 +153,8 @@ fun TextEditorDialog(
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = "Edit File",
text = title,
modifier = titleTag?.let { Modifier.testTag(it) } ?: Modifier,
color = TextPrimary,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
@@ -101,26 +163,40 @@ fun TextEditorDialog(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
EditorButton(label = "Save", enabled = state.saveAsPath.isNotBlank(), onClick = onSave)
EditorButton(label = "Dismiss", onClick = onClose)
EditorButton(
label = "Save",
enabled = saveEnabled,
modifier = Modifier.testTag("$testTagPrefix-save"),
onClick = onSave,
)
EditorButton(
label = "Dismiss",
modifier = Modifier.testTag("$testTagPrefix-dismiss"),
onClick = onClose,
)
}
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,
keyboardOptions = KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Done,
),
label = { Text("File name") },
text = metadata,
modifier = metadataTag?.let { Modifier.testTag(it) } ?: Modifier,
color = metadataStyle.color,
fontFamily = metadataStyle.fontFamily,
fontWeight = metadataStyle.fontWeight,
fontSize = metadataStyle.fontSize,
)
saveAsPath?.let { path ->
OutlinedTextField(
value = path,
onValueChange = onSaveAsPathChange,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Done,
),
label = { Text("File name") },
)
}
Box(
modifier = Modifier
.fillMaxWidth()
@@ -132,11 +208,11 @@ fun TextEditorDialog(
.verticalScroll(editorVerticalScroll),
) {
BasicTextField(
value = state.content,
value = content,
onValueChange = onContentChange,
modifier = Modifier
.sizeIn(minWidth = 1200.dp, minHeight = 1200.dp)
.focusRequester(contentFocusRequester),
.testTag("$testTagPrefix-content"),
textStyle = TextStyle(
color = TextPrimary,
fontFamily = FontFamily.Monospace,
@@ -155,15 +231,34 @@ fun TextEditorDialog(
}
}
private enum class EditorMetadataStyle(
val color: Color,
val fontFamily: FontFamily? = null,
val fontWeight: FontWeight? = null,
val fontSize: TextUnit = 14.sp,
) {
Prominent(
color = TextPrimary,
fontWeight = FontWeight.Bold,
),
SecondaryMonospace(
color = TextSecondary,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
),
}
@Composable
private fun EditorButton(
label: String,
enabled: Boolean = true,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier,
colors = ButtonDefaults.buttonColors(
containerColor = Accent,
contentColor = AppBackground,