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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,13 +48,23 @@ fun GitMessageEditorDialog(
|
||||
) {
|
||||
val horizontalScroll = rememberScrollState()
|
||||
val verticalScroll = rememberScrollState()
|
||||
val dialogScroll = rememberScrollState()
|
||||
|
||||
Dialog(onDismissRequest = onClose) {
|
||||
Dialog(
|
||||
onDismissRequest = onClose,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.verticalScroll(dialogScroll)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.72f)
|
||||
.imePadding(),
|
||||
.heightIn(min = 320.dp, max = 620.dp),
|
||||
color = PanelPrimary,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
@@ -109,6 +121,7 @@ fun GitMessageEditorDialog(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditorButton(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<GitFile>,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun InteractiveAddDialog(
|
||||
state: InteractiveAddState,
|
||||
onClose: () -> Unit,
|
||||
onStage: (Set<String>) -> 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<CommitNode>,
|
||||
)
|
||||
|
||||
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<RebaseCommitDraft>) -> 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<RebaseCommitDraft>): RepoState {
|
||||
val rebasedIds = drafts.map { it.commit.id }.toSet()
|
||||
val untouched = repo.commits.filterNot { it.id in rebasedIds }
|
||||
val rebased = drafts.fold(emptyList<CommitNode>()) { 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<RebaseCommitDraft>.replaceAt(index: Int, draft: RebaseCommitDraft): List<RebaseCommitDraft> {
|
||||
return mapIndexed { currentIndex, currentDraft -> if (currentIndex == index) draft else currentDraft }
|
||||
}
|
||||
|
||||
private fun List<RebaseCommitDraft>.move(from: Int, to: Int): List<RebaseCommitDraft> {
|
||||
return toMutableList().also { drafts ->
|
||||
val item = drafts.removeAt(from)
|
||||
drafts.add(to, item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<RebaseCommitDraft>.withSuggestedOrder(): List<RebaseCommitDraft> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<TextLayoutResult?>(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,
|
||||
|
||||
@@ -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,13 +55,23 @@ fun TextEditorDialog(
|
||||
) {
|
||||
val editorHorizontalScroll = rememberScrollState()
|
||||
val editorVerticalScroll = rememberScrollState()
|
||||
val dialogScroll = rememberScrollState()
|
||||
|
||||
Dialog(onDismissRequest = onClose) {
|
||||
Dialog(
|
||||
onDismissRequest = onClose,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.verticalScroll(dialogScroll)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.86f)
|
||||
.imePadding(),
|
||||
.heightIn(min = 360.dp, max = 720.dp),
|
||||
color = PanelPrimary,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
@@ -127,6 +139,7 @@ fun TextEditorDialog(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditorButton(
|
||||
|
||||
Reference in New Issue
Block a user