Fix runtime diagnostics, rotation stability, and manpage search positioning

- Expand the missing-native-Git message with the selected ABI, 32/64-bit ABI lists,
  Android version, device model/platform details, expected binary path, and a request
  to report the information so device support can be added.
- Use application-scoped DataStore delegates for game progress and pane layout
  preferences to avoid duplicate DataStore instances during activity recreation, such
  as screen orientation changes.
- Center manpage search matches in the visible scroll viewport when possible and apply
  IME padding so keyboard-covered space is accounted for.
- Support mobile-friendly handling for interactive add/stage commands (`git add -i`,
  `git stage -i`, and `git add --interactive`) by routing them through GitHug's
  staging semantics instead of rejecting them or launching Git's terminal UI.
- Apply IME padding to editor dialogs so controls remain reachable while the keyboard
  is displayed.
This commit is contained in:
Joe Tretter
2026-05-09 14:22:07 -05:00
parent edec66bfe6
commit 0acf8c9ab0
10 changed files with 80 additions and 213 deletions

View File

@@ -1,25 +1,24 @@
package solutions.tretter.githugandroid
import android.content.Context
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import java.io.IOException
private val Context.gameProgressDataStore by preferencesDataStore(name = "game_progress_preferences")
class GameProgressStore(private val context: Context) {
init {
AppLog.d("ProgressStore", "GameProgressStore initialized")
}
private val dataStore = PreferenceDataStoreFactory.create(
produceFile = { context.preferencesDataStoreFile("game_progress_preferences") }
)
private val dataStore = context.applicationContext.gameProgressDataStore
private val completedLevelsKey = stringPreferencesKey("completed_levels")
private val activeLevelIdKey = stringPreferencesKey("active_level_id")

View File

@@ -9,6 +9,7 @@ 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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.rememberScrollState
@@ -50,7 +51,8 @@ fun GitMessageEditorDialog(
Surface(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.72f),
.fillMaxHeight(0.72f)
.imePadding(),
color = PanelPrimary,
shape = RoundedCornerShape(8.dp),
) {

View File

@@ -58,12 +58,23 @@ class GitRepositoryRuntime private constructor(
fun isNativeGitAvailable(): Boolean = nativeGitBinary() != null
fun unavailableMessage(): String {
val selectedAbi = Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"
val nativeLibraryDir = context?.applicationInfo?.nativeLibraryDir ?: "unknown"
return buildString {
append("GitHug Android cannot start because this build does not include a native Git binary for this device ABI.")
append("\n\nSupported device ABIs: ")
append("\n\nCurrent device ABI: ")
append(selectedAbi)
append("\nSupported device ABIs: ")
append(Build.SUPPORTED_ABIS.joinToString(", ").ifBlank { "unknown" })
append("\nExpected binary: nativeLibraryDir/libgit.so")
append("\n\nInstall a build that bundles the cross-compiled Git binary for this device.")
append("\nSupported 64-bit ABIs: ")
append(Build.SUPPORTED_64_BIT_ABIS.joinToString(", ").ifBlank { "none" })
append("\nSupported 32-bit ABIs: ")
append(Build.SUPPORTED_32_BIT_ABIS.joinToString(", ").ifBlank { "none" })
append("\nPlatform: Android ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})")
append("\nDevice: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE}; ${Build.HARDWARE})")
append("\nExpected binary: $nativeLibraryDir/libgit.so")
append("\n\nPlease report this information to the developer so support can be added for this device/platform.")
append("\nInstall a build that bundles the cross-compiled Git binary for this device.")
}
}
@@ -124,8 +135,6 @@ class GitRepositoryRuntime private constructor(
expandShellPathspecs(currentRepo, shellTokens)
}.normalizeGitStageAlias()
rejectUnsupportedInteractiveGitCommand(currentRepo, expandedTokens)?.let { return it }
executeSyntheticGitCommand(currentRepo, command, expandedTokens)?.let { return it }
val result = when (expandedTokens.first()) {
@@ -538,7 +547,7 @@ class GitRepositoryRuntime private constructor(
) to listOf("Cloned ${tokens[2]} into $target")
}
val shouldUseSandboxSemantics = when (gitCommand) {
"add" -> tokens.any { it == "-p" || it == "--patch" }
"add" -> tokens.any { it == "-p" || it == "--patch" || it == "-i" || it == "--interactive" }
"rebase" -> "-i" in tokens || "--onto" in tokens
"merge" -> "--squash" in tokens || tokens.lastOrNull() == "mybranch" || tokens.lastOrNull() == "feature"
"revert", "stash" -> true
@@ -561,19 +570,6 @@ class GitRepositoryRuntime private constructor(
}
}
private fun rejectUnsupportedInteractiveGitCommand(
currentRepo: RepoState,
tokens: List<String>,
): Pair<RepoState, List<String>>? {
if (tokens.firstOrNull() != "git") return null
val subcommand = tokens.getOrNull(1) ?: return null
val interactive = tokens.drop(2).any { it == "-i" || it == "--interactive" }
if (subcommand == "add" && interactive) {
return currentRepo to listOf("Interactive staging is not supported in the mobile terminal. Use git add <path> or git add -p <path>.")
}
return null
}
private fun executeEcho(workingDir: File, currentRepo: RepoState, tokens: List<String>): Pair<RepoState, List<String>> {
val redirectIndex = tokens.indexOfFirst { it == ">" || it == ">>" }
if (redirectIndex == -1 || redirectIndex == tokens.lastIndex) {

View File

@@ -97,7 +97,7 @@ object GitSandboxEngine {
}
parts.size >= 2 && (parts[1] == "stage" || parts[1] == "add") -> {
if (parts.drop(2).any { it == "-i" || it == "--interactive" }) {
return repo to listOf("Interactive staging is not supported in the mobile terminal. Use git add <path> or git add -p <path>.")
return interactiveAdd(repo, parts.drop(2))
}
val target = parts.drop(2).lastOrNull { !it.startsWith("-") }
?: return repo to listOf("usage: git add <path>")
@@ -162,6 +162,28 @@ object GitSandboxEngine {
}
}
private fun interactiveAdd(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
val targets = arguments.filterNot { it == "-i" || it == "--interactive" || it.startsWith("--") }
val target = targets.lastOrNull()
val updated = repo.files.map { file ->
if (file.deleted) {
file
} else if (target == null || target == "." || file.name == target) {
file.copy(staged = true)
} else {
file
}
}
val stagedCount = updated.count { updatedFile ->
val before = repo.files.firstOrNull { it.name == updatedFile.name }
updatedFile.staged && before?.staged != true
}
return repo.copy(files = updated) to listOf(
"Interactive add is handled by GitHug Android.",
"Staged ${if (target == null || target == ".") "$stagedCount file(s)" else target}.",
)
}
fun tokenizeCommand(command: String): List<String> {
return tokenizeShellCommand(command).map { it.value }
}

View File

@@ -8,6 +8,7 @@ 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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
@@ -28,10 +29,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
@@ -55,6 +59,8 @@ fun ManPageDialog(
) {
val horizontalScrollState = rememberScrollState()
val verticalScrollState = rememberScrollState()
val density = LocalDensity.current
var contentViewportSize by remember { mutableStateOf(IntSize.Zero) }
var searchQuery by remember(state.content) { mutableStateOf("") }
var selectedMatch by remember(state.content) { mutableStateOf(0) }
val contentLines = remember(state.content) { state.content.lines() }
@@ -106,15 +112,28 @@ fun ManPageDialog(
LaunchedEffect(selectedMatch, matches) {
val match = matches.getOrNull(selectedMatch) ?: return@LaunchedEffect
verticalScrollState.animateScrollTo((match.lineIndex * 18).coerceAtMost(verticalScrollState.maxValue))
horizontalScrollState.animateScrollTo((match.columnIndex * 8).coerceAtMost(horizontalScrollState.maxValue))
val lineHeightPx = with(density) { 18.sp.toPx() }
val charWidthPx = with(density) { 8.sp.toPx() }
val viewportHeight = contentViewportSize.height.takeIf { it > 0 } ?: 0
val viewportWidth = contentViewportSize.width.takeIf { it > 0 } ?: 0
val verticalTarget = (
match.lineIndex * lineHeightPx - viewportHeight / 2f + lineHeightPx / 2f
).toInt()
.coerceIn(0, verticalScrollState.maxValue)
val horizontalTarget = (
match.columnIndex * charWidthPx - viewportWidth / 2f + (match.end - match.start) * charWidthPx / 2f
).toInt()
.coerceIn(0, horizontalScrollState.maxValue)
verticalScrollState.animateScrollTo(verticalTarget)
horizontalScrollState.animateScrollTo(horizontalTarget)
}
Dialog(onDismissRequest = onClose) {
Surface(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.86f),
.fillMaxHeight(0.86f)
.imePadding(),
color = PanelPrimary,
shape = RoundedCornerShape(8.dp),
) {
@@ -204,6 +223,7 @@ fun ManPageDialog(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.onSizeChanged { contentViewportSize = it }
.background(TerminalBackground, RoundedCornerShape(6.dp))
.padding(10.dp)
.horizontalScroll(horizontalScrollState)

View File

@@ -1,19 +1,18 @@
package solutions.tretter.githugandroid
import android.content.Context
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import java.io.IOException
private val Context.paneLayoutDataStore by preferencesDataStore(name = "pane_layout_preferences")
class PaneLayoutStore(private val context: Context) {
private val dataStore = PreferenceDataStoreFactory.create(
produceFile = { context.preferencesDataStoreFile("pane_layout_preferences") }
)
private val dataStore = context.applicationContext.paneLayoutDataStore
private val orderKey = stringPreferencesKey("pane_order")
private val collapsedKey = stringPreferencesKey("pane_collapsed")
@@ -68,4 +67,4 @@ class PaneLayoutStore(private val context: Context) {
}
}
}
}
}

View File

@@ -9,6 +9,7 @@ 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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.widthIn
@@ -57,7 +58,8 @@ fun TextEditorDialog(
Surface(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.86f),
.fillMaxHeight(0.86f)
.imePadding(),
color = PanelPrimary,
shape = RoundedCornerShape(8.dp),
) {

View File

@@ -282,8 +282,8 @@ class GitSandboxEngineTest {
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git stage -i")
assertEquals(repo, updatedRepo)
assertTrue(output.any { it.contains("Interactive staging is not supported") })
assertTrue(updatedRepo.files.single { it.name == "README" }.staged)
assertTrue(output.any { it.contains("Interactive add is handled by GitHug Android") })
}
private fun testGitBinary(): File {