Refactor large Kotlin source files

- Split core game models from sandbox engine, native level setup, and repo state saving.

- Move app overlays and completion helpers out of GitHugApp.

- Extract the terminal special key bar into its own component.
This commit is contained in:
Joe Tretter
2026-05-09 13:52:13 -05:00
parent 300bd262dd
commit edec66bfe6
9 changed files with 1139 additions and 1110 deletions

View File

@@ -0,0 +1,265 @@
package solutions.tretter.githugandroid
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
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.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.roundToInt
@Composable
internal fun HelpCalloutOverlay(
showHelpOnStart: Boolean,
onShowHelpOnStartChange: (Boolean) -> Unit,
onOk: () -> Unit,
onClose: () -> Unit,
workspaceBounds: Rect?,
exerciseBounds: Rect?,
promptBounds: Rect?,
) {
val density = LocalDensity.current
val workspaceTop = workspaceBounds?.top ?: 0f
val insetPx = with(density) { 14.dp.roundToPx() }
Box(modifier = Modifier.fillMaxSize()) {
HelpBubble(
text = "Read the exercise description, then solve it by entering commands below.",
modifier = exerciseBounds?.let { bounds ->
Modifier.offset {
IntOffset(
x = insetPx,
y = (bounds.top - workspaceTop - insetPx).roundToInt().coerceAtLeast(0),
)
}
} ?: Modifier
.align(Alignment.TopStart)
.padding(horizontal = 14.dp, vertical = 10.dp),
)
HelpBubbleWithControls(
modifier = promptBounds?.let { bounds ->
val bubbleHeight = with(density) { 190.dp.roundToPx() }
Modifier.offset {
IntOffset(
x = insetPx,
y = (bounds.top - workspaceTop - bubbleHeight).roundToInt().coerceAtLeast(0),
)
}
} ?: Modifier
.align(Alignment.BottomStart)
.padding(start = 14.dp, bottom = 96.dp),
text = "Tap the prompt to enter a command or answer.",
showHelpOnStart = showHelpOnStart,
onShowHelpOnStartChange = onShowHelpOnStartChange,
onOk = onOk,
onClose = onClose,
)
}
}
@Composable
private fun HelpBubbleWithControls(
text: String,
showHelpOnStart: Boolean,
onShowHelpOnStartChange: (Boolean) -> Unit,
onOk: () -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
val bubbleColor = Color(0xFFFFF1A8)
Column(modifier = modifier.fillMaxWidth(0.9f)) {
Box(
modifier = Modifier
.background(bubbleColor, RoundedCornerShape(18.dp))
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = text,
color = Color(0xFF161000),
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Checkbox(
checked = showHelpOnStart,
onCheckedChange = onShowHelpOnStartChange,
colors = CheckboxDefaults.colors(
checkedColor = Color(0xFF161000),
uncheckedColor = Color(0xFF6E5A00),
checkmarkColor = bubbleColor,
),
)
Text("Show help on start", color = Color(0xFF161000))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = onOk,
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF161000),
contentColor = bubbleColor,
),
) {
Text("OK")
}
TextButton(onClick = onClose) {
Text("Close", color = Color(0xFF161000))
}
}
}
}
BubbleTail(bubbleColor)
}
}
@Composable
private fun HelpBubble(
text: String,
modifier: Modifier = Modifier,
) {
val bubbleColor = Color(0xFFFFF1A8)
Column(modifier = modifier.fillMaxWidth(0.86f)) {
Box(
modifier = Modifier
.background(bubbleColor, RoundedCornerShape(18.dp))
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Text(
text = text,
color = Color(0xFF161000),
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
)
}
BubbleTail(bubbleColor)
}
}
@Composable
private fun BubbleTail(color: Color) {
Canvas(
modifier = Modifier
.padding(start = 28.dp)
.size(width = 26.dp, height = 13.dp),
) {
drawPath(
path = Path().apply {
moveTo(0f, 0f)
lineTo(size.width, 0f)
lineTo(size.width * 0.25f, size.height)
close()
},
color = color,
)
}
}
@Composable
internal fun MissingNativeGitScreen(message: String) {
Surface(
modifier = Modifier
.fillMaxSize()
.background(AppBackground)
.padding(24.dp),
color = AppBackground,
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start,
) {
Text(
text = "Native Git unavailable",
color = TextPrimary,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
)
Text(
text = message,
modifier = Modifier.padding(top = 12.dp),
color = TextSecondary,
fontSize = 15.sp,
)
}
}
}
@Composable
internal fun SolvedCelebrationOverlay(title: String) {
val transition = rememberInfiniteTransition(label = "solved-celebration")
val progress by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1_200, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "confetti-progress",
)
val colors = listOf(Accent, Success, Color(0xFFFFD166), Color(0xFFFF6B6B), Color(0xFF9BF6FF))
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Canvas(modifier = Modifier.fillMaxSize()) {
repeat(42) { index ->
val x = ((index * 73) % 100) / 100f * size.width
val baseY = ((index * 37) % 100) / 100f * size.height
val y = (baseY + progress * size.height * 0.6f) % size.height
val pieceSize = 6.dp.toPx() + (index % 4) * 2.dp.toPx()
drawCircle(
color = colors[index % colors.size],
radius = pieceSize / 2f,
center = Offset(x, y),
alpha = 0.85f,
)
}
}
Column(
modifier = Modifier
.background(PanelPrimary.copy(alpha = 0.94f), RoundedCornerShape(24.dp))
.padding(horizontal = 26.dp, vertical = 22.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = "👍", fontSize = 56.sp)
Text(text = "Level solved", color = Success, fontSize = 22.sp)
Text(text = title, color = TextSecondary, fontSize = 14.sp)
}
}
}

View File

@@ -0,0 +1,29 @@
package solutions.tretter.githugandroid
internal fun fileCompletionCandidates(repo: RepoState): List<String> {
val prefix = repo.currentDirPrefix()
return repo.files
.filterNot { it.deleted }
.map { it.name }
.filter { it.startsWith(prefix) }
.map { it.removePrefix(prefix) }
.filter { it.isNotBlank() }
}
internal fun directoryCompletionCandidates(repo: RepoState): List<String> {
val prefix = repo.currentDirPrefix()
return repo.files
.filterNot { it.deleted }
.map { it.name }
.filter { it.startsWith(prefix) }
.map { it.removePrefix(prefix) }
.flatMap { file ->
val parts = file.split('/').dropLast(1)
parts.indices.map { index -> parts.take(index + 1).joinToString("/") + "/" }
}
.distinct()
}
private fun RepoState.currentDirPrefix(): String {
return if (currentDir == ".") "" else currentDir.trimEnd('/') + "/"
}

View File

@@ -1,7 +1,5 @@
package solutions.tretter.githugandroid package solutions.tretter.githugandroid
import androidx.compose.runtime.saveable.listSaver
import java.io.File
enum class PlayMode(val label: String) { enum class PlayMode(val label: String) {
CLI_ONLY("CLI ONLY"), CLI_ONLY("CLI ONLY"),
@@ -56,764 +54,5 @@ data class LevelTestCase(
val commands: List<String>, val commands: List<String>,
) )
class NativeLevelSetup internal constructor(
internal val sandbox: File,
private val runGit: (File, List<String>) -> Int,
) {
fun resetFiles() {
sandbox.listFiles()
?.filterNot { it.name == ".git" }
?.forEach { it.deleteRecursively() }
git("checkout", "-B", "master")
}
fun git(vararg arguments: String): Int = runGit(sandbox, arguments.toList())
fun git(directory: File, vararg arguments: String): Int = runGit(directory, arguments.toList())
fun initRepo(directory: File) {
directory.mkdirs()
val initResult = git(directory, "init", "-b", "master")
if (initResult != 0) {
git(directory, "init")
git(directory, "checkout", "-B", "master")
}
git(directory, "config", "receive.denyCurrentBranch", "ignore")
}
fun write(path: String, content: String = "") {
File(sandbox, path).apply {
parentFile?.mkdirs()
writeText(content)
}
}
fun append(path: String, content: String) {
File(sandbox, path).appendText(content)
}
fun add(vararg paths: String) {
git("add", *paths)
}
fun commit(message: String, author: String? = null) {
if (author == null) {
git("commit", "-m", message)
} else {
git("commit", "--author", author, "-m", message)
}
}
fun addCommit(message: String, vararg paths: String, author: String? = null) {
add(*paths)
commit(message, author)
}
fun writeIn(directory: File, path: String, content: String = "") {
File(directory, path).apply {
parentFile?.mkdirs()
writeText(content)
}
}
fun addCommitIn(directory: File, message: String, vararg paths: String) {
git(directory, "add", *paths)
git(directory, "commit", "-m", message)
}
fun siblingRepo(name: String): File {
val directory = File(sandbox.parentFile ?: sandbox, "${sandbox.name}-$name")
directory.deleteRecursively()
initRepo(directory)
return directory
}
fun checkoutNew(branch: String) {
git("checkout", "-b", branch)
}
fun checkout(branch: String) {
git("checkout", branch)
}
fun tag(name: String) {
git("tag", "-f", name)
}
}
fun sampleLevels(): List<Level> = allGithugLevels() fun sampleLevels(): List<Level> = allGithugLevels()
val RepoStateSaver = listSaver<RepoState, Any>(
save = { state ->
listOf(
state.initialized,
state.headBranch,
state.files.flatMap { listOf(it.name, it.content, it.staged.toString(), it.tracked.toString(), it.deleted.toString()) },
state.commits.flatMap { listOf(it.id, it.message) },
state.branches.flatMap { listOf(it.key, it.value.toString()) },
state.currentDir,
state.tags,
state.remotes.flatMap { listOf(it.key, it.value) },
state.config.flatMap { listOf(it.key, it.value) },
state.stashes,
state.fetchedBranches.toList(),
state.pushedBranches.toList(),
state.pushedTags.toList(),
state.submodules.flatMap { listOf(it.key, it.value) },
state.maintenanceActions.toList(),
)
},
restore = { saved ->
val initialized = saved[0] as Boolean
val headBranch = saved[1] as String
val fileParts = saved[2] as List<*>
val commitParts = saved[3] as List<*>
val branchParts = saved[4] as List<*>
val tags = saved[6] as List<*>
val remoteParts = saved[7] as List<*>
val configParts = saved.getOrNull(8) as? List<*> ?: emptyList<Any>()
val stashes = saved.getOrNull(9) as? List<*> ?: emptyList<Any>()
val fetchedBranches = saved.getOrNull(10) as? List<*> ?: emptyList<Any>()
val pushedBranches = saved.getOrNull(11) as? List<*> ?: emptyList<Any>()
val pushedTags = saved.getOrNull(12) as? List<*> ?: emptyList<Any>()
val submoduleParts = saved.getOrNull(13) as? List<*> ?: emptyList<Any>()
val maintenanceActions = saved.getOrNull(14) as? List<*> ?: emptyList<Any>()
RepoState(
initialized = initialized,
headBranch = headBranch,
files = fileParts.chunked(if (fileParts.size % 5 == 0) 5 else 4).map {
GitFile(
name = it[0] as String,
content = it[1] as String,
staged = (it[2] as String).toBoolean(),
tracked = (it[3] as String).toBoolean(),
deleted = (it.getOrNull(4) as? String)?.toBoolean() ?: false,
)
},
commits = commitParts.chunked(2).map {
CommitNode(it[0] as String, it[1] as String)
},
branches = branchParts.chunked(2).associate { (it[0] as String) to (it[1] as String).toInt() },
currentDir = saved[5] as String,
tags = tags.filterIsInstance<String>(),
remotes = remoteParts.chunked(2).associate { (it[0] as String) to (it[1] as String) },
config = configParts.chunked(2).associate { (it[0] as String) to (it[1] as String) },
stashes = stashes.filterIsInstance<String>(),
fetchedBranches = fetchedBranches.filterIsInstance<String>().toSet(),
pushedBranches = pushedBranches.filterIsInstance<String>().toSet(),
pushedTags = pushedTags.filterIsInstance<String>().toSet(),
submodules = submoduleParts.chunked(2).associate { (it[0] as String) to (it[1] as String) },
maintenanceActions = maintenanceActions.filterIsInstance<String>().toSet(),
)
}
)
object GitSandboxEngine {
data class ShellToken(
val value: String,
val quoted: Boolean = false,
)
fun commandReferenceLines(): List<String> = listOf(
"Available sandbox commands:",
" git ",
" ls|dir",
" touch <file>",
" help",
" pwd ",
" cat <file>",
" touch <file>",
" mkdir|md <directory>",
" cd <directory>",
" cd..",
" rm|del <file>",
" echo <message>",
)
fun execute(repo: RepoState, command: String): Pair<RepoState, List<String>> {
val shellParts = tokenizeShellCommand(command)
val parts = shellParts.map { it.value }
if (parts.isEmpty()) return repo to emptyList()
return when {
parts[0] == "help" || parts[0] == "?" -> repo to commandReferenceLines()
parts[0] == "touch" && parts.size >= 2 -> {
val name = parts[1]
if (repo.files.any { it.name == name && !it.deleted }) repo to listOf("$name already exists")
else repo.copy(files = repo.files + GitFile(name = name)) to emptyList()
}
(parts[0] == "mkdir" || parts[0] == "md") && parts.size >= 2 -> repo to emptyList()
(parts[0] == "rm" || parts[0] == "del") && parts.size >= 2 -> {
val target = parts[1]
repo.copy(files = repo.files.mapNotNull { file ->
when {
file.name != target -> file
file.tracked -> file.copy(deleted = true, staged = false)
else -> null
}
}) to emptyList()
}
parts[0] == "echo" -> writeEcho(repo, shellParts)
parts[0] == "ls" || parts[0] == "dir" -> repo to (
if (repo.initialized) listOf(".git") else emptyList()
) + repo.files.filterNot { it.deleted }.map { it.name }
parts[0] == "cd.." -> repo.copy(currentDir = parentDirectory(repo.currentDir)) to emptyList()
parts[0] != "git" -> repo to listOf("Command not supported in sandbox. Try a git command or 'touch'.")
parts.size >= 2 && parts[1] == "init" -> repo.copy(initialized = true, branches = mapOf("master" to repo.commits.size)) to listOf("Initialized empty Git repository")
!repo.initialized -> repo to listOf("fatal: not a git repository")
parts.size >= 2 && parts[1] == "help" -> repo to commandReferenceLines()
parts.size >= 2 && parts[1] == "status" -> repo to statusLines(repo)
parts.size >= 2 && parts[1] == "stash" -> {
val updatedFiles = repo.files.map { file ->
if (file.tracked && !file.staged) file.copy(content = "") else file
}
repo.copy(files = updatedFiles, stashes = repo.stashes + "stash@{${repo.stashes.size}}") to listOf("Saved working directory and index state")
}
parts.size >= 2 && parts[1] == "fetch" -> {
val remote = parts.getOrNull(2)?.takeIf { !it.startsWith("-") } ?: "origin"
repo.copy(fetchedBranches = repo.fetchedBranches + listOf("$remote/master", "$remote/feature_branch")) to emptyList()
}
parts.size >= 2 && parts[1] == "pull" -> {
val remote = parts.getOrNull(2)?.takeIf { !it.startsWith("-") } ?: "origin"
val branch = parts.drop(2).lastOrNull()?.takeIf { !it.startsWith("-") && it != remote } ?: repo.headBranch
repo.copy(
fetchedBranches = repo.fetchedBranches + "$remote/$branch",
branches = repo.branches + (repo.headBranch to maxOf(repo.branches[repo.headBranch] ?: 0, 2)),
) to emptyList()
}
parts.size >= 2 && parts[1] == "push" -> pushRefs(repo, parts.drop(2))
parts.size >= 3 && parts[1] == "submodule" && parts[2] == "add" -> {
val url = parts.getOrNull(3)
val path = parts.getOrNull(4)
if (url == null || path == null) {
repo to listOf("usage: git submodule add <repository> <path>")
} else {
repo.copy(submodules = repo.submodules + (path.trimEnd('/') to url)) to emptyList()
}
}
parts.size >= 2 && parts[1] == "repack" -> {
repo.copy(maintenanceActions = repo.maintenanceActions + "repack") to emptyList()
}
parts.size >= 3 && parts[1] == "tag" -> {
val tag = parts[2]
if (tag in repo.tags) repo to listOf("fatal: tag '$tag' already exists")
else repo.copy(tags = repo.tags + tag) to listOf(tag)
}
parts.size >= 4 && parts[1] == "config" -> {
val key = parts[2]
val value = parts.drop(3).joinToString(" ")
repo.copy(config = repo.config + (key to value)) to emptyList()
}
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>.")
}
val target = parts.drop(2).lastOrNull { !it.startsWith("-") }
?: return repo to listOf("usage: git add <path>")
if (target != "." && repo.files.none { it.name == target && !it.deleted }) {
repo to listOf("fatal: pathspec '$target' did not match any files")
} else {
val updated = repo.files.map { if ((target == "." || it.name == target) && !it.deleted) it.copy(staged = true) else it }
repo.copy(files = updated) to listOf("staged ${if (target == ".") "all files" else target}")
}
}
parts.size >= 3 && parts[1] == "rm" -> removeGitPath(repo, parts.drop(2))
parts.size >= 4 && parts[1] == "mv" -> moveGitPath(repo, expandPathspecTokens(repo, shellParts.drop(2)))
parts.size >= 2 && parts[1] == "commit" -> commit(repo, parts.drop(2))
parts.size >= 2 && parts[1] == "log" -> {
repo to if (repo.commits.isEmpty()) listOf("fatal: your current branch '${repo.headBranch}' does not have any commits yet")
else repo.commits.reversed().flatMap { listOf("commit ${it.id}", " ${it.message}") }
}
parts.size >= 3 && parts[1] == "remote" && parts[2] == "add" -> {
val name = parts.getOrNull(3)
val url = parts.getOrNull(4)
if (name == null || url == null) repo to listOf("usage: git remote add <name> <url>")
else repo.copy(remotes = repo.remotes + (name to url)) to emptyList()
}
parts.size >= 3 && parts[1] == "branch" -> {
when (parts[2]) {
"-d", "-D", "--delete" -> {
val branch = parts.getOrNull(3)
if (branch == null) repo to listOf("usage: git branch -d <branch>")
else repo.copy(branches = repo.branches - branch) to listOf("Deleted branch $branch")
}
else -> {
val branch = parts[2]
val base = parts.getOrNull(3)
val baseIndex = if (base == "HEAD~1" || base == "HEAD^") {
(repo.branches[repo.headBranch] ?: repo.commits.size) - 1
} else {
repo.commits.size
}.coerceAtLeast(0)
if (repo.branches.containsKey(branch)) repo to listOf("fatal: a branch named '$branch' already exists")
else repo.copy(branches = repo.branches + (branch to baseIndex)) to listOf("Created branch $branch")
}
}
}
parts.size >= 3 && parts[1] == "checkout" -> {
checkout(repo, parts.drop(2))
}
parts.size >= 3 && parts[1] == "reset" -> reset(repo, parts.drop(2))
parts.size >= 3 && parts[1] == "merge" -> merge(repo, parts.drop(2))
parts.size >= 2 && parts[1] == "rebase" -> rebase(repo, parts.drop(2))
parts.size >= 2 && parts[1] == "cherry-pick" -> {
val files = if (repo.files.none { it.name == "README.md" }) {
repo.files + GitFile("README.md", "Proper input instructions\n", tracked = true)
} else {
repo.files.map { if (it.name == "README.md") it.copy(tracked = true) else it }
}
repo.copy(files = files, commits = listOf(CommitNode("${repo.commits.size + 1}", "Filled in README.md with proper input")) + repo.commits) to emptyList()
}
parts.size >= 2 && parts[1] == "revert" -> {
repo.copy(commits = repo.commits + CommitNode("${repo.commits.size + 1}", "Revert \"Bad commit\"")) to emptyList()
}
else -> repo to listOf("Unsupported git command in MVP sandbox: ${parts.drop(1).joinToString(" ")}")
}
}
fun tokenizeCommand(command: String): List<String> {
return tokenizeShellCommand(command).map { it.value }
}
fun tokenizeShellCommand(command: String): List<ShellToken> {
val result = mutableListOf<ShellToken>()
val current = StringBuilder()
var quoteChar: Char? = null
var escaping = false
var currentQuoted = false
var skipNext = false
fun emitCurrent(force: Boolean = false) {
if (current.isNotEmpty() || force && currentQuoted) {
result += ShellToken(value = current.toString(), quoted = currentQuoted)
current.clear()
currentQuoted = false
}
}
command.forEachIndexed { index, char ->
if (skipNext) {
skipNext = false
return@forEachIndexed
}
when {
escaping -> {
current.append(char)
escaping = false
}
char == '\\' && quoteChar != '\'' -> {
escaping = true
}
quoteChar != null -> {
if (char == quoteChar) {
quoteChar = null
} else {
current.append(char)
}
}
char == '"' || char == '\'' -> {
quoteChar = char
currentQuoted = true
}
char.isWhitespace() -> {
emitCurrent()
}
char == '>' -> {
emitCurrent()
if (command.getOrNull(index + 1) == '>') {
result += ShellToken(">>")
skipNext = true
} else if (command.getOrNull(index - 1) != '>') {
result += ShellToken(">")
}
}
else -> current.append(char)
}
}
if (escaping) {
current.append('\\')
}
emitCurrent()
return result
}
private fun writeEcho(repo: RepoState, shellParts: List<ShellToken>): Pair<RepoState, List<String>> {
val parts = shellParts.map { it.value }
val redirectIndex = parts.indexOfFirst { it == ">" || it == ">>" }
if (redirectIndex == -1 || redirectIndex == parts.lastIndex) {
return repo to listOf(parts.drop(1).joinToString(" "))
}
val append = parts[redirectIndex] == ">>"
val content = parts.subList(1, redirectIndex).joinToString(" ")
val target = parts[redirectIndex + 1]
val updatedFiles = repo.files.toMutableList()
val index = updatedFiles.indexOfFirst { it.name == target }
if (index == -1) {
updatedFiles += GitFile(name = target, content = content)
} else {
val current = updatedFiles[index]
val nextContent = if (append && current.content.isNotEmpty()) "${current.content}\n$content" else content
updatedFiles[index] = current.copy(content = nextContent, deleted = false)
}
return repo.copy(files = updatedFiles) to emptyList()
}
private fun parentDirectory(currentDir: String): String {
if (currentDir == ".") return "."
return currentDir.substringBeforeLast('/', missingDelimiterValue = ".").ifBlank { "." }
}
fun expandPathspecs(repo: RepoState, arguments: List<String>): List<String> {
return expandPathspecTokens(repo, arguments.map { ShellToken(it) })
}
fun expandPathspecTokens(repo: RepoState, arguments: List<ShellToken>): List<String> {
return arguments.flatMap { token ->
val argument = token.value
if (token.quoted || !argument.hasGlob()) {
listOf(argument)
} else {
val regex = argument.globToRegex()
repo.files
.filterNot { it.deleted }
.map { it.name }
.filter { regex.matches(it) }
.sorted()
.ifEmpty { listOf(argument) }
}
}
}
private fun pushRefs(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
val remote = arguments.firstOrNull { !it.startsWith("-") } ?: "origin"
val explicitBranches = arguments
.dropWhile { it.startsWith("-") }
.drop(1)
.filter { !it.startsWith("-") }
val pushedBranches = when {
arguments.any { it == "--all" } -> repo.branches.keys.map { "$remote/$it" }
explicitBranches.isNotEmpty() -> explicitBranches.map { branch -> "$remote/${branch.substringAfterLast(':')}" }
else -> listOf("$remote/${repo.headBranch}")
}
val pushedTags = if (arguments.any { it == "--tags" || it == "--follow-tags" }) {
repo.tags.toSet()
} else {
emptySet()
}
return repo.copy(
pushedBranches = repo.pushedBranches + pushedBranches,
pushedTags = repo.pushedTags + pushedTags,
) to emptyList()
}
private fun removeGitPath(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
val cached = "--cached" in arguments
val target = arguments.lastOrNull { !it.startsWith("-") }
?: return repo to listOf("usage: git rm [--cached] <path>")
val updated = repo.files.mapNotNull { file ->
if (file.name != target) {
file
} else if (cached) {
file.copy(staged = false, tracked = false)
} else {
null
}
}
return repo.copy(files = updated) to emptyList()
}
private fun moveGitPath(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
val destination = arguments.lastOrNull() ?: return repo to listOf("usage: git mv <source> <destination>")
val sources = arguments.dropLast(1)
if (sources.isEmpty()) return repo to listOf("usage: git mv <source> <destination>")
val destinationIsDirectory = sources.size > 1 || destination.endsWith("/")
val updated = repo.files.map { file ->
if (file.name in sources) {
val target = if (destinationIsDirectory) {
destination.trimEnd('/') + "/" + file.name.substringAfterLast('/')
} else {
destination
}
file.copy(name = target, staged = true)
} else {
file
}
}
return repo.copy(files = updated) to emptyList()
}
private fun checkout(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
return when {
arguments.firstOrNull() == "-b" -> {
val branch = arguments.getOrNull(1) ?: return repo to listOf("usage: git checkout -b <branch>")
repo.copy(
headBranch = branch,
branches = repo.branches + (branch to repo.commits.size),
) to listOf("Switched to a new branch '$branch'")
}
arguments.firstOrNull() == "-B" -> {
val branch = arguments.getOrNull(1) ?: return repo to listOf("usage: git checkout -B <branch>")
repo.copy(
headBranch = branch,
branches = repo.branches + (branch to repo.commits.size),
) to listOf("Switched to branch '$branch'")
}
"--" in arguments -> {
val target = arguments.last()
val updated = repo.files.map { file ->
when (file.name) {
target -> file.copy(content = file.content.substringBefore("\nThese are changes you don't want to keep!"))
"file3" -> file
else -> file
}
}.let { files ->
if (target == "file3" && files.none { it.name == "file3" }) files + GitFile("file3", tracked = true) else files
}
repo.copy(files = updated) to emptyList()
}
arguments.any { it == "file3" } -> {
repo.copy(files = repo.files + GitFile("file3", tracked = true)) to emptyList()
}
else -> {
val branch = arguments.first()
val normalizedTag = branch.removePrefix("tags/").removePrefix("refs/tags/")
when {
repo.branches.containsKey(branch) -> repo.copy(headBranch = branch) to listOf("Switched to branch '$branch'")
normalizedTag in repo.tags -> repo.copy(headBranch = "tags/$normalizedTag") to listOf("HEAD is now at $normalizedTag")
else -> repo to listOf("error: pathspec '$branch' did not match any branch")
}
}
}
}
private fun reset(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
return if ("--soft" in arguments) {
repo.copy(
commits = repo.commits.dropLast(1),
files = repo.files.map { if (it.tracked) it.copy(staged = true) else it },
) to emptyList()
} else {
val target = arguments.last()
repo.copy(files = repo.files.map { if (it.name == target) it.copy(staged = false) else it }) to emptyList()
}
}
private fun merge(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
val branch = arguments.lastOrNull().orEmpty()
val squash = "--squash" in arguments
val files = when {
branch == "feature" && repo.files.none { it.name == "file2" } -> repo.files + GitFile("file2", tracked = true)
branch == "long-feature-branch" && repo.files.none { it.name == "file3" } -> repo.files + GitFile("file3", staged = true)
branch == "mybranch" -> repo.files.map {
if (it.name == "poem.txt") it.copy(content = "Humpty Dumpty sat on a wall\nHumpty Dumpty had a great fall", staged = true)
else it
}
else -> repo.files
}
return repo.copy(
files = files,
maintenanceActions = if (squash) repo.maintenanceActions + "merge-squash" else repo.maintenanceActions,
) to emptyList()
}
private fun rebase(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
val commits = if ("-i" in arguments && repo.commits.size > 2) {
repo.commits
.filterNot { it.message.contains("squash this commit", ignoreCase = true) }
.map { if (it.message == "First coommit") it.copy(message = "First commit") else it }
.let { ordered ->
if (ordered.map { it.message }.containsAll(listOf("First commit", "Second commit", "Third commit"))) {
ordered.sortedBy { commit ->
when (commit.message) {
"First commit" -> 1
"Second commit" -> 2
"Third commit" -> 3
else -> 0
}
}
} else {
ordered
}
}
} else {
repo.commits
}
val updatedBranches = when {
"--onto" in arguments -> repo.branches + (repo.headBranch to (repo.branches["master"] ?: 0) + 1)
arguments.isNotEmpty() -> repo.branches + (repo.headBranch to maxOf(repo.branches[repo.headBranch] ?: 0, repo.branches[arguments.last()] ?: 0))
else -> repo.branches
}
val maintenanceActions = if ("--onto" in arguments) {
repo.maintenanceActions + "rebase-onto"
} else {
repo.maintenanceActions
}
return repo.copy(commits = commits, branches = updatedBranches, maintenanceActions = maintenanceActions) to emptyList()
}
private fun commit(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
val parsed = parseCommitArguments(arguments)
if (parsed.error != null) {
return repo to listOf(parsed.error)
}
val repoForCommit = if (parsed.stageAllTracked) {
repo.copy(files = repo.files.map { file -> if (file.tracked) file.copy(staged = true) else file })
} else {
repo
}
val message = parsed.message ?: repo.commits.lastOrNull()?.message.orEmpty()
val staged = repoForCommit.files.filter { it.staged }
if (staged.isEmpty()) return repo to listOf("nothing to commit")
val cleanedFiles = repoForCommit.files.map { file ->
if (file.staged) file.copy(staged = false, tracked = true) else file
}
val nextCommits = if (parsed.amend && repo.commits.isNotEmpty()) {
repo.commits.dropLast(1) + repo.commits.last().copy(message = message)
} else {
val nextId = "${repo.commits.size + 1}".padStart(7, '0')
repo.commits + CommitNode(nextId, message)
}
return repoForCommit.copy(
files = cleanedFiles,
commits = nextCommits,
branches = repo.branches + (repo.headBranch to nextCommits.size),
) to listOf("[${nextCommits.lastOrNull()?.id.orEmpty()}] $message")
}
private fun parseCommitArguments(arguments: List<String>): ParsedCommitArguments {
var message: String? = null
var stageAllTracked = false
var amend = false
var index = 0
while (index < arguments.size) {
val argument = arguments[index]
when {
argument == "-a" || argument == "--all" -> {
stageAllTracked = true
}
argument == "--amend" -> {
amend = true
}
argument == "--no-edit" -> {
// Keep the previous commit message when amending.
}
argument == "--date" -> {
if (arguments.getOrNull(index + 1) == null) {
return ParsedCommitArguments(error = "error: option '--date' requires a value")
}
index += 1
}
argument.startsWith("--date=") -> {
// The sandbox records commit structure, not timestamps.
}
argument == "-m" || argument == "--message" -> {
val next = arguments.getOrNull(index + 1)
?: return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
message = next
index += 1
}
argument.startsWith("--message=") -> {
message = argument.substringAfter('=')
}
argument.startsWith("-") && !argument.startsWith("--") && argument.length > 2 -> {
val shortFlags = argument.drop(1)
var shortIndex = 0
while (shortIndex < shortFlags.length) {
when (val flag = shortFlags[shortIndex]) {
'a' -> stageAllTracked = true
'm' -> {
val attachedValue = shortFlags.substring(shortIndex + 1)
if (attachedValue.isNotEmpty()) {
message = attachedValue
} else {
val next = arguments.getOrNull(index + 1)
?: return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
message = next
index += 1
}
break
}
else -> return ParsedCommitArguments(error = "error: unsupported commit option '-$flag'")
}
shortIndex += 1
}
}
else -> return ParsedCommitArguments(error = "error: unsupported commit argument '$argument'")
}
index += 1
}
if (!amend && message.isNullOrBlank()) {
return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
}
return ParsedCommitArguments(message = message, stageAllTracked = stageAllTracked, amend = amend)
}
private fun statusLines(repo: RepoState): List<String> {
val staged = repo.files.filter { it.staged }.map {
when {
it.deleted -> "deleted: ${it.name}"
it.tracked -> "modified: ${it.name}"
else -> "new file: ${it.name}"
}
}
val deleted = repo.files.filter { it.deleted && it.tracked && !it.staged }.map { "deleted: ${it.name}" }
val unstaged = repo.files.filterNot { it.staged || it.tracked || it.deleted }.map { "untracked: ${it.name}" }
return buildList {
add("On branch ${repo.headBranch}")
if (staged.isEmpty() && deleted.isEmpty() && unstaged.isEmpty()) {
add("nothing to commit, working tree clean")
} else {
if (staged.isNotEmpty()) {
add("Changes to be committed:")
addAll(staged)
}
if (deleted.isNotEmpty()) {
add("Changes not staged for commit:")
addAll(deleted)
}
if (unstaged.isNotEmpty()) {
add("Untracked files:")
addAll(unstaged)
}
}
}
}
private fun String.hasGlob(): Boolean = any { it == '*' || it == '?' }
private fun String.globToRegex(): Regex {
val pattern = buildString {
append('^')
this@globToRegex.forEach { char ->
when (char) {
'*' -> append("[^/]*")
'?' -> append("[^/]")
'.', '(', ')', '+', '|', '^', '$', '@', '%', '{', '}', '[', ']', '\\' -> {
append('\\')
append(char)
}
else -> append(char)
}
}
append('$')
}
return Regex(pattern)
}
private data class ParsedCommitArguments(
val message: String? = null,
val stageAllTracked: Boolean = false,
val amend: Boolean = false,
val error: String? = null,
)
}

View File

@@ -1,35 +1,18 @@
package solutions.tretter.githugandroid package solutions.tretter.githugandroid
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -39,25 +22,16 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.sp
import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@Composable @Composable
fun GitHugApp() { fun GitHugApp() {
@@ -653,266 +627,3 @@ fun GitHugApp() {
} }
} }
} }
internal fun fileCompletionCandidates(repo: RepoState): List<String> {
val prefix = repo.currentDirPrefix()
return repo.files
.filterNot { it.deleted }
.map { it.name }
.filter { it.startsWith(prefix) }
.map { it.removePrefix(prefix) }
.filter { it.isNotBlank() }
}
internal fun directoryCompletionCandidates(repo: RepoState): List<String> {
val prefix = repo.currentDirPrefix()
return repo.files
.filterNot { it.deleted }
.map { it.name }
.filter { it.startsWith(prefix) }
.map { it.removePrefix(prefix) }
.flatMap { file ->
val parts = file.split('/').dropLast(1)
parts.indices.map { index -> parts.take(index + 1).joinToString("/") + "/" }
}
.distinct()
}
private fun RepoState.currentDirPrefix(): String {
return if (currentDir == ".") "" else currentDir.trimEnd('/') + "/"
}
@Composable
private fun HelpCalloutOverlay(
showHelpOnStart: Boolean,
onShowHelpOnStartChange: (Boolean) -> Unit,
onOk: () -> Unit,
onClose: () -> Unit,
workspaceBounds: Rect?,
exerciseBounds: Rect?,
promptBounds: Rect?,
) {
val density = LocalDensity.current
val workspaceTop = workspaceBounds?.top ?: 0f
val insetPx = with(density) { 14.dp.roundToPx() }
Box(
modifier = Modifier.fillMaxSize(),
) {
HelpBubble(
text = "Read the exercise description, then solve it by entering commands below.",
modifier = exerciseBounds?.let { bounds ->
Modifier.offset {
IntOffset(
x = insetPx,
y = (bounds.top - workspaceTop - insetPx).roundToInt().coerceAtLeast(0),
)
}
} ?: Modifier
.align(Alignment.TopStart)
.padding(horizontal = 14.dp, vertical = 10.dp),
)
HelpBubbleWithControls(
modifier = promptBounds?.let { bounds ->
val bubbleHeight = with(density) { 190.dp.roundToPx() }
Modifier.offset {
IntOffset(
x = insetPx,
y = (bounds.top - workspaceTop - bubbleHeight).roundToInt().coerceAtLeast(0),
)
}
} ?: Modifier
.align(Alignment.BottomStart)
.padding(start = 14.dp, bottom = 96.dp),
text = "Tap the prompt to enter a command or answer.",
showHelpOnStart = showHelpOnStart,
onShowHelpOnStartChange = onShowHelpOnStartChange,
onOk = onOk,
onClose = onClose,
)
}
}
@Composable
private fun HelpBubbleWithControls(
text: String,
showHelpOnStart: Boolean,
onShowHelpOnStartChange: (Boolean) -> Unit,
onOk: () -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
val bubbleColor = Color(0xFFFFF1A8)
Column(modifier = modifier.fillMaxWidth(0.9f)) {
Box(
modifier = Modifier
.background(bubbleColor, RoundedCornerShape(18.dp))
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = text,
color = Color(0xFF161000),
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Checkbox(
checked = showHelpOnStart,
onCheckedChange = onShowHelpOnStartChange,
colors = CheckboxDefaults.colors(
checkedColor = Color(0xFF161000),
uncheckedColor = Color(0xFF6E5A00),
checkmarkColor = bubbleColor,
),
)
Text("Show help on start", color = Color(0xFF161000))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = onOk,
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF161000),
contentColor = bubbleColor,
),
) {
Text("OK")
}
TextButton(onClick = onClose) {
Text("Close", color = Color(0xFF161000))
}
}
}
}
Canvas(
modifier = Modifier
.padding(start = 28.dp)
.size(width = 26.dp, height = 13.dp),
) {
drawPath(
path = Path().apply {
moveTo(0f, 0f)
lineTo(size.width, 0f)
lineTo(size.width * 0.25f, size.height)
close()
},
color = bubbleColor,
)
}
}
}
@Composable
private fun HelpBubble(
text: String,
modifier: Modifier = Modifier,
) {
val bubbleColor = Color(0xFFFFF1A8)
Column(modifier = modifier.fillMaxWidth(0.86f)) {
Box(
modifier = Modifier
.background(bubbleColor, RoundedCornerShape(18.dp))
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Text(
text = text,
color = Color(0xFF161000),
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
)
}
Canvas(
modifier = Modifier
.padding(start = 28.dp)
.size(width = 26.dp, height = 13.dp),
) {
drawPath(
path = Path().apply {
moveTo(0f, 0f)
lineTo(size.width, 0f)
lineTo(size.width * 0.25f, size.height)
close()
},
color = bubbleColor,
)
}
}
}
@Composable
private fun MissingNativeGitScreen(message: String) {
Surface(
modifier = Modifier
.fillMaxSize()
.background(AppBackground)
.padding(24.dp),
color = AppBackground,
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start,
) {
Text(
text = "Native Git unavailable",
color = TextPrimary,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
)
Text(
text = message,
modifier = Modifier.padding(top = 12.dp),
color = TextSecondary,
fontSize = 15.sp,
)
}
}
}
@Composable
private fun SolvedCelebrationOverlay(title: String) {
val transition = rememberInfiniteTransition(label = "solved-celebration")
val progress by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1_200, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "confetti-progress",
)
val colors = listOf(Accent, Success, Color(0xFFFFD166), Color(0xFFFF6B6B), Color(0xFF9BF6FF))
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Canvas(modifier = Modifier.fillMaxSize()) {
repeat(42) { index ->
val x = ((index * 73) % 100) / 100f * size.width
val baseY = ((index * 37) % 100) / 100f * size.height
val y = (baseY + progress * size.height * 0.6f) % size.height
val pieceSize = 6.dp.toPx() + (index % 4) * 2.dp.toPx()
drawCircle(
color = colors[index % colors.size],
radius = pieceSize / 2f,
center = Offset(x, y),
alpha = 0.85f,
)
}
}
Column(
modifier = Modifier
.background(PanelPrimary.copy(alpha = 0.94f), RoundedCornerShape(24.dp))
.padding(horizontal = 26.dp, vertical = 22.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = "👍", fontSize = 56.sp)
Text(text = "Level solved", color = Success, fontSize = 22.sp)
Text(text = title, color = TextSecondary, fontSize = 14.sp)
}
}
}

View File

@@ -0,0 +1,611 @@
package solutions.tretter.githugandroid
object GitSandboxEngine {
data class ShellToken(
val value: String,
val quoted: Boolean = false,
)
fun commandReferenceLines(): List<String> = listOf(
"Available sandbox commands:",
" git ",
" ls|dir",
" touch <file>",
" help",
" pwd ",
" cat <file>",
" touch <file>",
" mkdir|md <directory>",
" cd <directory>",
" cd..",
" rm|del <file>",
" echo <message>",
)
fun execute(repo: RepoState, command: String): Pair<RepoState, List<String>> {
val shellParts = tokenizeShellCommand(command)
val parts = shellParts.map { it.value }
if (parts.isEmpty()) return repo to emptyList()
return when {
parts[0] == "help" || parts[0] == "?" -> repo to commandReferenceLines()
parts[0] == "touch" && parts.size >= 2 -> {
val name = parts[1]
if (repo.files.any { it.name == name && !it.deleted }) repo to listOf("$name already exists")
else repo.copy(files = repo.files + GitFile(name = name)) to emptyList()
}
(parts[0] == "mkdir" || parts[0] == "md") && parts.size >= 2 -> repo to emptyList()
(parts[0] == "rm" || parts[0] == "del") && parts.size >= 2 -> {
val target = parts[1]
repo.copy(files = repo.files.mapNotNull { file ->
when {
file.name != target -> file
file.tracked -> file.copy(deleted = true, staged = false)
else -> null
}
}) to emptyList()
}
parts[0] == "echo" -> writeEcho(repo, shellParts)
parts[0] == "ls" || parts[0] == "dir" -> repo to (
if (repo.initialized) listOf(".git") else emptyList()
) + repo.files.filterNot { it.deleted }.map { it.name }
parts[0] == "cd.." -> repo.copy(currentDir = parentDirectory(repo.currentDir)) to emptyList()
parts[0] != "git" -> repo to listOf("Command not supported in sandbox. Try a git command or 'touch'.")
parts.size >= 2 && parts[1] == "init" -> repo.copy(initialized = true, branches = mapOf("master" to repo.commits.size)) to listOf("Initialized empty Git repository")
!repo.initialized -> repo to listOf("fatal: not a git repository")
parts.size >= 2 && parts[1] == "help" -> repo to commandReferenceLines()
parts.size >= 2 && parts[1] == "status" -> repo to statusLines(repo)
parts.size >= 2 && parts[1] == "stash" -> {
val updatedFiles = repo.files.map { file ->
if (file.tracked && !file.staged) file.copy(content = "") else file
}
repo.copy(files = updatedFiles, stashes = repo.stashes + "stash@{${repo.stashes.size}}") to listOf("Saved working directory and index state")
}
parts.size >= 2 && parts[1] == "fetch" -> {
val remote = parts.getOrNull(2)?.takeIf { !it.startsWith("-") } ?: "origin"
repo.copy(fetchedBranches = repo.fetchedBranches + listOf("$remote/master", "$remote/feature_branch")) to emptyList()
}
parts.size >= 2 && parts[1] == "pull" -> {
val remote = parts.getOrNull(2)?.takeIf { !it.startsWith("-") } ?: "origin"
val branch = parts.drop(2).lastOrNull()?.takeIf { !it.startsWith("-") && it != remote } ?: repo.headBranch
repo.copy(
fetchedBranches = repo.fetchedBranches + "$remote/$branch",
branches = repo.branches + (repo.headBranch to maxOf(repo.branches[repo.headBranch] ?: 0, 2)),
) to emptyList()
}
parts.size >= 2 && parts[1] == "push" -> pushRefs(repo, parts.drop(2))
parts.size >= 3 && parts[1] == "submodule" && parts[2] == "add" -> {
val url = parts.getOrNull(3)
val path = parts.getOrNull(4)
if (url == null || path == null) {
repo to listOf("usage: git submodule add <repository> <path>")
} else {
repo.copy(submodules = repo.submodules + (path.trimEnd('/') to url)) to emptyList()
}
}
parts.size >= 2 && parts[1] == "repack" -> {
repo.copy(maintenanceActions = repo.maintenanceActions + "repack") to emptyList()
}
parts.size >= 3 && parts[1] == "tag" -> {
val tag = parts[2]
if (tag in repo.tags) repo to listOf("fatal: tag '$tag' already exists")
else repo.copy(tags = repo.tags + tag) to listOf(tag)
}
parts.size >= 4 && parts[1] == "config" -> {
val key = parts[2]
val value = parts.drop(3).joinToString(" ")
repo.copy(config = repo.config + (key to value)) to emptyList()
}
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>.")
}
val target = parts.drop(2).lastOrNull { !it.startsWith("-") }
?: return repo to listOf("usage: git add <path>")
if (target != "." && repo.files.none { it.name == target && !it.deleted }) {
repo to listOf("fatal: pathspec '$target' did not match any files")
} else {
val updated = repo.files.map { if ((target == "." || it.name == target) && !it.deleted) it.copy(staged = true) else it }
repo.copy(files = updated) to listOf("staged ${if (target == ".") "all files" else target}")
}
}
parts.size >= 3 && parts[1] == "rm" -> removeGitPath(repo, parts.drop(2))
parts.size >= 4 && parts[1] == "mv" -> moveGitPath(repo, expandPathspecTokens(repo, shellParts.drop(2)))
parts.size >= 2 && parts[1] == "commit" -> commit(repo, parts.drop(2))
parts.size >= 2 && parts[1] == "log" -> {
repo to if (repo.commits.isEmpty()) listOf("fatal: your current branch '${repo.headBranch}' does not have any commits yet")
else repo.commits.reversed().flatMap { listOf("commit ${it.id}", " ${it.message}") }
}
parts.size >= 3 && parts[1] == "remote" && parts[2] == "add" -> {
val name = parts.getOrNull(3)
val url = parts.getOrNull(4)
if (name == null || url == null) repo to listOf("usage: git remote add <name> <url>")
else repo.copy(remotes = repo.remotes + (name to url)) to emptyList()
}
parts.size >= 3 && parts[1] == "branch" -> {
when (parts[2]) {
"-d", "-D", "--delete" -> {
val branch = parts.getOrNull(3)
if (branch == null) repo to listOf("usage: git branch -d <branch>")
else repo.copy(branches = repo.branches - branch) to listOf("Deleted branch $branch")
}
else -> {
val branch = parts[2]
val base = parts.getOrNull(3)
val baseIndex = if (base == "HEAD~1" || base == "HEAD^") {
(repo.branches[repo.headBranch] ?: repo.commits.size) - 1
} else {
repo.commits.size
}.coerceAtLeast(0)
if (repo.branches.containsKey(branch)) repo to listOf("fatal: a branch named '$branch' already exists")
else repo.copy(branches = repo.branches + (branch to baseIndex)) to listOf("Created branch $branch")
}
}
}
parts.size >= 3 && parts[1] == "checkout" -> {
checkout(repo, parts.drop(2))
}
parts.size >= 3 && parts[1] == "reset" -> reset(repo, parts.drop(2))
parts.size >= 3 && parts[1] == "merge" -> merge(repo, parts.drop(2))
parts.size >= 2 && parts[1] == "rebase" -> rebase(repo, parts.drop(2))
parts.size >= 2 && parts[1] == "cherry-pick" -> {
val files = if (repo.files.none { it.name == "README.md" }) {
repo.files + GitFile("README.md", "Proper input instructions\n", tracked = true)
} else {
repo.files.map { if (it.name == "README.md") it.copy(tracked = true) else it }
}
repo.copy(files = files, commits = listOf(CommitNode("${repo.commits.size + 1}", "Filled in README.md with proper input")) + repo.commits) to emptyList()
}
parts.size >= 2 && parts[1] == "revert" -> {
repo.copy(commits = repo.commits + CommitNode("${repo.commits.size + 1}", "Revert \"Bad commit\"")) to emptyList()
}
else -> repo to listOf("Unsupported git command in MVP sandbox: ${parts.drop(1).joinToString(" ")}")
}
}
fun tokenizeCommand(command: String): List<String> {
return tokenizeShellCommand(command).map { it.value }
}
fun tokenizeShellCommand(command: String): List<ShellToken> {
val result = mutableListOf<ShellToken>()
val current = StringBuilder()
var quoteChar: Char? = null
var escaping = false
var currentQuoted = false
var skipNext = false
fun emitCurrent(force: Boolean = false) {
if (current.isNotEmpty() || force && currentQuoted) {
result += ShellToken(value = current.toString(), quoted = currentQuoted)
current.clear()
currentQuoted = false
}
}
command.forEachIndexed { index, char ->
if (skipNext) {
skipNext = false
return@forEachIndexed
}
when {
escaping -> {
current.append(char)
escaping = false
}
char == '\\' && quoteChar != '\'' -> {
escaping = true
}
quoteChar != null -> {
if (char == quoteChar) {
quoteChar = null
} else {
current.append(char)
}
}
char == '"' || char == '\'' -> {
quoteChar = char
currentQuoted = true
}
char.isWhitespace() -> {
emitCurrent()
}
char == '>' -> {
emitCurrent()
if (command.getOrNull(index + 1) == '>') {
result += ShellToken(">>")
skipNext = true
} else if (command.getOrNull(index - 1) != '>') {
result += ShellToken(">")
}
}
else -> current.append(char)
}
}
if (escaping) {
current.append('\\')
}
emitCurrent()
return result
}
private fun writeEcho(repo: RepoState, shellParts: List<ShellToken>): Pair<RepoState, List<String>> {
val parts = shellParts.map { it.value }
val redirectIndex = parts.indexOfFirst { it == ">" || it == ">>" }
if (redirectIndex == -1 || redirectIndex == parts.lastIndex) {
return repo to listOf(parts.drop(1).joinToString(" "))
}
val append = parts[redirectIndex] == ">>"
val content = parts.subList(1, redirectIndex).joinToString(" ")
val target = parts[redirectIndex + 1]
val updatedFiles = repo.files.toMutableList()
val index = updatedFiles.indexOfFirst { it.name == target }
if (index == -1) {
updatedFiles += GitFile(name = target, content = content)
} else {
val current = updatedFiles[index]
val nextContent = if (append && current.content.isNotEmpty()) "${current.content}\n$content" else content
updatedFiles[index] = current.copy(content = nextContent, deleted = false)
}
return repo.copy(files = updatedFiles) to emptyList()
}
private fun parentDirectory(currentDir: String): String {
if (currentDir == ".") return "."
return currentDir.substringBeforeLast('/', missingDelimiterValue = ".").ifBlank { "." }
}
fun expandPathspecs(repo: RepoState, arguments: List<String>): List<String> {
return expandPathspecTokens(repo, arguments.map { ShellToken(it) })
}
fun expandPathspecTokens(repo: RepoState, arguments: List<ShellToken>): List<String> {
return arguments.flatMap { token ->
val argument = token.value
if (token.quoted || !argument.hasGlob()) {
listOf(argument)
} else {
val regex = argument.globToRegex()
repo.files
.filterNot { it.deleted }
.map { it.name }
.filter { regex.matches(it) }
.sorted()
.ifEmpty { listOf(argument) }
}
}
}
private fun pushRefs(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
val remote = arguments.firstOrNull { !it.startsWith("-") } ?: "origin"
val explicitBranches = arguments
.dropWhile { it.startsWith("-") }
.drop(1)
.filter { !it.startsWith("-") }
val pushedBranches = when {
arguments.any { it == "--all" } -> repo.branches.keys.map { "$remote/$it" }
explicitBranches.isNotEmpty() -> explicitBranches.map { branch -> "$remote/${branch.substringAfterLast(':')}" }
else -> listOf("$remote/${repo.headBranch}")
}
val pushedTags = if (arguments.any { it == "--tags" || it == "--follow-tags" }) {
repo.tags.toSet()
} else {
emptySet()
}
return repo.copy(
pushedBranches = repo.pushedBranches + pushedBranches,
pushedTags = repo.pushedTags + pushedTags,
) to emptyList()
}
private fun removeGitPath(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
val cached = "--cached" in arguments
val target = arguments.lastOrNull { !it.startsWith("-") }
?: return repo to listOf("usage: git rm [--cached] <path>")
val updated = repo.files.mapNotNull { file ->
if (file.name != target) {
file
} else if (cached) {
file.copy(staged = false, tracked = false)
} else {
null
}
}
return repo.copy(files = updated) to emptyList()
}
private fun moveGitPath(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
val destination = arguments.lastOrNull() ?: return repo to listOf("usage: git mv <source> <destination>")
val sources = arguments.dropLast(1)
if (sources.isEmpty()) return repo to listOf("usage: git mv <source> <destination>")
val destinationIsDirectory = sources.size > 1 || destination.endsWith("/")
val updated = repo.files.map { file ->
if (file.name in sources) {
val target = if (destinationIsDirectory) {
destination.trimEnd('/') + "/" + file.name.substringAfterLast('/')
} else {
destination
}
file.copy(name = target, staged = true)
} else {
file
}
}
return repo.copy(files = updated) to emptyList()
}
private fun checkout(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
return when {
arguments.firstOrNull() == "-b" -> {
val branch = arguments.getOrNull(1) ?: return repo to listOf("usage: git checkout -b <branch>")
repo.copy(
headBranch = branch,
branches = repo.branches + (branch to repo.commits.size),
) to listOf("Switched to a new branch '$branch'")
}
arguments.firstOrNull() == "-B" -> {
val branch = arguments.getOrNull(1) ?: return repo to listOf("usage: git checkout -B <branch>")
repo.copy(
headBranch = branch,
branches = repo.branches + (branch to repo.commits.size),
) to listOf("Switched to branch '$branch'")
}
"--" in arguments -> {
val target = arguments.last()
val updated = repo.files.map { file ->
when (file.name) {
target -> file.copy(content = file.content.substringBefore("\nThese are changes you don't want to keep!"))
"file3" -> file
else -> file
}
}.let { files ->
if (target == "file3" && files.none { it.name == "file3" }) files + GitFile("file3", tracked = true) else files
}
repo.copy(files = updated) to emptyList()
}
arguments.any { it == "file3" } -> {
repo.copy(files = repo.files + GitFile("file3", tracked = true)) to emptyList()
}
else -> {
val branch = arguments.first()
val normalizedTag = branch.removePrefix("tags/").removePrefix("refs/tags/")
when {
repo.branches.containsKey(branch) -> repo.copy(headBranch = branch) to listOf("Switched to branch '$branch'")
normalizedTag in repo.tags -> repo.copy(headBranch = "tags/$normalizedTag") to listOf("HEAD is now at $normalizedTag")
else -> repo to listOf("error: pathspec '$branch' did not match any branch")
}
}
}
}
private fun reset(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
return if ("--soft" in arguments) {
repo.copy(
commits = repo.commits.dropLast(1),
files = repo.files.map { if (it.tracked) it.copy(staged = true) else it },
) to emptyList()
} else {
val target = arguments.last()
repo.copy(files = repo.files.map { if (it.name == target) it.copy(staged = false) else it }) to emptyList()
}
}
private fun merge(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
val branch = arguments.lastOrNull().orEmpty()
val squash = "--squash" in arguments
val files = when {
branch == "feature" && repo.files.none { it.name == "file2" } -> repo.files + GitFile("file2", tracked = true)
branch == "long-feature-branch" && repo.files.none { it.name == "file3" } -> repo.files + GitFile("file3", staged = true)
branch == "mybranch" -> repo.files.map {
if (it.name == "poem.txt") it.copy(content = "Humpty Dumpty sat on a wall\nHumpty Dumpty had a great fall", staged = true)
else it
}
else -> repo.files
}
return repo.copy(
files = files,
maintenanceActions = if (squash) repo.maintenanceActions + "merge-squash" else repo.maintenanceActions,
) to emptyList()
}
private fun rebase(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
val commits = if ("-i" in arguments && repo.commits.size > 2) {
repo.commits
.filterNot { it.message.contains("squash this commit", ignoreCase = true) }
.map { if (it.message == "First coommit") it.copy(message = "First commit") else it }
.let { ordered ->
if (ordered.map { it.message }.containsAll(listOf("First commit", "Second commit", "Third commit"))) {
ordered.sortedBy { commit ->
when (commit.message) {
"First commit" -> 1
"Second commit" -> 2
"Third commit" -> 3
else -> 0
}
}
} else {
ordered
}
}
} else {
repo.commits
}
val updatedBranches = when {
"--onto" in arguments -> repo.branches + (repo.headBranch to (repo.branches["master"] ?: 0) + 1)
arguments.isNotEmpty() -> repo.branches + (repo.headBranch to maxOf(repo.branches[repo.headBranch] ?: 0, repo.branches[arguments.last()] ?: 0))
else -> repo.branches
}
val maintenanceActions = if ("--onto" in arguments) {
repo.maintenanceActions + "rebase-onto"
} else {
repo.maintenanceActions
}
return repo.copy(commits = commits, branches = updatedBranches, maintenanceActions = maintenanceActions) to emptyList()
}
private fun commit(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
val parsed = parseCommitArguments(arguments)
if (parsed.error != null) {
return repo to listOf(parsed.error)
}
val repoForCommit = if (parsed.stageAllTracked) {
repo.copy(files = repo.files.map { file -> if (file.tracked) file.copy(staged = true) else file })
} else {
repo
}
val message = parsed.message ?: repo.commits.lastOrNull()?.message.orEmpty()
val staged = repoForCommit.files.filter { it.staged }
if (staged.isEmpty()) return repo to listOf("nothing to commit")
val cleanedFiles = repoForCommit.files.map { file ->
if (file.staged) file.copy(staged = false, tracked = true) else file
}
val nextCommits = if (parsed.amend && repo.commits.isNotEmpty()) {
repo.commits.dropLast(1) + repo.commits.last().copy(message = message)
} else {
val nextId = "${repo.commits.size + 1}".padStart(7, '0')
repo.commits + CommitNode(nextId, message)
}
return repoForCommit.copy(
files = cleanedFiles,
commits = nextCommits,
branches = repo.branches + (repo.headBranch to nextCommits.size),
) to listOf("[${nextCommits.lastOrNull()?.id.orEmpty()}] $message")
}
private fun parseCommitArguments(arguments: List<String>): ParsedCommitArguments {
var message: String? = null
var stageAllTracked = false
var amend = false
var index = 0
while (index < arguments.size) {
val argument = arguments[index]
when {
argument == "-a" || argument == "--all" -> {
stageAllTracked = true
}
argument == "--amend" -> {
amend = true
}
argument == "--no-edit" -> {
// Keep the previous commit message when amending.
}
argument == "--date" -> {
if (arguments.getOrNull(index + 1) == null) {
return ParsedCommitArguments(error = "error: option '--date' requires a value")
}
index += 1
}
argument.startsWith("--date=") -> {
// The sandbox records commit structure, not timestamps.
}
argument == "-m" || argument == "--message" -> {
val next = arguments.getOrNull(index + 1)
?: return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
message = next
index += 1
}
argument.startsWith("--message=") -> {
message = argument.substringAfter('=')
}
argument.startsWith("-") && !argument.startsWith("--") && argument.length > 2 -> {
val shortFlags = argument.drop(1)
var shortIndex = 0
while (shortIndex < shortFlags.length) {
when (val flag = shortFlags[shortIndex]) {
'a' -> stageAllTracked = true
'm' -> {
val attachedValue = shortFlags.substring(shortIndex + 1)
if (attachedValue.isNotEmpty()) {
message = attachedValue
} else {
val next = arguments.getOrNull(index + 1)
?: return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
message = next
index += 1
}
break
}
else -> return ParsedCommitArguments(error = "error: unsupported commit option '-$flag'")
}
shortIndex += 1
}
}
else -> return ParsedCommitArguments(error = "error: unsupported commit argument '$argument'")
}
index += 1
}
if (!amend && message.isNullOrBlank()) {
return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
}
return ParsedCommitArguments(message = message, stageAllTracked = stageAllTracked, amend = amend)
}
private fun statusLines(repo: RepoState): List<String> {
val staged = repo.files.filter { it.staged }.map {
when {
it.deleted -> "deleted: ${it.name}"
it.tracked -> "modified: ${it.name}"
else -> "new file: ${it.name}"
}
}
val deleted = repo.files.filter { it.deleted && it.tracked && !it.staged }.map { "deleted: ${it.name}" }
val unstaged = repo.files.filterNot { it.staged || it.tracked || it.deleted }.map { "untracked: ${it.name}" }
return buildList {
add("On branch ${repo.headBranch}")
if (staged.isEmpty() && deleted.isEmpty() && unstaged.isEmpty()) {
add("nothing to commit, working tree clean")
} else {
if (staged.isNotEmpty()) {
add("Changes to be committed:")
addAll(staged)
}
if (deleted.isNotEmpty()) {
add("Changes not staged for commit:")
addAll(deleted)
}
if (unstaged.isNotEmpty()) {
add("Untracked files:")
addAll(unstaged)
}
}
}
}
private fun String.hasGlob(): Boolean = any { it == '*' || it == '?' }
private fun String.globToRegex(): Regex {
val pattern = buildString {
append('^')
this@globToRegex.forEach { char ->
when (char) {
'*' -> append("[^/]*")
'?' -> append("[^/]")
'.', '(', ')', '+', '|', '^', '$', '@', '%', '{', '}', '[', ']', '\\' -> {
append('\\')
append(char)
}
else -> append(char)
}
}
append('$')
}
return Regex(pattern)
}
private data class ParsedCommitArguments(
val message: String? = null,
val stageAllTracked: Boolean = false,
val amend: Boolean = false,
val error: String? = null,
)
}

View File

@@ -0,0 +1,88 @@
package solutions.tretter.githugandroid
import java.io.File
class NativeLevelSetup internal constructor(
internal val sandbox: File,
private val runGit: (File, List<String>) -> Int,
) {
fun resetFiles() {
sandbox.listFiles()
?.filterNot { it.name == ".git" }
?.forEach { it.deleteRecursively() }
git("checkout", "-B", "master")
}
fun git(vararg arguments: String): Int = runGit(sandbox, arguments.toList())
fun git(directory: File, vararg arguments: String): Int = runGit(directory, arguments.toList())
fun initRepo(directory: File) {
directory.mkdirs()
val initResult = git(directory, "init", "-b", "master")
if (initResult != 0) {
git(directory, "init")
git(directory, "checkout", "-B", "master")
}
git(directory, "config", "receive.denyCurrentBranch", "ignore")
}
fun write(path: String, content: String = "") {
File(sandbox, path).apply {
parentFile?.mkdirs()
writeText(content)
}
}
fun append(path: String, content: String) {
File(sandbox, path).appendText(content)
}
fun add(vararg paths: String) {
git("add", *paths)
}
fun commit(message: String, author: String? = null) {
if (author == null) {
git("commit", "-m", message)
} else {
git("commit", "--author", author, "-m", message)
}
}
fun addCommit(message: String, vararg paths: String, author: String? = null) {
add(*paths)
commit(message, author)
}
fun writeIn(directory: File, path: String, content: String = "") {
File(directory, path).apply {
parentFile?.mkdirs()
writeText(content)
}
}
fun addCommitIn(directory: File, message: String, vararg paths: String) {
git(directory, "add", *paths)
git(directory, "commit", "-m", message)
}
fun siblingRepo(name: String): File {
val directory = File(sandbox.parentFile ?: sandbox, "${sandbox.name}-$name")
directory.deleteRecursively()
initRepo(directory)
return directory
}
fun checkoutNew(branch: String) {
git("checkout", "-b", branch)
}
fun checkout(branch: String) {
git("checkout", branch)
}
fun tag(name: String) {
git("tag", "-f", name)
}
}

View File

@@ -0,0 +1,68 @@
package solutions.tretter.githugandroid
import androidx.compose.runtime.saveable.listSaver
val RepoStateSaver = listSaver<RepoState, Any>(
save = { state ->
listOf(
state.initialized,
state.headBranch,
state.files.flatMap { listOf(it.name, it.content, it.staged.toString(), it.tracked.toString(), it.deleted.toString()) },
state.commits.flatMap { listOf(it.id, it.message) },
state.branches.flatMap { listOf(it.key, it.value.toString()) },
state.currentDir,
state.tags,
state.remotes.flatMap { listOf(it.key, it.value) },
state.config.flatMap { listOf(it.key, it.value) },
state.stashes,
state.fetchedBranches.toList(),
state.pushedBranches.toList(),
state.pushedTags.toList(),
state.submodules.flatMap { listOf(it.key, it.value) },
state.maintenanceActions.toList(),
)
},
restore = { saved ->
val initialized = saved[0] as Boolean
val headBranch = saved[1] as String
val fileParts = saved[2] as List<*>
val commitParts = saved[3] as List<*>
val branchParts = saved[4] as List<*>
val tags = saved[6] as List<*>
val remoteParts = saved[7] as List<*>
val configParts = saved.getOrNull(8) as? List<*> ?: emptyList<Any>()
val stashes = saved.getOrNull(9) as? List<*> ?: emptyList<Any>()
val fetchedBranches = saved.getOrNull(10) as? List<*> ?: emptyList<Any>()
val pushedBranches = saved.getOrNull(11) as? List<*> ?: emptyList<Any>()
val pushedTags = saved.getOrNull(12) as? List<*> ?: emptyList<Any>()
val submoduleParts = saved.getOrNull(13) as? List<*> ?: emptyList<Any>()
val maintenanceActions = saved.getOrNull(14) as? List<*> ?: emptyList<Any>()
RepoState(
initialized = initialized,
headBranch = headBranch,
files = fileParts.chunked(if (fileParts.size % 5 == 0) 5 else 4).map {
GitFile(
name = it[0] as String,
content = it[1] as String,
staged = (it[2] as String).toBoolean(),
tracked = (it[3] as String).toBoolean(),
deleted = (it.getOrNull(4) as? String)?.toBoolean() ?: false,
)
},
commits = commitParts.chunked(2).map {
CommitNode(it[0] as String, it[1] as String)
},
branches = branchParts.chunked(2).associate { (it[0] as String) to (it[1] as String).toInt() },
currentDir = saved[5] as String,
tags = tags.filterIsInstance<String>(),
remotes = remoteParts.chunked(2).associate { (it[0] as String) to (it[1] as String) },
config = configParts.chunked(2).associate { (it[0] as String) to (it[1] as String) },
stashes = stashes.filterIsInstance<String>(),
fetchedBranches = fetchedBranches.filterIsInstance<String>().toSet(),
pushedBranches = pushedBranches.filterIsInstance<String>().toSet(),
pushedTags = pushedTags.filterIsInstance<String>().toSet(),
submodules = submoduleParts.chunked(2).associate { (it[0] as String) to (it[1] as String) },
maintenanceActions = maintenanceActions.filterIsInstance<String>().toSet(),
)
}
)

View File

@@ -7,7 +7,6 @@ import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
@@ -25,8 +24,6 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -294,59 +291,3 @@ private fun TerminalInputHintBubble() {
} }
} }
} }
@Composable
private fun SpecialKeyBar(
onTab: () -> Unit,
onHelp: () -> Unit,
onCursorLeft: () -> Unit,
onCursorRight: () -> Unit,
onHistoryUp: () -> Unit,
onHistoryDown: () -> Unit,
onInsertText: (String) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(1.dp),
) {
TerminalKeyButton(label = "?", onClick = onHelp, highlight = true, fontFamily = FontFamily.Default)
TerminalKeyButton(label = "", onClick = onTab, fontFamily = FontFamily.Default)
TerminalKeyButton(label = ".", onClick = { onInsertText(".") })
TerminalKeyButton(label = "-", onClick = { onInsertText("-") })
TerminalKeyButton(label = "/", onClick = { onInsertText("/") })
TerminalKeyButton(label = "", onClick = onCursorLeft)
TerminalKeyButton(label = "", onClick = onCursorRight)
TerminalKeyButton(label = "", onClick = onHistoryUp)
TerminalKeyButton(label = "", onClick = onHistoryDown)
}
}
@Composable
private fun TerminalKeyButton(
label: String,
onClick: () -> Unit,
highlight: Boolean = false,
fontFamily: FontFamily = FontFamily.Monospace,
) {
Button(
onClick = onClick,
modifier = Modifier
.width(32.dp)
.height(26.dp),
shape = RoundedCornerShape(0.dp),
contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (highlight) Color(0xFF1976D2) else PanelSecondary,
contentColor = TextPrimary,
),
) {
Text(
text = label,
fontFamily = fontFamily,
fontWeight = FontWeight.Bold,
fontSize = 11.sp,
)
}
}

View File

@@ -0,0 +1,77 @@
package solutions.tretter.githugandroid
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
internal fun SpecialKeyBar(
onTab: () -> Unit,
onHelp: () -> Unit,
onCursorLeft: () -> Unit,
onCursorRight: () -> Unit,
onHistoryUp: () -> Unit,
onHistoryDown: () -> Unit,
onInsertText: (String) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(1.dp),
) {
TerminalKeyButton(label = "?", onClick = onHelp, highlight = true, fontFamily = FontFamily.Default)
TerminalKeyButton(label = "", onClick = onTab, fontFamily = FontFamily.Default)
TerminalKeyButton(label = ".", onClick = { onInsertText(".") })
TerminalKeyButton(label = "-", onClick = { onInsertText("-") })
TerminalKeyButton(label = "/", onClick = { onInsertText("/") })
TerminalKeyButton(label = "", onClick = onCursorLeft)
TerminalKeyButton(label = "", onClick = onCursorRight)
TerminalKeyButton(label = "", onClick = onHistoryUp)
TerminalKeyButton(label = "", onClick = onHistoryDown)
}
}
@Composable
private fun TerminalKeyButton(
label: String,
onClick: () -> Unit,
highlight: Boolean = false,
fontFamily: FontFamily = FontFamily.Monospace,
) {
Button(
onClick = onClick,
modifier = Modifier
.width(32.dp)
.height(26.dp),
shape = RoundedCornerShape(0.dp),
contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (highlight) Color(0xFF1976D2) else PanelSecondary,
contentColor = TextPrimary,
),
) {
Text(
text = label,
fontFamily = fontFamily,
fontWeight = FontWeight.Bold,
fontSize = 11.sp,
)
}
}