package solutions.tretter.githugandroid internal object InteractiveAddEngine { private const val PatchHunkPrompt = "(1/1) Stage this hunk [y,n,q,a,d,s,e,p,P,?]?" fun start(repo: RepoState, arguments: List): Pair> { val targets = arguments.filterNot { it == "-i" || it == "--interactive" || it.startsWith("--") } val target = targets.lastOrNull() val candidates = interactiveAddCandidates(repo, target) return repo.copy(interactiveAddSession = InteractiveAddSession(target = target)) to interactiveAddConsoleLines(candidates) } fun startPatch(repo: RepoState, arguments: List): Pair> { val targets = arguments.filterNot { it == "-p" || it == "--patch" || it.startsWith("--") } val target = targets.lastOrNull() val patchFile = interactiveAddCandidates(repo, target).firstOrNull() ?: return repo to listOf("No changes.") return startPatchHunkSession(repo, patchFile.name) } fun handleInput(repo: RepoState, input: String): Pair> { val session = repo.interactiveAddSession ?: return repo to emptyList() val answer = input.trim() if (session.selectionAction == "patch-hunk") { return handlePatchHunkInput(repo, session, answer) } 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" -> 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)) } } } fun parsePatchHunkEditorInvocation(repo: RepoState, command: String): GitEditorInvocation? { val session = repo.interactiveAddSession ?: return null if (session.selectionAction != "patch-hunk") return null if (command.trim().lowercase() != "e") return null val target = session.target ?: return null val file = repo.files.firstOrNull { it.name == target && !it.deleted } ?: return null return GitEditorInvocation( command = command, kind = GitEditorCommandKind.PATCH_HUNK, title = "Edit Patch Hunk", displayPath = target, initialContent = editablePatchHunkContent(file), ) } fun applyPatchHunkEdit(repo: RepoState, content: String): Pair> { val session = repo.interactiveAddSession ?: return repo to listOf("No patch hunk is active.") val target = session.target ?: return repo.copy(interactiveAddSession = null) to listOf("No patch hunk is active.") if (session.selectionAction != "patch-hunk") return repo to listOf("No patch hunk is active.") if (content.isBlank()) return repo to listOf("Edited hunk was empty; patch was not applied.", PatchHunkPrompt) val updatedFiles = repo.files.map { file -> if (file.name == target && !file.deleted) file.copy(staged = true) else file } return repo.copy(files = updatedFiles, interactiveAddSession = null) to listOf( "$PatchHunkPrompt e", "Applied edited hunk.", ) } private fun interactiveAddSelectionPrompt( repo: RepoState, session: InteractiveAddSession, answer: String, prompt: String, action: String, ): Pair> { 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, answer: String, ): Pair> { val candidates = interactiveAddCandidates(repo, session.target) val selectedNames = selectedInteractiveAddNames(candidates, answer) val prompt = session.selectionPrompt if (selectedNames.isEmpty()) { return repo to listOf("$prompt $answer", "No files selected.", prompt) } if (session.selectionAction == "patch" && selectedNames.size == 1) { return startPatchHunkSession(repo, selectedNames.single(), "$prompt $answer") } val updatedFiles = applyInteractiveAddSelectionAction(repo, selectedNames, session.selectionAction) val updatedRepo = repo.copy( files = updatedFiles, interactiveAddSession = session.copy( awaitingUpdateSelection = false, selectionPrompt = "Update>>", selectionAction = "update", ), ) val summary = interactiveAddSelectionSummary(repo, updatedFiles, selectedNames, session.selectionAction) return updatedRepo to listOf( "$prompt $answer", summary, ) + interactiveAddConsoleLines(interactiveAddCandidates(updatedRepo, session.target)) } private fun applyInteractiveAddSelectionAction(repo: RepoState, selectedNames: Set, action: String): List { 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, selectedNames: Set, 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 startPatchHunkSession(repo: RepoState, target: String, prefixLine: String? = null): Pair> { val file = repo.files.firstOrNull { it.name == target && !it.deleted } ?: return repo to listOfNotNull(prefixLine, "No changes.") val session = InteractiveAddSession( target = target, awaitingUpdateSelection = false, selectionPrompt = PatchHunkPrompt, selectionAction = "patch-hunk", ) val output = listOfNotNull(prefixLine) + patchHunkLines(file) return repo.copy(interactiveAddSession = session) to output } private fun handlePatchHunkInput(repo: RepoState, session: InteractiveAddSession, answer: String): Pair> { val target = session.target ?: return repo.copy(interactiveAddSession = null) to listOf("No changes.") return when (answer.lowercase()) { "y", "a" -> { val updatedFiles = repo.files.map { file -> if (file.name == target && !file.deleted) file.copy(staged = true) else file } repo.copy(files = updatedFiles, interactiveAddSession = null) to listOf("$PatchHunkPrompt $answer") } "n", "d" -> repo.copy(interactiveAddSession = null) to listOf("$PatchHunkPrompt $answer") "q" -> repo.copy(interactiveAddSession = null) to listOf("$PatchHunkPrompt $answer", "Quit") "?" -> repo to listOf( "$PatchHunkPrompt $answer", "y - stage this hunk", "n - do not stage this hunk", "q - quit; do not stage this hunk or any remaining ones", "a - stage this hunk and all later hunks in the file", "d - do not stage this hunk or any later hunks in the file", "s - split the current hunk into smaller hunks", "e - manually edit the current hunk", "p - print the current hunk", "? - print help", PatchHunkPrompt, ) "p" -> { val file = repo.files.firstOrNull { it.name == target && !it.deleted } if (file == null) { repo.copy(interactiveAddSession = null) to listOf("No changes.") } else { repo to listOf("$PatchHunkPrompt $answer") + patchHunkLines(file) } } "s" -> repo to listOf("$PatchHunkPrompt $answer", "Sorry, cannot split this hunk", PatchHunkPrompt) "e" -> repo to listOf("$PatchHunkPrompt $answer", "Opening patch editor") else -> repo to listOf("$PatchHunkPrompt $answer", "Unknown command '$answer'.", PatchHunkPrompt) } } private fun patchHunkLines(file: GitFile): List { return patchDiffHeaderLines(file) + patchHunkBodyLines(file) + PatchHunkPrompt } private fun editablePatchHunkContent(file: GitFile): String { return buildList { add("# Manual hunk edit mode -- see bottom for a quick guide.") addAll(patchHunkBodyLines(file)) add("# ---") add("# To remove '-' lines, make them ' ' lines (context).") add("# To remove '+' lines, delete them.") add("# Lines starting with # will be removed.") add("# If the patch applies cleanly, the edited hunk will immediately be marked for staging.") add("# If it does not apply cleanly, you will be given an opportunity to") add("# edit again. If all lines of the hunk are removed, then the edit is") add("# aborted and the hunk is left unchanged.") }.joinToString("\n") } private fun patchDiffHeaderLines(file: GitFile): List { return listOf( "diff --git a/${file.name} b/${file.name}", "index 0000000..0000001 100644", "--- a/${file.name}", "+++ b/${file.name}", ) } private fun patchHunkBodyLines(file: GitFile): List { val lines = file.content.lines() val nonEmptyLines = lines.dropLastWhile { it.isEmpty() } val addedCount = nonEmptyLines.size.coerceAtLeast(1) return buildList { add("@@ -1 +1,$addedCount @@") if (nonEmptyLines.isEmpty()) { add("+") } else { nonEmptyLines.forEach { line -> add("+$line") } } } } private fun interactiveAddConsoleLines(candidates: List): List { return buildList { add(" staged unstaged path") candidates.forEachIndexed { index, file -> val staged = if (file.staged) "unchanged" else "+0/-0" val unstaged = when { file.tracked -> "+1/-0" else -> "+0/-0" } add("${index + 1}: ${staged.padEnd(10)} ${unstaged.padEnd(9)} ${file.name}") } if (candidates.isEmpty()) { add("No changes.") } add("*** Commands ***") add(" 1: status 2: update 3: revert 4: add untracked") add(" 5: patch 6: diff 7: quit 8: help") add("What now>") } } 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() } }