Support interactive add/stage input flow
This commit is contained in:
@@ -19,8 +19,8 @@ android {
|
|||||||
applicationId = "solutions.tretter.githugandroid"
|
applicationId = "solutions.tretter.githugandroid"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 144
|
versionCode = 145
|
||||||
versionName = "0.1.143"
|
versionName = "0.1.144"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ data class CommitNode(
|
|||||||
val message: String,
|
val message: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class InteractiveAddSession(
|
||||||
|
val target: String? = null,
|
||||||
|
val awaitingUpdateSelection: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
data class RepoState(
|
data class RepoState(
|
||||||
val initialized: Boolean = false,
|
val initialized: Boolean = false,
|
||||||
val files: List<GitFile> = emptyList(),
|
val files: List<GitFile> = emptyList(),
|
||||||
@@ -36,6 +41,7 @@ data class RepoState(
|
|||||||
val pushedTags: Set<String> = emptySet(),
|
val pushedTags: Set<String> = emptySet(),
|
||||||
val submodules: Map<String, String> = emptyMap(),
|
val submodules: Map<String, String> = emptyMap(),
|
||||||
val maintenanceActions: Set<String> = emptySet(),
|
val maintenanceActions: Set<String> = emptySet(),
|
||||||
|
val interactiveAddSession: InteractiveAddSession? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Level(
|
data class Level(
|
||||||
|
|||||||
@@ -441,6 +441,7 @@ fun GitHugApp() {
|
|||||||
val submittedText = commandInput.text
|
val submittedText = commandInput.text
|
||||||
val raw = submittedText.trim()
|
val raw = submittedText.trim()
|
||||||
if (raw.isBlank()) return
|
if (raw.isBlank()) return
|
||||||
|
val isInteractiveInput = repo.interactiveAddSession != null
|
||||||
|
|
||||||
showHelpOverlay = false
|
showHelpOverlay = false
|
||||||
showTerminalInputHint = false
|
showTerminalInputHint = false
|
||||||
@@ -448,7 +449,7 @@ fun GitHugApp() {
|
|||||||
suppressedImeEcho = submittedText
|
suppressedImeEcho = submittedText
|
||||||
clearCommandInput(recreateField = true)
|
clearCommandInput(recreateField = true)
|
||||||
|
|
||||||
if (commandHistory.lastOrNull() != raw) {
|
if (!isInteractiveInput && commandHistory.lastOrNull() != raw) {
|
||||||
commandHistory = commandHistory + raw
|
commandHistory = commandHistory + raw
|
||||||
}
|
}
|
||||||
historyIndex = -1
|
historyIndex = -1
|
||||||
@@ -473,7 +474,7 @@ fun GitHugApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val (newRepo, lines) = runtime.execute(currentLevel, repo, raw)
|
val (newRepo, lines) = runtime.execute(currentLevel, repo, raw)
|
||||||
applyCommandResult(raw, newRepo, lines, echoCommand = true)
|
applyCommandResult(raw, newRepo, lines, echoCommand = !isInteractiveInput)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showCommandHelp() {
|
fun showCommandHelp() {
|
||||||
|
|||||||
@@ -129,6 +129,20 @@ class GitRepositoryRuntime private constructor(
|
|||||||
val shellTokens = GitSandboxEngine.tokenizeShellCommand(command)
|
val shellTokens = GitSandboxEngine.tokenizeShellCommand(command)
|
||||||
val tokens = shellTokens.map { it.value }
|
val tokens = shellTokens.map { it.value }
|
||||||
if (tokens.isEmpty()) return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to emptyList()
|
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") {
|
val expandedTokens = if (tokens.firstOrNull() == "echo") {
|
||||||
tokens
|
tokens
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ object GitSandboxEngine {
|
|||||||
val shellParts = tokenizeShellCommand(command)
|
val shellParts = tokenizeShellCommand(command)
|
||||||
val parts = shellParts.map { it.value }
|
val parts = shellParts.map { it.value }
|
||||||
if (parts.isEmpty()) return repo to emptyList()
|
if (parts.isEmpty()) return repo to emptyList()
|
||||||
|
repo.interactiveAddSession?.let {
|
||||||
|
return handleInteractiveAddInput(repo, command)
|
||||||
|
}
|
||||||
return when {
|
return when {
|
||||||
parts[0] == "help" || parts[0] == "?" -> repo to commandReferenceLines()
|
parts[0] == "help" || parts[0] == "?" -> repo to commandReferenceLines()
|
||||||
parts[0] == "touch" && parts.size >= 2 -> {
|
parts[0] == "touch" && parts.size >= 2 -> {
|
||||||
@@ -174,7 +177,7 @@ object GitSandboxEngine {
|
|||||||
val candidates = repo.files.filter { file ->
|
val candidates = repo.files.filter { file ->
|
||||||
!file.deleted && (target == null || target == "." || file.name == target || file.name.startsWith(target.trimEnd('/') + "/"))
|
!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<GitFile>): List<String> {
|
private fun interactiveAddConsoleLines(candidates: List<GitFile>): List<String> {
|
||||||
@@ -198,6 +201,69 @@ object GitSandboxEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleInteractiveAddInput(repo: RepoState, input: String): Pair<RepoState, List<String>> {
|
||||||
|
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<RepoState, List<String>> {
|
||||||
|
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<GitFile> {
|
||||||
|
return repo.files.filter { file ->
|
||||||
|
!file.deleted && (target == null || target == "." || file.name == target || file.name.startsWith(target.trimEnd('/') + "/"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectedInteractiveAddNames(candidates: List<GitFile>, answer: String): Set<String> {
|
||||||
|
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<String> {
|
fun tokenizeCommand(command: String): List<String> {
|
||||||
return tokenizeShellCommand(command).map { it.value }
|
return tokenizeShellCommand(command).map { it.value }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ val RepoStateSaver = listSaver<RepoState, Any>(
|
|||||||
state.submodules.flatMap { listOf(it.key, it.value) },
|
state.submodules.flatMap { listOf(it.key, it.value) },
|
||||||
state.maintenanceActions.toList(),
|
state.maintenanceActions.toList(),
|
||||||
state.fetchHeadCount,
|
state.fetchHeadCount,
|
||||||
|
state.interactiveAddSession?.let { listOf(it.target.orEmpty(), it.awaitingUpdateSelection.toString()) }.orEmpty(),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
restore = { saved ->
|
restore = { saved ->
|
||||||
@@ -39,6 +40,7 @@ val RepoStateSaver = listSaver<RepoState, Any>(
|
|||||||
val submoduleParts = saved.getOrNull(13) as? List<*> ?: emptyList<Any>()
|
val submoduleParts = saved.getOrNull(13) as? List<*> ?: emptyList<Any>()
|
||||||
val maintenanceActions = saved.getOrNull(14) as? List<*> ?: emptyList<Any>()
|
val maintenanceActions = saved.getOrNull(14) as? List<*> ?: emptyList<Any>()
|
||||||
val fetchHeadCount = saved.getOrNull(15) as? Int ?: 0
|
val fetchHeadCount = saved.getOrNull(15) as? Int ?: 0
|
||||||
|
val interactiveAddSessionParts = saved.getOrNull(16) as? List<*> ?: emptyList<Any>()
|
||||||
RepoState(
|
RepoState(
|
||||||
initialized = initialized,
|
initialized = initialized,
|
||||||
headBranch = headBranch,
|
headBranch = headBranch,
|
||||||
@@ -66,6 +68,12 @@ val RepoStateSaver = listSaver<RepoState, Any>(
|
|||||||
pushedTags = pushedTags.filterIsInstance<String>().toSet(),
|
pushedTags = pushedTags.filterIsInstance<String>().toSet(),
|
||||||
submodules = submoduleParts.chunked(2).associate { (it[0] as String) to (it[1] as String) },
|
submodules = submoduleParts.chunked(2).associate { (it[0] as String) to (it[1] as String) },
|
||||||
maintenanceActions = maintenanceActions.filterIsInstance<String>().toSet(),
|
maintenanceActions = maintenanceActions.filterIsInstance<String>().toSet(),
|
||||||
|
interactiveAddSession = interactiveAddSessionParts.takeIf { it.size >= 2 }?.let {
|
||||||
|
InteractiveAddSession(
|
||||||
|
target = (it[0] as String).ifBlank { null },
|
||||||
|
awaitingUpdateSelection = (it[1] as String).toBoolean(),
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -327,11 +327,61 @@ class GitSandboxEngineTest {
|
|||||||
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git add -i")
|
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git add -i")
|
||||||
|
|
||||||
assertFalse(updatedRepo.files.single { it.name == "README" }.staged)
|
assertFalse(updatedRepo.files.single { it.name == "README" }.staged)
|
||||||
|
assertTrue(updatedRepo.interactiveAddSession != null)
|
||||||
assertTrue(output.any { it.contains("What now>") })
|
assertTrue(output.any { it.contains("What now>") })
|
||||||
assertFalse(output.any { it.contains("What now> update") })
|
assertFalse(output.any { it.contains("What now> update") })
|
||||||
assertFalse(output.any { it.contains("What now> quit") })
|
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 {
|
private fun testGitBinary(): File {
|
||||||
System.getenv("GITHUG_TEST_GIT_BINARY")
|
System.getenv("GITHUG_TEST_GIT_BINARY")
|
||||||
?.takeIf { it.isNotBlank() }
|
?.takeIf { it.isNotBlank() }
|
||||||
|
|||||||
Reference in New Issue
Block a user