Handle interactive add menu commands

This commit is contained in:
Joe Tretter
2026-05-18 20:39:06 -05:00
parent 3c437c980a
commit eb24866a7a
5 changed files with 150 additions and 17 deletions

View File

@@ -19,8 +19,8 @@ android {
applicationId = "solutions.tretter.githugandroid"
minSdk = 26
targetSdk = 35
versionCode = 154
versionName = "0.1.153"
versionCode = 155
versionName = "0.1.154"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true

View File

@@ -23,6 +23,8 @@ data class CommitNode(
data class InteractiveAddSession(
val target: String? = null,
val awaitingUpdateSelection: Boolean = false,
val selectionPrompt: String = "Update>>",
val selectionAction: String = "update",
)
data class RepoState(

View File

@@ -209,9 +209,11 @@ object GitSandboxEngine {
} 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>>")
"2", "u", "update" -> interactiveAddSelectionPrompt(repo, session, answer, "Update>>", "update")
"3", "r", "revert" -> interactiveAddSelectionPrompt(repo, session, answer, "Revert>>", "revert")
"4", "a", "add untracked", "add-untracked" -> interactiveAddSelectionPrompt(repo, session, answer, "Add untracked>>", "add-untracked")
"5", "p", "patch" -> interactiveAddSelectionPrompt(repo, session, answer, "Patch update>>", "patch")
"6", "d", "diff" -> interactiveAddSelectionPrompt(repo, session, answer, "Diff>>", "diff")
"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))
@@ -219,6 +221,22 @@ object GitSandboxEngine {
}
}
private fun interactiveAddSelectionPrompt(
repo: RepoState,
session: InteractiveAddSession,
answer: String,
prompt: String,
action: String,
): Pair<RepoState, List<String>> {
return repo.copy(
interactiveAddSession = session.copy(
awaitingUpdateSelection = true,
selectionPrompt = prompt,
selectionAction = action,
),
) to listOf("What now> $answer", prompt)
}
private fun applyInteractiveAddUpdateSelection(
repo: RepoState,
session: InteractiveAddSession,
@@ -226,27 +244,64 @@ object GitSandboxEngine {
): Pair<RepoState, List<String>> {
val candidates = interactiveAddCandidates(repo, session.target)
val selectedNames = selectedInteractiveAddNames(candidates, answer)
val prompt = session.selectionPrompt
if (selectedNames.isEmpty()) {
return repo to listOf("Update>> $answer", "No files selected.", "Update>>")
return repo to listOf("$prompt $answer", "No files selected.", prompt)
}
val updatedFiles = repo.files.map { file ->
if (file.name in selectedNames && !file.deleted) file.copy(staged = true) else file
}
val updatedFiles = applyInteractiveAddSelectionAction(repo, selectedNames, session.selectionAction)
val updatedRepo = repo.copy(
files = updatedFiles,
interactiveAddSession = session.copy(awaitingUpdateSelection = false),
interactiveAddSession = session.copy(
awaitingUpdateSelection = false,
selectionPrompt = "Update>>",
selectionAction = "update",
),
)
val stagedCount = updatedFiles.count { updatedFile ->
val before = repo.files.firstOrNull { it.name == updatedFile.name }
updatedFile.staged && before?.staged != true
}
val summary = interactiveAddSelectionSummary(repo, updatedFiles, selectedNames, session.selectionAction)
return updatedRepo to listOf(
"Update>> $answer",
"updated $stagedCount path(s)",
"$prompt $answer",
summary,
) + interactiveAddConsoleLines(interactiveAddCandidates(updatedRepo, session.target))
}
private fun applyInteractiveAddSelectionAction(repo: RepoState, selectedNames: Set<String>, action: String): List<GitFile> {
return when (action) {
"revert" -> repo.files.mapNotNull { file ->
if (file.name !in selectedNames || file.deleted) {
file
} else if (file.tracked) {
file.copy(content = "", staged = false, deleted = false)
} else {
null
}
}
"diff" -> repo.files
else -> repo.files.map { file ->
if (file.name in selectedNames && !file.deleted) file.copy(staged = true) else file
}
}
}
private fun interactiveAddSelectionSummary(
repo: RepoState,
updatedFiles: List<GitFile>,
selectedNames: Set<String>,
action: String,
): String {
return when (action) {
"revert" -> "reverted ${selectedNames.size} path(s)"
"diff" -> selectedNames.joinToString("\n") { "diff -- $it" }
else -> {
val stagedCount = updatedFiles.count { updatedFile ->
val before = repo.files.firstOrNull { it.name == updatedFile.name }
updatedFile.staged && before?.staged != true
}
"updated $stagedCount path(s)"
}
}
}
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('/') + "/"))

View File

@@ -21,7 +21,14 @@ val RepoStateSaver = listSaver<RepoState, Any>(
state.submodules.flatMap { listOf(it.key, it.value) },
state.maintenanceActions.toList(),
state.fetchHeadCount,
state.interactiveAddSession?.let { listOf(it.target.orEmpty(), it.awaitingUpdateSelection.toString()) }.orEmpty(),
state.interactiveAddSession?.let {
listOf(
it.target.orEmpty(),
it.awaitingUpdateSelection.toString(),
it.selectionPrompt,
it.selectionAction,
)
}.orEmpty(),
)
},
restore = { saved ->
@@ -70,6 +77,8 @@ val RepoStateSaver = listSaver<RepoState, Any>(
InteractiveAddSession(
target = (it[0] as String).ifBlank { null },
awaitingUpdateSelection = (it[1] as String).toBoolean(),
selectionPrompt = it.getOrNull(2) as? String ?: "Update>>",
selectionAction = it.getOrNull(3) as? String ?: "update",
)
},
)

View File

@@ -369,6 +369,73 @@ class GitSandboxEngineTest {
assertTrue(quitOutput.any { it.contains("Bye.") })
}
@Test
fun interactiveAddHandlesEveryDisplayedMenuCommand() {
val menuCommands = listOf(
"1" to "What now> 1",
"2" to "Update>>",
"3" to "Revert>>",
"4" to "Add untracked>>",
"5" to "Patch update>>",
"6" to "Diff>>",
"7" to "Bye.",
"8" to "What now> 8",
)
menuCommands.forEach { (command, expectedOutput) ->
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
val (updatedRepo, output) = GitSandboxEngine.execute(menuRepo, command)
assertFalse("$command should not be rejected", output.any { it.contains("Huh ($command)?") })
assertTrue("$command should produce $expectedOutput", output.any { it.contains(expectedOutput) })
if (command in listOf("2", "3", "4", "5", "6")) {
assertTrue(updatedRepo.interactiveAddSession?.awaitingUpdateSelection == true)
}
}
}
@Test
fun interactiveAddHandlesMenuCommandAliases() {
val aliases = listOf(
"status" to "What now> status",
"update" to "Update>>",
"revert" to "Revert>>",
"add untracked" to "Add untracked>>",
"patch" to "Patch update>>",
"diff" to "Diff>>",
"quit" to "Bye.",
"help" to "What now> help",
)
aliases.forEach { (command, expectedOutput) ->
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
val (updatedRepo, output) = GitSandboxEngine.execute(menuRepo, command)
assertFalse("$command should not be rejected", output.any { it.contains("Huh ($command)?") })
assertTrue("$command should produce $expectedOutput", output.any { it.contains(expectedOutput) })
if (command in listOf("update", "revert", "add untracked", "patch", "diff")) {
assertTrue(updatedRepo.interactiveAddSession?.awaitingUpdateSelection == true)
}
}
}
@Test
fun interactiveAddPatchSelectionStagesSelectedPath() {
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
val (patchRepo, patchOutput) = GitSandboxEngine.execute(menuRepo, "patch")
val (selectedRepo, selectionOutput) = GitSandboxEngine.execute(patchRepo, "1")
assertTrue(patchRepo.interactiveAddSession?.awaitingUpdateSelection == true)
assertTrue(patchOutput.any { it.contains("Patch update>>") })
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
assertTrue(selectionOutput.any { it.contains("Patch update>> 1") })
assertTrue(selectionOutput.any { it.contains("updated 1 path(s)") })
}
@Test
fun nativeInteractiveAddSelectionUpdatesGitIndex() {
val git = testGitBinary()