diff --git a/ai-core/src/main/AndroidManifest.xml b/ai-core/src/main/AndroidManifest.xml
index 1f426b6c..b9756ed9 100644
--- a/ai-core/src/main/AndroidManifest.xml
+++ b/ai-core/src/main/AndroidManifest.xml
@@ -28,7 +28,7 @@
+ android:value="26.35" />
()
val replacement = slot()
@@ -487,7 +489,10 @@ class EditFileHandlerTest {
assertEquals(1, range.captured.endLine)
assertEquals(5, range.captured.endColumn)
assertEquals("LINE1", replacement.captured)
- verify { editorService.saveCurrentFile() }
+ coVerify { editorService.saveFile(target) }
+ // The save is file-targeted: the user's tab focus must not be read or moved (ADFA-5215).
+ verify(exactly = 0) { editorService.openFile(any()) }
+ verify(exactly = 0) { editorService.saveCurrentFile() }
}
@Test
@@ -496,7 +501,7 @@ class EditFileHandlerTest {
val file = createFile("Main.kt", "val a = 1\n")
every { editorService.getFileContent(file) } returns "val a = 1\nval userTyped = 2\n"
every { editorService.replaceRange(any(), any(), any()) } returns true
- every { editorService.saveCurrentFile() } returns true
+ coEvery { editorService.saveFile(any()) } returns true
val result = edit(
"file_path" to "Main.kt",
@@ -518,7 +523,7 @@ class EditFileHandlerTest {
val file = createFile("Main.kt", "a\na\n")
every { editorService.getFileContent(file) } returns "a\na\n"
every { editorService.replaceRange(any(), any(), any()) } returns true
- every { editorService.saveCurrentFile() } returns true
+ coEvery { editorService.saveFile(any()) } returns true
val range = slot()
val replacement = slot()
@@ -538,12 +543,11 @@ class EditFileHandlerTest {
}
@Test
- fun givenTheTabCannotBeFocused_whenEdited_thenNothingIsSavedAndTheResultSaysUnsaved() {
- // saveCurrentFile() saves the FOCUSED tab, so saving unfocused persists a different file.
+ fun givenTheHostCannotSaveTheFile_whenEdited_thenTheResultSaysUnsaved() {
val file = createFile("Main.kt", "old\n")
every { editorService.getFileContent(file) } returns "old\n"
every { editorService.replaceRange(any(), any(), any()) } returns true
- every { editorService.openFile(any()) } returns false
+ coEvery { editorService.saveFile(any()) } returns false
val result = edit("file_path" to "Main.kt", "old_string" to "old", "new_string" to "new")
@@ -552,6 +556,8 @@ class EditFileHandlerTest {
"Must not claim the file was saved; got: ${result.message}",
result.message.contains("left unsaved")
)
+ // A failed save must not be retried by focusing the tab and saving that instead.
+ verify(exactly = 0) { editorService.openFile(any()) }
verify(exactly = 0) { editorService.saveCurrentFile() }
assertEquals("old\n", file.readText())
}
@@ -566,7 +572,7 @@ class EditFileHandlerTest {
assertFalse(result.success)
assertEquals("old\n", file.readText())
- verify(exactly = 0) { editorService.saveCurrentFile() }
+ coVerify(exactly = 0) { editorService.saveFile(any()) }
}
@Test
@@ -589,7 +595,7 @@ class EditFileHandlerTest {
result.message.contains("changed")
)
verify(exactly = 0) { editorService.replaceRange(any(), any(), any()) }
- verify(exactly = 0) { editorService.saveCurrentFile() }
+ coVerify(exactly = 0) { editorService.saveFile(any()) }
assertEquals("the disk copy must not be touched either", analysed, file.readText())
}
@@ -602,7 +608,7 @@ class EditFileHandlerTest {
"line0\nline1\n",
)
every { editorService.replaceRange(any(), any(), any()) } returns true
- every { editorService.saveCurrentFile() } returns true
+ coEvery { editorService.saveFile(any()) } returns true
val result = edit("file_path" to "Main.kt", "old_string" to "line1", "new_string" to "LINE1")
@@ -680,7 +686,7 @@ class EditFileHandlerTest {
assertEquals("val a = 1\n", file.readText())
verify(exactly = 0) { editorService.replaceRange(any(), any(), any()) }
- verify(exactly = 0) { editorService.saveCurrentFile() }
+ coVerify(exactly = 0) { editorService.saveFile(any()) }
}
// --- Content changing while the approval dialog is open -----------------
diff --git a/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/edit/EditorBufferApplierTest.kt b/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/edit/EditorBufferApplierTest.kt
new file mode 100644
index 00000000..bd8c62a1
--- /dev/null
+++ b/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/edit/EditorBufferApplierTest.kt
@@ -0,0 +1,155 @@
+package com.itsaky.androidide.plugins.aicore.tool.handlers.edit
+
+import com.itsaky.androidide.plugins.services.IdeEditorService
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.asCoroutineDispatcher
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import java.io.File
+import java.util.concurrent.Executors
+
+/**
+ * Unit tests for [EditorBufferApplier] — specifically that the save targets the *file* and never the
+ * focused tab. Saving by focus persisted whichever tab the user happened to be in (ADFA-5215), so the
+ * negative verifications on `openFile`/`saveCurrentFile` below are the regression lock.
+ */
+class EditorBufferApplierTest {
+
+ private companion object {
+ const val EDITOR_THREAD = "test-editor"
+ }
+
+ private lateinit var editorService: IdeEditorService
+ private lateinit var applier: EditorBufferApplier
+
+ private val target = File("/project/A.kt")
+ private val buffer = "line0\nline1\nline2\n"
+
+ @Before
+ fun setup() {
+ editorService = mockk(relaxed = true)
+ every { editorService.getFileContent(target) } returns buffer
+ every { editorService.replaceRange(any(), any(), any()) } returns true
+ coEvery { editorService.saveFile(any()) } returns true
+ applier = EditorBufferApplier(editorService, Dispatchers.Unconfined)
+ }
+
+ /** The running thread, without the ` @coroutine#N` suffix the test JVM's debug mode appends. */
+ private fun currentThreadName() = Thread.currentThread().name.substringBefore(" @")
+
+ private fun apply(
+ matched: String = buffer,
+ applier: EditorBufferApplier = this.applier,
+ ): EditorBufferApplier.Outcome =
+ runBlocking {
+ applier.apply(
+ file = target,
+ displayPath = "A.kt",
+ matched = matched,
+ updated = matched.replace("line1", "LINE1"),
+ oldString = "line1",
+ newString = "LINE1",
+ occurrences = 1,
+ )
+ }
+
+ @Test
+ fun givenAnUnfocusedFile_whenApplied_thenItIsSavedByFileWithoutStealingFocus() {
+ // The user is looking at another tab; the edit must neither follow nor move focus.
+ every { editorService.getCurrentFile() } returns File("/project/B.kt")
+
+ val outcome = apply()
+
+ assertEquals(EditorBufferApplier.Outcome.Applied(saved = true), outcome)
+ coVerify { editorService.saveFile(target) }
+ verify(exactly = 0) { editorService.openFile(any()) }
+ verify(exactly = 0) { editorService.saveCurrentFile() }
+ }
+
+ @Test
+ fun givenTheHostSavesTheFile_whenApplied_thenTheOutcomeReportsSaved() {
+ assertEquals(EditorBufferApplier.Outcome.Applied(saved = true), apply())
+ }
+
+ @Test
+ fun givenTheHostCannotSaveTheFile_whenApplied_thenTheEditStillCountsAsApplied() {
+ coEvery { editorService.saveFile(any()) } returns false
+
+ assertEquals(EditorBufferApplier.Outcome.Applied(saved = false), apply())
+ }
+
+ @Test
+ fun givenSaveDeniedByPermissions_whenApplied_thenItIsReportedAsUnsavedNotPropagated() {
+ // Letting a permission miss escape would abort the turn over an edit already in the buffer.
+ coEvery { editorService.saveFile(any()) } throws SecurityException("FILESYSTEM_WRITE denied")
+
+ assertEquals(EditorBufferApplier.Outcome.Applied(saved = false), apply())
+ }
+
+ @Test
+ fun givenSaveFailingUnexpectedly_whenApplied_thenTheErrorIsNotSwallowed() {
+ // Only SecurityException means "unsaved"; a defect must not pass as an applied-but-unsaved edit.
+ coEvery { editorService.saveFile(any()) } throws IllegalStateException("host defect")
+
+ assertThrows(IllegalStateException::class.java) { apply() }
+ }
+
+ @Test
+ fun givenAnEditorDispatcher_whenApplied_thenOnlyTheBufferEditRunsOnIt() {
+ // saveFile is a suspending host call that reaches the editor thread on its own; re-dispatching
+ // it here - or saving inside the editor block - would put the applier back in that business.
+ val editorExecutor = Executors.newSingleThreadExecutor { Thread(it, EDITOR_THREAD) }
+ var replaceThread: String? = null
+ var saveThread: String? = null
+ every { editorService.replaceRange(any(), any(), any()) } answers {
+ replaceThread = currentThreadName()
+ true
+ }
+ coEvery { editorService.saveFile(any()) } answers {
+ saveThread = currentThreadName()
+ true
+ }
+
+ try {
+ val outcome = apply(
+ applier = EditorBufferApplier(editorService, editorExecutor.asCoroutineDispatcher()),
+ )
+
+ assertEquals(EditorBufferApplier.Outcome.Applied(saved = true), outcome)
+ assertEquals(EDITOR_THREAD, replaceThread)
+ assertEquals(currentThreadName(), saveThread)
+ } finally {
+ editorExecutor.shutdownNow()
+ }
+ }
+
+ @Test
+ fun givenAStaleBuffer_whenApplied_thenNothingIsEditedOrSaved() {
+ every { editorService.getFileContent(target) } returns "the user typed this instead\n"
+
+ val outcome = apply()
+
+ assertTrue("Expected a refusal, got: $outcome", outcome is EditorBufferApplier.Outcome.Failed)
+ verify(exactly = 0) { editorService.replaceRange(any(), any(), any()) }
+ coVerify(exactly = 0) { editorService.saveFile(any()) }
+ }
+
+ @Test
+ fun givenReplaceRangeRejectingTheEdit_whenApplied_thenNothingIsSaved() {
+ every { editorService.replaceRange(any(), any(), any()) } returns false
+
+ val outcome = apply()
+
+ assertTrue("Expected a refusal, got: $outcome", outcome is EditorBufferApplier.Outcome.Failed)
+ coVerify(exactly = 0) { editorService.saveFile(any()) }
+ }
+}