Misc Changes
This commit is contained in:
@@ -18,6 +18,7 @@ LOCAL_PROPERTIES_PATH="$PROJECT_DIR/local.properties"
|
||||
HOST_GIT_BINARY="$PROJECT_DIR/build/host-git/libgit.so"
|
||||
GIT_SRC_DIR="$TMP_DIR/git-src"
|
||||
HOST_GIT_DIR="$PROJECT_DIR/build/host-git"
|
||||
HOST_GIT_STAMP="$HOST_GIT_DIR/.source-fingerprint"
|
||||
ANDROID_JNI_DIR="$PROJECT_DIR/app/src/main/jniLibs"
|
||||
ANDROID_ASSET_MANPAGE_DIR="$PROJECT_DIR/app/src/main/assets/manpages"
|
||||
ANDROID_API=24
|
||||
@@ -309,10 +310,46 @@ bundle_git_manpages() {
|
||||
done < <(find "$GIT_SRC_DIR/Documentation" -maxdepth 1 -type f \( -name 'git*.txt' -o -name 'git*.adoc' -o -name 'gitignore.txt' -o -name 'gitignore.adoc' \))
|
||||
}
|
||||
|
||||
git_source_fingerprint() {
|
||||
ensure_git_source
|
||||
|
||||
local head
|
||||
local tracked_changes
|
||||
head="$(git -C "$GIT_SRC_DIR" rev-parse HEAD)"
|
||||
tracked_changes="$(git -C "$GIT_SRC_DIR" diff --binary HEAD -- | git hash-object --stdin)"
|
||||
printf '%s:%s\n' "$head" "$tracked_changes"
|
||||
}
|
||||
|
||||
target_is_current() {
|
||||
local output="$1"
|
||||
local stamp="$2"
|
||||
local fingerprint="$3"
|
||||
|
||||
[ -x "$output" ] \
|
||||
&& [ -f "$stamp" ] \
|
||||
&& [ "$(cat "$stamp" 2>/dev/null || true)" = "$fingerprint" ]
|
||||
}
|
||||
|
||||
mark_target_current() {
|
||||
local stamp="$1"
|
||||
local fingerprint="$2"
|
||||
|
||||
mkdir -p "$(dirname "$stamp")"
|
||||
printf '%s\n' "$fingerprint" > "$stamp"
|
||||
}
|
||||
|
||||
build_host_git() {
|
||||
ensure_git_source
|
||||
bundle_git_manpages
|
||||
|
||||
local fingerprint
|
||||
fingerprint="$(git_source_fingerprint):host"
|
||||
if target_is_current "$HOST_GIT_BINARY" "$HOST_GIT_STAMP" "$fingerprint"; then
|
||||
log "Host Git binary is current; skipping rebuild"
|
||||
"$HOST_GIT_BINARY" --version
|
||||
return
|
||||
fi
|
||||
|
||||
log "Building Git for development host"
|
||||
cd "$GIT_SRC_DIR"
|
||||
make clean >/dev/null 2>&1 || true
|
||||
@@ -322,6 +359,7 @@ build_host_git() {
|
||||
mkdir -p "$HOST_GIT_DIR"
|
||||
cp git "$HOST_GIT_BINARY"
|
||||
chmod 755 "$HOST_GIT_BINARY"
|
||||
mark_target_current "$HOST_GIT_STAMP" "$fingerprint"
|
||||
|
||||
log "Host test Git binary: $HOST_GIT_BINARY"
|
||||
"$HOST_GIT_BINARY" --version
|
||||
@@ -332,9 +370,18 @@ build_android_git_for_abi() {
|
||||
local abi="$1"
|
||||
local cc="$2"
|
||||
local output_dir="$ANDROID_JNI_DIR/$abi"
|
||||
local output_binary="$output_dir/libgit.so"
|
||||
local stamp="$output_dir/.source-fingerprint"
|
||||
local fingerprint="$3:android:$abi:$cc:$ANDROID_API"
|
||||
|
||||
require_path "$ANDROID_TOOLBIN/$cc"
|
||||
|
||||
if target_is_current "$output_binary" "$stamp" "$fingerprint"; then
|
||||
log "Android $abi Git binary is current; skipping rebuild"
|
||||
ls -lh "$output_binary"
|
||||
return
|
||||
fi
|
||||
|
||||
log "Building Git for Android $abi"
|
||||
cd "$GIT_SRC_DIR"
|
||||
make clean >/dev/null 2>&1 || true
|
||||
@@ -357,9 +404,10 @@ build_android_git_for_abi() {
|
||||
readelf -h git | sed -n '1,12p'
|
||||
|
||||
mkdir -p "$output_dir"
|
||||
llvm-strip -s git -o "$output_dir/libgit.so"
|
||||
chmod 755 "$output_dir/libgit.so"
|
||||
ls -lh "$output_dir/libgit.so"
|
||||
llvm-strip -s git -o "$output_binary"
|
||||
chmod 755 "$output_binary"
|
||||
mark_target_current "$stamp" "$fingerprint"
|
||||
ls -lh "$output_binary"
|
||||
cd "$PROJECT_DIR"
|
||||
}
|
||||
|
||||
@@ -369,10 +417,13 @@ build_android_git() {
|
||||
require_path "$ANDROID_NDK_DIR"
|
||||
export PATH="$ANDROID_TOOLBIN:$PATH"
|
||||
|
||||
build_android_git_for_abi "arm64-v8a" "aarch64-linux-android${ANDROID_API}-clang"
|
||||
build_android_git_for_abi "armeabi-v7a" "armv7a-linux-androideabi${ANDROID_API}-clang"
|
||||
build_android_git_for_abi "x86" "i686-linux-android${ANDROID_API}-clang"
|
||||
build_android_git_for_abi "x86_64" "x86_64-linux-android${ANDROID_API}-clang"
|
||||
local fingerprint
|
||||
fingerprint="$(git_source_fingerprint)"
|
||||
|
||||
build_android_git_for_abi "arm64-v8a" "aarch64-linux-android${ANDROID_API}-clang" "$fingerprint"
|
||||
build_android_git_for_abi "armeabi-v7a" "armv7a-linux-androideabi${ANDROID_API}-clang" "$fingerprint"
|
||||
build_android_git_for_abi "x86" "i686-linux-android${ANDROID_API}-clang" "$fingerprint"
|
||||
build_android_git_for_abi "x86_64" "x86_64-linux-android${ANDROID_API}-clang" "$fingerprint"
|
||||
}
|
||||
|
||||
build_all_git_targets() {
|
||||
|
||||
@@ -100,8 +100,8 @@ Options:
|
||||
|
||||
| Command | Purpose | Output |
|
||||
| --- | --- | --- |
|
||||
| `bash ./AndroidProjectTooling.sh --test` | Compile Git for the development machine, then run tests with it. | `build/host-git/libgit.so` and test reports |
|
||||
| `bash ./AndroidProjectTooling.sh --compile-git` | Compile host Git and cross-compile Android ABIs served by Google Play. | Host and Android outputs |
|
||||
| `bash ./AndroidProjectTooling.sh --test` | Ensure the host Git binary is current, then run tests with it. | `build/host-git/libgit.so` and test reports |
|
||||
| `bash ./AndroidProjectTooling.sh --compile-git` | Ensure host Git and Android ABI Git binaries are current. | Host and Android outputs |
|
||||
|
||||
Android ABI outputs:
|
||||
|
||||
@@ -110,7 +110,9 @@ Android ABI outputs:
|
||||
- `app/src/main/jniLibs/x86/libgit.so`
|
||||
- `app/src/main/jniLibs/x86_64/libgit.so`
|
||||
|
||||
The `--test` tooling command automatically builds host Git first and exports `GITHUG_TEST_GIT_BINARY=build/host-git/libgit.so`. This keeps JVM tests on the same `GitRepositoryRuntime` path as the app, including the packaged-runtime helper resolution behavior.
|
||||
The `--test` tooling command automatically builds host Git only when the Git source checkout has changed, then exports `GITHUG_TEST_GIT_BINARY=build/host-git/libgit.so`. This keeps JVM tests on the same `GitRepositoryRuntime` path as the app, including the packaged-runtime helper resolution behavior.
|
||||
|
||||
Git build outputs are stamped with a Git source fingerprint. Re-running `--test` or `--compile-git` reuses existing `libgit.so` binaries when the checked-out Git sources and target build flavor are unchanged.
|
||||
|
||||
Git manpage assets are also refreshed from the checked-out Git source whenever Git is compiled or an app artifact is built.
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ android {
|
||||
applicationId = "solutions.tretter.githugandroid"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 132
|
||||
versionName = "0.1.131"
|
||||
versionCode = 133
|
||||
versionName = "0.1.132"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
private val VisualEditorCommands = setOf("vi", "nano", "emacs", "ed", "ex")
|
||||
private val VisualEditorCommands = setOf("vi", "vim", "nano", "emacs", "ed", "ex", "edit", "notepad")
|
||||
|
||||
data class VisualEditorInvocation(
|
||||
val editor: String,
|
||||
@@ -10,7 +10,9 @@ data class VisualEditorInvocation(
|
||||
|
||||
fun parseVisualEditorInvocation(command: String): VisualEditorInvocation? {
|
||||
val tokens = GitSandboxEngine.tokenizeCommand(command)
|
||||
val editor = tokens.firstOrNull()?.takeIf { it in VisualEditorCommands } ?: return null
|
||||
val editor = tokens.firstOrNull()
|
||||
?.takeIf { it.lowercase() in VisualEditorCommands }
|
||||
?: return null
|
||||
return VisualEditorInvocation(
|
||||
editor = editor,
|
||||
path = tokens.drop(1).firstOrNull { !it.startsWith("-") },
|
||||
|
||||
@@ -70,8 +70,6 @@ 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) }
|
||||
@@ -211,8 +209,6 @@ fun GitHugApp() {
|
||||
historyDraft = ""
|
||||
editorState = null
|
||||
gitMessageEditorState = null
|
||||
interactiveAddState = null
|
||||
interactiveRebaseState = null
|
||||
manPageState = null
|
||||
applyRecommendedPaneWeights(persist = false)
|
||||
}
|
||||
@@ -231,8 +227,6 @@ fun GitHugApp() {
|
||||
historyDraft = ""
|
||||
editorState = null
|
||||
gitMessageEditorState = null
|
||||
interactiveAddState = null
|
||||
interactiveRebaseState = null
|
||||
manPageState = null
|
||||
paneLayout = paneLayout.copy(
|
||||
weights = paneLayout.weights + recommendedPaneWeights(
|
||||
@@ -424,59 +418,6 @@ 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()
|
||||
@@ -512,18 +453,6 @@ 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)
|
||||
}
|
||||
@@ -686,28 +615,6 @@ 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,
|
||||
|
||||
@@ -176,7 +176,7 @@ class GitRepositoryRuntime private constructor(
|
||||
add(" binary path: nativeLibraryDir/libgit.so")
|
||||
add(" selected ABI: ${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"}")
|
||||
add(" helper commands: ls/dir, pwd, cat, touch, mkdir/md, cd.., rm/del, echo")
|
||||
add(" visual editors: vi, nano, emacs, ed, ex")
|
||||
add(" visual editors: vi, vim, nano, emacs, ed, ex, edit, notepad")
|
||||
add(" git help <command>")
|
||||
}
|
||||
}
|
||||
@@ -548,7 +548,7 @@ class GitRepositoryRuntime private constructor(
|
||||
}
|
||||
val shouldUseSandboxSemantics = when (gitCommand) {
|
||||
"add" -> tokens.any { it == "-p" || it == "--patch" || it == "-i" || it == "--interactive" }
|
||||
"rebase" -> "-i" in tokens || "--onto" in tokens
|
||||
"rebase" -> "-i" in tokens || "--interactive" in tokens || "--onto" in tokens
|
||||
"merge" -> "--squash" in tokens || tokens.lastOrNull() == "mybranch" || tokens.lastOrNull() == "feature"
|
||||
"revert", "stash" -> true
|
||||
"checkout" -> tokens.any { it == "file3" } || tokens.takeLast(2) == listOf("--", "config.rb")
|
||||
|
||||
@@ -165,10 +165,13 @@ object GitSandboxEngine {
|
||||
private fun interactiveAdd(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val targets = arguments.filterNot { it == "-i" || it == "--interactive" || it.startsWith("--") }
|
||||
val target = targets.lastOrNull()
|
||||
val candidates = repo.files.filter { file ->
|
||||
!file.deleted && (target == null || target == "." || file.name == target || file.name.startsWith(target.trimEnd('/') + "/"))
|
||||
}
|
||||
val updated = repo.files.map { file ->
|
||||
if (file.deleted) {
|
||||
file
|
||||
} else if (target == null || target == "." || file.name == target) {
|
||||
} else if (target == null || target == "." || file.name == target || file.name.startsWith(target.trimEnd('/') + "/")) {
|
||||
file.copy(staged = true)
|
||||
} else {
|
||||
file
|
||||
@@ -178,10 +181,32 @@ object GitSandboxEngine {
|
||||
val before = repo.files.firstOrNull { it.name == updatedFile.name }
|
||||
updatedFile.staged && before?.staged != true
|
||||
}
|
||||
return repo.copy(files = updated) to listOf(
|
||||
"Interactive add is handled by GitHug Android.",
|
||||
"Staged ${if (target == null || target == ".") "$stagedCount file(s)" else target}.",
|
||||
)
|
||||
return repo.copy(files = updated) to interactiveAddConsoleLines(candidates, target, stagedCount)
|
||||
}
|
||||
|
||||
private fun interactiveAddConsoleLines(candidates: List<GitFile>, target: String?, stagedCount: Int): List<String> {
|
||||
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> update")
|
||||
add("Update>> ${target ?: "*"}")
|
||||
add("updated ${if (target == null || target == ".") "$stagedCount path(s)" else target}")
|
||||
add("What now> quit")
|
||||
add("Bye.")
|
||||
}
|
||||
}
|
||||
|
||||
fun tokenizeCommand(command: String): List<String> {
|
||||
@@ -434,7 +459,9 @@ object GitSandboxEngine {
|
||||
}
|
||||
|
||||
private fun rebase(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val commits = if ("-i" in arguments && repo.commits.size > 2) {
|
||||
val interactive = "-i" in arguments || "--interactive" in arguments
|
||||
val originalCommits = repo.commits
|
||||
val commits = if (interactive && repo.commits.size > 2) {
|
||||
repo.commits
|
||||
.filterNot { it.message.contains("squash this commit", ignoreCase = true) }
|
||||
.map { if (it.message == "First coommit") it.copy(message = "First commit") else it }
|
||||
@@ -465,7 +492,31 @@ object GitSandboxEngine {
|
||||
} else {
|
||||
repo.maintenanceActions
|
||||
}
|
||||
return repo.copy(commits = commits, branches = updatedBranches, maintenanceActions = maintenanceActions) to emptyList()
|
||||
val output = if (interactive) interactiveRebaseConsoleLines(originalCommits, commits, arguments, repo.headBranch) else emptyList()
|
||||
return repo.copy(commits = commits, branches = updatedBranches, maintenanceActions = maintenanceActions) to output
|
||||
}
|
||||
|
||||
private fun interactiveRebaseConsoleLines(originalCommits: List<CommitNode>, rebasedCommits: List<CommitNode>, arguments: List<String>, branch: String): List<String> {
|
||||
val target = arguments.lastOrNull { it != "-i" && it != "--interactive" }.orEmpty()
|
||||
val rebasedIds = rebasedCommits.map { it.id }.toSet()
|
||||
return buildList {
|
||||
originalCommits.forEach { commit ->
|
||||
val action = when {
|
||||
commit.id !in rebasedIds -> "squash"
|
||||
commit.message == "First coommit" -> "reword"
|
||||
else -> "pick"
|
||||
}
|
||||
add("$action ${commit.id} ${commit.message}")
|
||||
}
|
||||
add("")
|
||||
add("# Rebase ${target.ifBlank { "HEAD" }} in progress; onto HEAD")
|
||||
add("# Commands:")
|
||||
add("# p, pick <commit> = use commit")
|
||||
add("# r, reword <commit> = use commit, but edit the commit message")
|
||||
add("# s, squash <commit> = use commit, but meld into previous commit")
|
||||
add("# d, drop <commit> = remove commit")
|
||||
add("Successfully rebased and updated refs/heads/$branch.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun commit(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,13 @@ class EditorCommandsTest {
|
||||
assertEquals("vi .gitignore", invocation?.command)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesAdditionalEditorAliases() {
|
||||
assertEquals("vim", parseVisualEditorInvocation("vim .gitignore")?.editor)
|
||||
assertEquals("edit", parseVisualEditorInvocation("edit README")?.editor)
|
||||
assertEquals("notepad", parseVisualEditorInvocation("notepad notes.txt")?.editor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresEditorOptionsWhenChoosingPath() {
|
||||
val invocation = parseVisualEditorInvocation("nano -w README")
|
||||
|
||||
@@ -277,13 +277,14 @@ class GitSandboxEngineTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveStageDoesNotThrow() {
|
||||
fun interactiveStageUsesConsoleText() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git stage -i")
|
||||
|
||||
assertTrue(updatedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(output.any { it.contains("Interactive add is handled by GitHug Android") })
|
||||
assertTrue(output.any { it.contains("What now>") })
|
||||
assertFalse(output.any { it.contains("GitHug Android") })
|
||||
}
|
||||
|
||||
private fun testGitBinary(): File {
|
||||
|
||||
Reference in New Issue
Block a user