Skip to content

Commit 61a2477

Browse files
ADFA-2448: Add a document outline sidebar for Java, Kotlin and XML (#1804)
* ADFA-2448: Add outline symbol model and containment tree builder * ADFA-2448: Add outline tree-sitter queries for java, kotlin, xml * ADFA-2448: Add tree-sitter outline extraction provider * ADFA-2448: Add outline UDF state types and view model * ADFA-2448: Add outline Compose panel and row flattening * style: spotless reformat EditorSidebarActions, no functional change * ADFA-2448: Add outline sidebar panel with symbol navigation * ADFA-2448: Read outline change snapshots from FileManager * ADFA-2448: Color outline badges by symbol kind group * ADFA-2448: Scroll the outline target into view after the drawer closes * ADFA-2448: Use two-letter mnemonic badges for outline symbol kinds * ADFA-2448: Densify outline rows with inline top-aligned details * ADFA-2448: Draw hierarchy connector lines in the outline * ADFA-2448: Center the tapped symbol in the viewport * ADFA-2448: Keep the outline pipeline alive through parse failures * ADFA-2448: Track only the active document and harden the outline pipeline Review follow-ups from CodeRabbit on #1804: - OutlineFragment ignores DocumentChangeEvents for files other than the active editor's, so an edit to a background tab no longer swaps the outline away from the file navigateTo targets. The event always carries the new text (IDEEditor posts it), and the active editor's buffer is the fallback, so the main-thread FileManager disk read is gone. - Snapshots are keyed by normalized absolute path instead of basename, so two Main.kt files in different modules no longer share collapse state. - The snapshot collector catches non-cancellation failures around the whole computation (supports() included), so one bad file cannot stop later refreshes. - kt/outline.scm lists a class_parameter as a property only when it is declared val or var; class Repo(name: String) no longer shows "name". The instrumentation fixture gains a plain parameter that must not appear. - Asset readers for outline.scm are closed after reading (provider and query test). Not changed: the android.util.Log calls in EditorSidebarActions predate this branch and are outside its scope. * ADFA-2448: Fix outline extraction gaps, collapse-state race and stale-file tracking Review follow-ups from itsaky-adfa on #1804. Extraction: - Java: @symbol.field now sits on each variable_declarator, so `private int x, y;` lists both; dedup keys on the @name node instead of the symbol node. - Kotlin: property_declaration is anchored to class_body, enum_class_body and source_file, so locals inside functions no longer show as properties. `interface` and `enum class` get INTERFACE/ENUM instead of CLASS; when two patterns capture the same name the earlier pattern wins, so the specific patterns are listed first. - outlineOf runs on Dispatchers.Default inside the provider, so the guarantee no longer depends on the caller. View model: collapse state lives in a StateFlow combined into uiState, so the main-thread toggle and the compute thread never share a plain field. Fragment: DocumentCloseEvent is dropped before it reaches EventBus, so closing the last file left a stale outline. The panel now follows EditorViewModel's current-file LiveData (fires on open, tab switch and last-close) and onDocumentOpened has the same active-file guard as onDocumentChanged. Panel: the expand/collapse chevron is a 48.dp IconButton; name and detail sit in a column so a long name cannot squeeze the signature to zero width. Rows: every path segment carries its sibling index, so a symbol literally named `bind#2` cannot collide with a disambiguated sibling. CI: the nightly instrumentation job assembles and runs the editor module's test APK on Firebase Test Lab. Comments on the two androidTest build workarounds name the failure each avoids (duplicate kotlin.reflect.full classes from kt-android.jar; Sentry auto-init crashing the test process). * ADFA-2448: Stop parsing for a hidden panel, scope Kotlin functions, run the editor suite on red nights Round-3 review follow-ups from itsaky-adfa. Blocking three: - OutlineFragment holds the DrawerLayout it registers on and gates every snapshot on the drawer being open, so a closed panel stops re-parsing the file on each typing pause. A DrawerListener re-seeds on open, which is what keeps a file switch made while the drawer was closed from going unnoticed. - kt/outline.scm anchors function_declaration to class_body, enum_class_body and source_file like the property patterns, so a local fun no longer shows up in an outline that already drops local vals. The instrumented fixture gained `fun helper()` inside add(). - instrumentation-test.yml captures each suite's exit code with `|| SUITE_EXIT_CODE=$?` instead of letting bash -e abort the step, so the editor suite runs even when the Kotlin or Groovy one fails, and a trailing check re-fails the step if any suite failed. Plain `|| true` would have turned a failing nightly green. Also: - centerPositionInView re-checks isValidPosition inside onDrawerClosed; the earlier check was several hundred milliseconds stale by then. - A named companion keeps its name: one pattern with an optional capture, `(companion_object (type_identifier)? @name)`, covers named and anonymous companions without two patterns fighting over the same node. - Dedup compares patternIndex first and uses detail only within one pattern. That required reordering xml/outline.scm so its two detail-bearing patterns precede the plain ones; otherwise the id detail on an XML element would have been dropped by the new precedence. - OutlineSymbolKind.fromCaptureSuffix owns the capture-to-kind mapping, and a new JVM test (OutlineQueryCapturesTest) asserts every @symbol.* capture in all three .scm files resolves to a kind, so a typo fails the gating suite rather than showing an empty outline. - The kotlin-reflect exclude records that MockK is unusable in editor androidTest as a consequence. - extract() takes the coroutine context and calls ensureActive() per match, so a superseded parse stops at the next match instead of running to completion. - OutlineFragment.onDestroyView calls onNoEditor(), releasing the document copy the activity-scoped view model would otherwise hold after the panel is gone. - Content no longer carries an always-empty collapsedPaths; the panel collects collapsedPaths from its own StateFlow, which also removes the combine. - compute() resets the collapse set with a single getAndUpdate, so both writers now go through one atomic operation. - OutlineTreeBuilder pops until the stack top strictly contains the candidate, so a partial overlap or an identical range makes a sibling, not a child. * ADFA-2448: Keep the signature beside the name when it fits The Column introduced for the round-3 review put every signature on its own line, so `onDestroy` showed `()` underneath it with most of the row empty. FlowRow keeps name and detail on one line while there is room and wraps the detail only when there is not, which still prevents a long name from squeezing the signature to zero width. * ADFA-2448: Hold the deferred scroll as state and translate the row description Round-4 review follow-ups from itsaky-adfa. Blocking: navigateTo registered a second, anonymous DrawerLayout listener that removed itself only from its own onDrawerClosed. Swiping the drawer back open inside the close animation left it registered, holding an IDEEditor and a Position past the fragment's life, so the next close for any reason scrolled to an abandoned symbol. The deferred scroll is now state on the one long-lived listener: onDrawerClosed consumes it, onDrawerOpened discards it, and onDestroyView clears it alongside the listener it already removed. Also: - OutlineViewModel tracks the last computed path in its own field instead of reading it off collapsed.path, which both early returns leave untouched. A detour through an unsupported file no longer leaves that file's message on screen while the previous file recomputes, and collapse still survives the detour. - EditorViewModel.currentFile gets @get:JvmName("currentFileLiveData"); its default JVM getter collided by name with the existing getCurrentFile(): File? and no Java caller could have resolved either. - The row content description builds its kind word from :resources strings (cd_outline_kind_*, 14 entries) instead of the enum constant name, so TalkBack no longer announces English in a translated UI. The mapping is a when in the panel, next to badgeColorFor, rather than resource ids on the model enum. - xml/outline.scm matches "^(id|name)$", so res/values files show the element name as the detail. Before this, strings.xml and colors.xml listed N rows reading only "string" or "color". - loadQueries closes the TSQuery before throwing on a query that fails to compile. - The editor test APK glob is asserted to resolve to exactly one existing file, so a missing APK fails with that message rather than a gcloud usage error. - OutlineTreeBuilderTest gains the partial-overlap and identical-range cases. Both fail against the pre-fix disjointness-only pop condition, and the other seven cases pass either way, which is what the reviewer measured. --------- Co-authored-by: Daniel Alome <astrocoder007@gmail.com>
1 parent ee3e7d6 commit 61a2477

30 files changed

Lines changed: 2190 additions & 232 deletions

File tree

.github/workflows/instrumentation-test.yml

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,14 @@ jobs:
7878
run: |
7979
./gradlew :app:assembleV8DebugAndroidTest --no-daemon
8080
./gradlew :app:assembleV8Debug --no-daemon
81+
./gradlew :editor:assembleV8DebugAndroidTest --no-daemon
8182
8283
- name: Run Firebase Test Lab and Wait for Results
8384
run: |
84-
mkdir -p test-results/kotlin test-results/groovy
85+
mkdir -p test-results/kotlin test-results/groovy test-results/editor
86+
KOTLIN_EXIT_CODE=0
87+
GROOVY_EXIT_CODE=0
88+
EDITOR_EXIT_CODE=0
8589
8690
# Run Kotlin DSL tests
8791
echo "Running Kotlin DSL tests..."
@@ -97,9 +101,8 @@ jobs:
97101
--results-dir=test-results/kotlin \
98102
--results-bucket=gs://firebase-test-results-${RANDOM} \
99103
--project=codeonthego-64a92 \
100-
--test-targets "class com.itsaky.androidide.ProjectBuildTestWithKtsGradle" > test-results/kotlin/test_output.txt 2>&1
104+
--test-targets "class com.itsaky.androidide.ProjectBuildTestWithKtsGradle" > test-results/kotlin/test_output.txt 2>&1 || KOTLIN_EXIT_CODE=$?
101105
102-
KOTLIN_EXIT_CODE=$?
103106
echo "Kotlin tests exit code: $KOTLIN_EXIT_CODE"
104107
105108
# Extract Firebase Test Lab URL for Kotlin tests
@@ -126,9 +129,8 @@ jobs:
126129
--results-dir=test-results/groovy \
127130
--results-bucket=gs://firebase-test-results-${RANDOM} \
128131
--project=codeonthego-64a92 \
129-
--test-targets "class com.itsaky.androidide.ProjectBuildTestWithGroovyGradle" > test-results/groovy/test_output.txt 2>&1
132+
--test-targets "class com.itsaky.androidide.ProjectBuildTestWithGroovyGradle" > test-results/groovy/test_output.txt 2>&1 || GROOVY_EXIT_CODE=$?
130133
131-
GROOVY_EXIT_CODE=$?
132134
echo "Groovy tests exit code: $GROOVY_EXIT_CODE"
133135
134136
# Extract Firebase Test Lab URL for Groovy tests
@@ -141,6 +143,35 @@ jobs:
141143
grep -o 'gs://[^[:space:]]*/video\.mp4' test-results/groovy/test_output.txt | sed 's|gs://|https://storage.cloud.google.com/|' | head -1 || echo "")
142144
echo "GROOVY_VIDEO_URL=$GROOVY_VIDEO_URL" >> $GITHUB_ENV
143145
146+
# Run editor module instrumentation tests (self-instrumenting library test APK)
147+
echo "Running editor tests..."
148+
EDITOR_TEST_APK=(editor/build/outputs/apk/androidTest/v8/debug/*-androidTest.apk)
149+
if [ ${#EDITOR_TEST_APK[@]} -ne 1 ] || [ ! -f "${EDITOR_TEST_APK[0]}" ]; then
150+
echo "Expected exactly one editor androidTest APK, found: ${EDITOR_TEST_APK[*]}"
151+
exit 1
152+
fi
153+
EDITOR_TEST_APK="${EDITOR_TEST_APK[0]}"
154+
gcloud firebase test android run \
155+
--type instrumentation \
156+
--app "$EDITOR_TEST_APK" \
157+
--test "$EDITOR_TEST_APK" \
158+
--device model=oriole,version=33,locale=en,orientation=portrait \
159+
--timeout 15m \
160+
--num-flaky-test-attempts=1 \
161+
--results-dir=test-results/editor \
162+
--results-bucket=gs://firebase-test-results-${RANDOM} \
163+
--project=codeonthego-64a92 > test-results/editor/test_output.txt 2>&1 || EDITOR_EXIT_CODE=$?
164+
165+
echo "Editor tests exit code: $EDITOR_EXIT_CODE"
166+
EDITOR_TEST_URL=$(grep -o 'https://console.firebase.google.com/project/[^[:space:]]*' test-results/editor/test_output.txt | head -1 || echo "")
167+
echo "EDITOR_TEST_URL=$EDITOR_TEST_URL" >> $GITHUB_ENV
168+
169+
# Every suite runs even when an earlier one fails, but a failure still fails the step.
170+
if [ "$KOTLIN_EXIT_CODE" -ne 0 ] || [ "$GROOVY_EXIT_CODE" -ne 0 ] || [ "$EDITOR_EXIT_CODE" -ne 0 ]; then
171+
echo "Instrumentation suites failed (kotlin=$KOTLIN_EXIT_CODE groovy=$GROOVY_EXIT_CODE editor=$EDITOR_EXIT_CODE)"
172+
exit 1
173+
fi
174+
144175
- name: Notify Slack with parsed test results
145176
if: always() # Run even if tests fail
146177
run: |
@@ -179,6 +210,7 @@ jobs:
179210
echo "Parsing test results..."
180211
KOTLIN_RESULTS=$(parse_results test-results/kotlin/test_output.txt "Kotlin DSL")
181212
GROOVY_RESULTS=$(parse_results test-results/groovy/test_output.txt "Groovy DSL")
213+
EDITOR_RESULTS=$(parse_results test-results/editor/test_output.txt "Editor module")
182214
183215
# Also capture the actual output for debugging
184216
echo "Kotlin output file contents:"
@@ -199,6 +231,7 @@ jobs:
199231
SLACK_MESSAGE="*🔥 Firebase Test Lab Results*\\n\\n"
200232
SLACK_MESSAGE="$SLACK_MESSAGE*${KOTLIN_RESULTS}*\\n"
201233
SLACK_MESSAGE="$SLACK_MESSAGE*${GROOVY_RESULTS}*\\n"
234+
SLACK_MESSAGE="$SLACK_MESSAGE*${EDITOR_RESULTS}*\\n"
202235
203236
# Add Kotlin URLs
204237
if [[ -n "$KOTLIN_TEST_URL" ]]; then
@@ -218,6 +251,9 @@ jobs:
218251
SLACK_MESSAGE="$SLACK_MESSAGE\\n🎥 Groovy Test Video: $GROOVY_VIDEO_URL"
219252
fi
220253
254+
if [[ -n "$EDITOR_TEST_URL" ]]; then
255+
SLACK_MESSAGE="$SLACK_MESSAGE\\n\\n🔗 Editor Test Results: $EDITOR_TEST_URL"
256+
fi
221257
# Add debugging info to see what URLs were found
222258
echo "Debug - Kotlin Video URL: '$KOTLIN_VIDEO_URL'"
223259
echo "Debug - Groovy Video URL: '$GROOVY_VIDEO_URL'"

ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ Strategy: **layer-and-subsystem based**, not feature-by-feature. The Gradle buil
7171
|---|---|---|
7272
| Application | `app` | The IDE itself — activities, fragments, services, DI, agent, web server. Wires everything together. |
7373
| 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. |
74-
| 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. |
74+
| 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. |
7575
| UI design tooling | `layouteditor`, `uidesigner`, `xml-inflater`, `vectormaster`, `compose-preview` | Visual/XML design surfaces for the *user's* app. |
7676
| Shell | `termux:{termux-app,termux-shared,termux-view,termux-emulator}` | Embedded Termux shell and terminal. |
7777
| 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. |

app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@ dependencies {
310310
implementation(libs.compose.material3)
311311
implementation(libs.compose.activity)
312312
implementation(libs.compose.lifecycle.runtime)
313+
implementation(projects.commonCompose)
313314
implementation(libs.compose.ui.tooling.preview)
314315
debugImplementation(libs.compose.ui.tooling)
315316

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.itsaky.androidide.actions.sidebar
2+
3+
import android.content.Context
4+
import androidx.core.content.ContextCompat
5+
import androidx.fragment.app.Fragment
6+
import com.itsaky.androidide.R
7+
import com.itsaky.androidide.fragments.sidebar.OutlineFragment
8+
import com.itsaky.androidide.idetooltips.TooltipTag
9+
import kotlin.reflect.KClass
10+
11+
class OutlineSidebarAction(
12+
context: Context,
13+
override val order: Int,
14+
) : AbstractSidebarAction() {
15+
companion object {
16+
const val ID = "ide.editor.sidebar.outline"
17+
}
18+
19+
override val id: String = ID
20+
override val fragmentClass: KClass<out Fragment> = OutlineFragment::class
21+
22+
init {
23+
label = context.getString(R.string.title_document_outline)
24+
icon = ContextCompat.getDrawable(context, R.drawable.ic_outline)
25+
}
26+
27+
override fun retrieveTooltipTag(isAlternateContext: Boolean) = TooltipTag.OUTLINE_SIDEBAR
28+
}

app/src/main/java/com/itsaky/androidide/di/AppModule.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ import com.itsaky.androidide.actions.FileActionManager
44
import com.itsaky.androidide.analytics.AnalyticsManager
55
import com.itsaky.androidide.analytics.IAnalyticsManager
66
import com.itsaky.androidide.deeplink.PendingDeepLinkOpen
7+
import com.itsaky.androidide.editor.language.outline.OutlineProvider
8+
import com.itsaky.androidide.editor.language.outline.TreeSitterOutlineProvider
79
import com.itsaky.androidide.git.core.GitCredentialsManager
810
import com.itsaky.androidide.repositories.RecentProjectRepository
911
import com.itsaky.androidide.repositories.RecentProjectRepositoryImpl
1012
import com.itsaky.androidide.roomData.recentproject.RecentProjectRoomDatabase
1113
import com.itsaky.androidide.viewmodel.CloneRepositoryViewModel
1214
import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel
1315
import com.itsaky.androidide.viewmodel.MainViewModel
16+
import com.itsaky.androidide.viewmodel.OutlineViewModel
1417
import kotlinx.coroutines.CoroutineScope
1518
import kotlinx.coroutines.Dispatchers
1619
import kotlinx.coroutines.SupervisorJob
@@ -35,6 +38,8 @@ val coreModule =
3538
}
3639
viewModel { MainViewModel() }
3740
viewModel { CloneRepositoryViewModel(get(), get()) }
41+
single<OutlineProvider> { TreeSitterOutlineProvider(androidContext()) }
42+
viewModel { OutlineViewModel(get()) }
3843

3944
// Named, because an unqualified single<CoroutineScope> is claimed by type alone: this one
4045
// instance was serving both the Room database below and EditorHandlerActivity's saveAllAsync,
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package com.itsaky.androidide.fragments.sidebar
2+
3+
import android.os.Bundle
4+
import android.view.LayoutInflater
5+
import android.view.View
6+
import android.view.ViewGroup
7+
import androidx.compose.ui.platform.ComposeView
8+
import androidx.compose.ui.platform.ViewCompositionStrategy
9+
import androidx.core.view.GravityCompat
10+
import androidx.drawerlayout.widget.DrawerLayout
11+
import androidx.fragment.app.Fragment
12+
import androidx.fragment.app.activityViewModels
13+
import androidx.lifecycle.Lifecycle
14+
import androidx.lifecycle.lifecycleScope
15+
import androidx.lifecycle.repeatOnLifecycle
16+
import com.itsaky.androidide.activities.editor.EditorHandlerActivity
17+
import com.itsaky.androidide.common.compose.IdeTheme
18+
import com.itsaky.androidide.editor.ui.IDEEditor
19+
import com.itsaky.androidide.eventbus.events.editor.DocumentChangeEvent
20+
import com.itsaky.androidide.eventbus.events.editor.DocumentOpenEvent
21+
import com.itsaky.androidide.models.Position
22+
import com.itsaky.androidide.ui.models.OutlineUiEffect
23+
import com.itsaky.androidide.ui.outline.OutlinePanel
24+
import com.itsaky.androidide.viewmodel.EditorViewModel
25+
import com.itsaky.androidide.viewmodel.OutlineViewModel
26+
import kotlinx.coroutines.launch
27+
import org.greenrobot.eventbus.EventBus
28+
import org.greenrobot.eventbus.Subscribe
29+
import org.greenrobot.eventbus.ThreadMode.MAIN
30+
import org.koin.androidx.viewmodel.ext.android.activityViewModel
31+
import java.nio.file.Path
32+
33+
class OutlineFragment : Fragment() {
34+
private val viewModel: OutlineViewModel by activityViewModel()
35+
private val editorViewModel: EditorViewModel by activityViewModels()
36+
private var drawer: DrawerLayout? = null
37+
38+
private var pendingScroll: Pair<IDEEditor, Position>? = null
39+
40+
private val drawerListener =
41+
object : DrawerLayout.SimpleDrawerListener() {
42+
override fun onDrawerOpened(drawerView: View) {
43+
pendingScroll = null
44+
seedFromCurrentEditor()
45+
}
46+
47+
override fun onDrawerClosed(drawerView: View) {
48+
val (editor, position) = pendingScroll ?: return
49+
pendingScroll = null
50+
if (editor.isValidPosition(position, true)) {
51+
centerPositionInView(editor, position)
52+
}
53+
}
54+
}
55+
56+
override fun onCreateView(
57+
inflater: LayoutInflater,
58+
container: ViewGroup?,
59+
savedInstanceState: Bundle?,
60+
): View =
61+
ComposeView(requireContext()).apply {
62+
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
63+
setContent {
64+
IdeTheme {
65+
OutlinePanel(viewModel)
66+
}
67+
}
68+
}
69+
70+
override fun onViewCreated(
71+
view: View,
72+
savedInstanceState: Bundle?,
73+
) {
74+
super.onViewCreated(view, savedInstanceState)
75+
drawer =
76+
(activity as? EditorHandlerActivity)?.binding?.editorDrawerLayout?.also {
77+
it.addDrawerListener(drawerListener)
78+
}
79+
editorViewModel.currentFile.observe(viewLifecycleOwner) { view.post { seedFromCurrentEditor() } }
80+
viewLifecycleOwner.lifecycleScope.launch {
81+
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
82+
viewModel.effects.collect { effect ->
83+
when (effect) {
84+
is OutlineUiEffect.NavigateTo -> navigateTo(effect.position)
85+
}
86+
}
87+
}
88+
}
89+
}
90+
91+
override fun onDestroyView() {
92+
drawer?.removeDrawerListener(drawerListener)
93+
drawer = null
94+
pendingScroll = null
95+
viewModel.onNoEditor()
96+
super.onDestroyView()
97+
}
98+
99+
override fun onStart() {
100+
super.onStart()
101+
if (!EventBus.getDefault().isRegistered(this)) {
102+
EventBus.getDefault().register(this)
103+
}
104+
}
105+
106+
override fun onStop() {
107+
EventBus.getDefault().unregister(this)
108+
super.onStop()
109+
}
110+
111+
@Subscribe(threadMode = MAIN)
112+
fun onDocumentChanged(event: DocumentChangeEvent) {
113+
if (!isPanelVisible()) return
114+
val editor = currentEditor() ?: return
115+
val file = editor.file ?: return
116+
if (normalized(file.toPath()) != normalized(event.changedFile)) return
117+
viewModel.onSnapshot(
118+
path = normalized(event.changedFile),
119+
extension = file.extension,
120+
text = event.newText ?: editor.text.toString(),
121+
immediate = false,
122+
)
123+
}
124+
125+
@Subscribe(threadMode = MAIN)
126+
fun onDocumentOpened(event: DocumentOpenEvent) {
127+
if (!isPanelVisible()) return
128+
val file = currentEditor()?.file ?: return
129+
if (normalized(file.toPath()) != normalized(event.openedFile)) return
130+
viewModel.onSnapshot(
131+
path = normalized(event.openedFile),
132+
extension = extensionOf(event.openedFile),
133+
text = event.text,
134+
immediate = true,
135+
)
136+
}
137+
138+
private fun seedFromCurrentEditor() {
139+
if (!isPanelVisible()) return
140+
val editor = currentEditor()
141+
val file = editor?.file
142+
if (editor == null || file == null) {
143+
viewModel.onNoEditor()
144+
return
145+
}
146+
viewModel.onSnapshot(
147+
path = normalized(file.toPath()),
148+
extension = file.extension,
149+
text = editor.text.toString(),
150+
immediate = true,
151+
)
152+
}
153+
154+
private fun currentEditor() = (activity as? EditorHandlerActivity)?.getCurrentEditor()?.editor
155+
156+
private fun isPanelVisible(): Boolean = drawer?.isDrawerOpen(GravityCompat.START) == true
157+
158+
private fun normalized(path: Path): String = path.toAbsolutePath().normalize().toString()
159+
160+
private fun navigateTo(position: Position) {
161+
val drawer = drawer ?: return
162+
val editor = currentEditor()
163+
if (editor == null || !editor.isValidPosition(position, true)) {
164+
drawer.closeDrawer(GravityCompat.START)
165+
return
166+
}
167+
editor.setSelection(position)
168+
if (!drawer.isDrawerOpen(GravityCompat.START)) {
169+
centerPositionInView(editor, position)
170+
return
171+
}
172+
pendingScroll = editor to position
173+
drawer.closeDrawer(GravityCompat.START)
174+
}
175+
176+
private fun centerPositionInView(
177+
editor: IDEEditor,
178+
position: Position,
179+
) {
180+
val rowY = editor.layout.getCharLayoutOffset(position.line, position.column)[0]
181+
val targetY =
182+
(rowY - editor.height / 2f)
183+
.toInt()
184+
.coerceIn(0, editor.scrollMaxY)
185+
editor.scroller.startScroll(editor.offsetX, editor.offsetY, 0, targetY - editor.offsetY, 0)
186+
editor.postInvalidate()
187+
}
188+
189+
private fun extensionOf(path: Path): String = path.fileName.toString().substringAfterLast('.', "")
190+
}

0 commit comments

Comments
 (0)