From edec66bfe69c344b5d198f518fb06d4922c999c8 Mon Sep 17 00:00:00 2001 From: Joe Tretter Date: Sat, 9 May 2026 13:52:13 -0500 Subject: [PATCH] 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. --- .../tretter/githugandroid/AppOverlays.kt | 265 ++++++ .../githugandroid/CompletionCandidates.kt | 29 + .../tretter/githugandroid/GameModels.kt | 761 ------------------ .../tretter/githugandroid/GitHugApp.kt | 291 +------ .../tretter/githugandroid/GitSandboxEngine.kt | 611 ++++++++++++++ .../tretter/githugandroid/NativeLevelSetup.kt | 88 ++ .../tretter/githugandroid/RepoStateSaver.kt | 68 ++ .../tretter/githugandroid/Terminal.kt | 59 -- .../githugandroid/TerminalSpecialKeyBar.kt | 77 ++ 9 files changed, 1139 insertions(+), 1110 deletions(-) create mode 100644 app/src/main/java/solutions/tretter/githugandroid/AppOverlays.kt create mode 100644 app/src/main/java/solutions/tretter/githugandroid/CompletionCandidates.kt create mode 100644 app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt create mode 100644 app/src/main/java/solutions/tretter/githugandroid/NativeLevelSetup.kt create mode 100644 app/src/main/java/solutions/tretter/githugandroid/RepoStateSaver.kt create mode 100644 app/src/main/java/solutions/tretter/githugandroid/TerminalSpecialKeyBar.kt diff --git a/app/src/main/java/solutions/tretter/githugandroid/AppOverlays.kt b/app/src/main/java/solutions/tretter/githugandroid/AppOverlays.kt new file mode 100644 index 0000000..5c82342 --- /dev/null +++ b/app/src/main/java/solutions/tretter/githugandroid/AppOverlays.kt @@ -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) + } + } +} diff --git a/app/src/main/java/solutions/tretter/githugandroid/CompletionCandidates.kt b/app/src/main/java/solutions/tretter/githugandroid/CompletionCandidates.kt new file mode 100644 index 0000000..347ce6f --- /dev/null +++ b/app/src/main/java/solutions/tretter/githugandroid/CompletionCandidates.kt @@ -0,0 +1,29 @@ +package solutions.tretter.githugandroid + +internal fun fileCompletionCandidates(repo: RepoState): List { + 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 { + 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('/') + "/" +} diff --git a/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt b/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt index e023973..e2ec149 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt @@ -1,7 +1,5 @@ package solutions.tretter.githugandroid -import androidx.compose.runtime.saveable.listSaver -import java.io.File enum class PlayMode(val label: String) { CLI_ONLY("CLI ONLY"), @@ -56,764 +54,5 @@ data class LevelTestCase( val commands: List, ) -class NativeLevelSetup internal constructor( - internal val sandbox: File, - private val runGit: (File, List) -> 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 = allGithugLevels() -val RepoStateSaver = listSaver( - 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() - val stashes = saved.getOrNull(9) as? List<*> ?: emptyList() - val fetchedBranches = saved.getOrNull(10) as? List<*> ?: emptyList() - val pushedBranches = saved.getOrNull(11) as? List<*> ?: emptyList() - val pushedTags = saved.getOrNull(12) as? List<*> ?: emptyList() - val submoduleParts = saved.getOrNull(13) as? List<*> ?: emptyList() - val maintenanceActions = saved.getOrNull(14) as? List<*> ?: emptyList() - 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(), - 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(), - fetchedBranches = fetchedBranches.filterIsInstance().toSet(), - pushedBranches = pushedBranches.filterIsInstance().toSet(), - pushedTags = pushedTags.filterIsInstance().toSet(), - submodules = submoduleParts.chunked(2).associate { (it[0] as String) to (it[1] as String) }, - maintenanceActions = maintenanceActions.filterIsInstance().toSet(), - ) - } -) - -object GitSandboxEngine { - data class ShellToken( - val value: String, - val quoted: Boolean = false, - ) - - fun commandReferenceLines(): List = listOf( - "Available sandbox commands:", - " git ", - " ls|dir", - " touch ", - " help", - " pwd ", - " cat ", - " touch ", - " mkdir|md ", - " cd ", - " cd..", - " rm|del ", - " echo ", - ) - - fun execute(repo: RepoState, command: String): Pair> { - 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 ") - } 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 or git add -p .") - } - val target = parts.drop(2).lastOrNull { !it.startsWith("-") } - ?: return repo to listOf("usage: git add ") - 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 ") - 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 ") - 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 { - return tokenizeShellCommand(command).map { it.value } - } - - fun tokenizeShellCommand(command: String): List { - val result = mutableListOf() - 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): Pair> { - 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): List { - return expandPathspecTokens(repo, arguments.map { ShellToken(it) }) - } - - fun expandPathspecTokens(repo: RepoState, arguments: List): List { - 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): Pair> { - 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): Pair> { - val cached = "--cached" in arguments - val target = arguments.lastOrNull { !it.startsWith("-") } - ?: return repo to listOf("usage: git rm [--cached] ") - 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): Pair> { - val destination = arguments.lastOrNull() ?: return repo to listOf("usage: git mv ") - val sources = arguments.dropLast(1) - if (sources.isEmpty()) return repo to listOf("usage: git mv ") - 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): Pair> { - return when { - arguments.firstOrNull() == "-b" -> { - val branch = arguments.getOrNull(1) ?: return repo to listOf("usage: git checkout -b ") - 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 ") - 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): Pair> { - 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): Pair> { - 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): Pair> { - 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): Pair> { - 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): 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 { - 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, - ) -} diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt index cddc234..0db9f21 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt @@ -1,35 +1,18 @@ 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.imePadding -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape 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.TextButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -39,25 +22,16 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.Alignment 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.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.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue 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.onGloballyPositioned import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlin.math.roundToInt @Composable fun GitHugApp() { @@ -653,266 +627,3 @@ fun GitHugApp() { } } } - -internal fun fileCompletionCandidates(repo: RepoState): List { - 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 { - 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) - } - } -} diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt b/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt new file mode 100644 index 0000000..540c2d4 --- /dev/null +++ b/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt @@ -0,0 +1,611 @@ +package solutions.tretter.githugandroid + +object GitSandboxEngine { + data class ShellToken( + val value: String, + val quoted: Boolean = false, + ) + + fun commandReferenceLines(): List = listOf( + "Available sandbox commands:", + " git ", + " ls|dir", + " touch ", + " help", + " pwd ", + " cat ", + " touch ", + " mkdir|md ", + " cd ", + " cd..", + " rm|del ", + " echo ", + ) + + fun execute(repo: RepoState, command: String): Pair> { + 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 ") + } 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 or git add -p .") + } + val target = parts.drop(2).lastOrNull { !it.startsWith("-") } + ?: return repo to listOf("usage: git add ") + 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 ") + 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 ") + 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 { + return tokenizeShellCommand(command).map { it.value } + } + + fun tokenizeShellCommand(command: String): List { + val result = mutableListOf() + 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): Pair> { + 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): List { + return expandPathspecTokens(repo, arguments.map { ShellToken(it) }) + } + + fun expandPathspecTokens(repo: RepoState, arguments: List): List { + 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): Pair> { + 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): Pair> { + val cached = "--cached" in arguments + val target = arguments.lastOrNull { !it.startsWith("-") } + ?: return repo to listOf("usage: git rm [--cached] ") + 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): Pair> { + val destination = arguments.lastOrNull() ?: return repo to listOf("usage: git mv ") + val sources = arguments.dropLast(1) + if (sources.isEmpty()) return repo to listOf("usage: git mv ") + 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): Pair> { + return when { + arguments.firstOrNull() == "-b" -> { + val branch = arguments.getOrNull(1) ?: return repo to listOf("usage: git checkout -b ") + 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 ") + 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): Pair> { + 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): Pair> { + 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): Pair> { + 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): Pair> { + 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): 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 { + 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, + ) +} diff --git a/app/src/main/java/solutions/tretter/githugandroid/NativeLevelSetup.kt b/app/src/main/java/solutions/tretter/githugandroid/NativeLevelSetup.kt new file mode 100644 index 0000000..b323cb6 --- /dev/null +++ b/app/src/main/java/solutions/tretter/githugandroid/NativeLevelSetup.kt @@ -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) -> 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) + } +} diff --git a/app/src/main/java/solutions/tretter/githugandroid/RepoStateSaver.kt b/app/src/main/java/solutions/tretter/githugandroid/RepoStateSaver.kt new file mode 100644 index 0000000..be4d560 --- /dev/null +++ b/app/src/main/java/solutions/tretter/githugandroid/RepoStateSaver.kt @@ -0,0 +1,68 @@ +package solutions.tretter.githugandroid + +import androidx.compose.runtime.saveable.listSaver + +val RepoStateSaver = listSaver( + 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() + val stashes = saved.getOrNull(9) as? List<*> ?: emptyList() + val fetchedBranches = saved.getOrNull(10) as? List<*> ?: emptyList() + val pushedBranches = saved.getOrNull(11) as? List<*> ?: emptyList() + val pushedTags = saved.getOrNull(12) as? List<*> ?: emptyList() + val submoduleParts = saved.getOrNull(13) as? List<*> ?: emptyList() + val maintenanceActions = saved.getOrNull(14) as? List<*> ?: emptyList() + 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(), + 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(), + fetchedBranches = fetchedBranches.filterIsInstance().toSet(), + pushedBranches = pushedBranches.filterIsInstance().toSet(), + pushedTags = pushedTags.filterIsInstance().toSet(), + submodules = submoduleParts.chunked(2).associate { (it[0] as String) to (it[1] as String) }, + maintenanceActions = maintenanceActions.filterIsInstance().toSet(), + ) + } +) diff --git a/app/src/main/java/solutions/tretter/githugandroid/Terminal.kt b/app/src/main/java/solutions/tretter/githugandroid/Terminal.kt index 789a2de..99ad2b3 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/Terminal.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/Terminal.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope 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.selection.SelectionContainer import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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, - ) - } -} diff --git a/app/src/main/java/solutions/tretter/githugandroid/TerminalSpecialKeyBar.kt b/app/src/main/java/solutions/tretter/githugandroid/TerminalSpecialKeyBar.kt new file mode 100644 index 0000000..e4f6ed8 --- /dev/null +++ b/app/src/main/java/solutions/tretter/githugandroid/TerminalSpecialKeyBar.kt @@ -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, + ) + } +}