Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6943a9d
ADFA-2448: Add outline symbol model and containment tree builder
alome007 Aug 25, 2026
d6e2f45
ADFA-2448: Add outline tree-sitter queries for java, kotlin, xml
alome007 Aug 25, 2026
81c1eb6
ADFA-2448: Add tree-sitter outline extraction provider
alome007 Aug 25, 2026
dbb0a1a
ADFA-2448: Add outline UDF state types and view model
alome007 Aug 25, 2026
931f720
ADFA-2448: Add outline Compose panel and row flattening
alome007 Aug 25, 2026
7695fd3
style: spotless reformat EditorSidebarActions, no functional change
alome007 Sep 8, 2026
1f8fa62
ADFA-2448: Add outline sidebar panel with symbol navigation
alome007 Aug 25, 2026
5258438
ADFA-2448: Read outline change snapshots from FileManager
alome007 Aug 26, 2026
63b90d4
ADFA-2448: Color outline badges by symbol kind group
alome007 Aug 26, 2026
d902119
ADFA-2448: Scroll the outline target into view after the drawer closes
alome007 Aug 30, 2026
1b611d5
ADFA-2448: Use two-letter mnemonic badges for outline symbol kinds
alome007 Aug 30, 2026
017a64c
ADFA-2448: Densify outline rows with inline top-aligned details
alome007 Aug 30, 2026
bbbf6f1
ADFA-2448: Draw hierarchy connector lines in the outline
alome007 Aug 31, 2026
dd1e536
ADFA-2448: Center the tapped symbol in the viewport
alome007 Aug 31, 2026
3fefb06
ADFA-2448: Keep the outline pipeline alive through parse failures
alome007 Aug 31, 2026
f7b8597
ADFA-2448: Track only the active document and harden the outline pipe…
alome007 Sep 7, 2026
0208cfc
ADFA-2448: Fix outline extraction gaps, collapse-state race and stale…
alome007 Sep 8, 2026
01a4300
ADFA-2448: Stop parsing for a hidden panel, scope Kotlin functions, r…
alome007 Sep 9, 2026
44bbe8d
ADFA-2448: Keep the signature beside the name when it fits
alome007 Sep 9, 2026
0ce2bd3
ADFA-2448: Hold the deferred scroll as state and translate the row de…
alome007 Sep 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 41 additions & 5 deletions .github/workflows/instrumentation-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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..."
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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..."
Comment thread
Daniel-ADFA marked this conversation as resolved.
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: |
Expand Down Expand Up @@ -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:"
Expand All @@ -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
Expand All @@ -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'"
Expand Down
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,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` `<meta-data>` contract, permissions, extensions. See [plugin-api.md](docs/plugin-api.md) for the API surface & compatibility policy. |
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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<out Fragment> = 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
Comment thread
Daniel-ADFA marked this conversation as resolved.
}
5 changes: 5 additions & 0 deletions app/src/main/java/com/itsaky/androidide/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ 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
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
Expand All @@ -35,6 +38,8 @@ val coreModule =
}
viewModel { MainViewModel() }
viewModel { CloneRepositoryViewModel(get(), get()) }
single<OutlineProvider> { TreeSitterOutlineProvider(androidContext()) }
viewModel { OutlineViewModel(get()) }

// Named, because an unqualified single<CoroutineScope> is claimed by type alone: this one
// instance was serving both the Room database below and EditorHandlerActivity's saveAllAsync,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IDEEditor, Position>? = 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) {
Comment thread
Daniel-ADFA marked this conversation as resolved.
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,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

@Subscribe(threadMode = MAIN)
fun onDocumentOpened(event: DocumentOpenEvent) {
Comment thread
Daniel-ADFA marked this conversation as resolved.
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]
Comment thread
Daniel-ADFA marked this conversation as resolved.
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('.', "")
}
Loading
Loading