diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b42088b..4f6be5f 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 = 98 - versionName = "0.1.97" + versionCode = 99 + versionName = "0.1.98" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitHelpCommands.kt b/app/src/main/java/solutions/tretter/githugandroid/GitHelpCommands.kt new file mode 100644 index 0000000..5c14c48 --- /dev/null +++ b/app/src/main/java/solutions/tretter/githugandroid/GitHelpCommands.kt @@ -0,0 +1,13 @@ +package solutions.tretter.githugandroid + +data class GitHelpInvocation( + val topic: String, + val command: String, +) + +fun parseGitHelpInvocation(command: String): GitHelpInvocation? { + val tokens = GitSandboxEngine.tokenizeCommand(command) + if (tokens.size < 3 || tokens[0] != "git" || tokens[1] != "help") return null + val topic = tokens[2].takeIf { it.matches(Regex("[A-Za-z0-9_-]+")) } ?: return null + return GitHelpInvocation(topic = topic, command = command) +} diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt index fa91467..0ca1505 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt @@ -61,6 +61,7 @@ fun GitHugApp() { var historyDraft by remember { mutableStateOf("") } var hasRestoredProgress by remember { mutableStateOf(false) } var editorState by remember { mutableStateOf(null) } + var manPageState by remember { mutableStateOf(null) } val currentLevel = levels[currentLevelIndex] LaunchedEffect(persistedPaneLayout) { @@ -168,6 +169,7 @@ fun GitHugApp() { historyIndex = -1 historyDraft = "" editorState = null + manPageState = null applyRecommendedPaneWeights(persist = false) } @@ -184,6 +186,7 @@ fun GitHugApp() { historyIndex = -1 historyDraft = "" editorState = null + manPageState = null paneLayout = paneLayout.copy( weights = paneLayout.weights + recommendedPaneWeights( heights = recommendedPaneHeights( @@ -323,6 +326,17 @@ fun GitHugApp() { applyRecommendedPaneWeights(persist = false) } + fun openManPage(invocation: GitHelpInvocation) { + val content = runtime.gitManPage(currentLevel, repo, invocation.topic) + output = buildList { + addAll(output) + add("$ ${invocation.command}") + add("Opened git help viewer for ${invocation.topic}") + } + manPageState = ManPageState(topic = invocation.topic, content = content) + applyRecommendedPaneWeights(persist = false) + } + fun saveEditor() { val state = editorState ?: return val targetPath = state.saveAsPath.trim() @@ -352,6 +366,12 @@ fun GitHugApp() { return } + val helpInvocation = parseGitHelpInvocation(raw) + if (helpInvocation != null) { + openManPage(helpInvocation) + return + } + val (newRepo, lines) = runtime.execute(currentLevel, repo, raw) applyCommandResult(raw, newRepo, lines, echoCommand = true) } @@ -460,5 +480,12 @@ fun GitHugApp() { onSave = { saveEditor() }, ) } + + manPageState?.let { state -> + ManPageDialog( + state = state, + onClose = { manPageState = null }, + ) + } } } diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt index 7172f9d..3c7d471 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt @@ -85,9 +85,26 @@ class GitRepositoryRuntime(private val context: Context) { add(" selected ABI: ${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"}") add(" helper commands: ls, pwd, cat, touch, mkdir, rm, echo") add(" visual editors: vi, nano, emacs, ed, ex") + add(" git help ") } } + fun gitManPage(level: Level, currentRepo: RepoState, topic: String): String { + val nativeGit = nativeGitBinary() + if (nativeGit == null) { + return fallbackManPage(topic) + } + + val sandboxRoot = sandboxDir(level).canonicalFile + val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile + .takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot + val result = runGit(nativeGit, workingDir, listOf(topic, "-h")) + return result.outputLines + .filterNot { it.contains("no man viewer", ignoreCase = true) } + .joinToString("\n") + .ifBlank { fallbackManPage(topic) } + } + fun readEditorFile(level: Level, currentRepo: RepoState, path: String): Pair> { val nativeGit = nativeGitBinary() if (nativeGit == null) { @@ -198,6 +215,80 @@ class GitRepositoryRuntime(private val context: Context) { } } + private fun fallbackManPage(topic: String): String { + val body = when (topic) { + "tag" -> """ + NAME + git-tag - Create, list, delete or verify a tag object + + SYNOPSIS + git tag + git tag -d + git tag + + DESCRIPTION + Tags name specific points in history. In GitHug Android, + tag levels usually expect you to create or reference a tag + by name. + + EXAMPLES + git tag new_tag + git tag v1.2 + """.trimIndent() + "branch" -> """ + NAME + git-branch - List, create, or delete branches + + SYNOPSIS + git branch + git branch -d + + EXAMPLES + git branch test_code + git branch -d delete_me + """.trimIndent() + "checkout" -> """ + NAME + git-checkout - Switch branches or restore files + + SYNOPSIS + git checkout + git checkout -b + git checkout -- + + EXAMPLES + git checkout -b my_branch + git checkout -- config.rb + """.trimIndent() + "add" -> """ + NAME + git-add - Add file contents to the index + + SYNOPSIS + git add + git add . + git add -p + """.trimIndent() + "commit" -> """ + NAME + git-commit - Record changes to the repository + + SYNOPSIS + git commit -m + git commit --amend + """.trimIndent() + else -> """ + NAME + git-$topic + + DESCRIPTION + No bundled fallback manpage is available for git $topic. + Try the command suggestions or use git help for another command. + """.trimIndent() + } + return body + } + private fun materializeNativeGitState(nativeGit: File, sandbox: File, desired: RepoState) { desired.config.forEach { (key, value) -> runGit(nativeGit, sandbox, listOf("config", key, value)) diff --git a/app/src/main/java/solutions/tretter/githugandroid/ManPageDialog.kt b/app/src/main/java/solutions/tretter/githugandroid/ManPageDialog.kt new file mode 100644 index 0000000..dfe6d0c --- /dev/null +++ b/app/src/main/java/solutions/tretter/githugandroid/ManPageDialog.kt @@ -0,0 +1,89 @@ +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.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.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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 ManPageState( + val topic: String, + val content: String, +) + +@Composable +fun ManPageDialog( + state: ManPageState, + onClose: () -> Unit, +) { + val scrollState = rememberScrollState() + + Dialog(onDismissRequest = onClose) { + Surface( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.86f), + color = PanelPrimary, + shape = RoundedCornerShape(8.dp), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "Git Manpage", + color = TextPrimary, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = onClose, + colors = ButtonDefaults.buttonColors( + containerColor = Accent, + contentColor = AppBackground, + ), + ) { + Text("Close") + } + } + Text( + text = "git ${state.topic}", + color = TextPrimary, + fontWeight = FontWeight.Bold, + ) + Text( + text = state.content, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .background(TerminalBackground, RoundedCornerShape(6.dp)) + .padding(10.dp) + .verticalScroll(scrollState), + color = TextPrimary, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + ) + } + } + } +} diff --git a/app/src/test/java/solutions/tretter/githugandroid/GitHelpCommandsTest.kt b/app/src/test/java/solutions/tretter/githugandroid/GitHelpCommandsTest.kt new file mode 100644 index 0000000..64a048e --- /dev/null +++ b/app/src/test/java/solutions/tretter/githugandroid/GitHelpCommandsTest.kt @@ -0,0 +1,25 @@ +package solutions.tretter.githugandroid + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class GitHelpCommandsTest { + @Test + fun parsesGitHelpCommand() { + val invocation = parseGitHelpInvocation("git help tag") + + assertEquals("tag", invocation?.topic) + assertEquals("git help tag", invocation?.command) + } + + @Test + fun ignoresNonHelpCommands() { + assertNull(parseGitHelpInvocation("git tag -h")) + } + + @Test + fun rejectsUnsafeHelpTopic() { + assertNull(parseGitHelpInvocation("git help ../config")) + } +}