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:
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user