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:
265
app/src/main/java/solutions/tretter/githugandroid/AppOverlays.kt
Normal file
265
app/src/main/java/solutions/tretter/githugandroid/AppOverlays.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('/') + "/"
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user