From f49e8a96b167c8549bcda9b55a9e2e7c6be19420 Mon Sep 17 00:00:00 2001 From: Joe Tretter Date: Sat, 9 May 2026 15:41:55 -0500 Subject: [PATCH] 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. --- app/build.gradle.kts | 4 +- .../tretter/githugandroid/GitHugApp.kt | 93 +++++++ .../githugandroid/GitMessageEditorDialog.kt | 117 +++++---- .../githugandroid/InteractiveAddCommands.kt | 21 ++ .../githugandroid/InteractiveAddDialog.kt | 181 +++++++++++++ .../InteractiveRebaseCommands.kt | 25 ++ .../githugandroid/InteractiveRebaseDialog.kt | 238 ++++++++++++++++++ .../tretter/githugandroid/ManPageDialog.kt | 18 +- .../tretter/githugandroid/TextEditorDialog.kt | 131 +++++----- 9 files changed, 708 insertions(+), 120 deletions(-) create mode 100644 app/src/main/java/solutions/tretter/githugandroid/InteractiveAddCommands.kt create mode 100644 app/src/main/java/solutions/tretter/githugandroid/InteractiveAddDialog.kt create mode 100644 app/src/main/java/solutions/tretter/githugandroid/InteractiveRebaseCommands.kt create mode 100644 app/src/main/java/solutions/tretter/githugandroid/InteractiveRebaseDialog.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e2a1d00..145032c 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 = 131 - versionName = "0.1.130" + versionCode = 132 + versionName = "0.1.131" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt index 0db9f21..44d96ed 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt @@ -70,6 +70,8 @@ fun GitHugApp() { var hasRestoredProgress by remember { mutableStateOf(false) } var editorState by remember { mutableStateOf(null) } var gitMessageEditorState by remember { mutableStateOf(null) } + var interactiveAddState by remember { mutableStateOf(null) } + var interactiveRebaseState by remember { mutableStateOf(null) } var manPageState by remember { mutableStateOf(null) } var solvedCelebrationTitle by remember { mutableStateOf(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) { + 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) { + 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, diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitMessageEditorDialog.kt b/app/src/main/java/solutions/tretter/githugandroid/GitMessageEditorDialog.kt index 705b397..06fdd7d 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitMessageEditorDialog.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitMessageEditorDialog.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn @@ -31,6 +32,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties data class GitMessageEditorState( val invocation: GitEditorInvocation, @@ -46,64 +48,75 @@ fun GitMessageEditorDialog( ) { val horizontalScroll = rememberScrollState() val verticalScroll = rememberScrollState() + val dialogScroll = rememberScrollState() - Dialog(onDismissRequest = onClose) { - Surface( + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box( modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(0.72f) - .imePadding(), - color = PanelPrimary, - shape = RoundedCornerShape(8.dp), + .fillMaxSize() + .imePadding() + .verticalScroll(dialogScroll) + .padding(12.dp), ) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + Surface( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 320.dp, max = 620.dp), + color = PanelPrimary, + shape = RoundedCornerShape(8.dp), ) { - Text( - text = state.invocation.title, - color = TextPrimary, - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { - EditorButton(label = "Close", onClick = onClose) - EditorButton(label = "Save", enabled = state.content.isNotBlank(), onClick = onSave) - } - Text( - text = state.invocation.command, - color = TextSecondary, - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - ) - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .heightIn(min = 220.dp) - .background(TerminalBackground, RoundedCornerShape(6.dp)) - .padding(10.dp) - .horizontalScroll(horizontalScroll) - .verticalScroll(verticalScroll), - ) { - BasicTextField( - value = state.content, - onValueChange = onContentChange, - modifier = Modifier.sizeIn(minWidth = 1000.dp, minHeight = 700.dp), - textStyle = TextStyle( - color = TextPrimary, - fontFamily = FontFamily.Monospace, - fontSize = 14.sp, - ), - cursorBrush = SolidColor(Accent), - keyboardOptions = KeyboardOptions( - autoCorrect = false, - keyboardType = KeyboardType.Ascii, - ), + Text( + text = state.invocation.title, + color = TextPrimary, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + EditorButton(label = "Close", onClick = onClose) + EditorButton(label = "Save", enabled = state.content.isNotBlank(), onClick = onSave) + } + Text( + text = state.invocation.command, + color = TextSecondary, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + ) + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .heightIn(min = 220.dp) + .background(TerminalBackground, RoundedCornerShape(6.dp)) + .padding(10.dp) + .horizontalScroll(horizontalScroll) + .verticalScroll(verticalScroll), + ) { + BasicTextField( + value = state.content, + onValueChange = onContentChange, + modifier = Modifier.sizeIn(minWidth = 1000.dp, minHeight = 700.dp), + textStyle = TextStyle( + color = TextPrimary, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + ), + cursorBrush = SolidColor(Accent), + keyboardOptions = KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Ascii, + ), + ) + } } } } diff --git a/app/src/main/java/solutions/tretter/githugandroid/InteractiveAddCommands.kt b/app/src/main/java/solutions/tretter/githugandroid/InteractiveAddCommands.kt new file mode 100644 index 0000000..50c6ffe --- /dev/null +++ b/app/src/main/java/solutions/tretter/githugandroid/InteractiveAddCommands.kt @@ -0,0 +1,21 @@ +package solutions.tretter.githugandroid + +data class InteractiveAddInvocation( + val command: String, + val pathspec: String?, +) + +fun parseInteractiveAddInvocation(command: String): InteractiveAddInvocation? { + val tokens = GitSandboxEngine.tokenizeCommand(command) + if (tokens.size < 3 || tokens.firstOrNull() != "git") return null + + val subcommand = tokens.getOrNull(1) + if (subcommand != "add" && subcommand != "stage") return null + if (tokens.drop(2).none { it == "-i" || it == "--interactive" }) return null + + val pathspec = tokens + .drop(2) + .firstOrNull { token -> token != "-i" && token != "--interactive" && !token.startsWith("-") } + + return InteractiveAddInvocation(command = command, pathspec = pathspec) +} diff --git a/app/src/main/java/solutions/tretter/githugandroid/InteractiveAddDialog.kt b/app/src/main/java/solutions/tretter/githugandroid/InteractiveAddDialog.kt new file mode 100644 index 0000000..078ccdb --- /dev/null +++ b/app/src/main/java/solutions/tretter/githugandroid/InteractiveAddDialog.kt @@ -0,0 +1,181 @@ +package solutions.tretter.githugandroid + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog + +data class InteractiveAddState( + val invocation: InteractiveAddInvocation, + val files: List, +) + +@Composable +fun InteractiveAddDialog( + state: InteractiveAddState, + onClose: () -> Unit, + onStage: (Set) -> Unit, +) { + val candidates = remember(state.files, state.invocation.pathspec) { + state.files + .filter { file -> !file.staged } + .filter { file -> + val pathspec = state.invocation.pathspec + pathspec == null || pathspec == "." || file.name == pathspec || file.name.startsWith(pathspec.trimEnd('/') + "/") + } + } + var selectedFiles by remember(candidates) { + mutableStateOf(candidates.map { it.name }.toSet()) + } + val scrollState = rememberScrollState() + + Dialog(onDismissRequest = onClose) { + Surface( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.76f) + .imePadding(), + color = PanelPrimary, + shape = RoundedCornerShape(8.dp), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "Interactive Add", + color = TextPrimary, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + ) + Text( + text = state.invocation.command, + color = TextSecondary, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + DialogButton(label = "Close", onClick = onClose) + DialogButton( + label = "Stage", + enabled = selectedFiles.isNotEmpty(), + onClick = { onStage(selectedFiles) }, + ) + DialogButton( + label = "All", + enabled = candidates.isNotEmpty(), + onClick = { selectedFiles = candidates.map { it.name }.toSet() }, + ) + DialogButton( + label = "None", + enabled = selectedFiles.isNotEmpty(), + onClick = { selectedFiles = emptySet() }, + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .background(TerminalBackground, RoundedCornerShape(6.dp)) + .verticalScroll(scrollState) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (candidates.isEmpty()) { + Text( + text = "No changes available to stage.", + color = TextSecondary, + fontFamily = FontFamily.Monospace, + ) + } else { + candidates.forEach { file -> + val checked = file.name in selectedFiles + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = checked, + onCheckedChange = { isChecked -> + selectedFiles = if (isChecked) { + selectedFiles + file.name + } else { + selectedFiles - file.name + } + }, + colors = CheckboxDefaults.colors( + checkedColor = Accent, + uncheckedColor = TextMuted, + checkmarkColor = AppBackground, + ), + ) + Text( + text = "${file.statusLabel()} ${file.name}", + color = TextPrimary, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + ) + } + } + } + } + } + } + } +} + +private fun GitFile.statusLabel(): String { + return when { + deleted -> "deleted: " + tracked -> "modified: " + else -> "new file: " + } +} + +@Composable +private fun DialogButton( + label: String, + enabled: Boolean = true, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + enabled = enabled, + colors = ButtonDefaults.buttonColors( + containerColor = Accent, + contentColor = AppBackground, + disabledContainerColor = PanelTertiary, + disabledContentColor = TextMuted, + ), + ) { + Text(label) + } +} diff --git a/app/src/main/java/solutions/tretter/githugandroid/InteractiveRebaseCommands.kt b/app/src/main/java/solutions/tretter/githugandroid/InteractiveRebaseCommands.kt new file mode 100644 index 0000000..4fa27f5 --- /dev/null +++ b/app/src/main/java/solutions/tretter/githugandroid/InteractiveRebaseCommands.kt @@ -0,0 +1,25 @@ +package solutions.tretter.githugandroid + +data class InteractiveRebaseInvocation( + val command: String, + val commitCount: Int?, +) + +fun parseInteractiveRebaseInvocation(command: String): InteractiveRebaseInvocation? { + val tokens = GitSandboxEngine.tokenizeCommand(command) + if (tokens.size < 3 || tokens.firstOrNull() != "git") return null + if (tokens.getOrNull(1) != "rebase") return null + if (tokens.drop(2).none { it == "-i" || it == "--interactive" }) return null + + val commitCount = tokens + .drop(2) + .firstNotNullOfOrNull { token -> + Regex("""HEAD[~^](\d+)""") + .matchEntire(token) + ?.groupValues + ?.getOrNull(1) + ?.toIntOrNull() + } + + return InteractiveRebaseInvocation(command = command, commitCount = commitCount) +} diff --git a/app/src/main/java/solutions/tretter/githugandroid/InteractiveRebaseDialog.kt b/app/src/main/java/solutions/tretter/githugandroid/InteractiveRebaseDialog.kt new file mode 100644 index 0000000..5a9fde9 --- /dev/null +++ b/app/src/main/java/solutions/tretter/githugandroid/InteractiveRebaseDialog.kt @@ -0,0 +1,238 @@ +package solutions.tretter.githugandroid + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog + +data class InteractiveRebaseState( + val invocation: InteractiveRebaseInvocation, + val commits: List, +) + +enum class RebaseAction(val label: String) { + PICK("pick"), + SQUASH("squash"), + DROP("drop"), +} + +data class RebaseCommitDraft( + val commit: CommitNode, + val message: String, + val action: RebaseAction, +) + +@Composable +fun InteractiveRebaseDialog( + state: InteractiveRebaseState, + onClose: () -> Unit, + onApply: (List) -> Unit, +) { + val scrollState = rememberScrollState() + var drafts by remember(state.commits) { + mutableStateOf(state.commits.map { commit -> + RebaseCommitDraft( + commit = commit, + message = if (commit.message == "First coommit") "First commit" else commit.message, + action = if (commit.message.contains("squash this commit", ignoreCase = true)) { + RebaseAction.SQUASH + } else { + RebaseAction.PICK + }, + ) + }.withSuggestedOrder()) + } + + Dialog(onDismissRequest = onClose) { + Surface( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.84f) + .imePadding(), + color = PanelPrimary, + shape = RoundedCornerShape(8.dp), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "Interactive Rebase", + color = TextPrimary, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + ) + Text( + text = state.invocation.command, + color = TextSecondary, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + RebaseButton(label = "Close", onClick = onClose) + RebaseButton(label = "Apply", onClick = { onApply(drafts) }) + } + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .background(TerminalBackground, RoundedCornerShape(6.dp)) + .verticalScroll(scrollState) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (drafts.isEmpty()) { + Text( + text = "No commits available for interactive rebase.", + color = TextSecondary, + fontFamily = FontFamily.Monospace, + ) + } + drafts.forEachIndexed { index, draft -> + Column( + modifier = Modifier + .fillMaxWidth() + .background(PanelSecondary, RoundedCornerShape(6.dp)) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = "${draft.action.label} ${draft.commit.id}", + color = Accent, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + ) + OutlinedTextField( + value = draft.message, + onValueChange = { message -> + drafts = drafts.replaceAt(index, draft.copy(message = message)) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + RebaseButton( + label = "Pick", + enabled = draft.action != RebaseAction.PICK, + onClick = { drafts = drafts.replaceAt(index, draft.copy(action = RebaseAction.PICK)) }, + ) + RebaseButton( + label = "Squash", + enabled = draft.action != RebaseAction.SQUASH && index > 0, + onClick = { drafts = drafts.replaceAt(index, draft.copy(action = RebaseAction.SQUASH)) }, + ) + RebaseButton( + label = "Drop", + enabled = draft.action != RebaseAction.DROP, + onClick = { drafts = drafts.replaceAt(index, draft.copy(action = RebaseAction.DROP)) }, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + RebaseButton( + label = "Up", + enabled = index > 0, + onClick = { drafts = drafts.move(index, index - 1) }, + ) + RebaseButton( + label = "Down", + enabled = index < drafts.lastIndex, + onClick = { drafts = drafts.move(index, index + 1) }, + ) + } + } + } + } + } + } + } +} + +fun applyInteractiveRebase(repo: RepoState, drafts: List): RepoState { + val rebasedIds = drafts.map { it.commit.id }.toSet() + val untouched = repo.commits.filterNot { it.id in rebasedIds } + val rebased = drafts.fold(emptyList()) { commits, draft -> + when (draft.action) { + RebaseAction.PICK -> commits + draft.commit.copy(message = draft.message) + RebaseAction.DROP -> commits + RebaseAction.SQUASH -> commits + } + } + val nextCommits = untouched + rebased + return repo.copy( + commits = nextCommits, + branches = repo.branches + (repo.headBranch to nextCommits.size), + ) +} + +private fun List.replaceAt(index: Int, draft: RebaseCommitDraft): List { + return mapIndexed { currentIndex, currentDraft -> if (currentIndex == index) draft else currentDraft } +} + +private fun List.move(from: Int, to: Int): List { + return toMutableList().also { drafts -> + val item = drafts.removeAt(from) + drafts.add(to, item) + } +} + +private fun List.withSuggestedOrder(): List { + val messages = map { it.message } + if (!messages.containsAll(listOf("First commit", "Second commit", "Third commit"))) return this + return sortedBy { draft -> + when (draft.message) { + "First commit" -> 1 + "Second commit" -> 2 + "Third commit" -> 3 + else -> 0 + } + } +} + +@Composable +private fun RebaseButton( + label: String, + enabled: Boolean = true, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + enabled = enabled, + colors = ButtonDefaults.buttonColors( + containerColor = Accent, + contentColor = AppBackground, + disabledContainerColor = PanelTertiary, + disabledContentColor = TextMuted, + ), + ) { + Text(label) + } +} diff --git a/app/src/main/java/solutions/tretter/githugandroid/ManPageDialog.kt b/app/src/main/java/solutions/tretter/githugandroid/ManPageDialog.kt index c4d7c80..98bd74a 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/ManPageDialog.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/ManPageDialog.kt @@ -30,9 +30,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.IntSize @@ -59,8 +59,8 @@ fun ManPageDialog( ) { val horizontalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState() - val density = LocalDensity.current var contentViewportSize by remember { mutableStateOf(IntSize.Zero) } + var textLayoutResult by remember { mutableStateOf(null) } var searchQuery by remember(state.content) { mutableStateOf("") } var selectedMatch by remember(state.content) { mutableStateOf(0) } val contentLines = remember(state.content) { state.content.lines() } @@ -110,18 +110,21 @@ fun ManPageDialog( selectedMatch = selectedMatch.coerceIn(0, (matchCount - 1).coerceAtLeast(0)) } - LaunchedEffect(selectedMatch, matches) { + LaunchedEffect(selectedMatch, matches, textLayoutResult, contentViewportSize) { val match = matches.getOrNull(selectedMatch) ?: return@LaunchedEffect - val lineHeightPx = with(density) { 18.sp.toPx() } - val charWidthPx = with(density) { 8.sp.toPx() } + val layoutResult = textLayoutResult ?: return@LaunchedEffect + val startBounds = layoutResult.getBoundingBox(match.start) + val endBounds = layoutResult.getBoundingBox((match.end - 1).coerceAtLeast(match.start)) val viewportHeight = contentViewportSize.height.takeIf { it > 0 } ?: 0 val viewportWidth = contentViewportSize.width.takeIf { it > 0 } ?: 0 + val highlightCenterY = (startBounds.top + endBounds.bottom) / 2f + val highlightCenterX = (startBounds.left + endBounds.right) / 2f val verticalTarget = ( - match.lineIndex * lineHeightPx - viewportHeight / 2f + lineHeightPx / 2f + highlightCenterY - viewportHeight / 2f ).toInt() .coerceIn(0, verticalScrollState.maxValue) val horizontalTarget = ( - match.columnIndex * charWidthPx - viewportWidth / 2f + (match.end - match.start) * charWidthPx / 2f + highlightCenterX - viewportWidth / 2f ).toInt() .coerceIn(0, horizontalScrollState.maxValue) verticalScrollState.animateScrollTo(verticalTarget) @@ -233,6 +236,7 @@ fun ManPageDialog( Text( text = highlightedContent, modifier = Modifier.widthIn(min = 1200.dp), + onTextLayout = { textLayoutResult = it }, color = TextPrimary, fontFamily = FontFamily.Monospace, fontSize = 13.sp, diff --git a/app/src/main/java/solutions/tretter/githugandroid/TextEditorDialog.kt b/app/src/main/java/solutions/tretter/githugandroid/TextEditorDialog.kt index 876e417..395564e 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/TextEditorDialog.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/TextEditorDialog.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn @@ -33,6 +34,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.compose.foundation.text.KeyboardOptions data class TextEditorState( @@ -53,75 +55,86 @@ fun TextEditorDialog( ) { val editorHorizontalScroll = rememberScrollState() val editorVerticalScroll = rememberScrollState() + val dialogScroll = rememberScrollState() - Dialog(onDismissRequest = onClose) { - Surface( + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box( modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(0.86f) - .imePadding(), - color = PanelPrimary, - shape = RoundedCornerShape(8.dp), + .fillMaxSize() + .imePadding() + .verticalScroll(dialogScroll) + .padding(12.dp), ) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + Surface( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 360.dp, max = 720.dp), + color = PanelPrimary, + shape = RoundedCornerShape(8.dp), ) { - Text( - text = "Edit File", - color = TextPrimary, - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { - EditorButton(label = "Close", onClick = onClose) - EditorButton(label = "Save", enabled = state.saveAsPath.isNotBlank(), onClick = onSave) - } - Text( - text = "${state.editor} ${state.path.ifBlank { "" }}", - color = TextPrimary, - fontWeight = FontWeight.Bold, - ) - OutlinedTextField( - value = state.saveAsPath, - onValueChange = onSaveAsPathChange, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - keyboardOptions = KeyboardOptions( - autoCorrect = false, - keyboardType = KeyboardType.Ascii, - imeAction = ImeAction.Done, - ), - label = { Text("File name") }, - ) - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .heightIn(min = 260.dp) - .background(TerminalBackground, RoundedCornerShape(6.dp)) - .padding(10.dp) - .horizontalScroll(editorHorizontalScroll) - .verticalScroll(editorVerticalScroll), - ) { - BasicTextField( - value = state.content, - onValueChange = onContentChange, - modifier = Modifier.sizeIn(minWidth = 1200.dp, minHeight = 1200.dp), - textStyle = TextStyle( - color = TextPrimary, - fontFamily = FontFamily.Monospace, - fontSize = 14.sp, - ), - cursorBrush = SolidColor(Accent), + Text( + text = "Edit File", + color = TextPrimary, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + EditorButton(label = "Close", onClick = onClose) + EditorButton(label = "Save", enabled = state.saveAsPath.isNotBlank(), onClick = onSave) + } + Text( + text = "${state.editor} ${state.path.ifBlank { "" }}", + color = TextPrimary, + fontWeight = FontWeight.Bold, + ) + OutlinedTextField( + value = state.saveAsPath, + onValueChange = onSaveAsPathChange, + modifier = Modifier.fillMaxWidth(), + singleLine = true, keyboardOptions = KeyboardOptions( autoCorrect = false, keyboardType = KeyboardType.Ascii, + imeAction = ImeAction.Done, ), + label = { Text("File name") }, ) + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .heightIn(min = 260.dp) + .background(TerminalBackground, RoundedCornerShape(6.dp)) + .padding(10.dp) + .horizontalScroll(editorHorizontalScroll) + .verticalScroll(editorVerticalScroll), + ) { + BasicTextField( + value = state.content, + onValueChange = onContentChange, + modifier = Modifier.sizeIn(minWidth = 1200.dp, minHeight = 1200.dp), + textStyle = TextStyle( + color = TextPrimary, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + ), + cursorBrush = SolidColor(Accent), + keyboardOptions = KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Ascii, + ), + ) + } } } }