diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 104eaf4..0272484 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,8 +19,8 @@ android { applicationId = "solutions.tretter.githugandroid" minSdk = 26 targetSdk = 35 - versionCode = 144 - versionName = "0.1.143" + versionCode = 145 + versionName = "0.1.144" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt b/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt index 26fdbe3..8842b1a 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt @@ -19,6 +19,11 @@ data class CommitNode( val message: String, ) +data class InteractiveAddSession( + val target: String? = null, + val awaitingUpdateSelection: Boolean = false, +) + data class RepoState( val initialized: Boolean = false, val files: List = emptyList(), @@ -36,6 +41,7 @@ data class RepoState( val pushedTags: Set = emptySet(), val submodules: Map = emptyMap(), val maintenanceActions: Set = emptySet(), + val interactiveAddSession: InteractiveAddSession? = null, ) data class Level( diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt index bbbf241..c94cc51 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt @@ -441,6 +441,7 @@ fun GitHugApp() { val submittedText = commandInput.text val raw = submittedText.trim() if (raw.isBlank()) return + val isInteractiveInput = repo.interactiveAddSession != null showHelpOverlay = false showTerminalInputHint = false @@ -448,7 +449,7 @@ fun GitHugApp() { suppressedImeEcho = submittedText clearCommandInput(recreateField = true) - if (commandHistory.lastOrNull() != raw) { + if (!isInteractiveInput && commandHistory.lastOrNull() != raw) { commandHistory = commandHistory + raw } historyIndex = -1 @@ -473,7 +474,7 @@ fun GitHugApp() { } val (newRepo, lines) = runtime.execute(currentLevel, repo, raw) - applyCommandResult(raw, newRepo, lines, echoCommand = true) + applyCommandResult(raw, newRepo, lines, echoCommand = !isInteractiveInput) } fun showCommandHelp() { diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt index aafe956..64bd421 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt @@ -129,6 +129,20 @@ class GitRepositoryRuntime private constructor( val shellTokens = GitSandboxEngine.tokenizeShellCommand(command) val tokens = shellTokens.map { it.value } if (tokens.isEmpty()) return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to emptyList() + if (currentRepo.interactiveAddSession != null) { + val (updatedRepo, output) = GitSandboxEngine.execute(currentRepo, command) + val newlyStagedPaths = updatedRepo.files.filter { updatedFile -> + updatedFile.staged && currentRepo.files.firstOrNull { it.name == updatedFile.name }?.staged != true + }.map { it.name } + if (newlyStagedPaths.isNotEmpty()) { + runGit(nativeGit, workingDir, listOf("add") + newlyStagedPaths) + } + val inspectedRepo = inspectSandbox(level).copy( + currentDir = updatedRepo.currentDir, + interactiveAddSession = updatedRepo.interactiveAddSession, + ) + return augmentObservedRepoFacts(updatedRepo, inspectedRepo, tokens) to output + } val expandedTokens = if (tokens.firstOrNull() == "echo") { tokens } else { diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt b/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt index 2386d78..708ce69 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt @@ -26,6 +26,9 @@ object GitSandboxEngine { val shellParts = tokenizeShellCommand(command) val parts = shellParts.map { it.value } if (parts.isEmpty()) return repo to emptyList() + repo.interactiveAddSession?.let { + return handleInteractiveAddInput(repo, command) + } return when { parts[0] == "help" || parts[0] == "?" -> repo to commandReferenceLines() parts[0] == "touch" && parts.size >= 2 -> { @@ -174,7 +177,7 @@ object GitSandboxEngine { val candidates = repo.files.filter { file -> !file.deleted && (target == null || target == "." || file.name == target || file.name.startsWith(target.trimEnd('/') + "/")) } - return repo to interactiveAddConsoleLines(candidates) + return repo.copy(interactiveAddSession = InteractiveAddSession(target = target)) to interactiveAddConsoleLines(candidates) } private fun interactiveAddConsoleLines(candidates: List): List { @@ -198,6 +201,69 @@ object GitSandboxEngine { } } + private fun handleInteractiveAddInput(repo: RepoState, input: String): Pair> { + val session = repo.interactiveAddSession ?: return repo to emptyList() + val answer = input.trim() + return if (session.awaitingUpdateSelection) { + applyInteractiveAddUpdateSelection(repo, session, answer) + } else { + when (answer.lowercase()) { + "1", "s", "status" -> repo to listOf("What now> $answer") + interactiveAddConsoleLines(interactiveAddCandidates(repo, session.target)) + "2", "u", "update" -> repo.copy( + interactiveAddSession = session.copy(awaitingUpdateSelection = true), + ) to listOf("What now> $answer", "Update>>") + "7", "q", "quit" -> repo.copy(interactiveAddSession = null) to listOf("What now> $answer", "Bye.") + "8", "h", "help" -> repo to listOf("What now> $answer") + interactiveAddConsoleLines(interactiveAddCandidates(repo, session.target)) + else -> repo to listOf("What now> $answer", "Huh ($answer)?") + interactiveAddConsoleLines(interactiveAddCandidates(repo, session.target)) + } + } + } + + private fun applyInteractiveAddUpdateSelection( + repo: RepoState, + session: InteractiveAddSession, + answer: String, + ): Pair> { + val candidates = interactiveAddCandidates(repo, session.target) + val selectedNames = selectedInteractiveAddNames(candidates, answer) + if (selectedNames.isEmpty()) { + return repo to listOf("Update>> $answer", "No files selected.", "Update>>") + } + + val updatedFiles = repo.files.map { file -> + if (file.name in selectedNames && !file.deleted) file.copy(staged = true) else file + } + val updatedRepo = repo.copy( + files = updatedFiles, + interactiveAddSession = session.copy(awaitingUpdateSelection = false), + ) + val stagedCount = updatedFiles.count { updatedFile -> + val before = repo.files.firstOrNull { it.name == updatedFile.name } + updatedFile.staged && before?.staged != true + } + return updatedRepo to listOf( + "Update>> $answer", + "updated $stagedCount path(s)", + ) + interactiveAddConsoleLines(interactiveAddCandidates(updatedRepo, session.target)) + } + + private fun interactiveAddCandidates(repo: RepoState, target: String?): List { + return repo.files.filter { file -> + !file.deleted && (target == null || target == "." || file.name == target || file.name.startsWith(target.trimEnd('/') + "/")) + } + } + + private fun selectedInteractiveAddNames(candidates: List, answer: String): Set { + if (answer == "*") return candidates.map { it.name }.toSet() + return answer.split(Regex("[,\\s]+")) + .mapNotNull { token -> + token.toIntOrNull() + ?.takeIf { it in 1..candidates.size } + ?.let { candidates[it - 1].name } + } + .toSet() + } + fun tokenizeCommand(command: String): List { return tokenizeShellCommand(command).map { it.value } } diff --git a/app/src/main/java/solutions/tretter/githugandroid/RepoStateSaver.kt b/app/src/main/java/solutions/tretter/githugandroid/RepoStateSaver.kt index 6028f71..3670401 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/RepoStateSaver.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/RepoStateSaver.kt @@ -21,6 +21,7 @@ val RepoStateSaver = listSaver( state.submodules.flatMap { listOf(it.key, it.value) }, state.maintenanceActions.toList(), state.fetchHeadCount, + state.interactiveAddSession?.let { listOf(it.target.orEmpty(), it.awaitingUpdateSelection.toString()) }.orEmpty(), ) }, restore = { saved -> @@ -39,6 +40,7 @@ val RepoStateSaver = listSaver( val submoduleParts = saved.getOrNull(13) as? List<*> ?: emptyList() val maintenanceActions = saved.getOrNull(14) as? List<*> ?: emptyList() val fetchHeadCount = saved.getOrNull(15) as? Int ?: 0 + val interactiveAddSessionParts = saved.getOrNull(16) as? List<*> ?: emptyList() RepoState( initialized = initialized, headBranch = headBranch, @@ -66,6 +68,12 @@ val RepoStateSaver = listSaver( pushedTags = pushedTags.filterIsInstance().toSet(), submodules = submoduleParts.chunked(2).associate { (it[0] as String) to (it[1] as String) }, maintenanceActions = maintenanceActions.filterIsInstance().toSet(), + interactiveAddSession = interactiveAddSessionParts.takeIf { it.size >= 2 }?.let { + InteractiveAddSession( + target = (it[0] as String).ifBlank { null }, + awaitingUpdateSelection = (it[1] as String).toBoolean(), + ) + }, ) } ) diff --git a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt index afb8db8..63397d6 100644 --- a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt +++ b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt @@ -327,11 +327,61 @@ class GitSandboxEngineTest { val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git add -i") assertFalse(updatedRepo.files.single { it.name == "README" }.staged) + assertTrue(updatedRepo.interactiveAddSession != null) assertTrue(output.any { it.contains("What now>") }) assertFalse(output.any { it.contains("What now> update") }) assertFalse(output.any { it.contains("What now> quit") }) } + @Test + fun interactiveAddAcceptsUpdateSelectionFromNextInput() { + val repo = RepoState(initialized = true, files = listOf(GitFile("README"))) + + val (menuRepo, _) = GitSandboxEngine.execute(repo, "git stage -i") + val (updateRepo, updateOutput) = GitSandboxEngine.execute(menuRepo, "2") + val (selectedRepo, selectionOutput) = GitSandboxEngine.execute(updateRepo, "1") + val (quitRepo, quitOutput) = GitSandboxEngine.execute(selectedRepo, "7") + + assertTrue(updateRepo.interactiveAddSession?.awaitingUpdateSelection == true) + assertTrue(updateOutput.any { it.contains("Update>>") }) + assertTrue(selectedRepo.files.single { it.name == "README" }.staged) + assertTrue(selectedRepo.interactiveAddSession?.awaitingUpdateSelection == false) + assertTrue(selectionOutput.any { it.contains("updated 1 path(s)") }) + assertTrue(quitRepo.interactiveAddSession == null) + assertTrue(quitOutput.any { it.contains("Bye.") }) + } + + @Test + fun nativeInteractiveAddSelectionUpdatesGitIndex() { + val git = testGitBinary() + assumeTrue(git.exists() && git.canExecute()) + val root = Files.createTempDirectory("githug-interactive-add").toFile() + try { + val runtime = GitRepositoryRuntime(root, git) + val level = level( + id = "interactive-add-test", + title = "Interactive Add Test", + description = "", + hints = emptyList(), + commandSuggestions = emptyList(), + setup = { RepoState(initialized = true, files = listOf(GitFile("README")), branches = mapOf("master" to 0)) }, + validator = { _, _ -> false }, + ) + val repo = runtime.prepareLevel(level) + val (menuRepo, _) = runtime.execute(level, repo, "git stage -i") + val (updateRepo, _) = runtime.execute(level, menuRepo, "2") + val (selectedRepo, _) = runtime.execute(level, updateRepo, "1") + val (quitRepo, _) = runtime.execute(level, selectedRepo, "7") + val (_, statusOutput) = runtime.execute(level, quitRepo, "git status") + + assertTrue(selectedRepo.files.single { it.name == "README" }.staged) + assertTrue(quitRepo.interactiveAddSession == null) + assertTrue(statusOutput.any { it.contains("new file:") && it.contains("README") }) + } finally { + root.deleteRecursively() + } + } + private fun testGitBinary(): File { System.getenv("GITHUG_TEST_GIT_BINARY") ?.takeIf { it.isNotBlank() }