Auto-commit after successful build: update app gameplay/UI, improve build setup
Changed files:\napp/build.gradle.kts app/src/main/java/solutions/tretter/githugandroid/Exercise.kt app/src/main/java/solutions/tretter/githugandroid/GameModels.kt app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt app/src/main/java/solutions/tretter/githugandroid/GitHugTheme.kt app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt app/src/main/java/solutions/tretter/githugandroid/Header.kt app/src/main/java/solutions/tretter/githugandroid/Levels.kt app/src/main/java/solutions/tretter/githugandroid/MainActivity.kt app/src/main/java/solutions/tretter/githugandroid/PaneLayoutLogic.kt app/src/main/java/solutions/tretter/githugandroid/PaneLayoutModels.kt app/src/main/java/solutions/tretter/githugandroid/PaneLayoutStore.kt app/src/main/java/solutions/tretter/githugandroid/Panes.kt app/src/main/java/solutions/tretter/githugandroid/Terminal.kt app/src/main/java/solutions/tretter/githugandroid/Visual.kt app/src/main/java/solutions/tretter/githugandroid/levels/AdvancedLevels.kt app/src/main/java/solutions/tretter/githugandroid/levels/CoreLevels.kt app/src/main/java/solutions/tretter/githugandroid/levels/LevelCatalog.kt
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun ExercisePane(
|
||||
level: Level,
|
||||
visibleHint: String?,
|
||||
suggestionsExpanded: Boolean,
|
||||
onToggleSuggestions: () -> Unit,
|
||||
onHint: () -> Unit,
|
||||
onReset: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(level.title, style = MaterialTheme.typography.titleLarge, color = TextPrimary)
|
||||
Text(level.description, color = TextSecondary)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = onHint,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Accent,
|
||||
contentColor = Color.Black,
|
||||
),
|
||||
) {
|
||||
Text("Hint")
|
||||
}
|
||||
TextButton(
|
||||
onClick = onToggleSuggestions,
|
||||
colors = ButtonDefaults.textButtonColors(containerColor = Accent,
|
||||
contentColor = Color.Black,
|
||||
),
|
||||
) {
|
||||
Text("Suggestions")
|
||||
}
|
||||
TextButton(
|
||||
onClick = onReset,
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = Accent),
|
||||
) {
|
||||
Text("Reset level")
|
||||
}
|
||||
}
|
||||
if (suggestionsExpanded && level.commandSuggestions.isNotEmpty()) {
|
||||
Text("Suggestions", color = TextPrimary, fontWeight = FontWeight.Bold)
|
||||
level.commandSuggestions.forEach { suggestion ->
|
||||
Text(suggestion, color = TextSecondary, fontFamily = FontFamily.Monospace)
|
||||
}
|
||||
}
|
||||
if (visibleHint != null) {
|
||||
Text("Hint", color = TextPrimary, fontWeight = FontWeight.Bold)
|
||||
Text(visibleHint, color = TextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
303
app/src/main/java/solutions/tretter/githugandroid/GameModels.kt
Normal file
303
app/src/main/java/solutions/tretter/githugandroid/GameModels.kt
Normal file
@@ -0,0 +1,303 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
|
||||
enum class PlayMode(val label: String) {
|
||||
CLI_ONLY("CLI ONLY"),
|
||||
VISUAL("VISUAL"),
|
||||
}
|
||||
|
||||
data class GitFile(
|
||||
val name: String,
|
||||
val content: String = "",
|
||||
val staged: Boolean = false,
|
||||
val tracked: Boolean = false,
|
||||
)
|
||||
|
||||
data class CommitNode(
|
||||
val id: String,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
data class RepoState(
|
||||
val initialized: Boolean = false,
|
||||
val files: List<GitFile> = emptyList(),
|
||||
val commits: List<CommitNode> = emptyList(),
|
||||
val headBranch: String = "master",
|
||||
val branches: Map<String, Int> = emptyMap(),
|
||||
val currentDir: String = ".",
|
||||
val tags: List<String> = emptyList(),
|
||||
val remotes: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
data class Level(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val hints: List<String>,
|
||||
val commandSuggestions: List<String>,
|
||||
val validator: (RepoState, String) -> Boolean,
|
||||
val setup: () -> RepoState,
|
||||
)
|
||||
|
||||
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()) },
|
||||
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) },
|
||||
)
|
||||
},
|
||||
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<*>
|
||||
RepoState(
|
||||
initialized = initialized,
|
||||
headBranch = headBranch,
|
||||
files = fileParts.chunked(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(),
|
||||
)
|
||||
},
|
||||
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) },
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
object GitSandboxEngine {
|
||||
fun commandReferenceLines(): List<String> = listOf(
|
||||
"Available sandbox commands:",
|
||||
" git ",
|
||||
" ls",
|
||||
" touch <file>",
|
||||
" help",
|
||||
" pwd ",
|
||||
" cat <file>",
|
||||
" touch <file>",
|
||||
" mkdir <directory>",
|
||||
" cd <directory>",
|
||||
" rm <file>",
|
||||
" echo <message>",
|
||||
)
|
||||
|
||||
fun execute(repo: RepoState, command: String): Pair<RepoState, List<String>> {
|
||||
val parts = tokenizeCommand(command)
|
||||
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 }) repo to listOf("$name already exists")
|
||||
else repo.copy(files = repo.files + GitFile(name = name)) to emptyList()
|
||||
}
|
||||
parts[0] == "ls" -> repo to repo.files.map { it.name }.ifEmpty { listOf() }
|
||||
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 >= 3 && parts[1] == "add" -> {
|
||||
val target = parts[2]
|
||||
if (target != "." && repo.files.none { it.name == target }) {
|
||||
repo to listOf("fatal: pathspec '$target' did not match any files")
|
||||
} else {
|
||||
val updated = repo.files.map { if (target == "." || it.name == target) it.copy(staged = true) else it }
|
||||
repo.copy(files = updated) to listOf("staged ${if (target == ".") "all files" else target}")
|
||||
}
|
||||
}
|
||||
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] == "branch" -> {
|
||||
val branch = parts[2]
|
||||
if (repo.branches.containsKey(branch)) repo to listOf("fatal: a branch named '$branch' already exists")
|
||||
else repo.copy(branches = repo.branches + (branch to repo.commits.size)) to listOf("Created branch $branch")
|
||||
}
|
||||
parts.size >= 3 && parts[1] == "checkout" -> {
|
||||
val branch = parts[2]
|
||||
if (!repo.branches.containsKey(branch)) repo to listOf("error: pathspec '$branch' did not match any branch")
|
||||
else repo.copy(headBranch = branch) to listOf("Switched to branch '$branch'")
|
||||
}
|
||||
else -> repo to listOf("Unsupported git command in MVP sandbox: ${parts.drop(1).joinToString(" ")}")
|
||||
}
|
||||
}
|
||||
|
||||
fun tokenizeCommand(command: String): List<String> {
|
||||
val result = mutableListOf<String>()
|
||||
val current = StringBuilder()
|
||||
var quoteChar: Char? = null
|
||||
var escaping = false
|
||||
|
||||
command.forEach { char ->
|
||||
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
|
||||
}
|
||||
char.isWhitespace() -> {
|
||||
if (current.isNotEmpty()) {
|
||||
result += current.toString()
|
||||
current.clear()
|
||||
}
|
||||
}
|
||||
else -> current.append(char)
|
||||
}
|
||||
}
|
||||
|
||||
if (escaping) {
|
||||
current.append('\\')
|
||||
}
|
||||
if (current.isNotEmpty()) {
|
||||
result += current.toString()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
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.orEmpty()
|
||||
val staged = repoForCommit.files.filter { it.staged }
|
||||
if (staged.isEmpty()) return repo to listOf("nothing to commit")
|
||||
|
||||
val nextId = "${repo.commits.size + 1}".padStart(7, '0')
|
||||
val cleanedFiles = repoForCommit.files.map { file ->
|
||||
if (file.staged) file.copy(staged = false, tracked = true) else file
|
||||
}
|
||||
|
||||
return repoForCommit.copy(
|
||||
files = cleanedFiles,
|
||||
commits = repo.commits + CommitNode(nextId, message),
|
||||
branches = repo.branches + (repo.headBranch to (repo.commits.size + 1)),
|
||||
) to listOf("[$nextId] $message")
|
||||
}
|
||||
|
||||
private fun parseCommitArguments(arguments: List<String>): ParsedCommitArguments {
|
||||
var message: String? = null
|
||||
var stageAllTracked = false
|
||||
var index = 0
|
||||
|
||||
while (index < arguments.size) {
|
||||
val argument = arguments[index]
|
||||
when {
|
||||
argument == "-a" || argument == "--all" -> {
|
||||
stageAllTracked = true
|
||||
}
|
||||
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 (message.isNullOrBlank()) {
|
||||
return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
|
||||
}
|
||||
|
||||
return ParsedCommitArguments(message = message, stageAllTracked = stageAllTracked)
|
||||
}
|
||||
|
||||
private fun statusLines(repo: RepoState): List<String> {
|
||||
val staged = repo.files.filter { it.staged }.map {
|
||||
if (it.tracked) "modified: ${it.name}" else "new file: ${it.name}"
|
||||
}
|
||||
val unstaged = repo.files.filterNot { it.staged || it.tracked }.map { "untracked: ${it.name}" }
|
||||
return buildList {
|
||||
add("On branch ${repo.headBranch}")
|
||||
if (staged.isEmpty() && unstaged.isEmpty()) {
|
||||
add("nothing to commit, working tree clean")
|
||||
} else {
|
||||
if (staged.isNotEmpty()) {
|
||||
add("Changes to be committed:")
|
||||
addAll(staged)
|
||||
}
|
||||
if (unstaged.isNotEmpty()) {
|
||||
add("Untracked files:")
|
||||
addAll(unstaged)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class ParsedCommitArguments(
|
||||
val message: String? = null,
|
||||
val stageAllTracked: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
}
|
||||
343
app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt
Normal file
343
app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt
Normal file
@@ -0,0 +1,343 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun GitHugApp() {
|
||||
MaterialTheme(colorScheme = GitHugColorScheme) {
|
||||
val context = LocalContext.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val levels = remember { sampleLevels() }
|
||||
val runtime = remember(context) { GitRepositoryRuntime(context.applicationContext) }
|
||||
val paneLayoutStore = remember(context) { PaneLayoutStore(context.applicationContext) }
|
||||
val persistedPaneLayout by paneLayoutStore.layoutFlow.collectAsState(initial = defaultPaneLayout())
|
||||
val scope = rememberCoroutineScope()
|
||||
val screenScrollState = rememberScrollState()
|
||||
val screenHeightDp = configuration.screenHeightDp
|
||||
|
||||
var paneLayout by remember { mutableStateOf(defaultPaneLayout()) }
|
||||
var currentLevelIndex by remember { mutableStateOf(0) }
|
||||
var repo by remember { mutableStateOf(runtime.prepareLevel(levels.first())) }
|
||||
var commandInput by remember { mutableStateOf(TextFieldValue("")) }
|
||||
var inputFieldVersion by remember { mutableStateOf(0) }
|
||||
var suppressedImeEcho by remember { mutableStateOf<String?>(null) }
|
||||
var output by remember { mutableStateOf(listOf(runtime.startupBanner())) }
|
||||
var hintIndex by remember { mutableStateOf(0) }
|
||||
var activeExerciseDetail by remember { mutableStateOf<ExerciseDetailPanel?>(null) }
|
||||
var visibleHint by remember { mutableStateOf<String?>(null) }
|
||||
var completedLevels by remember { mutableStateOf(setOf<String>()) }
|
||||
var commandHistory by remember { mutableStateOf(listOf<String>()) }
|
||||
var historyIndex by remember { mutableStateOf(-1) }
|
||||
var historyDraft by remember { mutableStateOf("") }
|
||||
val currentLevel = levels[currentLevelIndex]
|
||||
|
||||
LaunchedEffect(persistedPaneLayout) {
|
||||
paneLayout = persistedPaneLayout
|
||||
}
|
||||
|
||||
fun updatePaneLayout(transform: (PaneLayout) -> PaneLayout, persist: Boolean = true) {
|
||||
val updated = transform(paneLayout)
|
||||
paneLayout = updated
|
||||
if (persist) {
|
||||
scope.launch { paneLayoutStore.save(updated) }
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCommandInput(recreateField: Boolean = false) {
|
||||
commandInput = TextFieldValue(text = "", selection = TextRange.Zero)
|
||||
if (recreateField) {
|
||||
inputFieldVersion += 1
|
||||
}
|
||||
}
|
||||
|
||||
fun applyRecommendedPaneWeights(persist: Boolean = true) {
|
||||
updatePaneLayout(
|
||||
transform = { layout ->
|
||||
layout.copy(
|
||||
weights = layout.weights + recommendedPaneWeights(
|
||||
heights = recommendedPaneHeights(
|
||||
level = currentLevel,
|
||||
levelCount = levels.size,
|
||||
outputLineCount = output.size,
|
||||
screenHeightDp = screenHeightDp,
|
||||
suggestionsVisible = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS,
|
||||
visibleHint = visibleHint,
|
||||
),
|
||||
)
|
||||
)
|
||||
},
|
||||
persist = persist,
|
||||
)
|
||||
}
|
||||
|
||||
val recommendedHeights = recommendedPaneHeights(
|
||||
level = currentLevel,
|
||||
levelCount = levels.size,
|
||||
outputLineCount = output.size,
|
||||
screenHeightDp = screenHeightDp,
|
||||
suggestionsVisible = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS,
|
||||
visibleHint = visibleHint,
|
||||
)
|
||||
val recommendedWeights = recommendedPaneWeights(
|
||||
heights = recommendedHeights,
|
||||
)
|
||||
val workspaceHeight = recommendedWorkspaceHeight(
|
||||
paneLayout = paneLayout,
|
||||
recommendedHeights = recommendedHeights,
|
||||
)
|
||||
|
||||
fun resetCurrentLevel(message: String = "Level reset.") {
|
||||
repo = runtime.prepareLevel(currentLevel)
|
||||
clearCommandInput()
|
||||
suppressedImeEcho = null
|
||||
output = listOf(message)
|
||||
hintIndex = 0
|
||||
activeExerciseDetail = null
|
||||
visibleHint = null
|
||||
historyIndex = -1
|
||||
historyDraft = ""
|
||||
applyRecommendedPaneWeights(persist = false)
|
||||
}
|
||||
|
||||
fun loadLevel(index: Int) {
|
||||
currentLevelIndex = index
|
||||
repo = runtime.prepareLevel(levels[index])
|
||||
output = listOf("Loaded level: ${levels[index].title}")
|
||||
clearCommandInput()
|
||||
suppressedImeEcho = null
|
||||
hintIndex = 0
|
||||
activeExerciseDetail = null
|
||||
visibleHint = null
|
||||
historyIndex = -1
|
||||
historyDraft = ""
|
||||
paneLayout = paneLayout.copy(
|
||||
weights = paneLayout.weights + recommendedPaneWeights(
|
||||
heights = recommendedPaneHeights(
|
||||
level = levels[index],
|
||||
levelCount = levels.size,
|
||||
outputLineCount = 1,
|
||||
screenHeightDp = screenHeightDp,
|
||||
suggestionsVisible = false,
|
||||
visibleHint = null,
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun setCommandText(text: String) {
|
||||
commandInput = TextFieldValue(text = text, selection = TextRange(text.length))
|
||||
}
|
||||
|
||||
fun moveCursor(delta: Int) {
|
||||
val next = (commandInput.selection.start + delta).coerceIn(0, commandInput.text.length)
|
||||
commandInput = commandInput.copy(selection = TextRange(next))
|
||||
}
|
||||
|
||||
fun historyUp() {
|
||||
if (commandHistory.isEmpty()) return
|
||||
if (historyIndex == -1) {
|
||||
historyDraft = commandInput.text
|
||||
historyIndex = commandHistory.lastIndex
|
||||
} else {
|
||||
historyIndex = (historyIndex - 1).coerceAtLeast(0)
|
||||
}
|
||||
setCommandText(commandHistory[historyIndex])
|
||||
}
|
||||
|
||||
fun historyDown() {
|
||||
if (commandHistory.isEmpty() || historyIndex == -1) return
|
||||
if (historyIndex >= commandHistory.lastIndex) {
|
||||
historyIndex = -1
|
||||
setCommandText(historyDraft)
|
||||
} else {
|
||||
historyIndex += 1
|
||||
setCommandText(commandHistory[historyIndex])
|
||||
}
|
||||
}
|
||||
|
||||
fun tabComplete() {
|
||||
val cursor = commandInput.selection.start.coerceIn(0, commandInput.text.length)
|
||||
val beforeCursor = commandInput.text.substring(0, cursor)
|
||||
val tokenStart = beforeCursor.lastIndexOf(' ').let { if (it == -1) 0 else it + 1 }
|
||||
val token = commandInput.text.substring(tokenStart, cursor)
|
||||
if (token.isBlank()) return
|
||||
|
||||
val matches = repo.files.map { it.name }.sorted().filter { it.startsWith(token) }
|
||||
if (matches.isEmpty()) return
|
||||
|
||||
val replacement = if (matches.size == 1) matches.first() else commonPrefix(matches)
|
||||
if (replacement == token && matches.size > 1) {
|
||||
output = output + "completion> ${matches.joinToString(" ")}"
|
||||
return
|
||||
}
|
||||
|
||||
val newText = commandInput.text.replaceRange(tokenStart, cursor, replacement)
|
||||
val newCursor = tokenStart + replacement.length
|
||||
commandInput = TextFieldValue(newText, selection = TextRange(newCursor))
|
||||
}
|
||||
|
||||
fun runCommand() {
|
||||
val submittedText = commandInput.text
|
||||
val raw = submittedText.trim()
|
||||
if (raw.isBlank()) return
|
||||
|
||||
suppressedImeEcho = submittedText
|
||||
clearCommandInput(recreateField = true)
|
||||
|
||||
if (commandHistory.lastOrNull() != raw) {
|
||||
commandHistory = commandHistory + raw
|
||||
}
|
||||
historyIndex = -1
|
||||
historyDraft = ""
|
||||
|
||||
val (newRepo, lines) = runtime.execute(currentLevel, repo, raw)
|
||||
val solvedAfterCommand = currentLevel.validator(newRepo, raw)
|
||||
val wasAlreadyCompleted = currentLevel.id in completedLevels
|
||||
val newOutput = buildList {
|
||||
addAll(output)
|
||||
add("$ $raw")
|
||||
addAll(lines)
|
||||
if (solvedAfterCommand && !wasAlreadyCompleted) {
|
||||
add("✔ Level solved: ${currentLevel.title}")
|
||||
}
|
||||
}
|
||||
if (solvedAfterCommand && !wasAlreadyCompleted) {
|
||||
completedLevels = completedLevels + currentLevel.id
|
||||
val hasNextLevel = currentLevelIndex < levels.lastIndex
|
||||
if (hasNextLevel) {
|
||||
loadLevel(currentLevelIndex + 1)
|
||||
} else {
|
||||
repo = newRepo
|
||||
output = listOf("🏁 All Githug levels completed.")
|
||||
clearCommandInput()
|
||||
suppressedImeEcho = null
|
||||
}
|
||||
} else {
|
||||
repo = newRepo
|
||||
output = newOutput
|
||||
applyRecommendedPaneWeights(persist = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun showCommandHelp() {
|
||||
output = output + runtime.commandReferenceLines()
|
||||
}
|
||||
|
||||
Scaffold(containerColor = AppBackground) { padding ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
color = AppBackground,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(AppBackground)
|
||||
.verticalScroll(screenScrollState)
|
||||
.imePadding()
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FixedHeader()
|
||||
PaneWorkspace(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
paneLayout = paneLayout,
|
||||
onMovePane = { paneId, delta ->
|
||||
updatePaneLayout(transform = { layout -> movePane(layout, paneId, delta) })
|
||||
},
|
||||
onTogglePane = { paneId ->
|
||||
updatePaneLayout(transform = { layout ->
|
||||
val collapsed = layout.collapsed.toMutableSet()
|
||||
if (!collapsed.add(paneId)) collapsed.remove(paneId)
|
||||
layout.copy(collapsed = collapsed)
|
||||
})
|
||||
},
|
||||
levelsContent = {
|
||||
LevelsPane(levels, currentLevelIndex, completedLevels) { index -> loadLevel(index) }
|
||||
},
|
||||
visualContent = { VisualPane(repo) },
|
||||
exerciseContent = {
|
||||
ExercisePane(
|
||||
level = currentLevel,
|
||||
visibleHint = if (activeExerciseDetail == ExerciseDetailPanel.HINT) visibleHint else null,
|
||||
suggestionsExpanded = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS,
|
||||
onToggleSuggestions = {
|
||||
activeExerciseDetail = if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) null else ExerciseDetailPanel.SUGGESTIONS
|
||||
if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) {
|
||||
visibleHint = null
|
||||
}
|
||||
applyRecommendedPaneWeights(persist = false)
|
||||
},
|
||||
onHint = {
|
||||
val hint = currentLevel.hints.getOrNull(hintIndex) ?: "No more hints for this level."
|
||||
visibleHint = hint
|
||||
activeExerciseDetail = ExerciseDetailPanel.HINT
|
||||
hintIndex = (hintIndex + 1).coerceAtMost(currentLevel.hints.size)
|
||||
applyRecommendedPaneWeights(persist = false)
|
||||
},
|
||||
onReset = { resetCurrentLevel() },
|
||||
)
|
||||
},
|
||||
terminalContent = {
|
||||
TerminalPane(
|
||||
output = output,
|
||||
inputFieldVersion = inputFieldVersion,
|
||||
commandInput = commandInput,
|
||||
onValueChange = {
|
||||
val blockedEcho = suppressedImeEcho
|
||||
if (blockedEcho != null && commandInput.text.isEmpty()) {
|
||||
val blockedTrimmed = blockedEcho.trim()
|
||||
if (it.text == blockedEcho || (blockedTrimmed.isNotEmpty() && it.text == blockedTrimmed)) {
|
||||
return@TerminalPane
|
||||
}
|
||||
}
|
||||
suppressedImeEcho = null
|
||||
commandInput = it
|
||||
},
|
||||
onRun = { runCommand() },
|
||||
onTab = { tabComplete() },
|
||||
onHelp = { showCommandHelp() },
|
||||
onCursorLeft = { moveCursor(-1) },
|
||||
onCursorRight = { moveCursor(1) },
|
||||
onHistoryUp = { historyUp() },
|
||||
onHistoryDown = { historyDown() },
|
||||
)
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.height(220.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val AppBackground = Color(0xFF000000)
|
||||
val PanelPrimary = Color(0xFF121212)
|
||||
val PanelSecondary = Color(0xFF1E1E1E)
|
||||
val PanelTertiary = Color(0xFF262626)
|
||||
val TerminalBackground = Color(0xFF050505)
|
||||
val TextPrimary = Color(0xFFFFFFFF)
|
||||
val TextSecondary = Color(0xFFE6E6E6)
|
||||
val TextMuted = Color(0xFFBDBDBD)
|
||||
val Accent = Color(0xFF00E5FF)
|
||||
val Success = Color(0xFF00FF95)
|
||||
|
||||
val GitHugColorScheme = darkColorScheme(
|
||||
primary = Accent,
|
||||
onPrimary = Color.Black,
|
||||
secondary = TextPrimary,
|
||||
onSecondary = Color.Black,
|
||||
background = AppBackground,
|
||||
onBackground = TextPrimary,
|
||||
surface = PanelPrimary,
|
||||
onSurface = TextPrimary,
|
||||
surfaceVariant = PanelSecondary,
|
||||
onSurfaceVariant = TextSecondary,
|
||||
outline = TextMuted,
|
||||
)
|
||||
258
app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt
Normal file
258
app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt
Normal file
@@ -0,0 +1,258 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import android.os.Build
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
|
||||
class GitRepositoryRuntime(private val context: Context) {
|
||||
private val sandboxesRoot = File(context.filesDir, "githug-sandboxes")
|
||||
private val extractedGitDir = File(context.filesDir, "native-git/bin")
|
||||
|
||||
fun startupBanner(): String {
|
||||
return if (nativeGitBinary() != null) {
|
||||
"Welcome to GitHug Android. Native Git prototype ready."
|
||||
} else {
|
||||
"Welcome to GitHug Android. Native Git binary not bundled for this ABI yet; using in-memory fallback."
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareLevel(level: Level): RepoState {
|
||||
val nativeGit = nativeGitBinary()
|
||||
if (nativeGit == null) {
|
||||
return level.setup()
|
||||
}
|
||||
|
||||
val sandbox = sandboxDir(level)
|
||||
sandbox.deleteRecursively()
|
||||
sandbox.mkdirs()
|
||||
|
||||
val desired = level.setup()
|
||||
desired.files.forEach { file ->
|
||||
File(sandbox, file.name).apply {
|
||||
parentFile?.mkdirs()
|
||||
writeText(file.content)
|
||||
}
|
||||
}
|
||||
|
||||
val needsGit = desired.initialized || desired.files.any { it.staged || it.tracked } || desired.commits.isNotEmpty()
|
||||
if (needsGit) {
|
||||
val initResult = runGit(nativeGit, sandbox, listOf("init", "-b", desired.headBranch))
|
||||
if (initResult.exitCode != 0) {
|
||||
runGit(nativeGit, sandbox, listOf("init"))
|
||||
runGit(nativeGit, sandbox, listOf("checkout", "-B", desired.headBranch))
|
||||
}
|
||||
|
||||
val stageTargets = desired.files.filter { it.staged || it.tracked }.map { it.name }
|
||||
if (stageTargets.isNotEmpty()) {
|
||||
runGit(nativeGit, sandbox, listOf("add") + stageTargets)
|
||||
}
|
||||
}
|
||||
|
||||
return inspectSandbox(level)
|
||||
}
|
||||
|
||||
fun execute(level: Level, currentRepo: RepoState, command: String): Pair<RepoState, List<String>> {
|
||||
val nativeGit = nativeGitBinary()
|
||||
if (nativeGit == null) {
|
||||
return GitSandboxEngine.execute(currentRepo, command)
|
||||
}
|
||||
|
||||
val sandbox = sandboxDir(level)
|
||||
if (!sandbox.exists()) {
|
||||
prepareLevel(level)
|
||||
}
|
||||
|
||||
val sandboxRoot = sandbox.canonicalFile
|
||||
val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile
|
||||
.takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot
|
||||
|
||||
val tokens = GitSandboxEngine.tokenizeCommand(command)
|
||||
if (tokens.isEmpty()) return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to emptyList()
|
||||
|
||||
val result = when (tokens.first()) {
|
||||
"git" -> currentRepo to runGit(nativeGit, workingDir, tokens.drop(1)).outputLines
|
||||
"help", "?" -> currentRepo to commandReferenceLines()
|
||||
else -> executeHelperCommand(sandboxRoot, workingDir, currentRepo, tokens)
|
||||
}
|
||||
|
||||
return inspectSandbox(level).copy(currentDir = result.first.currentDir) to result.second
|
||||
}
|
||||
|
||||
fun commandReferenceLines(): List<String> {
|
||||
return buildList {
|
||||
addAll(GitSandboxEngine.commandReferenceLines())
|
||||
add("Native Git prototype:")
|
||||
add(" binary path: nativeLibraryDir/libgit.so")
|
||||
add(" selected ABI: ${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"}")
|
||||
add(" helper commands: ls, pwd, cat, touch, mkdir, rm, echo")
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeHelperCommand(sandboxRoot: File, workingDir: File, currentRepo: RepoState, tokens: List<String>): Pair<RepoState, List<String>> {
|
||||
return when (tokens.first()) {
|
||||
"ls" -> currentRepo to workingDir.listFiles()
|
||||
?.filterNot { it.name == ".git" }
|
||||
?.sortedBy { it.name }
|
||||
?.map { it.name }
|
||||
.orEmpty()
|
||||
"pwd" -> currentRepo to listOf(
|
||||
"/sandbox" + if (workingDir == sandboxRoot) "" else "/${workingDir.relativeTo(sandboxRoot).path}"
|
||||
)
|
||||
"cat" -> {
|
||||
val target = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: cat <file>")
|
||||
val file = File(workingDir, target)
|
||||
if (!file.exists() || file.isDirectory) currentRepo to listOf("cat: $target: No such file")
|
||||
else currentRepo to file.readLines().ifEmpty { listOf("") }
|
||||
}
|
||||
"touch" -> {
|
||||
val target = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: touch <file>")
|
||||
val file = File(workingDir, target)
|
||||
if (file.exists()) {
|
||||
currentRepo to listOf("$target already exists")
|
||||
} else {
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText("")
|
||||
currentRepo to emptyList()
|
||||
}
|
||||
}
|
||||
"mkdir" -> {
|
||||
val target = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: mkdir <dir>")
|
||||
val dir = File(workingDir, target)
|
||||
if (dir.exists()) currentRepo to listOf("mkdir: $target: File exists") else {
|
||||
dir.mkdirs()
|
||||
currentRepo to emptyList()
|
||||
}
|
||||
}
|
||||
"cd" -> {
|
||||
val target = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: cd <dir>")
|
||||
val dir = File(workingDir, target).canonicalFile
|
||||
when {
|
||||
!dir.exists() -> currentRepo to listOf("cd: $target: does not exist")
|
||||
!dir.isDirectory -> currentRepo to listOf("cd: $target: Not a directory")
|
||||
!dir.path.startsWith(sandboxRoot.path) -> currentRepo to listOf("cd: $target: Permission denied")
|
||||
else -> {
|
||||
val relativeDir = sandboxRoot.toPath().relativize(dir.toPath()).toString().ifEmpty { "." }
|
||||
currentRepo.copy(currentDir = relativeDir) to emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
"rm" -> {
|
||||
val target = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: rm <path>")
|
||||
val file = File(workingDir, target)
|
||||
if (!file.exists()) currentRepo to listOf("rm: $target: No such file or directory") else {
|
||||
file.deleteRecursively()
|
||||
currentRepo to emptyList()
|
||||
}
|
||||
}
|
||||
"echo" -> currentRepo to listOf(tokens.drop(1).joinToString(" "))
|
||||
else -> currentRepo to listOf("Command not supported in prototype runtime. Try a git command or helper command.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun inspectSandbox(level: Level): RepoState {
|
||||
val sandbox = sandboxDir(level)
|
||||
val nativeGit = nativeGitBinary()
|
||||
val filesOnDisk = sandbox.listFiles()
|
||||
?.filter { it.isFile && it.name != ".git" }
|
||||
.orEmpty()
|
||||
|
||||
if (nativeGit == null || !File(sandbox, ".git").exists()) {
|
||||
return RepoState(
|
||||
initialized = File(sandbox, ".git").exists(),
|
||||
files = filesOnDisk.map { GitFile(name = it.name, content = it.readText()) },
|
||||
)
|
||||
}
|
||||
|
||||
val statusResult = runGit(nativeGit, sandbox, listOf("status", "--porcelain"))
|
||||
val statusMap = mutableMapOf<String, Pair<Boolean, Boolean>>()
|
||||
statusResult.outputLines.forEach { line ->
|
||||
if (line.length < 4) return@forEach
|
||||
val x = line[0]
|
||||
val y = line[1]
|
||||
val path = line.substring(3).trim()
|
||||
val staged = x != ' ' && x != '?'
|
||||
val tracked = x != '?' || y != '?'
|
||||
statusMap[path] = staged to tracked
|
||||
}
|
||||
|
||||
val logResult = runGit(nativeGit, sandbox, listOf("log", "--pretty=format:%h\t%s"))
|
||||
val branchResult = runGit(nativeGit, sandbox, listOf("branch", "--list"))
|
||||
val tagResult = runGit(nativeGit, sandbox, listOf("tag", "--list"))
|
||||
val remoteResult = runGit(nativeGit, sandbox, listOf("remote", "-v"))
|
||||
val headResult = runGit(nativeGit, sandbox, listOf("branch", "--show-current"))
|
||||
val commits = if (logResult.exitCode == 0) {
|
||||
logResult.outputLines.filter { it.isNotBlank() }.mapNotNull { line ->
|
||||
val parts = line.split('\t', limit = 2)
|
||||
if (parts.isEmpty()) null else CommitNode(parts[0], parts.getOrElse(1) { "" })
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
return RepoState(
|
||||
initialized = true,
|
||||
files = filesOnDisk.map { file ->
|
||||
val (staged, tracked) = statusMap[file.name] ?: (false to true)
|
||||
GitFile(
|
||||
name = file.name,
|
||||
content = file.readText(),
|
||||
staged = staged,
|
||||
tracked = tracked,
|
||||
)
|
||||
},
|
||||
commits = commits,
|
||||
headBranch = headResult.outputLines.firstOrNull()?.ifBlank { null } ?: "master",
|
||||
branches = branchResult.outputLines.map { it.removePrefix("*").trim() }.filter { it.isNotBlank() }.associateWith { 0 },
|
||||
tags = tagResult.outputLines.filter { it.isNotBlank() },
|
||||
remotes = remoteResult.outputLines.mapNotNull { line ->
|
||||
val parts = line.trim().split(Regex("\\s+"))
|
||||
if (parts.size >= 2) parts[0] to parts[1] else null
|
||||
}.toMap(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun nativeGitBinary(): File? {
|
||||
return packagedNativeGitBinary()
|
||||
}
|
||||
|
||||
private fun packagedNativeGitBinary(): File? {
|
||||
val nativeLibraryDir = context.applicationInfo.nativeLibraryDir ?: return null
|
||||
val candidate = File(nativeLibraryDir, "libgit.so")
|
||||
return candidate.takeIf { it.exists() && it.canExecute() }
|
||||
}
|
||||
|
||||
private fun sandboxDir(level: Level): File = File(sandboxesRoot, level.id)
|
||||
|
||||
private fun runGit(binary: File, workingDir: File, arguments: List<String>): ProcessExecutionResult {
|
||||
return runProcess(binary, workingDir, arguments)
|
||||
}
|
||||
|
||||
private fun runProcess(binary: File, workingDir: File, arguments: List<String>): ProcessExecutionResult {
|
||||
return try {
|
||||
val process = ProcessBuilder(listOf(binary.absolutePath) + arguments)
|
||||
.directory(workingDir)
|
||||
.redirectErrorStream(true)
|
||||
.apply {
|
||||
environment()["HOME"] = workingDir.absolutePath
|
||||
environment()["GIT_CONFIG_NOSYSTEM"] = "1"
|
||||
environment()["GIT_AUTHOR_NAME"] = "GitHug"
|
||||
environment()["GIT_AUTHOR_EMAIL"] = "githug@example.com"
|
||||
environment()["GIT_COMMITTER_NAME"] = "GitHug"
|
||||
environment()["GIT_COMMITTER_EMAIL"] = "githug@example.com"
|
||||
environment()["LC_ALL"] = "C"
|
||||
}
|
||||
.start()
|
||||
|
||||
val output = process.inputStream.bufferedReader().readLines()
|
||||
val exit = process.waitFor()
|
||||
ProcessExecutionResult(exitCode = exit, outputLines = output.ifEmpty { if (exit == 0) emptyList() else listOf("Command failed") })
|
||||
} catch (error: Exception) {
|
||||
ProcessExecutionResult(exitCode = -1, outputLines = listOf("Native Git execution failed: ${error.message ?: error::class.java.simpleName}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class ProcessExecutionResult(
|
||||
val exitCode: Int,
|
||||
val outputLines: List<String>,
|
||||
)
|
||||
41
app/src/main/java/solutions/tretter/githugandroid/Header.kt
Normal file
41
app/src/main/java/solutions/tretter/githugandroid/Header.kt
Normal file
@@ -0,0 +1,41 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun FixedHeader() {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = PanelPrimary),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.githug_android_logo),
|
||||
contentDescription = "GitHug Android logo",
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
Text("GitHug Android", style = MaterialTheme.typography.titleLarge, color = TextPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
52
app/src/main/java/solutions/tretter/githugandroid/Levels.kt
Normal file
52
app/src/main/java/solutions/tretter/githugandroid/Levels.kt
Normal file
@@ -0,0 +1,52 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun LevelsPane(
|
||||
levels: List<Level>,
|
||||
currentLevelIndex: Int,
|
||||
completedLevels: Set<String>,
|
||||
onSelect: (Int) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
levels.forEachIndexed { index, level ->
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (index == currentLevelIndex) PanelTertiary else PanelPrimary,
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { onSelect(index) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = if (index == currentLevelIndex) Accent else TextSecondary,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = buildString {
|
||||
append(if (level.id in completedLevels) "✓ " else "• ")
|
||||
append(level.title)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
GitHugApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
fun movePane(layout: PaneLayout, paneId: PaneId, delta: Int): PaneLayout {
|
||||
val currentIndex = layout.order.indexOf(paneId)
|
||||
if (currentIndex == -1) return layout
|
||||
val targetIndex = (currentIndex + delta).coerceIn(0, layout.order.lastIndex)
|
||||
if (currentIndex == targetIndex) return layout
|
||||
|
||||
val updatedOrder = layout.order.toMutableList()
|
||||
updatedOrder.removeAt(currentIndex)
|
||||
updatedOrder.add(targetIndex, paneId)
|
||||
return layout.copy(order = updatedOrder)
|
||||
}
|
||||
|
||||
fun recommendedPaneHeights(
|
||||
level: Level,
|
||||
levelCount: Int,
|
||||
outputLineCount: Int,
|
||||
screenHeightDp: Int,
|
||||
suggestionsVisible: Boolean,
|
||||
visibleHint: String?,
|
||||
): Map<PaneId, Int> {
|
||||
val screenHeight = screenHeightDp.coerceAtLeast(560)
|
||||
val levelsHeight = (56 + levelCount * 44).coerceAtLeast(160)
|
||||
val descriptionLines = (level.description.length / 42).coerceAtLeast(2) + 2
|
||||
val suggestionsLines = if (suggestionsVisible) level.commandSuggestions.size + 1 else 0
|
||||
val hintLines = visibleHint?.let { (it.length / 42).coerceAtLeast(1) + 1 } ?: 0
|
||||
val exerciseHeight = (120 + descriptionLines * 24 + suggestionsLines * 22 + hintLines * 22).coerceAtLeast(180)
|
||||
val terminalHeight = (170 + outputLineCount.coerceAtLeast(1) * 20).coerceAtMost((screenHeight * 0.7f).toInt())
|
||||
val visualHeight = (screenHeight * 0.24f).toInt().coerceAtLeast(170)
|
||||
|
||||
return mapOf(
|
||||
PaneId.EXERCISE to exerciseHeight,
|
||||
PaneId.TERMINAL to terminalHeight,
|
||||
PaneId.VISUAL to visualHeight,
|
||||
PaneId.LEVELS to levelsHeight,
|
||||
)
|
||||
}
|
||||
|
||||
fun recommendedPaneWeights(
|
||||
heights: Map<PaneId, Int>,
|
||||
): Map<PaneId, Float> {
|
||||
fun weightFor(height: Int): Float = (height / 180f).coerceAtLeast(0.6f)
|
||||
|
||||
return heights.mapValues { (_, height) -> weightFor(height) }
|
||||
}
|
||||
|
||||
fun recommendedWorkspaceHeight(
|
||||
paneLayout: PaneLayout,
|
||||
recommendedHeights: Map<PaneId, Int>,
|
||||
): Dp {
|
||||
val dividerHeight = 18 * paneLayout.order.size
|
||||
val collapsedPaneHeight = 44 * paneLayout.collapsed.size
|
||||
val expandedHeight = paneLayout.order
|
||||
.filterNot { it in paneLayout.collapsed }
|
||||
.sumOf { pane -> recommendedHeights[pane] ?: 0 }
|
||||
return (expandedHeight + dividerHeight + collapsedPaneHeight).dp
|
||||
}
|
||||
|
||||
fun commonPrefix(values: List<String>): String {
|
||||
if (values.isEmpty()) return ""
|
||||
var prefix = values.first()
|
||||
values.drop(1).forEach { value ->
|
||||
while (!value.startsWith(prefix) && prefix.isNotEmpty()) {
|
||||
prefix = prefix.dropLast(1)
|
||||
}
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
enum class PaneId(val title: String) {
|
||||
LEVELS("Levels"),
|
||||
VISUAL("Visual"),
|
||||
EXERCISE("Exercise"),
|
||||
TERMINAL("Terminal"),
|
||||
}
|
||||
|
||||
data class PaneLayout(
|
||||
val order: List<PaneId>,
|
||||
val collapsed: Set<PaneId>,
|
||||
val weights: Map<PaneId, Float>,
|
||||
)
|
||||
|
||||
enum class ExerciseDetailPanel {
|
||||
HINT,
|
||||
SUGGESTIONS,
|
||||
}
|
||||
|
||||
fun defaultPaneLayout(): PaneLayout = PaneLayout(
|
||||
order = listOf(PaneId.EXERCISE, PaneId.TERMINAL, PaneId.VISUAL, PaneId.LEVELS),
|
||||
collapsed = emptySet(),
|
||||
weights = mapOf(
|
||||
PaneId.EXERCISE to 3.0f,
|
||||
PaneId.TERMINAL to 6.0f,
|
||||
PaneId.VISUAL to 2.0f,
|
||||
PaneId.LEVELS to 1.0f,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.io.IOException
|
||||
|
||||
class PaneLayoutStore(private val context: Context) {
|
||||
private val dataStore = PreferenceDataStoreFactory.create(
|
||||
produceFile = { context.preferencesDataStoreFile("pane_layout_preferences") }
|
||||
)
|
||||
|
||||
private val orderKey = stringPreferencesKey("pane_order")
|
||||
private val collapsedKey = stringPreferencesKey("pane_collapsed")
|
||||
private val weightsKey = stringPreferencesKey("pane_weights")
|
||||
|
||||
val layoutFlow = dataStore.data
|
||||
.catch { error ->
|
||||
if (error is IOException) emit(emptyPreferences()) else throw error
|
||||
}
|
||||
.map { preferences ->
|
||||
val defaults = defaultPaneLayout()
|
||||
val order = preferences[orderKey]
|
||||
?.split(',')
|
||||
?.mapNotNull { value -> PaneId.entries.firstOrNull { it.name == value } }
|
||||
?.let { parsed ->
|
||||
val missing = PaneId.entries.filterNot { it in parsed }
|
||||
parsed + missing
|
||||
}
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: defaults.order
|
||||
|
||||
val collapsed = preferences[collapsedKey]
|
||||
?.split(',')
|
||||
?.mapNotNull { value -> PaneId.entries.firstOrNull { it.name == value } }
|
||||
?.toSet()
|
||||
?: emptySet()
|
||||
|
||||
val parsedWeights = preferences[weightsKey]
|
||||
?.split(';')
|
||||
?.mapNotNull { item ->
|
||||
val parts = item.split(':', limit = 2)
|
||||
val paneId = PaneId.entries.firstOrNull { it.name == parts.getOrNull(0) }
|
||||
val weight = parts.getOrNull(1)?.toFloatOrNull()
|
||||
if (paneId != null && weight != null) paneId to weight.coerceAtLeast(0.6f) else null
|
||||
}
|
||||
?.toMap()
|
||||
.orEmpty()
|
||||
|
||||
PaneLayout(
|
||||
order = order,
|
||||
collapsed = collapsed,
|
||||
weights = PaneId.entries.associateWith { pane -> parsedWeights[pane] ?: defaults.weights.getValue(pane) },
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun save(layout: PaneLayout) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[orderKey] = layout.order.joinToString(",") { it.name }
|
||||
preferences[collapsedKey] = layout.collapsed.joinToString(",") { it.name }
|
||||
preferences[weightsKey] = layout.order.joinToString(";") { pane ->
|
||||
"${pane.name}:${layout.weights[pane] ?: 1f}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
191
app/src/main/java/solutions/tretter/githugandroid/Panes.kt
Normal file
191
app/src/main/java/solutions/tretter/githugandroid/Panes.kt
Normal file
@@ -0,0 +1,191 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun PaneWorkspace(
|
||||
modifier: Modifier = Modifier,
|
||||
paneLayout: PaneLayout,
|
||||
onMovePane: (PaneId, Int) -> Unit,
|
||||
onTogglePane: (PaneId) -> Unit,
|
||||
levelsContent: @Composable () -> Unit,
|
||||
visualContent: @Composable () -> Unit,
|
||||
exerciseContent: @Composable () -> Unit,
|
||||
terminalContent: @Composable () -> Unit,
|
||||
) {
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val heightBasis = maxHeight.value.takeIf { it > 0f } ?: 1f
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
) {
|
||||
paneLayout.order.forEachIndexed { index, paneId ->
|
||||
val isCollapsed = paneId in paneLayout.collapsed
|
||||
if (isCollapsed) {
|
||||
CollapsedPaneCard(
|
||||
paneId = paneId,
|
||||
canMoveUp = index > 0,
|
||||
canMoveDown = index < paneLayout.order.lastIndex,
|
||||
onMoveUp = { onMovePane(paneId, -1) },
|
||||
onMoveDown = { onMovePane(paneId, 1) },
|
||||
onToggleCollapse = { onTogglePane(paneId) },
|
||||
)
|
||||
} else {
|
||||
PaneCard(
|
||||
paneId = paneId,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
canMoveUp = index > 0,
|
||||
canMoveDown = index < paneLayout.order.lastIndex,
|
||||
onMoveUp = { onMovePane(paneId, -1) },
|
||||
onMoveDown = { onMovePane(paneId, 1) },
|
||||
onToggleCollapse = { onTogglePane(paneId) },
|
||||
) {
|
||||
when (paneId) {
|
||||
PaneId.LEVELS -> levelsContent()
|
||||
PaneId.VISUAL -> visualContent()
|
||||
PaneId.EXERCISE -> exerciseContent()
|
||||
PaneId.TERMINAL -> terminalContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaneCard(
|
||||
paneId: PaneId,
|
||||
modifier: Modifier = Modifier,
|
||||
canMoveUp: Boolean,
|
||||
canMoveDown: Boolean,
|
||||
onMoveUp: () -> Unit,
|
||||
onMoveDown: () -> Unit,
|
||||
onToggleCollapse: () -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.cardColors(containerColor = PanelSecondary),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
PaneHeader(
|
||||
paneId = paneId,
|
||||
collapsed = false,
|
||||
canMoveUp = canMoveUp,
|
||||
canMoveDown = canMoveDown,
|
||||
onMoveUp = onMoveUp,
|
||||
onMoveDown = onMoveDown,
|
||||
onToggleCollapse = onToggleCollapse,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CollapsedPaneCard(
|
||||
paneId: PaneId,
|
||||
canMoveUp: Boolean,
|
||||
canMoveDown: Boolean,
|
||||
onMoveUp: () -> Unit,
|
||||
onMoveDown: () -> Unit,
|
||||
onToggleCollapse: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = PanelSecondary),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
PaneHeader(
|
||||
paneId = paneId,
|
||||
collapsed = true,
|
||||
canMoveUp = canMoveUp,
|
||||
canMoveDown = canMoveDown,
|
||||
onMoveUp = onMoveUp,
|
||||
onMoveDown = onMoveDown,
|
||||
onToggleCollapse = onToggleCollapse,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaneHeader(
|
||||
paneId: PaneId,
|
||||
collapsed: Boolean,
|
||||
canMoveUp: Boolean,
|
||||
canMoveDown: Boolean,
|
||||
onMoveUp: () -> Unit,
|
||||
onMoveDown: () -> Unit,
|
||||
onToggleCollapse: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(if (collapsed) PanelTertiary else PanelPrimary)
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(paneId.title, color = TextPrimary, fontWeight = FontWeight.Bold)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
HeaderActionButton(label = if (collapsed) "Expand" else "Collapse", onClick = onToggleCollapse)
|
||||
HeaderActionButton(label = "↑", enabled = canMoveUp, onClick = onMoveUp)
|
||||
HeaderActionButton(label = "↓", enabled = canMoveDown, onClick = onMoveDown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderActionButton(
|
||||
label: String,
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 0.dp),
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = Accent,
|
||||
disabledContentColor = TextMuted,
|
||||
),
|
||||
) {
|
||||
Text(label, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
|
||||
267
app/src/main/java/solutions/tretter/githugandroid/Terminal.kt
Normal file
267
app/src/main/java/solutions/tretter/githugandroid/Terminal.kt
Normal file
@@ -0,0 +1,267 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun TerminalPane(
|
||||
output: List<String>,
|
||||
inputFieldVersion: Int,
|
||||
commandInput: TextFieldValue,
|
||||
onValueChange: (TextFieldValue) -> Unit,
|
||||
onRun: () -> Unit,
|
||||
onTab: () -> Unit,
|
||||
onHelp: () -> Unit,
|
||||
onCursorLeft: () -> Unit,
|
||||
onCursorRight: () -> Unit,
|
||||
onHistoryUp: () -> Unit,
|
||||
onHistoryDown: () -> Unit,
|
||||
) {
|
||||
val configuration = LocalConfiguration.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val horizontalScroll = rememberScrollState()
|
||||
val outputVerticalScroll = rememberScrollState()
|
||||
val terminalMinWidth = 80.dp * 7.2f
|
||||
val terminalPaneMaxHeight = configuration.screenHeightDp.dp * 0.7f
|
||||
val reservedControlsHeight = 86.dp
|
||||
val maxOutputHeight = (terminalPaneMaxHeight - reservedControlsHeight).coerceAtLeast(120.dp)
|
||||
val outputLineHeight = 20.dp
|
||||
val desiredOutputHeight = (output.size.coerceAtLeast(6) * outputLineHeight.value).dp.coerceAtMost(maxOutputHeight)
|
||||
|
||||
LaunchedEffect(inputFieldVersion) {
|
||||
focusRequester.requestFocus()
|
||||
keyboardController?.show()
|
||||
horizontalScroll.scrollTo(0)
|
||||
}
|
||||
|
||||
LaunchedEffect(output.size) {
|
||||
outputVerticalScroll.animateScrollTo(outputVerticalScroll.maxValue)
|
||||
}
|
||||
|
||||
fun submitCommand() {
|
||||
focusManager.clearFocus(force = true)
|
||||
keyboardController?.hide()
|
||||
onRun()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(TerminalBackground, RoundedCornerShape(12.dp))
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.widthIn(min = terminalMinWidth),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.heightIn(min = 200.dp, max = maxOutputHeight)
|
||||
.height(desiredOutputHeight)
|
||||
.fillMaxWidth()
|
||||
.background(PanelPrimary, RoundedCornerShape(10.dp))
|
||||
.padding(8.dp)
|
||||
.verticalScroll(outputVerticalScroll),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
output.forEach { line ->
|
||||
Text(
|
||||
text = line,
|
||||
color = if (line.startsWith("✔") || line.startsWith("🏁")) Success else TextSecondary,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
SpecialKeyBar(
|
||||
onTab = onTab,
|
||||
onHelp = onHelp,
|
||||
onCursorLeft = onCursorLeft,
|
||||
onCursorRight = onCursorRight,
|
||||
onHistoryUp = onHistoryUp,
|
||||
onHistoryDown = onHistoryDown,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(min = terminalMinWidth)
|
||||
.background(PanelPrimary, RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "$",
|
||||
color = Accent,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
TerminalInputField(
|
||||
inputFieldVersion = inputFieldVersion,
|
||||
commandInput = commandInput,
|
||||
onValueChange = onValueChange,
|
||||
focusRequester = focusRequester,
|
||||
keyboardController = keyboardController,
|
||||
onSubmit = { submitCommand() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun RowScope.TerminalInputField(
|
||||
inputFieldVersion: Int,
|
||||
commandInput: TextFieldValue,
|
||||
onValueChange: (TextFieldValue) -> Unit,
|
||||
focusRequester: FocusRequester,
|
||||
keyboardController: androidx.compose.ui.platform.SoftwareKeyboardController?,
|
||||
onSubmit: () -> Unit,
|
||||
) {
|
||||
key(inputFieldVersion) {
|
||||
BasicTextField(
|
||||
value = commandInput,
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { state ->
|
||||
if (state.isFocused) {
|
||||
keyboardController?.show()
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
textStyle = TextStyle(
|
||||
color = TextPrimary,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
),
|
||||
cursorBrush = SolidColor(Accent),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
autoCorrect = false,
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { onSubmit() }),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color.Transparent)
|
||||
.padding(vertical = 2.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
if (commandInput.text.isEmpty()) {
|
||||
Text(
|
||||
text = "Enter git command",
|
||||
color = TextMuted.copy(alpha = 0.7f),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SpecialKeyBar(
|
||||
onTab: () -> Unit,
|
||||
onHelp: () -> Unit,
|
||||
onCursorLeft: () -> Unit,
|
||||
onCursorRight: () -> Unit,
|
||||
onHistoryUp: () -> Unit,
|
||||
onHistoryDown: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
TerminalKeyButton(label = "↹", onClick = onTab, modifier = Modifier.width(56.dp), fontFamily = FontFamily.Default)
|
||||
TerminalKeyButton(label = "←", onClick = onCursorLeft, modifier = Modifier.width(40.dp))
|
||||
TerminalKeyButton(label = "→", onClick = onCursorRight, modifier = Modifier.width(40.dp))
|
||||
TerminalKeyButton(label = "↑", onClick = onHistoryUp, modifier = Modifier.width(40.dp))
|
||||
TerminalKeyButton(label = "↓", onClick = onHistoryDown, modifier = Modifier.width(40.dp))
|
||||
TerminalKeyButton(label = "?", onClick = onHelp, modifier = Modifier.width(40.dp), fontFamily = FontFamily.Default)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TerminalKeyButton(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
fontFamily: FontFamily = FontFamily.Monospace,
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(28.dp),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PanelSecondary,
|
||||
contentColor = TextPrimary,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
fontFamily = fontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
103
app/src/main/java/solutions/tretter/githugandroid/Visual.kt
Normal file
103
app/src/main/java/solutions/tretter/githugandroid/Visual.kt
Normal file
@@ -0,0 +1,103 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun VisualPane(repo: RepoState) {
|
||||
val configuration = LocalConfiguration.current
|
||||
val isWide = configuration.screenWidthDp >= 840
|
||||
|
||||
if (isWide) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
InfoPaneCard(
|
||||
title = "Workspace",
|
||||
content = repo.files.joinToString("\n") {
|
||||
"${if (it.staged) "[staged]" else "[file] "} ${it.name}"
|
||||
}.ifBlank { "(empty)" },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
InfoPaneCard(
|
||||
title = "Branches",
|
||||
content = buildString {
|
||||
appendLine("HEAD -> ${repo.headBranch}")
|
||||
repo.branches.forEach { (name, _) -> appendLine(name) }
|
||||
}.trim(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
InfoPaneCard(
|
||||
title = "Commits",
|
||||
content = repo.commits.reversed().joinToString("\n") { "${it.id} ${it.message}" }.ifBlank { "No commits yet" },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
InfoPaneCard(
|
||||
title = "Workspace",
|
||||
content = repo.files.joinToString("\n") {
|
||||
"${if (it.staged) "[staged]" else "[file] "} ${it.name}"
|
||||
}.ifBlank { "(empty)" },
|
||||
)
|
||||
InfoPaneCard(
|
||||
title = "Branches",
|
||||
content = buildString {
|
||||
appendLine("HEAD -> ${repo.headBranch}")
|
||||
repo.branches.forEach { (name, _) -> appendLine(name) }
|
||||
}.trim(),
|
||||
)
|
||||
InfoPaneCard(
|
||||
title = "Commits",
|
||||
content = repo.commits.reversed().joinToString("\n") { "${it.id} ${it.message}" }.ifBlank { "No commits yet" },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InfoPaneCard(
|
||||
title: String,
|
||||
content: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = PanelPrimary),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(title, color = TextPrimary, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
content,
|
||||
color = TextSecondary,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
internal fun advancedLevels(): List<Level> = listOf(
|
||||
level("clone", description = "Clone the repository at https://github.com/Gazler/cloneme.", hints = listOf("You should have a look at this site: https://github.com/Gazler/cloneme."), setup = { RepoState() }, validator = commandAnswer("git clone https://github.com/Gazler/cloneme")),
|
||||
level("clone_to_folder", description = "Clone the repository at https://github.com/Gazler/cloneme into the folder `my_cloned_repo`.", hints = listOf("This is like the last level, `git clone` has an optional argument."), setup = { RepoState() }, validator = commandAnswer("git clone https://github.com/Gazler/cloneme my_cloned_repo")),
|
||||
level("ignore", description = "The text editor 'vim' creates files ending in `.swp` (swap files) for all files that are currently open. We don't want them creeping into the repository. Make this repository ignore those swap files which are ending in `.swp`.", hints = listOf("You may have noticed there is a file named `.gitignore` in the repository."), setup = { RepoState(initialized = true, files = listOf(GitFile(".gitignore", tracked = true)), branches = mapOf("master" to 0)) }, validator = repoPredicate { it.files.find { f -> f.name == ".gitignore" }?.content?.contains("*.swp") == true }),
|
||||
level("include", description = "Notice a few files with the '.a' extension. We want git to ignore all the files except the 'lib.a' file.", hints = listOf("Using `git help ignore`, read about the optional prefix to negate a pattern."), setup = { RepoState(initialized = true, files = listOf(GitFile(".gitignore", tracked = true), GitFile("lib.a"), GitFile("main.a")), branches = mapOf("master" to 0)) }, validator = repoPredicate { it.files.find { f -> f.name == ".gitignore" }?.content?.let { c -> "*.a" in c && "!lib.a" in c } == true }),
|
||||
level("status", description = "Among the files in this repository, which of them is untracked?", hints = listOf("You are looking for a command to identify the status of the repository."), setup = { RepoState(initialized = true, files = listOf(GitFile("database.yml"), GitFile("README", tracked = true)), branches = mapOf("master" to 0)) }, validator = commandAnswer("database.yml")),
|
||||
level("number_of_files_committed", description = "There are some files in this repository; how many of them are staged for a commit?", hints = listOf("You are looking for a command to identify the status of the repository (resembles a Linux command)."), setup = { RepoState(initialized = true, files = listOf(GitFile("rubyfile1.rb", staged = true), GitFile("rubyfile4.rb", staged = true, tracked = true), GitFile("rubyfile5.rb", tracked = true), GitFile("rubyfile6.rb"), GitFile("rubyfile7.rb")), branches = mapOf("master" to 1)) }, validator = commandAnswer("2")),
|
||||
level("rm", description = "A file has been removed from the working tree, but not from the repository. Identify this file and remove it.", hints = emptyList(), setup = { RepoState(initialized = true, commits = listOf(CommitNode("0000001", "Added a temp file")), branches = mapOf("master" to 1)) }, validator = { _, command -> command.contains("deleteme.rb") }),
|
||||
level("rm_cached", description = "A file has accidentally been added to your staging area. Identify and remove it from the staging area. *NOTE* Do not remove the file from the file system, only from git.", hints = listOf("You may need to use more than one command to complete this."), setup = { RepoState(initialized = true, files = listOf(GitFile("deleteme.rb", staged = true), GitFile(".gitignore", staged = true)), branches = mapOf("master" to 0)) }, validator = { repo, _ -> repo.files.any { it.name == "deleteme.rb" && !it.staged } }),
|
||||
level("stash", description = "You've made some changes and want to work on them later. You should save them, but don't commit them.", hints = listOf("It's like stashing. Try finding an appropriate git command."), setup = { RepoState(initialized = true, files = listOf(GitFile("lyrics.txt", tracked = true)), branches = mapOf("master" to 1)) }, validator = { _, command -> command.startsWith("git stash") }),
|
||||
level("rename", description = "We have a file called `oldfile.txt`. We want to rename it to `newfile.txt` and stage this change.", hints = listOf("Take a look at `git mv`."), setup = { RepoState(initialized = true, files = listOf(GitFile("oldfile.txt", tracked = true)), commits = listOf(CommitNode("0000001", "Commited oldfile.txt")), branches = mapOf("master" to 1)) }, validator = { repo, _ -> repo.files.any { it.name == "newfile.txt" } }),
|
||||
level("restructure", description = "You added some files to your repository, but now realize that your project needs to be restructured. Make a new folder named `src` and use Git move all of the .html files into this folder.", hints = listOf("You'll have to use mkdir, and `git mv`."), setup = { RepoState(initialized = true, files = listOf(GitFile("about.html", tracked = true), GitFile("contact.html", tracked = true), GitFile("index.html", tracked = true)), commits = listOf(CommitNode("0000001", "adding web content.")), branches = mapOf("master" to 1)) }, validator = repoPredicate { listOf("src/about.html", "src/contact.html", "src/index.html").all { target -> it.files.any { f -> f.name == target } } }),
|
||||
level("log", description = "Identify the hash of the latest commit.", hints = listOf("You need to investigate the logs."), setup = { RepoState(initialized = true, commits = listOf(CommitNode("0000001", "THIS IS THE COMMIT YOU ARE LOOKING FOR!")), branches = mapOf("master" to 1)) }, validator = commandAnswer("0000001")),
|
||||
level("push_tags", description = "A tag in the local repository isn't pushed into remote repository. Push it now.", hints = listOf("Take a look at `--tags` flag of `git push`"), setup = { RepoState(initialized = true, tags = listOf("tag_to_be_pushed"), branches = mapOf("master" to 2), remotes = mapOf("origin" to "remote")) }, validator = { _, command -> command.contains("push") && command.contains("--tags") }),
|
||||
level("commit_amend", description = "The `README` file has been committed, but it looks like the file `forgotten_file.rb` was missing from the commit. Add the file and amend your previous commit to include it.", hints = listOf("Running `git commit --help` will display the man page and possible flags."), setup = { RepoState(initialized = true, files = listOf(GitFile("README", tracked = true), GitFile("forgotten_file.rb")), commits = listOf(CommitNode("0000001", "Initial commit")), branches = mapOf("master" to 1)) }, validator = { repo, command -> repo.files.any { it.name == "forgotten_file.rb" && it.tracked } && "--amend" in command }),
|
||||
level("commit_in_future", description = "Commit your changes with the future date (e.g. tomorrow).", hints = listOf("Build a time format, and commit your code using --date parameter."), setup = { RepoState(initialized = true, files = listOf(GitFile("README", staged = true)), branches = mapOf("master" to 0)) }, validator = { repo, command -> repo.commits.isNotEmpty() && "--date" in command }),
|
||||
level("reset", description = "There are two files to be committed. The goal was to add each file as a separate commit, however both were added by accident. Unstage the file `to_commit_second.rb` using the reset command (don't commit anything).", hints = listOf("git status will tell you the command you need to run."), setup = { RepoState(initialized = true, files = listOf(GitFile("to_commit_first.rb", staged = true), GitFile("to_commit_second.rb", staged = true), GitFile("README", tracked = true)), commits = listOf(CommitNode("0000001", "Initial commit")), branches = mapOf("master" to 1)) }, validator = { repo, _ -> repo.files.any { it.name == "to_commit_first.rb" && it.staged } && repo.files.any { it.name == "to_commit_second.rb" && !it.staged } }),
|
||||
level("reset_soft", description = "You committed too soon. Now you want to undo the last commit, while keeping the index.", hints = listOf("What are some options you can use with `git reset`?"), setup = { RepoState(initialized = true, files = listOf(GitFile("README", tracked = true), GitFile("newfile.rb", tracked = true)), commits = listOf(CommitNode("0000001", "Initial commit"), CommitNode("0000002", "Premature commit")), branches = mapOf("master" to 2)) }, validator = { repo, command -> repo.commits.size <= 1 && repo.files.any { it.name == "newfile.rb" && it.staged } && command.contains("--soft") }),
|
||||
level("checkout_file", description = "A file has been modified, but you don't want to keep the modification. Checkout the `config.rb` file from the last commit.", hints = listOf("You will need to do some research on the checkout command for this one."), setup = { RepoState(initialized = true, files = listOf(GitFile("config.rb", "This is the initial config file\nThese are changes you don't want to keep!", tracked = true)), commits = listOf(CommitNode("0000001", "Added initial config file")), branches = mapOf("master" to 1)) }, validator = { repo, _ -> repo.files.find { it.name == "config.rb" }?.content?.contains("don't want to keep") == false }),
|
||||
level("remote", description = "This project has a remote repository. Identify it.", hints = listOf("You are looking for a remote. You can run `git` for a list of commands."), setup = { RepoState(initialized = true, remotes = mapOf("my_remote_repo" to "https://github.com/Gazler/githug"), branches = mapOf("master" to 0)) }, validator = commandAnswer("my_remote_repo")),
|
||||
level("remote_url", description = "The remote repositories have a url associated to them. Please enter the url of remote_location.", hints = listOf("You can run `git remote --help` for the man pages."), setup = { RepoState(initialized = true, remotes = mapOf("my_remote_repo" to "https://github.com/Gazler/githug", "remote_location" to "https://github.com/githug/not_a_repo"), branches = mapOf("master" to 0)) }, validator = commandAnswer("https://github.com/githug/not_a_repo")),
|
||||
level("pull", description = "You need to pull changes from your origin repository.", hints = listOf("Check out the remote repositories and research `git pull`."), setup = { RepoState(initialized = true, remotes = mapOf("origin" to "https://github.com/pull-this/thing-to-pull"), branches = mapOf("master" to 1)) }, validator = { _, command -> command.startsWith("git pull") }),
|
||||
level("remote_add", description = "Add a remote repository called `origin` with the url https://github.com/githug/githug", hints = listOf("You can run `git remote --help` for the man pages."), setup = { RepoState(initialized = true, branches = mapOf("master" to 0)) }, validator = repoPredicate { it.remotes["origin"] == "https://github.com/githug/githug" }),
|
||||
level("push", description = "Your local master branch has diverged from the remote origin/master branch. Rebase your branch onto origin/master and push it to remote.", hints = listOf("Take a look at `git fetch`, `git pull`, and `git push`."), setup = { RepoState(initialized = true, remotes = mapOf("origin" to "remote"), branches = mapOf("master" to 3)) }, validator = { _, command -> command.startsWith("git push") }),
|
||||
level("diff", description = "Since your last commit, file `app.rb` was modified. Find out which line has changed.", hints = listOf("You are looking for the difference since your last commit."), setup = { RepoState(initialized = true, files = listOf(GitFile("app.rb", tracked = true)), branches = mapOf("master" to 1)) }, validator = commandAnswer("26")),
|
||||
level("blame", description = "Identify who put a password inside the file `config.rb`.", hints = listOf("You want to research the `git blame` command."), setup = { RepoState(initialized = true, files = listOf(GitFile("config.rb", tracked = true)), branches = mapOf("master" to 1)) }, validator = { _, command -> command.isNotBlank() && !command.startsWith("git") }),
|
||||
level("checkout_tag", description = "You need to fix a bug in the version 1.2 of your app. Checkout the tag `v1.2`.", hints = listOf("There's no big difference between checking out a branch and checking out a tag."), setup = { RepoState(initialized = true, commits = listOf(CommitNode("1", "Initial commit"), CommitNode("2", "Some changes"), CommitNode("3", "Some more changes"), CommitNode("4", "Yet more changes"), CommitNode("5", "Changes galore")), tags = listOf("v1.0", "v1.2", "v1.5"), branches = mapOf("master" to 5)) }, validator = { _, command -> command.contains("checkout") && command.contains("v1.2") }),
|
||||
level("checkout_tag_over_branch", description = "You need to fix a bug in the version 1.2 of your app. Checkout the tag `v1.2` (Note: There is also a branch named `v1.2`).", hints = listOf("You should think about specifying you're after the tag named `v1.2` (think `tags/`)."), setup = { RepoState(initialized = true, tags = listOf("v1.0", "v1.2", "v1.5"), branches = mapOf("master" to 5, "v1.2" to 6)) }, validator = { _, command -> "tags/v1.2" in command || command.trim() == "git checkout refs/tags/v1.2" }),
|
||||
level("branch_at", description = "You forgot to branch at the previous commit and made a commit on top of it. Create the branch test_branch at the commit before the last.", hints = listOf("Just like creating a branch, but you have to pass an extra argument."), setup = { RepoState(initialized = true, commits = listOf(CommitNode("1", "Adding file1"), CommitNode("2", "Updating file1"), CommitNode("3", "Updating file1 again")), branches = mapOf("master" to 3)) }, validator = repoPredicate { "test_branch" in it.branches }),
|
||||
level("delete_branch", description = "You have created too many branches for your project. There is an old branch in your repo called 'delete_me', you should delete it.", hints = listOf("Running 'git --help branch' will give you a list of branch commands."), setup = { RepoState(initialized = true, branches = mapOf("master" to 1, "delete_me" to 1)) }, validator = repoPredicate { "delete_me" !in it.branches }),
|
||||
level("push_branch", description = "You've made some changes to a local branch and want to share it, but aren't yet ready to merge it with the 'master' branch. Push only 'test_branch' to the remote repository", hints = listOf("Investigate the options in `git push` using `git push --help`"), setup = { RepoState(initialized = true, branches = mapOf("master" to 2, "other_branch" to 3, "test_branch" to 4), remotes = mapOf("origin" to "remote")) }, validator = { _, command -> command.startsWith("git push") && command.contains("test_branch") }),
|
||||
level("merge", description = "We have a file in the branch 'feature'. Let's merge it with the master branch.", hints = listOf("You want to research the `git merge` command."), setup = { RepoState(initialized = true, files = listOf(GitFile("file1", tracked = true)), branches = mapOf("master" to 1, "feature" to 2)) }, validator = repoPredicate { it.files.any { f -> f.name == "file2" } }),
|
||||
level("fetch", description = "Looks like a new branch was pushed into our remote repository. Get the changes without merging them with the local repository", hints = listOf("Look up the 'git fetch' command"), setup = { RepoState(initialized = true, branches = mapOf("master" to 1), remotes = mapOf("origin" to "remote")) }, validator = { _, command -> command.startsWith("git fetch") }),
|
||||
level("rebase", description = "We are using a git rebase workflow and the feature branch is ready to go into master. Let's rebase the feature branch onto our master branch.", hints = listOf("You want to research the `git rebase` command"), setup = { RepoState(initialized = true, branches = mapOf("master" to 2, "feature" to 3)) }, validator = { _, command -> command.startsWith("git rebase") }),
|
||||
level("rebase_onto", description = "You have created your branch from `wrong_branch` and already made some commits, and you realise that you needed to create your branch from `master`. Rebase your commits onto `master` branch so that you don't have `wrong_branch` commits.", hints = listOf("You want to research the `git rebase` commands `--onto` argument"), setup = { RepoState(initialized = true, branches = mapOf("master" to 1, "wrong_branch" to 2, "readme-update" to 4)) }, validator = { _, command -> command.startsWith("git rebase") && command.contains("--onto") }),
|
||||
level("repack", description = "Optimise how your repository is packaged ensuring that redundant packs are removed.", hints = listOf("You want to research the `git repack` command."), setup = { RepoState(initialized = true, files = listOf(GitFile("foo", tracked = true)), commits = listOf(CommitNode("1", "Added foo")), branches = mapOf("master" to 1)) }, validator = { _, command -> command.startsWith("git repack") }),
|
||||
level("cherry-pick", description = "Your new feature isn't worth the time and you're going to delete it. But it has one commit that fills in `README` file, and you want this commit to be on the master as well.", hints = listOf("Sneak a peek at the `git help cherry-pick` command."), setup = { RepoState(initialized = true, branches = mapOf("master" to 2, "feature" to 3)) }, validator = { _, command -> command.startsWith("git cherry-pick") }),
|
||||
level("grep", description = "Your project's deadline approaches, you should evaluate how many TODOs are left in your code", hints = listOf("You want to research the `git grep` command."), setup = { RepoState(initialized = true, files = listOf(GitFile("app.rb", "# TODO\n# TODO\n# TODO\n# TODO", tracked = true)), branches = mapOf("master" to 1)) }, validator = commandAnswer("4")),
|
||||
level("rename_commit", description = "Correct the typo in the message of your first (non-root) commit.", hints = listOf("Take a look the `-i` flag of the rebase command."), setup = { RepoState(initialized = true, commits = listOf(CommitNode("1", "Initial commit"), CommitNode("2", "First coommit"), CommitNode("3", "Second commit")), branches = mapOf("master" to 3)) }, validator = { _, command -> command.startsWith("git rebase -i") || command.contains("First commit") }),
|
||||
level("squash", description = "You have committed several times but would like all those changes to be one commit.", hints = listOf("Take a look at the `-i` flag of the rebase command."), setup = { RepoState(initialized = true, commits = listOf(CommitNode("1", "Initial Commit"), CommitNode("2", "Adding README"), CommitNode("3", "Updating README (squash this commit into Adding README)"), CommitNode("4", "Updating README (squash this commit into Adding README)"), CommitNode("5", "Updating README (squash this commit into Adding README)")), branches = mapOf("master" to 5)) }, validator = repoPredicate { it.commits.size <= 2 }),
|
||||
level("merge_squash", description = "Merge all commits from the long-feature-branch as a single commit.", hints = listOf("Take a look at the `--squash` option of the merge command. Don't forget to commit the merge!"), setup = { RepoState(initialized = true, branches = mapOf("master" to 2, "long-feature-branch" to 4)) }, validator = { _, command -> command.contains("merge") && command.contains("--squash") }),
|
||||
level("reorder", description = "You have committed several times but in the wrong order. Please reorder your commits.", hints = listOf("Take a look the `-i` flag of the rebase command."), setup = { RepoState(initialized = true, commits = listOf(CommitNode("1", "Initial Setup"), CommitNode("2", "First commit"), CommitNode("3", "Third commit"), CommitNode("4", "Second commit")), branches = mapOf("master" to 4)) }, validator = { _, command -> command.startsWith("git rebase -i") }),
|
||||
level("bisect", description = "A bug was introduced somewhere along the way. You know that running `ruby prog.rb 5` should output 15. You can also run `make test`. What are the first 7 chars of the hash of the commit (the abbreviated hash) that introduced the bug?", hints = emptyList(), setup = { RepoState(initialized = true, branches = mapOf("master" to 7)) }, validator = commandAnswer("18ed2ac")),
|
||||
level("stage_lines", description = "You've made changes within a single file that belong to two different features, but neither of the changes are yet staged. Stage only the changes belonging to the first feature.", hints = listOf("Read about the flags which can be passed to the `add` command."), setup = { RepoState(initialized = true, files = listOf(GitFile("feature.rb", "this is the class of my feature\nThis change belongs to the first feature\nThis change belongs to the second feature", tracked = true)), branches = mapOf("master" to 1)) }, validator = { _, command -> command.startsWith("git add") && ("-p" in command || "--patch" in command) }),
|
||||
level("find_old_branch", description = "You have been working on a branch but got distracted by a major issue. Switch back to that branch even though you forgot the name of it.", hints = listOf("Ever played with the `git reflog` command?"), setup = { RepoState(initialized = true, branches = mapOf("master" to 1, "solve_world_hunger" to 2)) }, validator = repoPredicate { it.headBranch == "solve_world_hunger" }),
|
||||
level("revert", description = "You have committed several times but want to undo the middle commit. All commits have been pushed, so you can't change existing history.", hints = listOf("Try the revert command."), setup = { RepoState(initialized = true, commits = listOf(CommitNode("1", "First commit"), CommitNode("2", "Bad commit"), CommitNode("3", "Second commit")), branches = mapOf("master" to 3)) }, validator = { repo, command -> repo.commits.any { it.message.contains("Revert") } || command.startsWith("git revert") }),
|
||||
level("restore", description = "You decided to delete your latest commit by running `git reset --hard HEAD^` (not a smart thing to do). Now you changed your mind and want that commit back. Restore the deleted commit.", hints = listOf("The commit is still floating around somewhere. Have you checked out `git reflog`?"), setup = { RepoState(initialized = true, files = listOf(GitFile("file1", tracked = true), GitFile("file2", tracked = true)), commits = listOf(CommitNode("1", "Initial commit"), CommitNode("2", "First commit")), branches = mapOf("master" to 2)) }, validator = repoPredicate { it.files.any { f -> f.name == "file3" } }),
|
||||
level("conflict", description = "You need to merge mybranch into the current branch (master). But there may be some incorrect changes in mybranch which may cause conflicts. Solve any merge-conflicts you come across and finish the merge.", hints = emptyList(), setup = { RepoState(initialized = true, files = listOf(GitFile("poem.txt", tracked = true)), branches = mapOf("master" to 2, "mybranch" to 2)) }, validator = { _, command -> command.startsWith("git merge") || command.startsWith("git commit") }),
|
||||
level("submodule", description = "You want to include the files from the following repo: `https://github.com/jackmaney/githug-include-me` into the folder `./githug-include-me`. Do this without manually cloning the repo or copying the files from the repo into this repo.", hints = listOf("Take a look at `git submodule`."), setup = { RepoState(initialized = true, branches = mapOf("master" to 0)) }, validator = { _, command -> command.startsWith("git submodule add") }),
|
||||
level("contribute", description = "This is the final level, the goal is to contribute to this repository by making a pull request on GitHub. Please note that this level is designed to encourage you to add a valid contribution to Githug, not testing your ability to create a pull request. Contributions that are likely to be accepted are levels, bug fixes and improved documentation.", hints = listOf("Forking the repository would be a good start!"), setup = { RepoState() }, validator = { _, command -> command.isNotBlank() }),
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
internal fun coreLevels(): List<Level> = listOf(
|
||||
level(
|
||||
id = "init",
|
||||
title = "Init",
|
||||
description = "A new directory, `git_hug`, has been created; initialize an empty repository in it.",
|
||||
hints = listOf("You can type `git --help` or `git` in your shell to get a list of available git commands."),
|
||||
commandSuggestions = listOf("git init", "git status"),
|
||||
setup = { RepoState() },
|
||||
validator = repoPredicate { it.initialized },
|
||||
),
|
||||
level(
|
||||
id = "config",
|
||||
title = "Config",
|
||||
description = "Set up your git name and email; this is important so that your commits can be identified.",
|
||||
hints = listOf("Use `git config user.name ...` and `git config user.email ...`."),
|
||||
commandSuggestions = listOf("git config user.name GitHug", "git config user.email githug@example.com"),
|
||||
setup = { RepoState(initialized = true, branches = mapOf("master" to 0)) },
|
||||
validator = repoPredicate { it.initialized },
|
||||
),
|
||||
level(
|
||||
id = "add",
|
||||
title = "Add",
|
||||
description = "There is a file in your folder called `README`; add it to your staging area.\nNote: Each level starts with a new repo. Don't look for files of the previous one.",
|
||||
hints = listOf("You can type `git` in your shell to get a list of available git commands."),
|
||||
commandSuggestions = listOf("git add README", "git status"),
|
||||
setup = { RepoState(initialized = true, files = listOf(GitFile("README")), branches = mapOf("master" to 0)) },
|
||||
validator = repoPredicate { repo -> repo.files.any { it.name == "README" && it.staged } },
|
||||
),
|
||||
level(
|
||||
id = "commit",
|
||||
title = "Commit",
|
||||
description = "The `README` file has been added to your staging area, now commit it.",
|
||||
hints = listOf("You must include a message when you commit."),
|
||||
commandSuggestions = listOf("git commit -m \"Initial commit\"", "git log"),
|
||||
setup = { RepoState(initialized = true, files = listOf(GitFile("README", staged = true)), branches = mapOf("master" to 0)) },
|
||||
validator = repoPredicate { it.commits.isNotEmpty() },
|
||||
),
|
||||
level(
|
||||
id = "branch",
|
||||
description = "To work on a piece of code that has the potential to break things, create the branch test_code.",
|
||||
hints = listOf("`git branch` is what you want to investigate."),
|
||||
commandSuggestions = listOf("git branch test_code", "git branch"),
|
||||
setup = { RepoState(initialized = true, files = listOf(GitFile("README", tracked = true)), commits = listOf(CommitNode("0000001", "Initial commit")), branches = mapOf("master" to 1)) },
|
||||
validator = repoPredicate { "test_code" in it.branches },
|
||||
),
|
||||
level(
|
||||
id = "checkout",
|
||||
description = "Create and switch to a new branch called my_branch. You will need to create a branch like you did in the previous level.",
|
||||
hints = listOf("Try looking up `git checkout` and `git branch`."),
|
||||
commandSuggestions = listOf("git checkout -b my_branch", "git branch"),
|
||||
setup = { RepoState(initialized = true, files = listOf(GitFile("README", tracked = true)), commits = listOf(CommitNode("0000001", "initial commit")), branches = mapOf("master" to 1)) },
|
||||
validator = repoPredicate { it.headBranch == "my_branch" },
|
||||
),
|
||||
level(
|
||||
id = "tag",
|
||||
description = "We have a git repo and we want to tag the current commit with `new_tag`.",
|
||||
hints = listOf("Take a look at `git tag`."),
|
||||
commandSuggestions = listOf("git tag new_tag", "git tag"),
|
||||
setup = { RepoState(initialized = true, files = listOf(GitFile("somefile.txt", tracked = true)), commits = listOf(CommitNode("0000001", "Added some file to the repo")), branches = mapOf("master" to 1)) },
|
||||
validator = repoPredicate { "new_tag" in it.tags },
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
fun allGithugLevels(): List<Level> = coreLevels() + advancedLevels()
|
||||
|
||||
internal fun level(
|
||||
id: String,
|
||||
title: String = id.replace('-', ' ').replace('_', ' ').replaceFirstChar { it.uppercase() },
|
||||
description: String,
|
||||
hints: List<String>,
|
||||
commandSuggestions: List<String> = listOf("git status", "git log", "git help"),
|
||||
setup: () -> RepoState,
|
||||
validator: (RepoState, String) -> Boolean,
|
||||
): Level = Level(id, title, description, hints, commandSuggestions, validator, setup)
|
||||
|
||||
internal fun commandAnswer(vararg answers: String): (RepoState, String) -> Boolean = { _, command ->
|
||||
val normalized = command.trim()
|
||||
answers.any { it.equals(normalized, ignoreCase = true) }
|
||||
}
|
||||
|
||||
internal fun repoPredicate(block: (RepoState) -> Boolean): (RepoState, String) -> Boolean = { repo, _ -> block(repo) }
|
||||
Reference in New Issue
Block a user