Add app-native interactive Git dialogs

- Open an interactive staging dialog for `git add -i`, `git stage -i`, and
  `git add --interactive`, allowing paths to be selected before staging.
- Open an interactive rebase dialog for `git rebase -i` / `--interactive`, with
  pick, squash, drop, commit-message editing, and reorder controls.
- Keep the existing synthetic runtime behavior available for direct engine/test calls,
  while making app-entered interactive commands show real UI instead of auto-staging.
- Center manpage search navigation using actual text-layout highlight bounds instead
  of approximate line and column math.
- Make editor dialogs use a full-screen IME-padded outer scroll container so controls
  remain reachable when the keyboard pans or resizes the dialog.
This commit is contained in:
Joe Tretter
2026-05-09 15:41:55 -05:00
parent 0acf8c9ab0
commit f49e8a96b1
9 changed files with 708 additions and 120 deletions

View File

@@ -70,6 +70,8 @@ fun GitHugApp() {
var hasRestoredProgress by remember { mutableStateOf(false) }
var editorState by remember { mutableStateOf<TextEditorState?>(null) }
var gitMessageEditorState by remember { mutableStateOf<GitMessageEditorState?>(null) }
var interactiveAddState by remember { mutableStateOf<InteractiveAddState?>(null) }
var interactiveRebaseState by remember { mutableStateOf<InteractiveRebaseState?>(null) }
var manPageState by remember { mutableStateOf<ManPageState?>(null) }
var solvedCelebrationTitle by remember { mutableStateOf<String?>(null) }
var showTerminalInputHint by remember { mutableStateOf(true) }
@@ -209,6 +211,8 @@ fun GitHugApp() {
historyDraft = ""
editorState = null
gitMessageEditorState = null
interactiveAddState = null
interactiveRebaseState = null
manPageState = null
applyRecommendedPaneWeights(persist = false)
}
@@ -227,6 +231,8 @@ fun GitHugApp() {
historyDraft = ""
editorState = null
gitMessageEditorState = null
interactiveAddState = null
interactiveRebaseState = null
manPageState = null
paneLayout = paneLayout.copy(
weights = paneLayout.weights + recommendedPaneWeights(
@@ -418,6 +424,59 @@ fun GitHugApp() {
applyCommandResult(state.invocation.command, newRepo, lines, echoCommand = false)
}
fun openInteractiveAdd(invocation: InteractiveAddInvocation) {
output = buildList {
addAll(output)
add("$ ${invocation.command}")
add("Opened interactive add")
}
interactiveAddState = InteractiveAddState(
invocation = invocation,
files = repo.files,
)
applyRecommendedPaneWeights(persist = false)
}
fun stageInteractiveSelection(selectedFiles: Set<String>) {
val state = interactiveAddState ?: return
val updatedRepo = repo.copy(
files = repo.files.map { file ->
if (file.name in selectedFiles) file.copy(staged = true) else file
},
)
interactiveAddState = null
val lines = if (selectedFiles.isEmpty()) {
listOf("No changes staged")
} else {
listOf("staged ${selectedFiles.size} path(s)") + selectedFiles.sorted().map { " $it" }
}
applyCommandResult(state.invocation.command, updatedRepo, lines, echoCommand = false)
}
fun openInteractiveRebase(invocation: InteractiveRebaseInvocation) {
val count = invocation.commitCount ?: repo.commits.size
val commits = repo.commits.takeLast(count.coerceAtLeast(0))
output = buildList {
addAll(output)
add("$ ${invocation.command}")
add("Opened interactive rebase")
}
interactiveRebaseState = InteractiveRebaseState(invocation = invocation, commits = commits)
applyRecommendedPaneWeights(persist = false)
}
fun applyInteractiveRebaseSelection(drafts: List<RebaseCommitDraft>) {
val state = interactiveRebaseState ?: return
interactiveRebaseState = null
val newRepo = applyInteractiveRebase(repo, drafts)
applyCommandResult(
raw = state.invocation.command,
newRepo = newRepo,
lines = listOf("Successfully rebased and updated ${repo.headBranch}."),
echoCommand = false,
)
}
fun runCommand() {
val submittedText = commandInput.text
val raw = submittedText.trim()
@@ -453,6 +512,18 @@ fun GitHugApp() {
return
}
val interactiveAddInvocation = parseInteractiveAddInvocation(raw)
if (interactiveAddInvocation != null) {
openInteractiveAdd(interactiveAddInvocation)
return
}
val interactiveRebaseInvocation = parseInteractiveRebaseInvocation(raw)
if (interactiveRebaseInvocation != null) {
openInteractiveRebase(interactiveRebaseInvocation)
return
}
val (newRepo, lines) = runtime.execute(currentLevel, repo, raw)
applyCommandResult(raw, newRepo, lines, echoCommand = true)
}
@@ -615,6 +686,28 @@ fun GitHugApp() {
)
}
interactiveAddState?.let { state ->
InteractiveAddDialog(
state = state,
onClose = {
output = output + "Interactive add closed without staging"
interactiveAddState = null
},
onStage = { selectedFiles -> stageInteractiveSelection(selectedFiles) },
)
}
interactiveRebaseState?.let { state ->
InteractiveRebaseDialog(
state = state,
onClose = {
output = output + "Interactive rebase closed without applying"
interactiveRebaseState = null
},
onApply = { drafts -> applyInteractiveRebaseSelection(drafts) },
)
}
manPageState?.let { state ->
ManPageDialog(
state = state,