diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5475c85..3394609 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 = 96 - versionName = "0.1.95" + versionCode = 97 + versionName = "0.1.96" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/app/src/main/java/solutions/tretter/githugandroid/EditorCommands.kt b/app/src/main/java/solutions/tretter/githugandroid/EditorCommands.kt new file mode 100644 index 0000000..023304d --- /dev/null +++ b/app/src/main/java/solutions/tretter/githugandroid/EditorCommands.kt @@ -0,0 +1,19 @@ +package solutions.tretter.githugandroid + +private val VisualEditorCommands = setOf("vi", "nano", "emacs", "ed", "ex") + +data class VisualEditorInvocation( + val editor: String, + val path: String?, + val command: String, +) + +fun parseVisualEditorInvocation(command: String): VisualEditorInvocation? { + val tokens = GitSandboxEngine.tokenizeCommand(command) + val editor = tokens.firstOrNull()?.takeIf { it in VisualEditorCommands } ?: return null + return VisualEditorInvocation( + editor = editor, + path = tokens.drop(1).firstOrNull { !it.startsWith("-") }, + 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 ec5bb08..cbf6700 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt @@ -60,6 +60,7 @@ fun GitHugApp() { var historyIndex by remember { mutableStateOf(-1) } var historyDraft by remember { mutableStateOf("") } var hasRestoredProgress by remember { mutableStateOf(false) } + var editorState by remember { mutableStateOf(null) } val currentLevel = levels[currentLevelIndex] LaunchedEffect(persistedPaneLayout) { @@ -166,6 +167,7 @@ fun GitHugApp() { visibleHint = null historyIndex = -1 historyDraft = "" + editorState = null applyRecommendedPaneWeights(persist = false) } @@ -181,6 +183,7 @@ fun GitHugApp() { visibleHint = null historyIndex = -1 historyDraft = "" + editorState = null paneLayout = paneLayout.copy( weights = paneLayout.weights + recommendedPaneWeights( heights = recommendedPaneHeights( @@ -247,6 +250,87 @@ fun GitHugApp() { commandInput = TextFieldValue(newText, selection = TextRange(newCursor)) } + fun applyCommandResult(raw: String, newRepo: RepoState, lines: List, echoCommand: Boolean) { + val levelForResult = currentLevel + val solvedAfterCommand = levelForResult.validator(newRepo, raw) + val wasAlreadyCompleted = currentLevel.id in completedLevels + AppLog.d( + "GitHugApp", + "Command='$raw' level=${levelForResult.id} solved=$solvedAfterCommand alreadyCompleted=$wasAlreadyCompleted completedBefore=${completedLevels.sorted()}", + ) + val newOutput = buildList { + addAll(output) + if (echoCommand) { + add("$ $raw") + } + addAll(lines) + if (solvedAfterCommand && !wasAlreadyCompleted) { + add("✔ Level solved: ${levelForResult.title}") + } + } + if (solvedAfterCommand && !wasAlreadyCompleted) { + val updatedCompletedLevels = completedLevels + levelForResult.id + completedLevels = updatedCompletedLevels + val nextLevelIndex = levels.indexOfFirst { it.id !in updatedCompletedLevels } + if (nextLevelIndex >= 0) { + val nextLevelId = levels[nextLevelIndex].id + AppLog.d( + "GitHugApp", + "Level solved ${levelForResult.id}; advancing to next incomplete index=$nextLevelIndex id=$nextLevelId", + ) + scope.launch { persistProgress(updatedCompletedLevels, nextLevelId) } + loadLevel(nextLevelIndex) + } else { + AppLog.d("GitHugApp", "All levels completed") + scope.launch { persistProgress(updatedCompletedLevels, levels.lastOrNull()?.id) } + repo = newRepo + output = listOf("🏁 All Githug levels completed.") + clearCommandInput() + suppressedImeEcho = null + } + } else { + AppLog.d("GitHugApp", "Staying on level=${levelForResult.id}") + repo = newRepo + output = newOutput + applyRecommendedPaneWeights(persist = false) + } + } + + fun openEditor(invocation: VisualEditorInvocation) { + val path = invocation.path.orEmpty() + val (content, lines) = if (path.isBlank()) { + "" to emptyList() + } else { + runtime.readEditorFile(currentLevel, repo, path) + } + output = buildList { + addAll(output) + add("$ ${invocation.command}") + addAll(lines) + if (lines.isEmpty()) { + add("Opened ${invocation.editor} editor${if (path.isBlank()) "" else " for $path"}") + } + } + if (lines.isEmpty()) { + editorState = TextEditorState( + editor = invocation.editor, + originalCommand = invocation.command, + path = path, + content = content, + saveAsPath = path, + ) + } + applyRecommendedPaneWeights(persist = false) + } + + fun saveEditor(targetPath: String) { + val state = editorState ?: return + if (targetPath.isBlank()) return + val (newRepo, lines) = runtime.writeEditorFile(currentLevel, repo, targetPath, state.content) + editorState = null + applyCommandResult(state.originalCommand, newRepo, lines, echoCommand = false) + } + fun runCommand() { val submittedText = commandInput.text val raw = submittedText.trim() @@ -261,47 +345,14 @@ fun GitHugApp() { historyIndex = -1 historyDraft = "" + val editorInvocation = parseVisualEditorInvocation(raw) + if (editorInvocation != null) { + openEditor(editorInvocation) + return + } + val (newRepo, lines) = runtime.execute(currentLevel, repo, raw) - val solvedAfterCommand = currentLevel.validator(newRepo, raw) - val wasAlreadyCompleted = currentLevel.id in completedLevels - AppLog.d( - "GitHugApp", - "Command='$raw' level=${currentLevel.id} solved=$solvedAfterCommand alreadyCompleted=$wasAlreadyCompleted completedBefore=${completedLevels.sorted()}", - ) - val newOutput = buildList { - addAll(output) - add("$ $raw") - addAll(lines) - if (solvedAfterCommand && !wasAlreadyCompleted) { - add("✔ Level solved: ${currentLevel.title}") - } - } - if (solvedAfterCommand && !wasAlreadyCompleted) { - val updatedCompletedLevels = completedLevels + currentLevel.id - completedLevels = updatedCompletedLevels - val nextLevelIndex = levels.indexOfFirst { it.id !in updatedCompletedLevels } - if (nextLevelIndex >= 0) { - val nextLevelId = levels[nextLevelIndex].id - AppLog.d( - "GitHugApp", - "Level solved ${currentLevel.id}; advancing to next incomplete index=$nextLevelIndex id=$nextLevelId", - ) - scope.launch { persistProgress(updatedCompletedLevels, nextLevelId) } - loadLevel(nextLevelIndex) - } else { - AppLog.d("GitHugApp", "All levels completed") - scope.launch { persistProgress(updatedCompletedLevels, levels.lastOrNull()?.id) } - repo = newRepo - output = listOf("🏁 All Githug levels completed.") - clearCommandInput() - suppressedImeEcho = null - } - } else { - AppLog.d("GitHugApp", "Staying on level=${currentLevel.id}") - repo = newRepo - output = newOutput - applyRecommendedPaneWeights(persist = false) - } + applyCommandResult(raw, newRepo, lines, echoCommand = true) } fun showCommandHelp() { @@ -395,5 +446,25 @@ fun GitHugApp() { } } } + + editorState?.let { state -> + TextEditorDialog( + state = state, + onContentChange = { editorState = state.copy(content = it) }, + onSaveAsPathChange = { editorState = state.copy(saveAsPath = it) }, + onCancel = { + output = output + "Editor closed without saving" + editorState = null + }, + onSave = { saveEditor(state.path) }, + onSaveAs = { + val targetPath = state.saveAsPath.trim() + if (targetPath.isNotEmpty()) { + editorState = state.copy(path = targetPath, saveAsPath = targetPath) + saveEditor(targetPath) + } + }, + ) + } } } diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt index e4ec455..7172f9d 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt @@ -84,9 +84,59 @@ class GitRepositoryRuntime(private val context: Context) { add(" binary path: nativeLibraryDir/libgit.so") 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") } } + fun readEditorFile(level: Level, currentRepo: RepoState, path: String): Pair> { + val nativeGit = nativeGitBinary() + if (nativeGit == null) { + return currentRepo.files.find { it.name == path }?.content.orEmpty() to emptyList() + } + + val sandboxRoot = sandboxDir(level).canonicalFile + val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile + .takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot + val file = File(workingDir, path).canonicalFile + if (!file.path.startsWith(sandboxRoot.path)) { + return "" to listOf("editor: $path: Permission denied") + } + if (file.exists() && file.isDirectory) { + return "" to listOf("editor: $path: Is a directory") + } + + return if (file.exists()) file.readText() to emptyList() else "" to emptyList() + } + + fun writeEditorFile(level: Level, currentRepo: RepoState, path: String, content: String): Pair> { + val nativeGit = nativeGitBinary() + if (nativeGit == null) { + val updatedFiles = currentRepo.files.toMutableList() + val index = updatedFiles.indexOfFirst { it.name == path } + if (index == -1) { + updatedFiles += GitFile(name = path, content = content) + } else { + updatedFiles[index] = updatedFiles[index].copy(content = content) + } + return currentRepo.copy(files = updatedFiles) to listOf("Saved $path") + } + + val sandboxRoot = sandboxDir(level).canonicalFile + val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile + .takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot + val file = File(workingDir, path).canonicalFile + if (!file.path.startsWith(sandboxRoot.path)) { + return currentRepo to listOf("editor: $path: Permission denied") + } + if (file.exists() && file.isDirectory) { + return currentRepo to listOf("editor: $path: Is a directory") + } + + file.parentFile?.mkdirs() + file.writeText(content) + return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to listOf("Saved $path") + } + private fun executeHelperCommand(sandboxRoot: File, workingDir: File, currentRepo: RepoState, tokens: List): Pair> { return when (tokens.first()) { "ls" -> currentRepo to workingDir.listFiles() diff --git a/app/src/main/java/solutions/tretter/githugandroid/TextEditorDialog.kt b/app/src/main/java/solutions/tretter/githugandroid/TextEditorDialog.kt new file mode 100644 index 0000000..038e38d --- /dev/null +++ b/app/src/main/java/solutions/tretter/githugandroid/TextEditorDialog.kt @@ -0,0 +1,116 @@ +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +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.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +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 TextEditorState( + val editor: String, + val originalCommand: String, + val path: String, + val content: String, + val saveAsPath: String, +) + +@Composable +fun TextEditorDialog( + state: TextEditorState, + onContentChange: (String) -> Unit, + onSaveAsPathChange: (String) -> Unit, + onCancel: () -> Unit, + onSave: () -> Unit, + onSaveAs: () -> Unit, +) { + Dialog(onDismissRequest = onCancel) { + Surface( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.86f), + color = PanelPrimary, + shape = RoundedCornerShape(8.dp), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + EditorButton(label = "Cancel", onClick = onCancel) + EditorButton(label = "Save", enabled = state.path.isNotBlank(), onClick = onSave) + EditorButton(label = "Save As", enabled = state.saveAsPath.isNotBlank(), onClick = onSaveAs) + } + Text( + text = "${state.editor} ${state.path.ifBlank { "" }}", + color = TextPrimary, + fontWeight = FontWeight.Bold, + ) + OutlinedTextField( + value = state.saveAsPath, + onValueChange = onSaveAsPathChange, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Save As") }, + ) + BasicTextField( + value = state.content, + onValueChange = onContentChange, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .heightIn(min = 260.dp) + .background(TerminalBackground, RoundedCornerShape(6.dp)) + .padding(10.dp), + textStyle = TextStyle( + color = TextPrimary, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + ), + cursorBrush = SolidColor(Accent), + ) + } + } + } +} + +@Composable +private fun EditorButton( + 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/test/java/solutions/tretter/githugandroid/EditorCommandsTest.kt b/app/src/test/java/solutions/tretter/githugandroid/EditorCommandsTest.kt new file mode 100644 index 0000000..31ef734 --- /dev/null +++ b/app/src/test/java/solutions/tretter/githugandroid/EditorCommandsTest.kt @@ -0,0 +1,29 @@ +package solutions.tretter.githugandroid + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class EditorCommandsTest { + @Test + fun parsesSupportedVisualEditorCommandWithPath() { + val invocation = parseVisualEditorInvocation("vi .gitignore") + + assertEquals("vi", invocation?.editor) + assertEquals(".gitignore", invocation?.path) + assertEquals("vi .gitignore", invocation?.command) + } + + @Test + fun ignoresEditorOptionsWhenChoosingPath() { + val invocation = parseVisualEditorInvocation("nano -w README") + + assertEquals("nano", invocation?.editor) + assertEquals("README", invocation?.path) + } + + @Test + fun ignoresNonEditorCommands() { + assertNull(parseVisualEditorInvocation("git status")) + } +}