diff --git a/.github/workflows/instrumentation-test.yml b/.github/workflows/instrumentation-test.yml index f1c077aabe..ac07df9f54 100644 --- a/.github/workflows/instrumentation-test.yml +++ b/.github/workflows/instrumentation-test.yml @@ -78,10 +78,14 @@ jobs: run: | ./gradlew :app:assembleV8DebugAndroidTest --no-daemon ./gradlew :app:assembleV8Debug --no-daemon + ./gradlew :editor:assembleV8DebugAndroidTest --no-daemon - name: Run Firebase Test Lab and Wait for Results run: | - mkdir -p test-results/kotlin test-results/groovy + mkdir -p test-results/kotlin test-results/groovy test-results/editor + KOTLIN_EXIT_CODE=0 + GROOVY_EXIT_CODE=0 + EDITOR_EXIT_CODE=0 # Run Kotlin DSL tests echo "Running Kotlin DSL tests..." @@ -97,9 +101,8 @@ jobs: --results-dir=test-results/kotlin \ --results-bucket=gs://firebase-test-results-${RANDOM} \ --project=codeonthego-64a92 \ - --test-targets "class com.itsaky.androidide.ProjectBuildTestWithKtsGradle" > test-results/kotlin/test_output.txt 2>&1 + --test-targets "class com.itsaky.androidide.ProjectBuildTestWithKtsGradle" > test-results/kotlin/test_output.txt 2>&1 || KOTLIN_EXIT_CODE=$? - KOTLIN_EXIT_CODE=$? echo "Kotlin tests exit code: $KOTLIN_EXIT_CODE" # Extract Firebase Test Lab URL for Kotlin tests @@ -126,9 +129,8 @@ jobs: --results-dir=test-results/groovy \ --results-bucket=gs://firebase-test-results-${RANDOM} \ --project=codeonthego-64a92 \ - --test-targets "class com.itsaky.androidide.ProjectBuildTestWithGroovyGradle" > test-results/groovy/test_output.txt 2>&1 + --test-targets "class com.itsaky.androidide.ProjectBuildTestWithGroovyGradle" > test-results/groovy/test_output.txt 2>&1 || GROOVY_EXIT_CODE=$? - GROOVY_EXIT_CODE=$? echo "Groovy tests exit code: $GROOVY_EXIT_CODE" # Extract Firebase Test Lab URL for Groovy tests @@ -141,6 +143,35 @@ jobs: grep -o 'gs://[^[:space:]]*/video\.mp4' test-results/groovy/test_output.txt | sed 's|gs://|https://storage.cloud.google.com/|' | head -1 || echo "") echo "GROOVY_VIDEO_URL=$GROOVY_VIDEO_URL" >> $GITHUB_ENV + # Run editor module instrumentation tests (self-instrumenting library test APK) + echo "Running editor tests..." + EDITOR_TEST_APK=(editor/build/outputs/apk/androidTest/v8/debug/*-androidTest.apk) + if [ ${#EDITOR_TEST_APK[@]} -ne 1 ] || [ ! -f "${EDITOR_TEST_APK[0]}" ]; then + echo "Expected exactly one editor androidTest APK, found: ${EDITOR_TEST_APK[*]}" + exit 1 + fi + EDITOR_TEST_APK="${EDITOR_TEST_APK[0]}" + gcloud firebase test android run \ + --type instrumentation \ + --app "$EDITOR_TEST_APK" \ + --test "$EDITOR_TEST_APK" \ + --device model=oriole,version=33,locale=en,orientation=portrait \ + --timeout 15m \ + --num-flaky-test-attempts=1 \ + --results-dir=test-results/editor \ + --results-bucket=gs://firebase-test-results-${RANDOM} \ + --project=codeonthego-64a92 > test-results/editor/test_output.txt 2>&1 || EDITOR_EXIT_CODE=$? + + echo "Editor tests exit code: $EDITOR_EXIT_CODE" + EDITOR_TEST_URL=$(grep -o 'https://console.firebase.google.com/project/[^[:space:]]*' test-results/editor/test_output.txt | head -1 || echo "") + echo "EDITOR_TEST_URL=$EDITOR_TEST_URL" >> $GITHUB_ENV + + # Every suite runs even when an earlier one fails, but a failure still fails the step. + if [ "$KOTLIN_EXIT_CODE" -ne 0 ] || [ "$GROOVY_EXIT_CODE" -ne 0 ] || [ "$EDITOR_EXIT_CODE" -ne 0 ]; then + echo "Instrumentation suites failed (kotlin=$KOTLIN_EXIT_CODE groovy=$GROOVY_EXIT_CODE editor=$EDITOR_EXIT_CODE)" + exit 1 + fi + - name: Notify Slack with parsed test results if: always() # Run even if tests fail run: | @@ -179,6 +210,7 @@ jobs: echo "Parsing test results..." KOTLIN_RESULTS=$(parse_results test-results/kotlin/test_output.txt "Kotlin DSL") GROOVY_RESULTS=$(parse_results test-results/groovy/test_output.txt "Groovy DSL") + EDITOR_RESULTS=$(parse_results test-results/editor/test_output.txt "Editor module") # Also capture the actual output for debugging echo "Kotlin output file contents:" @@ -199,6 +231,7 @@ jobs: SLACK_MESSAGE="*šŸ”„ Firebase Test Lab Results*\\n\\n" SLACK_MESSAGE="$SLACK_MESSAGE*${KOTLIN_RESULTS}*\\n" SLACK_MESSAGE="$SLACK_MESSAGE*${GROOVY_RESULTS}*\\n" + SLACK_MESSAGE="$SLACK_MESSAGE*${EDITOR_RESULTS}*\\n" # Add Kotlin URLs if [[ -n "$KOTLIN_TEST_URL" ]]; then @@ -218,6 +251,9 @@ jobs: SLACK_MESSAGE="$SLACK_MESSAGE\\nšŸŽ„ Groovy Test Video: $GROOVY_VIDEO_URL" fi + if [[ -n "$EDITOR_TEST_URL" ]]; then + SLACK_MESSAGE="$SLACK_MESSAGE\\n\\nšŸ”— Editor Test Results: $EDITOR_TEST_URL" + fi # Add debugging info to see what URLs were found echo "Debug - Kotlin Video URL: '$KOTLIN_VIDEO_URL'" echo "Debug - Groovy Video URL: '$GROOVY_VIDEO_URL'" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 711e448e2f..8706f7553e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -71,7 +71,7 @@ Strategy: **layer-and-subsystem based**, not feature-by-feature. The Gradle buil |---|---|---| | Application | `app` | The IDE itself — activities, fragments, services, DI, agent, web server. Wires everything together. | | Build engine | `subprojects:tooling-api*`, `gradle-plugin*`, `subprojects:projects`, `subprojects:builder-model-impl` | Runs a real Gradle build of the user's project out-of-process and streams events back. | -| Language tooling | `lsp:{api,java,kotlin,xml,indexing,refactor-core,ui,…}`, `lexers`, `editor*`, `editor-treesitter` | Language servers, indexing, the Sora-based editor and highlighting. `lsp:refactor-core` holds the language-agnostic half of the refactorings (offset spans, block geometry, rewrite composition, name primitives) so `lsp:java` and `lsp:kotlin` share one copy; `lsp:ui` holds the Compose sheets they share. Neither depends on a language server. | +| Language tooling | `lsp:{api,java,kotlin,xml,indexing,refactor-core,ui,…}`, `lexers`, `editor*`, `editor-treesitter` | Language servers, indexing, the Sora-based editor and highlighting, and the tree-sitter document outline (`editor/.../language/outline`, rendered by `app`'s sidebar `OutlineFragment`). `lsp:refactor-core` holds the language-agnostic half of the refactorings (offset spans, block geometry, rewrite composition, name primitives) so `lsp:java` and `lsp:kotlin` share one copy; `lsp:ui` holds the Compose sheets they share. Neither depends on a language server. | | UI design tooling | `layouteditor`, `uidesigner`, `xml-inflater`, `vectormaster`, `compose-preview` | Visual/XML design surfaces for the *user's* app. | | Shell | `termux:{termux-app,termux-shared,termux-view,termux-emulator}` | Embedded Termux shell and terminal. | | Plugin system | `plugin-api`, `plugin-api:plugin-builder`, `plugin-manager` | In-app plugin SDK + manager — `AndroidManifest.xml` `` contract, permissions, extensions. See [plugin-api.md](docs/plugin-api.md) for the API surface & compatibility policy. | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b21c2d872f..5b5f2dce5a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -307,6 +307,7 @@ dependencies { implementation(libs.compose.material3) implementation(libs.compose.activity) implementation(libs.compose.lifecycle.runtime) + implementation(projects.commonCompose) implementation(libs.compose.ui.tooling.preview) debugImplementation(libs.compose.ui.tooling) diff --git a/app/src/main/java/com/itsaky/androidide/actions/sidebar/OutlineSidebarAction.kt b/app/src/main/java/com/itsaky/androidide/actions/sidebar/OutlineSidebarAction.kt new file mode 100644 index 0000000000..1b2436855f --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/actions/sidebar/OutlineSidebarAction.kt @@ -0,0 +1,28 @@ +package com.itsaky.androidide.actions.sidebar + +import android.content.Context +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import com.itsaky.androidide.R +import com.itsaky.androidide.fragments.sidebar.OutlineFragment +import com.itsaky.androidide.idetooltips.TooltipTag +import kotlin.reflect.KClass + +class OutlineSidebarAction( + context: Context, + override val order: Int, +) : AbstractSidebarAction() { + companion object { + const val ID = "ide.editor.sidebar.outline" + } + + override val id: String = ID + override val fragmentClass: KClass = OutlineFragment::class + + init { + label = context.getString(R.string.title_document_outline) + icon = ContextCompat.getDrawable(context, R.drawable.ic_outline) + } + + override fun retrieveTooltipTag(isAlternateContext: Boolean) = TooltipTag.OUTLINE_SIDEBAR +} diff --git a/app/src/main/java/com/itsaky/androidide/di/AppModule.kt b/app/src/main/java/com/itsaky/androidide/di/AppModule.kt index b1aece6ba5..a9c1a7a8fb 100644 --- a/app/src/main/java/com/itsaky/androidide/di/AppModule.kt +++ b/app/src/main/java/com/itsaky/androidide/di/AppModule.kt @@ -4,6 +4,8 @@ import com.itsaky.androidide.actions.FileActionManager import com.itsaky.androidide.analytics.AnalyticsManager import com.itsaky.androidide.analytics.IAnalyticsManager import com.itsaky.androidide.deeplink.PendingDeepLinkOpen +import com.itsaky.androidide.editor.language.outline.OutlineProvider +import com.itsaky.androidide.editor.language.outline.TreeSitterOutlineProvider import com.itsaky.androidide.git.core.GitCredentialsManager import com.itsaky.androidide.repositories.RecentProjectRepository import com.itsaky.androidide.repositories.RecentProjectRepositoryImpl @@ -11,6 +13,7 @@ import com.itsaky.androidide.roomData.recentproject.RecentProjectRoomDatabase import com.itsaky.androidide.viewmodel.CloneRepositoryViewModel import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel import com.itsaky.androidide.viewmodel.MainViewModel +import com.itsaky.androidide.viewmodel.OutlineViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -35,6 +38,8 @@ val coreModule = } viewModel { MainViewModel() } viewModel { CloneRepositoryViewModel(get(), get()) } + single { TreeSitterOutlineProvider(androidContext()) } + viewModel { OutlineViewModel(get()) } // Named, because an unqualified single is claimed by type alone: this one // instance was serving both the Room database below and EditorHandlerActivity's saveAllAsync, diff --git a/app/src/main/java/com/itsaky/androidide/fragments/sidebar/OutlineFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/sidebar/OutlineFragment.kt new file mode 100644 index 0000000000..8d5094cb4a --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/fragments/sidebar/OutlineFragment.kt @@ -0,0 +1,190 @@ +package com.itsaky.androidide.fragments.sidebar + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.view.GravityCompat +import androidx.drawerlayout.widget.DrawerLayout +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.itsaky.androidide.activities.editor.EditorHandlerActivity +import com.itsaky.androidide.common.compose.IdeTheme +import com.itsaky.androidide.editor.ui.IDEEditor +import com.itsaky.androidide.eventbus.events.editor.DocumentChangeEvent +import com.itsaky.androidide.eventbus.events.editor.DocumentOpenEvent +import com.itsaky.androidide.models.Position +import com.itsaky.androidide.ui.models.OutlineUiEffect +import com.itsaky.androidide.ui.outline.OutlinePanel +import com.itsaky.androidide.viewmodel.EditorViewModel +import com.itsaky.androidide.viewmodel.OutlineViewModel +import kotlinx.coroutines.launch +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode.MAIN +import org.koin.androidx.viewmodel.ext.android.activityViewModel +import java.nio.file.Path + +class OutlineFragment : Fragment() { + private val viewModel: OutlineViewModel by activityViewModel() + private val editorViewModel: EditorViewModel by activityViewModels() + private var drawer: DrawerLayout? = null + + private var pendingScroll: Pair? = null + + private val drawerListener = + object : DrawerLayout.SimpleDrawerListener() { + override fun onDrawerOpened(drawerView: View) { + pendingScroll = null + seedFromCurrentEditor() + } + + override fun onDrawerClosed(drawerView: View) { + val (editor, position) = pendingScroll ?: return + pendingScroll = null + if (editor.isValidPosition(position, true)) { + centerPositionInView(editor, position) + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = + ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + IdeTheme { + OutlinePanel(viewModel) + } + } + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + drawer = + (activity as? EditorHandlerActivity)?.binding?.editorDrawerLayout?.also { + it.addDrawerListener(drawerListener) + } + editorViewModel.currentFile.observe(viewLifecycleOwner) { view.post { seedFromCurrentEditor() } } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.effects.collect { effect -> + when (effect) { + is OutlineUiEffect.NavigateTo -> navigateTo(effect.position) + } + } + } + } + } + + override fun onDestroyView() { + drawer?.removeDrawerListener(drawerListener) + drawer = null + pendingScroll = null + viewModel.onNoEditor() + super.onDestroyView() + } + + override fun onStart() { + super.onStart() + if (!EventBus.getDefault().isRegistered(this)) { + EventBus.getDefault().register(this) + } + } + + override fun onStop() { + EventBus.getDefault().unregister(this) + super.onStop() + } + + @Subscribe(threadMode = MAIN) + fun onDocumentChanged(event: DocumentChangeEvent) { + if (!isPanelVisible()) return + val editor = currentEditor() ?: return + val file = editor.file ?: return + if (normalized(file.toPath()) != normalized(event.changedFile)) return + viewModel.onSnapshot( + path = normalized(event.changedFile), + extension = file.extension, + text = event.newText ?: editor.text.toString(), + immediate = false, + ) + } + + @Subscribe(threadMode = MAIN) + fun onDocumentOpened(event: DocumentOpenEvent) { + if (!isPanelVisible()) return + val file = currentEditor()?.file ?: return + if (normalized(file.toPath()) != normalized(event.openedFile)) return + viewModel.onSnapshot( + path = normalized(event.openedFile), + extension = extensionOf(event.openedFile), + text = event.text, + immediate = true, + ) + } + + private fun seedFromCurrentEditor() { + if (!isPanelVisible()) return + val editor = currentEditor() + val file = editor?.file + if (editor == null || file == null) { + viewModel.onNoEditor() + return + } + viewModel.onSnapshot( + path = normalized(file.toPath()), + extension = file.extension, + text = editor.text.toString(), + immediate = true, + ) + } + + private fun currentEditor() = (activity as? EditorHandlerActivity)?.getCurrentEditor()?.editor + + private fun isPanelVisible(): Boolean = drawer?.isDrawerOpen(GravityCompat.START) == true + + private fun normalized(path: Path): String = path.toAbsolutePath().normalize().toString() + + private fun navigateTo(position: Position) { + val drawer = drawer ?: return + val editor = currentEditor() + if (editor == null || !editor.isValidPosition(position, true)) { + drawer.closeDrawer(GravityCompat.START) + return + } + editor.setSelection(position) + if (!drawer.isDrawerOpen(GravityCompat.START)) { + centerPositionInView(editor, position) + return + } + pendingScroll = editor to position + drawer.closeDrawer(GravityCompat.START) + } + + private fun centerPositionInView( + editor: IDEEditor, + position: Position, + ) { + val rowY = editor.layout.getCharLayoutOffset(position.line, position.column)[0] + val targetY = + (rowY - editor.height / 2f) + .toInt() + .coerceIn(0, editor.scrollMaxY) + editor.scroller.startScroll(editor.offsetX, editor.offsetY, 0, targetY - editor.offsetY, 0) + editor.postInvalidate() + } + + private fun extensionOf(path: Path): String = path.fileName.toString().substringAfterLast('.', "") +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/models/OutlineUiState.kt b/app/src/main/java/com/itsaky/androidide/ui/models/OutlineUiState.kt new file mode 100644 index 0000000000..b08f2961d3 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/models/OutlineUiState.kt @@ -0,0 +1,41 @@ +package com.itsaky.androidide.ui.models + +import com.itsaky.androidide.editor.language.outline.OutlineSymbol +import com.itsaky.androidide.models.Position + +sealed interface OutlineUiState { + data object NoFileOpen : OutlineUiState + + data class Unsupported( + val fileName: String, + ) : OutlineUiState + + data class Loading( + val fileName: String, + ) : OutlineUiState + + data class Empty( + val fileName: String, + ) : OutlineUiState + + data class Content( + val fileName: String, + val symbols: List, + ) : OutlineUiState +} + +sealed interface OutlineUiEvent { + data class SymbolClicked( + val symbol: OutlineSymbol, + ) : OutlineUiEvent + + data class ToggleCollapsed( + val path: String, + ) : OutlineUiEvent +} + +sealed interface OutlineUiEffect { + data class NavigateTo( + val position: Position, + ) : OutlineUiEffect +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/outline/OutlinePanel.kt b/app/src/main/java/com/itsaky/androidide/ui/outline/OutlinePanel.kt new file mode 100644 index 0000000000..d9d3bc3c08 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/outline/OutlinePanel.kt @@ -0,0 +1,256 @@ +package com.itsaky.androidide.ui.outline + +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.itsaky.androidide.R +import com.itsaky.androidide.editor.language.outline.OutlineSymbolKind +import com.itsaky.androidide.ui.models.OutlineUiEvent +import com.itsaky.androidide.ui.models.OutlineUiState +import com.itsaky.androidide.viewmodel.OutlineViewModel + +private const val MAX_INDENT_DEPTH = 6 +private val INDENT_STEP = 14.dp +private val TOGGLE_TARGET = 48.dp + +private val TYPE_BADGE_LIGHT = Color(0xFF6650C4) +private val TYPE_BADGE_DARK = Color(0xFFB9A8FF) +private val CALLABLE_BADGE_LIGHT = Color(0xFF0B6E77) +private val CALLABLE_BADGE_DARK = Color(0xFF7FD8DF) +private val DATA_BADGE_LIGHT = Color(0xFFA05A00) +private val DATA_BADGE_DARK = Color(0xFFF0B45C) + +@StringRes +private fun kindLabelRes(kind: OutlineSymbolKind): Int = + when (kind) { + OutlineSymbolKind.CLASS -> R.string.cd_outline_kind_class + OutlineSymbolKind.INTERFACE -> R.string.cd_outline_kind_interface + OutlineSymbolKind.ENUM -> R.string.cd_outline_kind_enum + OutlineSymbolKind.ENUM_MEMBER -> R.string.cd_outline_kind_enum_member + OutlineSymbolKind.RECORD -> R.string.cd_outline_kind_record + OutlineSymbolKind.ANNOTATION -> R.string.cd_outline_kind_annotation + OutlineSymbolKind.OBJECT -> R.string.cd_outline_kind_object + OutlineSymbolKind.COMPANION -> R.string.cd_outline_kind_companion + OutlineSymbolKind.TYPE_ALIAS -> R.string.cd_outline_kind_type_alias + OutlineSymbolKind.CONSTRUCTOR -> R.string.cd_outline_kind_constructor + OutlineSymbolKind.METHOD -> R.string.cd_outline_kind_method + OutlineSymbolKind.FIELD -> R.string.cd_outline_kind_field + OutlineSymbolKind.PROPERTY -> R.string.cd_outline_kind_property + OutlineSymbolKind.ELEMENT -> R.string.cd_outline_kind_element + } + +@Composable +private fun badgeColorFor(kind: OutlineSymbolKind): Color { + val dark = isSystemInDarkTheme() + return when (kind) { + OutlineSymbolKind.METHOD, + OutlineSymbolKind.CONSTRUCTOR, + -> if (dark) CALLABLE_BADGE_DARK else CALLABLE_BADGE_LIGHT + + OutlineSymbolKind.FIELD, + OutlineSymbolKind.PROPERTY, + OutlineSymbolKind.ENUM_MEMBER, + -> if (dark) DATA_BADGE_DARK else DATA_BADGE_LIGHT + + else -> if (dark) TYPE_BADGE_DARK else TYPE_BADGE_LIGHT + } +} + +@Composable +fun OutlinePanel( + viewModel: OutlineViewModel, + modifier: Modifier = Modifier, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val collapsedPaths by viewModel.collapsedPaths.collectAsStateWithLifecycle() + OutlinePanelContent(state = state, collapsedPaths = collapsedPaths, onEvent = viewModel::onEvent, modifier = modifier) +} + +@Composable +internal fun OutlinePanelContent( + state: OutlineUiState, + collapsedPaths: Set, + onEvent: (OutlineUiEvent) -> Unit, + modifier: Modifier = Modifier, +) { + when (state) { + is OutlineUiState.NoFileOpen -> { + CenteredMessage(stringResource(R.string.outline_no_file_open), modifier) + } + + is OutlineUiState.Unsupported -> { + CenteredMessage(stringResource(R.string.outline_unsupported, state.fileName), modifier) + } + + is OutlineUiState.Loading -> { + CenteredMessage(stringResource(R.string.outline_loading), modifier) + } + + is OutlineUiState.Empty -> { + CenteredMessage(stringResource(R.string.outline_empty), modifier) + } + + is OutlineUiState.Content -> { + OutlineTree(state, collapsedPaths, onEvent, modifier) + } + } +} + +@Composable +private fun CenteredMessage( + message: String, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxSize() + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun OutlineTree( + state: OutlineUiState.Content, + collapsedPaths: Set, + onEvent: (OutlineUiEvent) -> Unit, + modifier: Modifier = Modifier, +) { + val rows = + remember(state.symbols, collapsedPaths) { + flattenOutline(state.symbols, collapsedPaths) + } + LazyColumn(modifier = modifier.fillMaxSize()) { + items(rows, key = { it.path }) { row -> + OutlineRow(row = row, onEvent = onEvent) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun OutlineRow( + row: OutlineRowModel, + onEvent: (OutlineUiEvent) -> Unit, + modifier: Modifier = Modifier, +) { + val symbol = row.symbol + val kindLabel = stringResource(kindLabelRes(symbol.kind)) + val rowDescription = listOfNotNull(kindLabel, symbol.name, symbol.detail).joinToString(", ") + val cappedDepth = minOf(row.depth, MAX_INDENT_DEPTH) + val indent = INDENT_STEP * cappedDepth + val guideColor = MaterialTheme.colorScheme.outlineVariant + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .fillMaxWidth() + .clickable { onEvent(OutlineUiEvent.SymbolClicked(symbol)) } + .drawBehind { + val step = INDENT_STEP.toPx() + val stroke = 1.dp.toPx() + val base = 15.dp.toPx() + for (level in 0 until cappedDepth) { + val x = base + level * step + drawLine( + color = guideColor, + start = Offset(x, 0f), + end = Offset(x, size.height), + strokeWidth = stroke, + ) + } + }.padding(start = 4.dp + indent, end = 12.dp) + .semantics { contentDescription = rowDescription }, + ) { + if (row.hasChildren) { + val toggleDescription = + stringResource( + if (row.collapsed) R.string.cd_outline_expand else R.string.cd_outline_collapse, + ) + IconButton( + onClick = { onEvent(OutlineUiEvent.ToggleCollapsed(row.path)) }, + modifier = Modifier.size(TOGGLE_TARGET), + ) { + Icon( + imageVector = Icons.Filled.ArrowDropDown, + contentDescription = toggleDescription, + modifier = Modifier.rotate(if (row.collapsed) -90f else 0f), + ) + } + } else { + Spacer(modifier = Modifier.size(TOGGLE_TARGET)) + } + Text( + text = symbol.kind.badge, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyMedium, + color = badgeColorFor(symbol.kind), + modifier = Modifier.padding(start = 2.dp, end = 8.dp), + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = + Modifier + .weight(1f) + .padding(vertical = 4.dp), + ) { + Text( + text = symbol.name, + style = MaterialTheme.typography.bodyMedium, + ) + symbol.detail?.let { detail -> + Text( + text = detail, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/outline/OutlineRows.kt b/app/src/main/java/com/itsaky/androidide/ui/outline/OutlineRows.kt new file mode 100644 index 0000000000..1e8e116d63 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/outline/OutlineRows.kt @@ -0,0 +1,41 @@ +package com.itsaky.androidide.ui.outline + +import com.itsaky.androidide.editor.language.outline.OutlineSymbol + +internal data class OutlineRowModel( + val symbol: OutlineSymbol, + val path: String, + val depth: Int, + val hasChildren: Boolean, + val collapsed: Boolean, +) + +internal fun flattenOutline( + symbols: List, + collapsedPaths: Set, +): List { + val rows = mutableListOf() + + fun walk(siblings: List, parentPath: String, depth: Int) { + siblings.forEachIndexed { index, symbol -> + val segment = "${symbol.name}#$index" + val path = if (parentPath.isEmpty()) segment else "$parentPath/$segment" + val collapsed = path in collapsedPaths + rows.add( + OutlineRowModel( + symbol = symbol, + path = path, + depth = depth, + hasChildren = symbol.children.isNotEmpty(), + collapsed = collapsed, + ), + ) + if (!collapsed) { + walk(symbol.children, path, depth + 1) + } + } + } + + walk(symbols, "", 0) + return rows +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/EditorSidebarActions.kt b/app/src/main/java/com/itsaky/androidide/utils/EditorSidebarActions.kt index 39395b33ce..0d68cc12f8 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/EditorSidebarActions.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/EditorSidebarActions.kt @@ -39,20 +39,21 @@ import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.ActionItem import com.itsaky.androidide.actions.ActionsRegistry import com.itsaky.androidide.actions.FillMenuParams +import com.itsaky.androidide.actions.PluginSidebarActionItem import com.itsaky.androidide.actions.SidebarActionItem +import com.itsaky.androidide.actions.SidebarSlotManager import com.itsaky.androidide.actions.internal.DefaultActionsRegistry import com.itsaky.androidide.actions.sidebar.BuildVariantsSidebarAction import com.itsaky.androidide.actions.sidebar.CloseProjectSidebarAction import com.itsaky.androidide.actions.sidebar.FileTreeSidebarAction import com.itsaky.androidide.actions.sidebar.HelpSideBarAction +import com.itsaky.androidide.actions.sidebar.OutlineSidebarAction import com.itsaky.androidide.actions.sidebar.PreferencesSidebarAction import com.itsaky.androidide.actions.sidebar.TerminalSidebarAction +import com.itsaky.androidide.eventbus.events.plugin.PluginCrashedEvent import com.itsaky.androidide.fragments.sidebar.EditorSidebarFragment import com.itsaky.androidide.plugins.extensions.UIExtension -import com.itsaky.androidide.actions.PluginSidebarActionItem -import com.itsaky.androidide.actions.SidebarSlotManager import com.itsaky.androidide.plugins.manager.core.PluginManager -import com.itsaky.androidide.eventbus.events.plugin.PluginCrashedEvent import org.greenrobot.eventbus.EventBus import java.lang.ref.WeakReference @@ -64,228 +65,242 @@ import java.lang.ref.WeakReference */ object ContactDetails { - const val EMAIL_SUPPORT = "feedback@appdevforall.org" + const val EMAIL_SUPPORT = "feedback@appdevforall.org" } internal object EditorSidebarActions { - - private var previousDestinationListener: NavController.OnDestinationChangedListener? = null - private var previousNavController: WeakReference? = null - - @JvmStatic - fun registerActions(context: Context) { - val registry = ActionsRegistry.getInstance() - var order = -1 - - @Suppress("KotlinConstantConditions") - registry.registerAction(FileTreeSidebarAction(context, ++order)) - registry.registerAction(BuildVariantsSidebarAction(context, ++order)) - registry.registerAction(TerminalSidebarAction(context, ++order)) - registry.registerAction(PreferencesSidebarAction(context, ++order)) - registry.registerAction(CloseProjectSidebarAction(context, ++order)) - registry.registerAction(HelpSideBarAction(context, ++order)) - - // Set built-in item count (6 items) for sidebar slot management - SidebarSlotManager.setBuiltInItemCount(order + 1) - - // Register plugin sidebar items - registerPluginSidebarActions(context, registry, ++order) - } - - @JvmStatic - fun setup(sidebarFragment: EditorSidebarFragment) { - val binding = sidebarFragment.getBinding() ?: return - val controller = binding.editorSidebarFragmentContainer.getFragment().navController - val context = sidebarFragment.requireContext() - val rail = binding.navigation - - - val registry = ActionsRegistry.getInstance() - val actions = registry.getActions(ActionItem.Location.EDITOR_SIDEBAR) - if (actions.isEmpty()) { - return - } - - rail.background = (rail.background as MaterialShapeDrawable).apply { - shapeAppearanceModel = shapeAppearanceModel.roundedOnRight() - } - - rail.menu.clear() - - val data = ActionData.create(context) - val titleRef = WeakReference(binding.title) - val params = FillMenuParams( - data, - ActionItem.Location.EDITOR_SIDEBAR, - rail.menu - ) { actionsRegistry, action, item, actionsData -> - action as SidebarActionItem - - if (action.fragmentClass == null) { - (actionsRegistry as DefaultActionsRegistry).executeAction(action, actionsData) - return@FillMenuParams true - } - - return@FillMenuParams try { - controller.navigate(action.id, navOptions { - launchSingleTop = true - restoreState = true - }) - - val result = controller.currentDestination?.matchDestination(action.id) == true - if (result) { - item.isChecked = true - titleRef.get()?.text = item.title - } - - result - } catch (e: IllegalArgumentException) { - false - } - } - - registry.fillMenu(params) - - rail.menu.forEach { item -> - val view = rail.findViewById(item.itemId) - val action = actions.values.find { it.itemId == item.itemId } as? SidebarActionItem - - if (view != null && action != null) { - val tag = action.retrieveTooltipTag(false) - sidebarFragment.setupTooltip(view, tag, action.retrieveTooltipCategory()) - } - } - - controller.graph = controller.createGraph(startDestination = FileTreeSidebarAction.ID) { - actions.forEach { (actionId, action) -> - if (action !is SidebarActionItem) { - throw IllegalStateException( - "Actions registered at location ${ActionItem.Location.EDITOR_SIDEBAR}" + - " must implement ${SidebarActionItem::class.java.simpleName}" - ) - } - - val fragment = action.fragmentClass ?: return@forEach - - val builder = FragmentNavigatorDestinationBuilder( - provider[FragmentNavigator::class], - actionId, - fragment - ) - - builder.apply { - action.apply { buildNavigation() } - } - - destination(builder) - } - } - - previousDestinationListener?.let { stale -> - previousNavController?.get()?.removeOnDestinationChangedListener(stale) - } - - val railRef = WeakReference(rail) - val destinationListener = object : NavController.OnDestinationChangedListener { - override fun onDestinationChanged( - controller: NavController, - destination: NavDestination, - arguments: Bundle? - ) { - val railView = railRef.get() - if (railView == null) { - controller.removeOnDestinationChangedListener(this) - return - } - railView.menu.forEach { item -> - if (destination.matchDestination(item.itemId)) { - item.isChecked = true - titleRef.get()?.text = item.title - } - } - } - } - controller.addOnDestinationChangedListener(destinationListener) - previousDestinationListener = destinationListener - previousNavController = WeakReference(controller) - - rail.menu.findItem(FileTreeSidebarAction.ID.hashCode())?.also { - it.isChecked = true - binding.title.text = it.title - } - } - - /** - * Determines whether the given `route` matches the NavDestination. This handles - * both the default case (the destination's route matches the given route) and the nested case where - * the given route is a parent/grandparent/etc of the destination. - */ - @JvmStatic - internal fun NavDestination.matchDestination(route: String): Boolean = - hierarchy.any { it.route == route } - - @JvmStatic - internal fun NavDestination.matchDestination(@IdRes destId: Int): Boolean = - hierarchy.any { it.id == destId } - - @JvmStatic - internal fun ShapeAppearanceModel.roundedOnRight(cornerSize: Float = 28f): ShapeAppearanceModel { - return toBuilder().run { - setTopRightCorner(CornerFamily.ROUNDED, cornerSize) - setBottomRightCorner(CornerFamily.ROUNDED, cornerSize) - build() - } - } - - /** - * Register plugin UI contributions to the sidebar. - * - * @param context The application context - * @param registry The actions registry - * @param startOrder The starting order for plugin actions - */ - @JvmStatic - private fun registerPluginSidebarActions(context: Context, registry: ActionsRegistry, startOrder: Int) { - var order = startOrder - - val pluginManager = PluginManager.getInstance() ?: return - - pluginManager.getAllPluginInstances() - .filterIsInstance() - .forEach { plugin -> - val pluginId = pluginManager.getPluginIdForInstance(plugin as com.itsaky.androidide.plugins.IPlugin) - ?: return@forEach - - try { - val declaredSlots = SidebarSlotManager.getDeclaredSlots(pluginId) - val sideMenuItems = plugin.getSideMenuItems() - - if (sideMenuItems.isEmpty()) return@forEach - - if (sideMenuItems.size > declaredSlots) { - Log.w("EditorSidebarActions", - "Plugin '$pluginId' returned ${sideMenuItems.size} sidebar items " + - "but only declared $declaredSlots in manifest — skipping" - ) - return@forEach - } - - sideMenuItems.forEach { navItem -> - val action = PluginSidebarActionItem(context, navItem, order++, pluginId) - registry.registerAction(action) - } - } catch (e: Exception) { - Log.e("EditorSidebarActions", "Plugin '$pluginId' crashed in getSideMenuItems()", e) - val result = pluginManager.recordPluginCrash(pluginId) - val wasDisabled = result is PluginManager.CrashResult.Disabled - val crashCount = when (result) { - is PluginManager.CrashResult.Recorded -> result.crashCount - is PluginManager.CrashResult.Disabled -> pluginManager.crashTracker.getCrashCount(pluginId) - } - EventBus.getDefault().post( - PluginCrashedEvent(pluginId, result.pluginName, crashCount, wasDisabled, Log.getStackTraceString(e)) - ) - } - } - } -} \ No newline at end of file + private var previousDestinationListener: NavController.OnDestinationChangedListener? = null + private var previousNavController: WeakReference? = null + + @JvmStatic + fun registerActions(context: Context) { + val registry = ActionsRegistry.getInstance() + var order = -1 + + @Suppress("KotlinConstantConditions") + registry.registerAction(FileTreeSidebarAction(context, ++order)) + registry.registerAction(OutlineSidebarAction(context, ++order)) + registry.registerAction(BuildVariantsSidebarAction(context, ++order)) + registry.registerAction(TerminalSidebarAction(context, ++order)) + registry.registerAction(PreferencesSidebarAction(context, ++order)) + registry.registerAction(CloseProjectSidebarAction(context, ++order)) + registry.registerAction(HelpSideBarAction(context, ++order)) + + // Set built-in item count (7 items) for sidebar slot management + SidebarSlotManager.setBuiltInItemCount(order + 1) + + // Register plugin sidebar items + registerPluginSidebarActions(context, registry, ++order) + } + + @JvmStatic + fun setup(sidebarFragment: EditorSidebarFragment) { + val binding = sidebarFragment.getBinding() ?: return + val controller = binding.editorSidebarFragmentContainer.getFragment().navController + val context = sidebarFragment.requireContext() + val rail = binding.navigation + + val registry = ActionsRegistry.getInstance() + val actions = registry.getActions(ActionItem.Location.EDITOR_SIDEBAR) + if (actions.isEmpty()) { + return + } + + rail.background = + (rail.background as MaterialShapeDrawable).apply { + shapeAppearanceModel = shapeAppearanceModel.roundedOnRight() + } + + rail.menu.clear() + + val data = ActionData.create(context) + val titleRef = WeakReference(binding.title) + val params = + FillMenuParams( + data, + ActionItem.Location.EDITOR_SIDEBAR, + rail.menu, + ) { actionsRegistry, action, item, actionsData -> + action as SidebarActionItem + + if (action.fragmentClass == null) { + (actionsRegistry as DefaultActionsRegistry).executeAction(action, actionsData) + return@FillMenuParams true + } + + return@FillMenuParams try { + controller.navigate( + action.id, + navOptions { + launchSingleTop = true + restoreState = true + }, + ) + + val result = controller.currentDestination?.matchDestination(action.id) == true + if (result) { + item.isChecked = true + titleRef.get()?.text = item.title + } + + result + } catch (e: IllegalArgumentException) { + false + } + } + + registry.fillMenu(params) + + rail.menu.forEach { item -> + val view = rail.findViewById(item.itemId) + val action = actions.values.find { it.itemId == item.itemId } as? SidebarActionItem + + if (view != null && action != null) { + val tag = action.retrieveTooltipTag(false) + sidebarFragment.setupTooltip(view, tag, action.retrieveTooltipCategory()) + } + } + + controller.graph = + controller.createGraph(startDestination = FileTreeSidebarAction.ID) { + actions.forEach { (actionId, action) -> + if (action !is SidebarActionItem) { + throw IllegalStateException( + "Actions registered at location ${ActionItem.Location.EDITOR_SIDEBAR}" + + " must implement ${SidebarActionItem::class.java.simpleName}", + ) + } + + val fragment = action.fragmentClass ?: return@forEach + + val builder = + FragmentNavigatorDestinationBuilder( + provider[FragmentNavigator::class], + actionId, + fragment, + ) + + builder.apply { + action.apply { buildNavigation() } + } + + destination(builder) + } + } + + previousDestinationListener?.let { stale -> + previousNavController?.get()?.removeOnDestinationChangedListener(stale) + } + + val railRef = WeakReference(rail) + val destinationListener = + object : NavController.OnDestinationChangedListener { + override fun onDestinationChanged( + controller: NavController, + destination: NavDestination, + arguments: Bundle?, + ) { + val railView = railRef.get() + if (railView == null) { + controller.removeOnDestinationChangedListener(this) + return + } + railView.menu.forEach { item -> + if (destination.matchDestination(item.itemId)) { + item.isChecked = true + titleRef.get()?.text = item.title + } + } + } + } + controller.addOnDestinationChangedListener(destinationListener) + previousDestinationListener = destinationListener + previousNavController = WeakReference(controller) + + rail.menu.findItem(FileTreeSidebarAction.ID.hashCode())?.also { + it.isChecked = true + binding.title.text = it.title + } + } + + /** + * Determines whether the given `route` matches the NavDestination. This handles + * both the default case (the destination's route matches the given route) and the nested case where + * the given route is a parent/grandparent/etc of the destination. + */ + @JvmStatic + internal fun NavDestination.matchDestination(route: String): Boolean = hierarchy.any { it.route == route } + + @JvmStatic + internal fun NavDestination.matchDestination( + @IdRes destId: Int, + ): Boolean = hierarchy.any { it.id == destId } + + @JvmStatic + internal fun ShapeAppearanceModel.roundedOnRight(cornerSize: Float = 28f): ShapeAppearanceModel = + toBuilder().run { + setTopRightCorner(CornerFamily.ROUNDED, cornerSize) + setBottomRightCorner(CornerFamily.ROUNDED, cornerSize) + build() + } + + /** + * Register plugin UI contributions to the sidebar. + * + * @param context The application context + * @param registry The actions registry + * @param startOrder The starting order for plugin actions + */ + @JvmStatic + private fun registerPluginSidebarActions( + context: Context, + registry: ActionsRegistry, + startOrder: Int, + ) { + var order = startOrder + + val pluginManager = PluginManager.getInstance() ?: return + + pluginManager + .getAllPluginInstances() + .filterIsInstance() + .forEach { plugin -> + val pluginId = + pluginManager.getPluginIdForInstance(plugin as com.itsaky.androidide.plugins.IPlugin) + ?: return@forEach + + try { + val declaredSlots = SidebarSlotManager.getDeclaredSlots(pluginId) + val sideMenuItems = plugin.getSideMenuItems() + + if (sideMenuItems.isEmpty()) return@forEach + + if (sideMenuItems.size > declaredSlots) { + Log.w( + "EditorSidebarActions", + "Plugin '$pluginId' returned ${sideMenuItems.size} sidebar items " + + "but only declared $declaredSlots in manifest — skipping", + ) + return@forEach + } + + sideMenuItems.forEach { navItem -> + val action = PluginSidebarActionItem(context, navItem, order++, pluginId) + registry.registerAction(action) + } + } catch (e: Exception) { + Log.e("EditorSidebarActions", "Plugin '$pluginId' crashed in getSideMenuItems()", e) + val result = pluginManager.recordPluginCrash(pluginId) + val wasDisabled = result is PluginManager.CrashResult.Disabled + val crashCount = + when (result) { + is PluginManager.CrashResult.Recorded -> result.crashCount + is PluginManager.CrashResult.Disabled -> pluginManager.crashTracker.getCrashCount(pluginId) + } + EventBus.getDefault().post( + PluginCrashedEvent(pluginId, result.pluginName, crashCount, wasDisabled, Log.getStackTraceString(e)), + ) + } + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt index 58ae01bb52..c4c2f0cd98 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt @@ -18,6 +18,7 @@ package com.itsaky.androidide.viewmodel import android.view.Gravity.CENTER import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import androidx.lifecycle.ViewModel @@ -109,6 +110,9 @@ class EditorViewModel : ViewModel() { */ private val mCurrentFile = MutableLiveData?>(null) + @get:JvmName("currentFileLiveData") + val currentFile: LiveData?> get() = mCurrentFile + var areFilesModified: Boolean get() = _filesModified.value ?: false set(value) { diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/OutlineViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/OutlineViewModel.kt new file mode 100644 index 0000000000..f26977b296 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/OutlineViewModel.kt @@ -0,0 +1,139 @@ +package com.itsaky.androidide.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.itsaky.androidide.editor.language.outline.OutlineProvider +import com.itsaky.androidide.ui.models.OutlineUiEffect +import com.itsaky.androidide.ui.models.OutlineUiEvent +import com.itsaky.androidide.ui.models.OutlineUiState +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory + +class OutlineViewModel( + private val outlineProvider: OutlineProvider, + private val computeDispatcher: CoroutineDispatcher = Dispatchers.Default, +) : ViewModel() { + private data class Snapshot( + val path: String, + val extension: String, + val text: String, + val immediate: Boolean, + ) { + val fileName: String get() = path.substringAfterLast('/') + } + + private data class Collapsed( + val path: String?, + val paths: Set, + ) + + private val snapshots = MutableStateFlow(null) + private val _uiState = MutableStateFlow(OutlineUiState.NoFileOpen) + private val collapsed = MutableStateFlow(Collapsed(path = null, paths = emptySet())) + private var lastComputedPath: String? = null + + val uiState: StateFlow = _uiState.asStateFlow() + val collapsedPaths: StateFlow> = + collapsed.map { it.paths }.stateIn(viewModelScope, SharingStarted.Eagerly, emptySet()) + + private val _effects = MutableSharedFlow() + val effects = _effects.asSharedFlow() + + companion object { + private const val DEBOUNCE_MILLIS = 250L + private val log = LoggerFactory.getLogger(OutlineViewModel::class.java) + } + + init { + viewModelScope.launch(computeDispatcher) { + @OptIn(FlowPreview::class) + snapshots + .debounce { snapshot -> + if (snapshot == null || snapshot.immediate) 0L else DEBOUNCE_MILLIS + }.collectLatest { snapshot -> + try { + compute(snapshot) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.error("Failed to refresh outline for {}", snapshot?.fileName, e) + } + } + } + } + + fun onSnapshot( + path: String, + extension: String, + text: String, + immediate: Boolean, + ) { + snapshots.value = Snapshot(path, extension, text, immediate) + } + + fun onNoEditor() { + snapshots.value = null + } + + fun onEvent(event: OutlineUiEvent) { + when (event) { + is OutlineUiEvent.SymbolClicked -> { + viewModelScope.launch { + _effects.emit(OutlineUiEffect.NavigateTo(event.symbol.selectionRange.start)) + } + } + + is OutlineUiEvent.ToggleCollapsed -> { + collapsed.update { it.copy(paths = if (event.path in it.paths) it.paths - event.path else it.paths + event.path) } + } + } + } + + private suspend fun compute(snapshot: Snapshot?) { + if (snapshot == null) { + lastComputedPath = null + _uiState.value = OutlineUiState.NoFileOpen + return + } + if (!outlineProvider.supports(snapshot.extension)) { + lastComputedPath = snapshot.path + _uiState.value = OutlineUiState.Unsupported(snapshot.fileName) + return + } + collapsed.update { if (it.path == snapshot.path) it else Collapsed(snapshot.path, emptySet()) } + if (lastComputedPath != snapshot.path) { + _uiState.value = OutlineUiState.Loading(snapshot.fileName) + } + val symbols = + try { + outlineProvider.outlineOf(snapshot.extension, snapshot.text) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.error("Failed to compute outline for {}", snapshot.fileName, e) + emptyList() + } + lastComputedPath = snapshot.path + _uiState.value = + if (symbols.isEmpty()) { + OutlineUiState.Empty(snapshot.fileName) + } else { + OutlineUiState.Content(snapshot.fileName, symbols) + } + } +} diff --git a/app/src/test/java/com/itsaky/androidide/ui/outline/OutlineRowsTest.kt b/app/src/test/java/com/itsaky/androidide/ui/outline/OutlineRowsTest.kt new file mode 100644 index 0000000000..3832cdd367 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/outline/OutlineRowsTest.kt @@ -0,0 +1,65 @@ +package com.itsaky.androidide.ui.outline + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.editor.language.outline.OutlineSymbol +import com.itsaky.androidide.editor.language.outline.OutlineSymbolKind +import com.itsaky.androidide.models.Position +import com.itsaky.androidide.models.Range +import org.junit.Test + +class OutlineRowsTest { + private fun symbol( + name: String, + children: List = emptyList(), + ) = OutlineSymbol( + name = name, + detail = null, + kind = OutlineSymbolKind.METHOD, + range = Range(Position(0, 0, 0), Position(0, 0, 1)), + selectionRange = Range(Position(0, 0, 0), Position(0, 0, 1)), + children = children, + ) + + @Test + fun `flattens depth-first with depths`() { + val tree = listOf(symbol("a", listOf(symbol("b", listOf(symbol("c"))))), symbol("d")) + val rows = flattenOutline(tree, emptySet()) + assertThat(rows.map { it.symbol.name }).containsExactly("a", "b", "c", "d").inOrder() + assertThat(rows.map { it.depth }).containsExactly(0, 1, 2, 0).inOrder() + } + + @Test + fun `collapsed path hides descendants but keeps the row`() { + val tree = listOf(symbol("a", listOf(symbol("b", listOf(symbol("c")))))) + val rows = flattenOutline(tree, setOf("a#0/b#0")) + assertThat(rows.map { it.symbol.name }).containsExactly("a", "b").inOrder() + assertThat(rows[1].collapsed).isTrue() + assertThat(rows[1].hasChildren).isTrue() + } + + @Test + fun `same-named siblings get ordinal-suffixed paths`() { + val tree = listOf(symbol("cls", listOf(symbol("bind"), symbol("bind"), symbol("other")))) + val rows = flattenOutline(tree, emptySet()) + assertThat(rows.map { it.path }) + .containsExactly("cls#0", "cls#0/bind#0", "cls#0/bind#1", "cls#0/other#2") + .inOrder() + } + + @Test + fun `paths are unique even in deep duplicate trees`() { + val tree = + listOf( + symbol("a", listOf(symbol("x"))), + symbol("a", listOf(symbol("x"))), + ) + val rows = flattenOutline(tree, emptySet()) + assertThat(rows.map { it.path }.toSet()).hasSize(4) + } + + @Test + fun `hasChildren reflects the tree not the visibility`() { + val rows = flattenOutline(listOf(symbol("leaf")), emptySet()) + assertThat(rows[0].hasChildren).isFalse() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/OutlineViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/OutlineViewModelTest.kt new file mode 100644 index 0000000000..9d724f8e5e --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/OutlineViewModelTest.kt @@ -0,0 +1,236 @@ +package com.itsaky.androidide.viewmodel + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.editor.language.outline.OutlineProvider +import com.itsaky.androidide.editor.language.outline.OutlineSymbol +import com.itsaky.androidide.editor.language.outline.OutlineSymbolKind +import com.itsaky.androidide.models.Position +import com.itsaky.androidide.models.Range +import com.itsaky.androidide.ui.models.OutlineUiEffect +import com.itsaky.androidide.ui.models.OutlineUiEvent +import com.itsaky.androidide.ui.models.OutlineUiState +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class OutlineViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private class FakeOutlineProvider : OutlineProvider { + var symbols: List = emptyList() + var callCount = 0 + var gate: CompletableDeferred? = null + var supportsFailsOnce = false + + override fun supports(fileExtension: String): Boolean { + if (supportsFailsOnce) { + supportsFailsOnce = false + throw IllegalStateException("grammar unavailable") + } + return fileExtension == "java" + } + + override suspend fun outlineOf( + fileExtension: String, + text: CharSequence, + ): List { + callCount++ + gate?.await() + return symbols + } + } + + private fun symbol( + name: String, + start: Int = 0, + end: Int = 100, + ) = OutlineSymbol( + name = name, + detail = null, + kind = OutlineSymbolKind.CLASS, + range = Range(Position(0, 0, start), Position(9, 0, end)), + selectionRange = + Range( + Position(0, 6, start + 6), + Position(0, 6 + name.length, start + 6 + name.length), + ), + children = emptyList(), + ) + + private fun TestScope.viewModel(provider: FakeOutlineProvider) = OutlineViewModel(provider, UnconfinedTestDispatcher(testScheduler)) + + @Test + fun `collapse state is keyed by path, not basename`() = + runTest { + val provider = FakeOutlineProvider().apply { symbols = listOf(symbol("Main")) } + val vm = viewModel(provider) + vm.onSnapshot("/a/Main.java", "java", "class Main {}", immediate = true) + advanceTimeBy(1) + vm.onEvent(OutlineUiEvent.ToggleCollapsed("Main#0")) + assertThat(vm.collapsedPaths.value).containsExactly("Main#0") + + vm.onSnapshot("/b/Main.java", "java", "class Main {}", immediate = true) + advanceTimeBy(1) + + assertThat(vm.collapsedPaths.value).isEmpty() + } + + @Test + fun `a failing supports check does not stop later snapshots from refreshing`() = + runTest { + val provider = + FakeOutlineProvider().apply { + symbols = listOf(symbol("Main")) + supportsFailsOnce = true + } + val vm = viewModel(provider) + vm.onSnapshot("/a/Broken.java", "java", "class Broken {}", immediate = true) + advanceTimeBy(1) + + vm.onSnapshot("/a/Main.java", "java", "class Main {}", immediate = true) + advanceTimeBy(1) + + assertThat(vm.uiState.value).isInstanceOf(OutlineUiState.Content::class.java) + assertThat((vm.uiState.value as OutlineUiState.Content).fileName).isEqualTo("Main.java") + } + + @Test + fun `initial state is NoFileOpen`() = + runTest { + val vm = viewModel(FakeOutlineProvider()) + assertThat(vm.uiState.value).isEqualTo(OutlineUiState.NoFileOpen) + } + + @Test + fun `unsupported extension yields Unsupported`() = + runTest { + val vm = viewModel(FakeOutlineProvider()) + vm.onSnapshot("build.gradle", "gradle", "task a {}", immediate = true) + advanceUntilIdle() + assertThat(vm.uiState.value).isEqualTo(OutlineUiState.Unsupported("build.gradle")) + } + + @Test + fun `immediate snapshot with symbols yields Content without debounce delay`() = + runTest { + val provider = FakeOutlineProvider().apply { symbols = listOf(symbol("Main")) } + val vm = viewModel(provider) + vm.onSnapshot("Main.java", "java", "class Main {}", immediate = true) + advanceTimeBy(1) + val state = vm.uiState.value + assertThat(state).isInstanceOf(OutlineUiState.Content::class.java) + assertThat((state as OutlineUiState.Content).symbols.map { it.name }) + .containsExactly("Main") + } + + @Test + fun `no symbols yields Empty`() = + runTest { + val vm = viewModel(FakeOutlineProvider()) + vm.onSnapshot("Main.java", "java", "", immediate = true) + advanceUntilIdle() + assertThat(vm.uiState.value).isEqualTo(OutlineUiState.Empty("Main.java")) + } + + @Test + fun `rapid edits coalesce into one computation`() = + runTest { + val provider = FakeOutlineProvider().apply { symbols = listOf(symbol("Main")) } + val vm = viewModel(provider) + vm.onSnapshot("Main.java", "java", "class Main {}", immediate = true) + advanceUntilIdle() + val callsAfterSeed = provider.callCount + vm.onSnapshot("Main.java", "java", "class Main {a}", immediate = false) + advanceTimeBy(100) + vm.onSnapshot("Main.java", "java", "class Main {ab}", immediate = false) + advanceTimeBy(100) + vm.onSnapshot("Main.java", "java", "class Main {abc}", immediate = false) + advanceUntilIdle() + assertThat(provider.callCount).isEqualTo(callsAfterSeed + 1) + } + + @Test + fun `collapse survives a re-parse of the same file`() = + runTest { + val provider = FakeOutlineProvider().apply { symbols = listOf(symbol("Main")) } + val vm = viewModel(provider) + vm.onSnapshot("Main.java", "java", "v1", immediate = true) + advanceUntilIdle() + vm.onEvent(OutlineUiEvent.ToggleCollapsed("Main#0")) + assertThat(vm.collapsedPaths.value) + .containsExactly("Main#0") + vm.onSnapshot("Main.java", "java", "v2", immediate = false) + advanceUntilIdle() + assertThat(vm.collapsedPaths.value) + .containsExactly("Main#0") + } + + @Test + fun `switching files shows Loading while computing and resets collapse state`() = + runTest { + val provider = FakeOutlineProvider().apply { symbols = listOf(symbol("Main")) } + val vm = viewModel(provider) + vm.onSnapshot("Main.java", "java", "v1", immediate = true) + advanceUntilIdle() + vm.onEvent(OutlineUiEvent.ToggleCollapsed("Main#0")) + provider.gate = CompletableDeferred() + vm.onSnapshot("Other.java", "java", "v1", immediate = true) + advanceUntilIdle() + assertThat(vm.uiState.value).isEqualTo(OutlineUiState.Loading("Other.java")) + provider.gate!!.complete(Unit) + advanceUntilIdle() + assertThat(vm.collapsedPaths.value).isEmpty() + } + + @Test + fun `re-parse of same file keeps previous content visible while computing`() = + runTest { + val provider = FakeOutlineProvider().apply { symbols = listOf(symbol("Main")) } + val vm = viewModel(provider) + vm.onSnapshot("Main.java", "java", "v1", immediate = true) + advanceUntilIdle() + provider.gate = CompletableDeferred() + vm.onSnapshot("Main.java", "java", "v2", immediate = false) + advanceUntilIdle() + assertThat(vm.uiState.value).isInstanceOf(OutlineUiState.Content::class.java) + provider.gate!!.complete(Unit) + advanceUntilIdle() + assertThat(vm.uiState.value).isInstanceOf(OutlineUiState.Content::class.java) + } + + @Test + fun `symbol click emits NavigateTo the selection start`() = + runTest { + val target = symbol("Main") + val vm = viewModel(FakeOutlineProvider()) + val effects = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + vm.effects.collect { effects.add(it) } + } + vm.onEvent(OutlineUiEvent.SymbolClicked(target)) + advanceUntilIdle() + assertThat(effects) + .containsExactly(OutlineUiEffect.NavigateTo(target.selectionRange.start)) + } + + @Test + fun `onNoEditor returns to NoFileOpen`() = + runTest { + val provider = FakeOutlineProvider().apply { symbols = listOf(symbol("Main")) } + val vm = viewModel(provider) + vm.onSnapshot("Main.java", "java", "v1", immediate = true) + advanceUntilIdle() + vm.onNoEditor() + advanceUntilIdle() + assertThat(vm.uiState.value).isEqualTo(OutlineUiState.NoFileOpen) + } +} diff --git a/editor/build.gradle.kts b/editor/build.gradle.kts index c58ee88d42..33a84d1d7b 100644 --- a/editor/build.gradle.kts +++ b/editor/build.gradle.kts @@ -59,5 +59,10 @@ dependencies { implementation(projects.idetooltips) testImplementation(projects.testing.unit) - androidTestImplementation(projects.testing.android) + androidTestImplementation(projects.testing.android) { + // kt-android.jar (kotlin-analysis-api) bundles kotlin.reflect.full; a second copy fails packaging with + // duplicate classes. Consequence: MockK, which testing:android exposes, cannot be used in this source + // set - it needs kotlin-reflect at runtime and will fail on device with NoClassDefFoundError. + exclude(group = "org.jetbrains.kotlin", module = "kotlin-reflect") + } } diff --git a/editor/src/androidTest/AndroidManifest.xml b/editor/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000000..c0cac8d32f --- /dev/null +++ b/editor/src/androidTest/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/editor/src/androidTest/java/com/itsaky/androidide/editor/language/outline/TreeSitterOutlineProviderTest.kt b/editor/src/androidTest/java/com/itsaky/androidide/editor/language/outline/TreeSitterOutlineProviderTest.kt new file mode 100644 index 0000000000..a68deb3033 --- /dev/null +++ b/editor/src/androidTest/java/com/itsaky/androidide/editor/language/outline/TreeSitterOutlineProviderTest.kt @@ -0,0 +1,194 @@ +package com.itsaky.androidide.editor.language.outline + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TreeSitterOutlineProviderTest { + private lateinit var provider: TreeSitterOutlineProvider + + private val javaSource = + """ + public class Main { + private int count; + private int x, y; + public Main() { + } + public void run(String[] args) { + } + interface Inner { + void call(); + } + enum Kind { + LOCAL, REMOTE + } + } + """.trimIndent() + + private val kotlinSource = + """ + class Repo(val name: String, tag: String) { + companion object Factory { + val EMPTY = Repo("") + } + var count = 0 + fun add(item: String): Boolean { + val tmp = item.length + fun helper() = tmp + return helper() > 0 + } + constructor(name: String, count: Int) : this(name) { + this.count = count + } + } + + interface Greeter { + fun greet() + } + + enum class Kind { + LOCAL, REMOTE + } + fun topLevel() { + val local = 1 + } + """.trimIndent() + + private val xmlSource = + """ + + + +