Remove Kotlin interactive-add emulation and run patch add via the native Git runtime with PTY-backed session IO.
793 lines
38 KiB
Kotlin
793 lines
38 KiB
Kotlin
package solutions.tretter.githugandroid
|
|
|
|
import android.os.SystemClock
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.WindowInsets
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.ime
|
|
import androidx.compose.foundation.layout.navigationBars
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.foundation.layout.statusBarsPadding
|
|
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.runtime.saveable.rememberSaveable
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.geometry.Rect
|
|
import androidx.compose.ui.platform.LocalConfiguration
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.platform.LocalDensity
|
|
import androidx.compose.ui.text.TextRange
|
|
import androidx.compose.ui.text.input.TextFieldValue
|
|
import androidx.compose.ui.unit.dp
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
|
|
@Composable
|
|
fun GitHugApp() {
|
|
MaterialTheme(colorScheme = GitHugColorScheme) {
|
|
val context = LocalContext.current
|
|
val configuration = LocalConfiguration.current
|
|
val density = LocalDensity.current
|
|
val runtime = remember(context) { GitRepositoryRuntime(context.applicationContext) }
|
|
if (!runtime.isNativeGitAvailable()) {
|
|
MissingNativeGitScreen(message = runtime.unavailableMessage())
|
|
return@MaterialTheme
|
|
}
|
|
|
|
val levels = remember { sampleLevels() }
|
|
val paneLayoutStore = remember(context) { PaneLayoutStore(context.applicationContext) }
|
|
val gameProgressStore = remember(context) { GameProgressStore(context.applicationContext) }
|
|
val persistedPaneLayout by paneLayoutStore.layoutFlow.collectAsState(initial = defaultPaneLayout())
|
|
val persistedProgress by gameProgressStore.progressFlow.collectAsState(initial = null)
|
|
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("") }
|
|
var hasRestoredProgress by remember { mutableStateOf(false) }
|
|
var isPreparingLevel by remember { mutableStateOf(false) }
|
|
var levelLoadRequestId by remember { mutableStateOf(0) }
|
|
var editorState by remember { mutableStateOf<TextEditorState?>(null) }
|
|
var gitMessageEditorState by remember { mutableStateOf<GitMessageEditorState?>(null) }
|
|
var manPageState by remember { mutableStateOf<ManPageState?>(null) }
|
|
var solvedCelebrationTitle by remember { mutableStateOf<String?>(null) }
|
|
var showTerminalInputHint by remember { mutableStateOf(true) }
|
|
var showExerciseDescriptionHint by remember { mutableStateOf(true) }
|
|
var showHelpOverlay by rememberSaveable { mutableStateOf(false) }
|
|
var showHelpOnStart by rememberSaveable { mutableStateOf(true) }
|
|
var helpPreferenceInitialized by rememberSaveable { mutableStateOf(false) }
|
|
var showDiagnosticDialog by rememberSaveable { mutableStateOf(false) }
|
|
var diagnosticSkinEnabled by rememberSaveable { mutableStateOf(false) }
|
|
var headerTapTimes by remember { mutableStateOf(emptyList<Long>()) }
|
|
var exerciseBounds by remember { mutableStateOf<Rect?>(null) }
|
|
var promptBounds by remember { mutableStateOf<Rect?>(null) }
|
|
val currentLevel = levels[currentLevelIndex]
|
|
val appBackground = if (diagnosticSkinEnabled) DiagnosticAppBackground else AppBackground
|
|
val imeBottom = WindowInsets.ime.getBottom(density)
|
|
val navigationBottom = WindowInsets.navigationBars.getBottom(density)
|
|
val bottomSystemPadding = with(density) {
|
|
if (imeBottom > navigationBottom) 0.dp else navigationBottom.toDp()
|
|
}
|
|
|
|
LaunchedEffect(solvedCelebrationTitle) {
|
|
if (solvedCelebrationTitle != null) {
|
|
delay(3_000)
|
|
solvedCelebrationTitle = null
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(persistedPaneLayout) {
|
|
paneLayout = persistedPaneLayout
|
|
}
|
|
|
|
LaunchedEffect(currentLevelIndex) {
|
|
AppLog.d("GitHugApp", "Current level index changed to $currentLevelIndex id=${levels[currentLevelIndex].id}")
|
|
delay(100)
|
|
screenScrollState.animateScrollTo(0)
|
|
AppLog.d("GitHugApp", "Scrolled to top for level index=$currentLevelIndex id=${levels[currentLevelIndex].id}")
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
suspend fun persistProgress(completed: Set<String>, activeLevelId: String?) {
|
|
gameProgressStore.saveProgress(completed, activeLevelId)
|
|
}
|
|
|
|
LaunchedEffect(persistedProgress) {
|
|
val restoredProgress = persistedProgress ?: return@LaunchedEffect
|
|
val restoredCompletedLevels = restoredProgress.completedLevels
|
|
completedLevels = restoredCompletedLevels
|
|
showHelpOnStart = restoredProgress.showHelpOnStart
|
|
if (!helpPreferenceInitialized) {
|
|
helpPreferenceInitialized = true
|
|
showHelpOverlay = restoredProgress.showHelpOnStart
|
|
}
|
|
AppLog.d(
|
|
"GitHugApp",
|
|
"Observed persisted progress completed=${restoredCompletedLevels.sorted()} activeLevelId=${restoredProgress.activeLevelId} restored=$hasRestoredProgress",
|
|
)
|
|
if (!hasRestoredProgress) {
|
|
hasRestoredProgress = true
|
|
val resumeIndex = restoredProgress.activeLevelId
|
|
?.let { activeId -> levels.indexOfFirst { it.id == activeId }.takeIf { it >= 0 } }
|
|
?: levels.indexOfFirst { it.id !in restoredCompletedLevels }.takeIf { it >= 0 }
|
|
?: levels.lastIndex
|
|
AppLog.d(
|
|
"GitHugApp",
|
|
"Restoring app to levelIndex=$resumeIndex levelId=${levels[resumeIndex].id}",
|
|
)
|
|
currentLevelIndex = resumeIndex
|
|
repo = runtime.prepareLevel(levels[resumeIndex])
|
|
output = listOf(
|
|
if (restoredCompletedLevels.size == levels.size) {
|
|
"🏁 All Githug levels completed."
|
|
} else if (restoredCompletedLevels.isEmpty()) {
|
|
runtime.startupBanner()
|
|
} else {
|
|
"Resumed at level: ${levels[resumeIndex].title}"
|
|
}
|
|
)
|
|
commandInput = TextFieldValue(text = "", selection = TextRange.Zero)
|
|
suppressedImeEcho = null
|
|
hintIndex = 0
|
|
activeExerciseDetail = null
|
|
visibleHint = null
|
|
historyIndex = -1
|
|
historyDraft = ""
|
|
paneLayout = paneLayout.copy(
|
|
weights = paneLayout.weights + recommendedPaneWeights(
|
|
heights = recommendedPaneHeights(
|
|
level = levels[resumeIndex],
|
|
levelCount = levels.size,
|
|
outputLineCount = 1,
|
|
screenHeightDp = screenHeightDp,
|
|
suggestionsVisible = false,
|
|
visibleHint = null,
|
|
),
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
fun applyRecommendedPaneWeights(
|
|
persist: Boolean = true,
|
|
outputLineCount: Int = terminalOutputLineCount(output),
|
|
) {
|
|
updatePaneLayout(
|
|
transform = { layout ->
|
|
layout.copy(
|
|
weights = layout.weights + recommendedPaneWeights(
|
|
heights = recommendedPaneHeights(
|
|
level = currentLevel,
|
|
levelCount = levels.size,
|
|
outputLineCount = outputLineCount,
|
|
screenHeightDp = screenHeightDp,
|
|
suggestionsVisible = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS,
|
|
visibleHint = visibleHint,
|
|
),
|
|
)
|
|
)
|
|
},
|
|
persist = persist,
|
|
)
|
|
}
|
|
|
|
fun resetCurrentLevel(message: String = "Level reset.") {
|
|
val startedAt = System.nanoTime()
|
|
val resetLevelId = currentLevel.id
|
|
AppLog.d("GitHugApp", "Reset requested level=$resetLevelId")
|
|
val updatedCompletedLevels = completedLevels - resetLevelId
|
|
completedLevels = updatedCompletedLevels
|
|
scope.launch { persistProgress(updatedCompletedLevels, resetLevelId) }
|
|
repo = runtime.prepareLevel(currentLevel)
|
|
clearCommandInput()
|
|
suppressedImeEcho = null
|
|
output = listOf(message)
|
|
hintIndex = 0
|
|
activeExerciseDetail = null
|
|
visibleHint = null
|
|
historyIndex = -1
|
|
historyDraft = ""
|
|
editorState = null
|
|
gitMessageEditorState = null
|
|
manPageState = null
|
|
applyRecommendedPaneWeights(persist = false, outputLineCount = terminalOutputLineCount(listOf(message)))
|
|
AppLog.d(
|
|
"GitHugApp",
|
|
"Reset finished level=$resetLevelId durationMs=${elapsedMillisSince(startedAt)} repo=${repo.diagnosticSnapshot()}",
|
|
)
|
|
}
|
|
|
|
fun loadLevel(
|
|
index: Int,
|
|
message: List<String> = listOf("Loaded level: ${levels[index].title}"),
|
|
keepSuppressedImeEcho: Boolean = false,
|
|
prepareInBackground: Boolean = false,
|
|
) {
|
|
val startedAt = System.nanoTime()
|
|
val level = levels[index]
|
|
val requestId = levelLoadRequestId + 1
|
|
levelLoadRequestId = requestId
|
|
AppLog.d("GitHugApp", "Loading level index=$index id=${level.id} title=${level.title} background=$prepareInBackground")
|
|
currentLevelIndex = index
|
|
output = if (prepareInBackground) listOf("Preparing level: ${level.title}") else message
|
|
clearCommandInput(recreateField = true)
|
|
if (!keepSuppressedImeEcho) {
|
|
suppressedImeEcho = null
|
|
}
|
|
hintIndex = 0
|
|
activeExerciseDetail = null
|
|
visibleHint = null
|
|
historyIndex = -1
|
|
historyDraft = ""
|
|
editorState = null
|
|
gitMessageEditorState = null
|
|
manPageState = null
|
|
if (prepareInBackground) {
|
|
isPreparingLevel = true
|
|
repo = RepoState()
|
|
AppLog.d(
|
|
"GitHugApp",
|
|
"Level load state updated index=$index id=${level.id} requestId=$requestId durationMs=${elapsedMillisSince(startedAt)} preparing=true",
|
|
)
|
|
scope.launch {
|
|
val prepareStartedAt = System.nanoTime()
|
|
AppLog.d("GitHugApp", "Background prepare started index=$index id=${level.id} requestId=$requestId")
|
|
val preparedRepo = withContext(Dispatchers.Default) {
|
|
runtime.prepareLevel(level)
|
|
}
|
|
val prepareMs = elapsedMillisSince(prepareStartedAt)
|
|
if (levelLoadRequestId == requestId) {
|
|
AppLog.d(
|
|
"GitHugApp",
|
|
"Background prepare applying index=$index id=${level.id} requestId=$requestId prepareMs=$prepareMs " +
|
|
"repo=${preparedRepo.diagnosticSnapshot()}",
|
|
)
|
|
repo = preparedRepo
|
|
output = message
|
|
isPreparingLevel = false
|
|
clearCommandInput(recreateField = true)
|
|
AppLog.d(
|
|
"GitHugApp",
|
|
"Background prepare applied index=$index id=${level.id} requestId=$requestId totalMs=${elapsedMillisSince(startedAt)}",
|
|
)
|
|
} else {
|
|
AppLog.d(
|
|
"GitHugApp",
|
|
"Background prepare discarded index=$index id=${level.id} requestId=$requestId activeRequestId=$levelLoadRequestId prepareMs=$prepareMs",
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
isPreparingLevel = false
|
|
val prepareStartedAt = System.nanoTime()
|
|
repo = runtime.prepareLevel(level)
|
|
AppLog.d(
|
|
"GitHugApp",
|
|
"Synchronous prepare applied index=$index id=${level.id} prepareMs=${elapsedMillisSince(prepareStartedAt)}",
|
|
)
|
|
}
|
|
paneLayout = paneLayout.copy(
|
|
weights = paneLayout.weights + recommendedPaneWeights(
|
|
heights = recommendedPaneHeights(
|
|
level = level,
|
|
levelCount = levels.size,
|
|
outputLineCount = 1,
|
|
screenHeightDp = screenHeightDp,
|
|
suggestionsVisible = false,
|
|
visibleHint = null,
|
|
),
|
|
)
|
|
)
|
|
AppLog.d(
|
|
"GitHugApp",
|
|
"Level load returned index=$index id=${level.id} requestId=$requestId durationMs=${elapsedMillisSince(startedAt)} preparing=$isPreparingLevel",
|
|
)
|
|
}
|
|
|
|
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)
|
|
val leadingCommand = beforeCursor.trimStart().substringBefore(' ')
|
|
val isCdCompletion = leadingCommand == "cd"
|
|
if (token.isBlank() && !isCdCompletion) return
|
|
|
|
val candidates = runtime.completionCandidates(currentLevel, repo, directoriesOnly = isCdCompletion)
|
|
val matches = contextualCompletionCandidates(
|
|
candidates = candidates,
|
|
commandBeforeToken = beforeCursor.substring(0, tokenStart),
|
|
token = token,
|
|
directoriesOnly = isCdCompletion,
|
|
)
|
|
if (matches.isEmpty()) return
|
|
|
|
val replacement = if (matches.size == 1) matches.first() else commonPrefix(matches)
|
|
if (replacement == token && matches.size > 1) {
|
|
val newOutput = output + "completion> ${matches.joinToString(" ")}"
|
|
output = newOutput
|
|
applyRecommendedPaneWeights(persist = false, outputLineCount = terminalOutputLineCount(newOutput))
|
|
return
|
|
}
|
|
|
|
val newText = commandInput.text.replaceRange(tokenStart, cursor, replacement)
|
|
val newCursor = tokenStart + replacement.length
|
|
commandInput = TextFieldValue(newText, selection = TextRange(newCursor))
|
|
}
|
|
|
|
fun applyCommandResult(raw: String, newRepo: RepoState, lines: List<String>, echoCommand: Boolean) {
|
|
val startedAt = System.nanoTime()
|
|
val levelForResult = currentLevel
|
|
val validationBlockedByEditor = editorState != null || gitMessageEditorState != null || newRepo.nativeGitSession != null
|
|
val solvedAfterCommand = if (validationBlockedByEditor) {
|
|
false
|
|
} else {
|
|
levelForResult.validator(newRepo, raw)
|
|
}
|
|
val wasAlreadyCompleted = currentLevel.id in completedLevels
|
|
AppLog.d(
|
|
"GitHugApp",
|
|
"Command='$raw' level=${levelForResult.id} solved=$solvedAfterCommand validationBlockedByEditor=$validationBlockedByEditor alreadyCompleted=$wasAlreadyCompleted " +
|
|
"completedBefore=${completedLevels.sorted()} outputLineCount=${lines.size} repo=${newRepo.diagnosticSnapshot()}",
|
|
)
|
|
val newOutput = buildList {
|
|
addAll(output)
|
|
if (echoCommand) {
|
|
add("$ $raw")
|
|
}
|
|
addAll(lines)
|
|
if (solvedAfterCommand && !wasAlreadyCompleted) {
|
|
add("")
|
|
add("👍 Level solved: ${levelForResult.title}")
|
|
add("Nice. ${completedLevels.size + 1}/${levels.size} levels complete.")
|
|
}
|
|
}
|
|
if (solvedAfterCommand && !wasAlreadyCompleted) {
|
|
val updatedCompletedLevels = completedLevels + levelForResult.id
|
|
completedLevels = updatedCompletedLevels
|
|
solvedCelebrationTitle = levelForResult.title
|
|
val nextLevelIndex = levels.indexOfFirst { it.id !in updatedCompletedLevels }
|
|
if (nextLevelIndex >= 0) {
|
|
val nextLevelId = levels[nextLevelIndex].id
|
|
AppLog.d(
|
|
"GitHugApp",
|
|
"Level solved ${levelForResult.id}; advancing to next incomplete index=$nextLevelIndex id=$nextLevelId",
|
|
)
|
|
scope.launch { persistProgress(updatedCompletedLevels, nextLevelId) }
|
|
loadLevel(nextLevelIndex, message = emptyList(), keepSuppressedImeEcho = true)
|
|
} else {
|
|
AppLog.d("GitHugApp", "All levels completed")
|
|
scope.launch { persistProgress(updatedCompletedLevels, levels.lastOrNull()?.id) }
|
|
repo = newRepo
|
|
val completedOutput = newOutput + listOf("🏁 All Githug levels completed.")
|
|
output = completedOutput
|
|
clearCommandInput()
|
|
suppressedImeEcho = null
|
|
applyRecommendedPaneWeights(persist = false, outputLineCount = terminalOutputLineCount(completedOutput))
|
|
}
|
|
} else {
|
|
AppLog.d("GitHugApp", "Staying on level=${levelForResult.id}")
|
|
repo = newRepo
|
|
output = newOutput
|
|
applyRecommendedPaneWeights(persist = false, outputLineCount = terminalOutputLineCount(newOutput))
|
|
}
|
|
AppLog.d(
|
|
"GitHugApp",
|
|
"Command result applied level=${levelForResult.id} solved=$solvedAfterCommand durationMs=${elapsedMillisSince(startedAt)}",
|
|
)
|
|
}
|
|
|
|
fun openEditor(invocation: VisualEditorInvocation) {
|
|
val path = invocation.path.orEmpty()
|
|
val (content, lines) = if (path.isBlank()) {
|
|
"" to emptyList()
|
|
} else {
|
|
runtime.readEditorFile(currentLevel, repo, path)
|
|
}
|
|
val newOutput = buildList {
|
|
addAll(output)
|
|
add("$ ${invocation.command}")
|
|
addAll(lines)
|
|
if (lines.isEmpty()) {
|
|
add("Opened ${invocation.editor} editor${if (path.isBlank()) "" else " for $path"}")
|
|
}
|
|
}
|
|
output = newOutput
|
|
if (lines.isEmpty()) {
|
|
editorState = TextEditorState(
|
|
editor = invocation.editor,
|
|
originalCommand = invocation.command,
|
|
path = path,
|
|
content = content,
|
|
saveAsPath = path,
|
|
)
|
|
}
|
|
applyRecommendedPaneWeights(persist = false, outputLineCount = terminalOutputLineCount(newOutput))
|
|
}
|
|
|
|
fun openManPage(invocation: GitHelpInvocation) {
|
|
val content = runtime.gitManPage(currentLevel, repo, invocation.topic)
|
|
val newOutput = buildList {
|
|
addAll(output)
|
|
add("$ ${invocation.command}")
|
|
add("Opened git help viewer for ${invocation.topic}")
|
|
}
|
|
output = newOutput
|
|
manPageState = ManPageState(topic = invocation.topic, content = content)
|
|
applyRecommendedPaneWeights(persist = false, outputLineCount = terminalOutputLineCount(newOutput))
|
|
}
|
|
|
|
fun openGitMessageEditor(invocation: GitEditorInvocation) {
|
|
val initialContent = runtime.gitEditorInitialContent(currentLevel, repo, invocation)
|
|
val openedMessage = when (invocation.kind) {
|
|
GitEditorCommandKind.REBASE_TODO -> "Opened Git rebase editor"
|
|
else -> "Opened Git message editor"
|
|
}
|
|
val newOutput = buildList {
|
|
addAll(output)
|
|
add("$ ${invocation.command}")
|
|
add(openedMessage)
|
|
}
|
|
output = newOutput
|
|
gitMessageEditorState = GitMessageEditorState(
|
|
invocation = invocation.copy(initialContent = initialContent),
|
|
content = initialContent,
|
|
)
|
|
applyRecommendedPaneWeights(persist = false, outputLineCount = terminalOutputLineCount(newOutput))
|
|
}
|
|
|
|
fun saveEditor() {
|
|
val state = editorState ?: return
|
|
val targetPath = state.saveAsPath.trim()
|
|
if (targetPath.isBlank()) return
|
|
val (newRepo, lines) = runtime.writeEditorFile(currentLevel, repo, targetPath, state.content)
|
|
editorState = null
|
|
applyCommandResult(state.originalCommand, newRepo, lines, echoCommand = false)
|
|
}
|
|
|
|
fun saveGitMessageEditor() {
|
|
val state = gitMessageEditorState ?: return
|
|
val result = runtime.executeGitEditorCommandWithResult(
|
|
level = currentLevel,
|
|
currentRepo = repo,
|
|
invocation = state.invocation,
|
|
message = state.content,
|
|
)
|
|
gitMessageEditorState = result.nextEditor?.let { nextEditor ->
|
|
GitMessageEditorState(
|
|
invocation = nextEditor.invocation,
|
|
content = nextEditor.content,
|
|
)
|
|
}
|
|
applyCommandResult(state.invocation.command, result.repo, result.outputLines, echoCommand = false)
|
|
}
|
|
|
|
fun runCommand() {
|
|
val startedAt = System.nanoTime()
|
|
val submittedText = commandInput.text
|
|
val raw = submittedText.trim()
|
|
if (raw.isBlank()) return
|
|
if (isPreparingLevel) {
|
|
AppLog.d("GitHugApp", "Command blocked while preparing level=${currentLevel.id} raw='$raw'")
|
|
output = output + "Still preparing ${currentLevel.title}. Try again in a moment."
|
|
clearCommandInput(recreateField = true)
|
|
applyRecommendedPaneWeights(persist = false, outputLineCount = terminalOutputLineCount(output))
|
|
return
|
|
}
|
|
val submittedLevelId = currentLevel.id
|
|
val isInteractiveInput = repo.nativeGitSession != null
|
|
AppLog.d(
|
|
"GitHugApp",
|
|
"Command submitted level=$submittedLevelId raw='$raw' interactive=$isInteractiveInput repo=${repo.diagnosticSnapshot()}",
|
|
)
|
|
|
|
showHelpOverlay = false
|
|
showTerminalInputHint = false
|
|
showExerciseDescriptionHint = false
|
|
suppressedImeEcho = submittedText
|
|
clearCommandInput(recreateField = true)
|
|
|
|
if (!isInteractiveInput && commandHistory.lastOrNull() != raw) {
|
|
commandHistory = commandHistory + raw
|
|
}
|
|
historyIndex = -1
|
|
historyDraft = ""
|
|
|
|
val editorInvocation = parseVisualEditorInvocation(raw)
|
|
if (editorInvocation != null) {
|
|
openEditor(editorInvocation)
|
|
return
|
|
}
|
|
|
|
val helpInvocation = parseGitHelpInvocation(raw)
|
|
if (helpInvocation != null) {
|
|
openManPage(helpInvocation)
|
|
return
|
|
}
|
|
|
|
val gitEditorInvocation = parseGitEditorInvocation(raw)
|
|
if (gitEditorInvocation != null) {
|
|
openGitMessageEditor(gitEditorInvocation)
|
|
return
|
|
}
|
|
|
|
val (newRepo, lines) = runtime.execute(currentLevel, repo, raw)
|
|
applyCommandResult(raw, newRepo, lines, echoCommand = !isInteractiveInput)
|
|
AppLog.d("GitHugApp", "Command handling finished level=$submittedLevelId raw='$raw' durationMs=${elapsedMillisSince(startedAt)}")
|
|
}
|
|
|
|
fun showCommandHelp() {
|
|
val newOutput = output + runtime.commandReferenceLines()
|
|
output = newOutput
|
|
applyRecommendedPaneWeights(persist = false, outputLineCount = terminalOutputLineCount(newOutput))
|
|
}
|
|
|
|
fun registerHeaderTap() {
|
|
val now = SystemClock.elapsedRealtime()
|
|
val recentTaps = (headerTapTimes + now).filter { tapTime -> now - tapTime <= 15_000L }
|
|
if (recentTaps.size >= 7) {
|
|
headerTapTimes = emptyList()
|
|
showDiagnosticDialog = true
|
|
} else {
|
|
headerTapTimes = recentTaps
|
|
}
|
|
}
|
|
|
|
Scaffold(
|
|
containerColor = appBackground,
|
|
contentWindowInsets = WindowInsets(0.dp),
|
|
) { padding ->
|
|
Surface(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.padding(padding),
|
|
color = appBackground,
|
|
) {
|
|
Box(modifier = Modifier.fillMaxSize()) {
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.background(appBackground)
|
|
.statusBarsPadding()
|
|
.verticalScroll(screenScrollState)
|
|
.padding(
|
|
start = 10.dp,
|
|
top = 8.dp,
|
|
end = 10.dp,
|
|
bottom = 8.dp + bottomSystemPadding,
|
|
),
|
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
) {
|
|
FixedHeader(onTap = { registerHeaderTap() })
|
|
Box(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
PaneWorkspace(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
paneLayout = paneLayout,
|
|
diagnosticSkinEnabled = diagnosticSkinEnabled,
|
|
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, prepareInBackground = true)
|
|
}
|
|
},
|
|
visualContent = { VisualPane(repo) },
|
|
exerciseContent = {
|
|
ExercisePane(
|
|
level = currentLevel,
|
|
visibleHint = if (activeExerciseDetail == ExerciseDetailPanel.HINT) visibleHint else null,
|
|
suggestionsExpanded = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS,
|
|
showDescriptionHint = false,
|
|
onBoundsChanged = { exerciseBounds = it },
|
|
onToggleSuggestions = {
|
|
showExerciseDescriptionHint = false
|
|
showHelpOverlay = false
|
|
activeExerciseDetail = if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) null else ExerciseDetailPanel.SUGGESTIONS
|
|
if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) {
|
|
visibleHint = null
|
|
}
|
|
applyRecommendedPaneWeights(persist = false)
|
|
},
|
|
onHint = {
|
|
showExerciseDescriptionHint = false
|
|
showHelpOverlay = false
|
|
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 = {
|
|
showExerciseDescriptionHint = false
|
|
showHelpOverlay = false
|
|
resetCurrentLevel()
|
|
},
|
|
)
|
|
},
|
|
terminalContent = {
|
|
TerminalPane(
|
|
output = output,
|
|
inputFieldVersion = inputFieldVersion,
|
|
commandInput = commandInput,
|
|
showInputHint = false,
|
|
onInputHintDismiss = {
|
|
showTerminalInputHint = false
|
|
showHelpOverlay = false
|
|
},
|
|
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
|
|
if (it.text.isNotEmpty()) {
|
|
showTerminalInputHint = false
|
|
showExerciseDescriptionHint = false
|
|
showHelpOverlay = false
|
|
}
|
|
commandInput = it
|
|
},
|
|
onRun = { runCommand() },
|
|
onTab = { tabComplete() },
|
|
onHelp = { showCommandHelp() },
|
|
onCursorLeft = { moveCursor(-1) },
|
|
onCursorRight = { moveCursor(1) },
|
|
onHistoryUp = { historyUp() },
|
|
onHistoryDown = { historyDown() },
|
|
onPromptBoundsChanged = { promptBounds = it },
|
|
)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
if (showHelpOverlay) {
|
|
HelpCalloutOverlay(
|
|
showHelpOnStart = showHelpOnStart,
|
|
onShowHelpOnStartChange = { showHelpOnStart = it },
|
|
onOk = {
|
|
showHelpOverlay = false
|
|
scope.launch { gameProgressStore.saveShowHelpOnStart(showHelpOnStart) }
|
|
},
|
|
onClose = { showHelpOverlay = false },
|
|
exerciseBounds = exerciseBounds,
|
|
promptBounds = promptBounds,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
editorState?.let { state ->
|
|
TextEditorDialog(
|
|
state = state,
|
|
onContentChange = { editorState = state.copy(content = it) },
|
|
onSaveAsPathChange = { editorState = state.copy(saveAsPath = it) },
|
|
onClose = {
|
|
output = output + "Editor closed without saving"
|
|
editorState = null
|
|
},
|
|
onSave = { saveEditor() },
|
|
)
|
|
}
|
|
|
|
gitMessageEditorState?.let { state ->
|
|
GitMessageEditorDialog(
|
|
state = state,
|
|
onContentChange = { gitMessageEditorState = state.copy(content = it) },
|
|
onClose = {
|
|
output = output + "Git editor closed without saving"
|
|
gitMessageEditorState = null
|
|
},
|
|
onSave = { saveGitMessageEditor() },
|
|
)
|
|
}
|
|
|
|
manPageState?.let { state ->
|
|
ManPageDialog(
|
|
state = state,
|
|
onClose = { manPageState = null },
|
|
)
|
|
}
|
|
|
|
if (showDiagnosticDialog) {
|
|
DiagnosticSkinDialog(
|
|
diagnosticSkinEnabled = diagnosticSkinEnabled,
|
|
onDiagnosticSkinEnabledChange = { diagnosticSkinEnabled = it },
|
|
onDismiss = { showDiagnosticDialog = false },
|
|
)
|
|
}
|
|
|
|
solvedCelebrationTitle?.let { title ->
|
|
SolvedCelebrationOverlay(title = title)
|
|
}
|
|
}
|
|
}
|