-
-
Notifications
You must be signed in to change notification settings - Fork 55
ADFA-5231: Publish document version and content together #1743
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -104,12 +104,15 @@ import kotlinx.coroutines.cancel | |
| import kotlinx.coroutines.isActive | ||
| import kotlinx.coroutines.launch | ||
| import kotlinx.coroutines.suspendCancellableCoroutine | ||
| import kotlinx.coroutines.sync.Mutex | ||
| import kotlinx.coroutines.sync.withLock | ||
| import kotlinx.coroutines.withContext | ||
| import org.greenrobot.eventbus.EventBus | ||
| import org.greenrobot.eventbus.Subscribe | ||
| import org.greenrobot.eventbus.ThreadMode | ||
| import org.slf4j.LoggerFactory | ||
| import java.io.File | ||
| import java.util.concurrent.atomic.AtomicInteger | ||
| import kotlin.coroutines.resume | ||
|
|
||
| fun interface OnEditorLongPressListener { | ||
|
|
@@ -141,7 +144,17 @@ open class IDEEditor | |
| private var actionsMenu: EditorActionsMenu? = null | ||
| private var _signatureHelpWindow: SignatureHelpWindow? = null | ||
| private var _diagnosticWindow: DiagnosticWindow? = null | ||
| private var fileVersion = 0 | ||
|
|
||
| /** | ||
| * [documentChangeMutex] only serialises change dispatches against each other; the resets in | ||
| * [release] and [dispatchDocumentOpenEvent] run outside it, so a reset can race an in-flight | ||
| * [dispatchDocumentChangeEvent]'s `incrementAndGet()` and stamp a low version right after a | ||
| * newly-opened file's counter is zeroed. This is tolerated: it is bounded (self-heals on the | ||
| * next edit) and distinct from the same-document backwards-version bug this ticket fixes, | ||
| * which `ActiveDocument.update` now guards regardless of how `fileVersion` got there. | ||
| */ | ||
| private val fileVersion = AtomicInteger(0) | ||
| private val documentChangeMutex = Mutex() | ||
| internal var isModified = false | ||
|
|
||
| // Length and content hash of the content the last time the file was loaded or saved. | ||
|
|
@@ -570,7 +583,7 @@ open class IDEEditor | |
| languageClient = null | ||
|
|
||
| _file = null | ||
| fileVersion = 0 | ||
| fileVersion.set(0) | ||
| markUnmodified() | ||
|
|
||
| editorFeatures.editor = null | ||
|
|
@@ -960,7 +973,9 @@ open class IDEEditor | |
| file ?: return@subscribeEvent | ||
|
|
||
| editorScope.launch { | ||
| dispatchDocumentChangeEvent(event) | ||
| // Serialised so the version a change is stamped with is never older than the text | ||
| // snapshot taken with it: two edits in one frame land here as two coroutines. | ||
| documentChangeMutex.withLock { dispatchDocumentChangeEvent(event) } | ||
| checkForSignatureHelp(event) | ||
| handleCustomTextReplacement(event) | ||
| } | ||
|
Comment on lines
975
to
981
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -eu
file="editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt"
printf '%s\n' '--- changed-file symbols ---'
ast-grep outline "$file" | sed -n '1,220p'
printf '%s\n' '--- relevant references ---'
rg -n -C 8 'ContentChangeEvent|DocumentChangeEvent|documentChangeMutex|dispatchDocumentChangeEvent|fileVersion|editorScope|checkForSignatureHelp|handleCustomTextReplacement|dispatchDocumentOpenEvent|fun release|override fun release' "$file"Repository: appdevforall/CodeOnTheGo Length of output: 16054 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- ContentChangeEvent declarations and references ---'
rg -n -C 5 'class ContentChangeEvent|data class ContentChangeEvent|ContentChangeEvent\(' . \
-g '!build' -g '!dist' -g '!node_modules' -g '!target' | sed -n '1,240p'
printf '%s\n' '--- DocumentChangeEvent declaration and consumers ---'
rg -n -C 8 'class DocumentChangeEvent|data class DocumentChangeEvent|DocumentChangeEvent\(|DocumentChangeEvent::class|event\.document|changedText|changeRange' \
-g '*.kt' -g '*.java' . | sed -n '1,320p'
printf '%s\n' '--- dependency declarations for Sora editor ---'
rg -n -C 4 'sora|rosemoe|editor' gradle/libs.versions.toml build.gradle.kts editor/build.gradle.kts settings.gradle.kts 2>/dev/null | sed -n '1,220p'Repository: appdevforall/CodeOnTheGo Length of output: 38613 🌐 Web query:
💡 Result: In the io.github.Rosemoe.sora-editor library (version 0.23.6), the ContentChangeEvent class is part of the event system and is triggered whenever the text content in the editor changes or when setText is called [1]. While the event exists to notify listeners of content modifications [1], it is distinct from similarly named events in other libraries (such as those found in docx-editor or opentui [2][3][4]). In the context of Sora Editor, ContentChangeEvent does not inherently expose a standardized public API containing fields named source, changedText, changeStart, and changeEnd directly on the event object itself as part of its primary public interface documentation [1]. Users looking to track specific text changes typically interact with the underlying document model (often accessed via the CodeEditor component) to retrieve information about edits. If you are developing a plugin or integration requiring specific diff information (such as start/end positions or the text replaced/inserted), you may need to consult the source code for the ContentChangeEvent class within the editor module, as internal implementations may evolve between versions [5][6]. For further implementation details, you can refer to the official documentation and repository [1][7]: - Documentation: https://project-sora.github.io/sora-editor-docs/ - Repository: https://github.com/Rosemoe/sora-editor Citations:
🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- FileManager change path ---'
sed -n '80,170p' subprojects/projects/src/main/java/com/itsaky/androidide/projects/FileManager.kt
printf '%s\n' '--- newText consumers ---'
rg -n -C 6 '\.newText|newText\b|onDocumentContentChange|ActiveDocument\.update|fun update' \
subprojects/projects app lsp editor eventbus-events -g '*.kt' -g '*.java' | sed -n '1,320p'Repository: appdevforall/CodeOnTheGo Length of output: 39135 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- EditorEventDispatcher ---'
sed -n '1,105p' editor/src/main/java/com/itsaky/androidide/editor/ui/EditorEventDispatcher.kt
printf '%s\n' '--- relevant Sora imports and API usage ---'
sed -n '1,115p' editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.ktRepository: appdevforall/CodeOnTheGo Length of output: 9338 🌐 Web query:
💡 Result: The terms changeStart and changedText are associated with the ContentChangeEvent class within the sora-editor project, which is a multifunctional code editor library for Android [1][2]. In the context of this library, ContentChangeEvent is an event used to track modifications made to the document's text [1][3]. The class typically includes properties that describe the nature of the change: - changeStart: Represents the starting index or position where the content change begins. - changedText: Represents the new text content that has been inserted or has replaced the previous content at the specified location. These fields are essential for maintaining synchronization between the editor's internal text buffer and external components, such as Language Server Protocol (LSP) clients, which require precise information about text edits to perform operations like syntax highlighting, diagnostics, and incremental updates [4][5]. As of August 2026, the sora-editor project has undergone significant architectural changes, including the introduction of a CodeEditorDelegate to support both traditional Android View-based editors and Jetpack Compose [6]. Developers working with this library should refer to the official documentation site (https://project-sora.github.io/sora-editor-docs/) for the most current API specifications and migration guides [2]. Citations:
Capture the document snapshot before launching the coroutine. 🤖 Prompt for AI Agents |
||
|
|
@@ -1242,9 +1257,9 @@ open class IDEEditor | |
|
|
||
| val file = this.file ?: return | ||
|
|
||
| this.fileVersion = 0 | ||
| this.fileVersion.set(0) | ||
|
|
||
| val openEvent = DocumentOpenEvent(file.toPath(), text.toString(), fileVersion) | ||
| val openEvent = DocumentOpenEvent(file.toPath(), text.toString(), fileVersion.get()) | ||
|
|
||
| eventDispatcher.dispatch(openEvent) | ||
| } | ||
|
|
@@ -1278,7 +1293,7 @@ open class IDEEditor | |
| file, | ||
| changedText, | ||
| text.toString(), | ||
| ++fileVersion, | ||
| fileVersion.incrementAndGet(), | ||
| type, | ||
| changeDelta, | ||
| changeRange, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,19 +29,60 @@ import java.time.Instant | |
| */ | ||
| open class ActiveDocument( | ||
| val file: Path, | ||
| var version: Int, | ||
| var modified: Instant, | ||
| content: String = "" | ||
| version: Int, | ||
| modified: Instant, | ||
| content: String = "", | ||
| ) { | ||
| private data class Snapshot( | ||
| val version: Int, | ||
| val modified: Instant, | ||
| val content: String, | ||
| ) | ||
|
|
||
| var content: String = content | ||
| internal set | ||
| /* | ||
| * One volatile reference, so a reader can never pair a new version with the old content. The editor | ||
| * dispatches change events from a background coroutine per edit, so two edits in one frame do reach | ||
| * this concurrently. | ||
| */ | ||
| @Volatile | ||
| private var snapshot = Snapshot(version, modified, content) | ||
|
|
||
| fun inputStream(): BufferedInputStream { | ||
| return content.byteInputStream().buffered() | ||
| } | ||
| /** The version last published via [update]. Always consistent with [content] and [modified]. */ | ||
| val version: Int | ||
| get() = snapshot.version | ||
|
|
||
| /** The timestamp of the last [update]. Always consistent with [version] and [content]. */ | ||
| val modified: Instant | ||
| get() = snapshot.modified | ||
|
|
||
| /** The content last published via [update]. Always consistent with [version] and [modified]. */ | ||
| val content: String | ||
| get() = snapshot.content | ||
|
Comment on lines
+50
to
+60
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the declared Kotlin toolchain and all ActiveDocument consumers.
fd -a -t f '^(gradle\.properties|libs\.versions\.toml|build\.gradle\.kts)$' . \
-x rg -n 'kotlin|languageVersion|jvmTarget|compilerOptions' {}
rg -n -C 6 --glob '*.kt' '\b(getActiveDocument|ActiveDocument)\b' .Repository: appdevforall/CodeOnTheGo Length of output: 13401 🏁 Script executed: #!/bin/bash
set -euo pipefail
file="$(fd -a -t f '^ActiveDocument\.kt$' . | head -n 1)"
printf '%s\n' "FILE=$file"
wc -l "$file"
cat -n "$file"
printf '%s\n' '--- declarations and direct references ---'
rg -n -C 4 --glob '*.kt' \
'ActiveDocument|\.version\b|\.modified\b|\.content\b|snapshot\b' \
"$(dirname "$file")" .Repository: appdevforall/CodeOnTheGo Length of output: 50381 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- ActiveDocument files ---'
fd -a -t f '^ActiveDocument\.kt$' .
printf '%s\n' '--- ActiveDocument source ---'
for file in $(fd -a -t f '^ActiveDocument\.kt$' .); do
printf '%s\n' "FILE=$file"
cat -n "$file"
done
printf '%s\n' '--- exact type and accessor references ---'
rg -n --glob '*.kt' \
'\bActiveDocument\b|\bgetActiveDocument\b|\bactiveDocument\b' .Repository: appdevforall/CodeOnTheGo Length of output: 7028 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- FileManager document operations ---'
cat -n subprojects/projects/src/main/java/com/itsaky/androidide/projects/FileManager.kt | sed -n '35,105p;130,165p'
printf '%s\n' '--- KtSymbolIndex document path ---'
cat -n lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt | sed -n '175,220p'
printf '%s\n' '--- ActiveDocument version tests ---'
cat -n subprojects/projects/src/test/java/com/itsaky/androidide/projects/ActiveDocumentVersionTest.ktRepository: appdevforall/CodeOnTheGo Length of output: 7739 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- KtSymbolIndex refresh path ---'
cat -n lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt | sed -n '194,285p'
printf '%s\n' '--- FileManager change/update path ---'
cat -n subprojects/projects/src/main/java/com/itsaky/androidide/projects/FileManager.kt | sed -n '96,130p'Repository: appdevforall/CodeOnTheGo Length of output: 5953 Bind the document snapshot before asynchronous refresh. When Expose an immutable snapshot and pass the same snapshot to the refresh path. 🤖 Prompt for AI Agents |
||
|
|
||
| fun reader(): BufferedReader { | ||
| return content.reader().buffered() | ||
| /** | ||
| * Publishes [content] at [version], or returns false if [version] is older than what is already | ||
| * published. | ||
| * | ||
| * A version that moves backwards makes the Kotlin index mint a second `KtFile` for text that never | ||
| * changed, which is what surfaced as redeclaration errors across a whole file (ADFA-5231). | ||
| * | ||
| * An equal version is accepted and overwrites, rather than being rejected like an older one. The | ||
| * only writer, `IDEEditor`, stamps versions from a single serialised `AtomicInteger.incrementAndGet()` | ||
| * per document, so distinct edits never share a version - an equal version is a re-delivery of the | ||
| * same edit, and taking its (identical) content is harmless. | ||
| */ | ||
| internal fun update( | ||
| version: Int, | ||
| content: String, | ||
| ): Boolean { | ||
| synchronized(this) { | ||
| if (version < snapshot.version) return false | ||
| snapshot = Snapshot(version, Instant.now(), content) | ||
| return true | ||
| } | ||
| } | ||
|
|
||
| fun inputStream(): BufferedInputStream = content.byteInputStream().buffered() | ||
|
|
||
| fun reader(): BufferedReader = content.reader().buffered() | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| package com.itsaky.androidide.projects | ||
|
|
||
| import com.google.common.truth.Truth.assertThat | ||
| import com.itsaky.androidide.eventbus.events.editor.ChangeType | ||
| import com.itsaky.androidide.eventbus.events.editor.DocumentChangeEvent | ||
| import com.itsaky.androidide.eventbus.events.editor.DocumentCloseEvent | ||
| import com.itsaky.androidide.eventbus.events.editor.DocumentOpenEvent | ||
| import com.itsaky.androidide.models.Range | ||
| import org.junit.After | ||
| import org.junit.Test | ||
| import java.nio.file.Paths | ||
|
|
||
| /** A document's version and content always move forward together. */ | ||
| class ActiveDocumentVersionTest { | ||
| private val path = Paths.get("/tmp/adfa5231/Main.kt") | ||
|
|
||
| @After | ||
| fun close() { | ||
| FileManager.onDocumentClose(DocumentCloseEvent(path)) | ||
| } | ||
|
|
||
| private fun change( | ||
| text: String, | ||
| version: Int, | ||
| ) = DocumentChangeEvent(path, text, text, version, ChangeType.NEW_TEXT, 0, Range.NONE) | ||
|
|
||
| @Test | ||
| fun `a backwards version is rejected and leaves the newer content in place`() { | ||
| FileManager.onDocumentOpen(DocumentOpenEvent(path, "v1", 1)) | ||
| FileManager.onDocumentContentChange(change("v3", 3)) | ||
|
|
||
| FileManager.onDocumentContentChange(change("v2", 2)) | ||
|
|
||
| val document = FileManager.getActiveDocument(path)!! | ||
| assertThat(document.version).isEqualTo(3) | ||
| assertThat(document.content).isEqualTo("v3") | ||
| } | ||
|
|
||
| @Test | ||
| fun `a version and its content are never observed apart`() { | ||
| FileManager.onDocumentOpen(DocumentOpenEvent(path, "v1", 1)) | ||
| FileManager.onDocumentContentChange(change("v2", 2)) | ||
|
|
||
| val document = FileManager.getActiveDocument(path)!! | ||
| assertThat(document.version to document.content).isEqualTo(2 to "v2") | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
🔎 Supported by static analysis
🏁 Script executed:
Repository: appdevforall/CodeOnTheGo
Length of output: 20662
🏁 Script executed:
Repository: appdevforall/CodeOnTheGo
Length of output: 50381
🏁 Script executed:
Repository: appdevforall/CodeOnTheGo
Length of output: 20755
Serialize document open/release with change delivery.
dispatchDocumentOpenEvent()replacesFileManager'sActiveDocumentat version0, while a queued change can still be stamped and dispatched.FileManager.onDocumentContentChange()then accepts that stale version, andActiveDocument.update()rejects subsequent new-session versions until they exceed it. Add a generation token or serialize open/release with change dispatch, and drop stale changes.🤖 Prompt for AI Agents