Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup.LayoutParams
import android.widget.TextView
import androidx.annotation.VisibleForTesting
import androidx.collection.MutableIntObjectMap
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.GravityCompat
Expand Down Expand Up @@ -114,6 +115,7 @@ import java.util.WeakHashMap
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.function.Consumer

/**
Expand All @@ -136,6 +138,9 @@ open class EditorHandlerActivity :

private val fileTimestamps = ConcurrentHashMap<String, Long>()

/** Number of saves in flight. Main-thread confined; see [beginFileSave]. */
private val activeSaveCount = AtomicInteger(0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F01 - activeSaveCount outlives the flag it guards

The counter is per-activity-instance, but the flag it guards (editorViewModel.areFilesSaving) lives in the retained EditorViewModel. EditorActivityKt's manifest configChanges (AndroidManifest.xml:101) covers only orientation|screenSize|screenLayout|smallestScreenSize|fontScale, so a dark-mode / locale / display-size change destroys and recreates the activity.

saveAllAsync runs its save under withContext(NonCancellable), so save A keeps running against the old instance (its counter is 1). The new instance starts save B: its fresh counter goes 0 -> 1 and sets areFilesSaving = true. Save A then finishes on the old instance, its counter goes 1 -> 0, and it writes areFilesSaving = false on the shared retained ViewModel while B is still writing. SaveFileAction re-enables mid-write - exactly the failure this PR was written to fix.

The counter needs to live in EditorViewModel, next to the flag it is paired with.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F13 - AtomicInteger contradicts the KDoc one line above

The doc says "Main-thread confined; see [beginFileSave]", and both mutations now happen inside withContext(NonCancellable + Dispatchers.Main.immediate). Under that invariant the atomics buy nothing and actively obscure it.

A plain private var activeSaveCount = 0 with if (++activeSaveCount == 1) / if (--activeSaveCount == 0) states the confinement honestly and drops the java.util.concurrent.atomic.AtomicInteger import. Leaving AtomicInteger in place invites a future maintainer to conclude the counter is safe to touch off-main - which is precisely the mistake commit a49b6a6 undid.

(If F01 is addressed by moving the counter into the ViewModel, decide the threading story there and document it once.)


private val pluginTabIndices = mutableMapOf<String, Int>()
private val tabIndexToPluginId = mutableMapOf<Int, String>()
private var lastAppliedPluginFontScale = EditorPreferences.editorFontScale
Expand Down Expand Up @@ -938,6 +943,35 @@ open class EditorHandlerActivity :
}
}

/**
* Saves the buffer for [file] regardless of which tab has focus, and reports whether the
* bytes reached disk. Returns `false` when no open editor holds [file].
*
* A clean buffer counts as saved: [CodeEditorView.save] reports "nothing to do" and
* "write failed" with the same `false`, so the two are separated here.
*
* Resolution and the write share one main-thread continuation, so a tab close cannot shift

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F07 - the atomicity claim does not survive the write

"Resolution and the write share one main-thread continuation, so a tab close cannot shift the index out from under the save" holds only up to the frag capture.

saveResultInternal then calls frag.save(), which suspends into readWriteContext, and afterwards runs withContext(Dispatchers.Main) { val tabPosition = getTabPositionForFileIndex(index); ... if (text.startsWith("*")) tab.text = text.substring(1) } using the index captured before the write.

Close a tab to the left of the saved file during a large write and the asterisk is stripped from the wrong tab, so a genuinely dirty buffer renders as clean. Re-resolve the tab from the file (or from frag) after the write rather than reusing index.

* the index out from under the save.
*/
suspend fun saveFileResult(file: File): Boolean =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F11 - the headline feature has no test

OverlappingSaveFlagTest calls beginFileSave/endFileSave directly and never touches saveFileResult, EditorProviderImpl.saveFile, or IdeEditorServiceImpl.saveFile.

Untested and non-obvious behaviors that would silently regress:

  • the clean-buffer-counts-as-saved branch returning true
  • the no-open-editor branch returning false
  • the catch (Exception) that turns a ContentReadWrite.writeTo throw into false
  • the CancellationException rethrow
  • EditorProviderImpl.saveFile's destroyed-activity false
  • IdeEditorServiceImpl.saveFile's requireWrite() / ensureFileAccessible() SecurityException contract

That last one is the highest-value gap: the plugin-facing KDoc explicitly advertises it ("Authorization failures throw SecurityException rather than returning false"), and a plugin-visible security guarantee with zero coverage is worth a test on its own. A plain JUnit test over IdeEditorServiceImpl with a stub EditorProvider covers it without Robolectric.

try {
performFileSave {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F12 - the flag is raised before there is any work to do

performFileSave calls beginFileSave() first; only then does the Main.immediate block discover getEditorForFile(file) == null (file not open) or a clean buffer, and return.

_filesSaving emits true then false with no save in between, so observers of areFilesSaving - and SaveFileAction's enabled state via invalidateOptionsMenu - churn for a no-op. A plugin polling saveFile on a closed file in a loop produces continuous flag flapping.

Resolve the editor first and enter performFileSave only once there is real work.

withContext(Dispatchers.Main.immediate) {
val view = getEditorForFile(file) ?: return@withContext false
if (!view.isModified && file.exists()) return@withContext true

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F03 - main-thread disk IO trips StrictMode

StrictModeManager.install (DeviceProtectedApplicationLoader.kt:78) applies ThreadPolicy.Builder().detectAll() to the main thread in every debug build, and detectAll() includes detectDiskReads.

Both file.exists() here and the trailing && file.exists() two lines down run inside the withContext(Dispatchers.Main.immediate) continuation, so every plugin saveFile on a debug build reports a DiskReadViolation through ViolationDispatcher.

It also cuts against the established pattern in this same file - restoreOpenedPluginTabs and readOpenedFilesCache both wrap their prefs/file IO in withContext(Dispatchers.IO). Stat the file off-main (or pass the existence result in) before entering the main-thread section.

val index = findIndexOfEditorByFile(file)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F05 - the file is resolved twice through two different indexing schemes, unchecked

getEditorForFile(file) walks content.editorContainer children matching file == child.file. findIndexOfEditorByFile(file) walks editorViewModel.getOpenedFiles(). saveResultInternal then calls getEditorAtIndex(index), which maps that file index through getTabPositionForFileIndex (skipping plugin tabs) and takes editorContainer.getChildAt(tabPosition).

Nothing asserts that the resolved frag is the same view as view, or even that frag.file == file. If editor-container child order and the tab / plugin-tab bookkeeping ever diverge - and updatePluginTabIndices exists precisely because those indices shift - saveResultInternal saves a different open buffer and saveFileResult still returns true. The plugin believes its file was persisted while another file was overwritten.

Add if (frag !== view) return false, or simply save view directly instead of round-tripping through the index.

index >= 0 && saveResultInternal(index, SaveResult()) && file.exists()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F02 - the SaveResult is discarded, so plugin saves skip sync and resource generation

saveResultInternal(index, SaveResult()) populates result.gradleSaved / result.xmlSaved, and the anonymous SaveResult is thrown away. Every other save path consumes them:

  • saveAll() does if (result.gradleSaved && requestSync) editorViewModel.isSyncNeeded = true
  • SaveFileAction.postExec does if (saveResult.xmlSaved) ProjectManagerImpl.getInstance().generateSources() and if (saveResult.gradleSaved) context.editorViewModel.isSyncNeeded = true

So a plugin that edits build.gradle.kts and calls IdeEditorService.saveFile gets the bytes on disk but no sync prompt. A plugin that edits a layout XML gets no generateSources(), so R fields for the new resource never appear and the next build fails on unresolved references.

Hoist the SaveResult into a local and apply the same two follow-ups.

}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
} catch (err: CancellationException) {
throw err
} catch (err: Exception) {
// ContentReadWrite.writeTo reports a failed write by throwing; that must not escape
// into the plugin coroutine awaiting this call.
log.error("Failed to save {}", file.name, err)
false
}

override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)

Expand Down Expand Up @@ -1011,18 +1045,49 @@ open class EditorHandlerActivity :
getEditorForFile(file)?.isModified == true
}

/**
* Runs [action] with the "files are saving" flag raised.
*
* Counted rather than a plain boolean: a plugin-thread save can overlap a UI save, and the
* first one to finish must not clear the flag while the other is still writing.
*/
private suspend inline fun <T : Any?> performFileSave(crossinline action: suspend () -> T): T {
setFilesSaving(true)
beginFileSave()
try {
return action()
} finally {
setFilesSaving(false)
endFileSave()
}
}

private suspend fun setFilesSaving(saving: Boolean) {
withContext(Dispatchers.Main.immediate) {
editorViewModel.areFilesSaving = saving
/**
* Raises the saving flag for one save.
*
* The count moves in the same main-thread section as the flag, so the counter's ordering
* *is* the flag's ordering. Bumping the count off-main instead let a finished off-main
* save's queued `false` land after a main-thread save had already written `true` inline
* (`Main.immediate` skips the queue when it is already on main), leaving
* [EditorViewModel.areFilesSaving] false while that save was still writing.
*
* NonCancellable: a cancelled save (e.g. a plugin-side timeout) must still reach its
* matching [endFileSave], or SaveFileAction stays disabled for the rest of the session.
*/
@VisibleForTesting
internal suspend fun beginFileSave() {
withContext(NonCancellable + Dispatchers.Main.immediate) {
if (activeSaveCount.incrementAndGet() == 1) {
editorViewModel.areFilesSaving = true
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/** Lowers the saving flag once the last in-flight save finishes. See [beginFileSave]. */
@VisibleForTesting
internal suspend fun endFileSave() {
withContext(NonCancellable + Dispatchers.Main.immediate) {
if (activeSaveCount.decrementAndGet() == 0) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F04 - the counter has no floor, so one unbalanced decrement latches the flag on forever

decrementAndGet() == 0 only fires on an exact match. If the count ever reaches -1 the equality never matches again, areFilesSaving stays true for the life of the ViewModel, and SaveFileAction is permanently disabled (enabled = context.areFilesModified() && !context.areFilesSaving()) - precisely the failure beginFileSave's own KDoc warns about.

This is newly reachable: endFileSave was widened from private to internal for the test, so anything in :app can now call it, and the new test already calls it directly.

The removed setFilesSaving(false) in the finally forced the flag down unconditionally and could not get stuck. Clamp it - activeSaveCount.updateAndGet { (it - 1).coerceAtLeast(0) }, or fire on <= 0.

editorViewModel.areFilesSaving = false
}
}
}

Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,17 @@ class EditorProviderImpl(
return true
}

/**
* Saves [file]'s buffer whatever tab has focus, suspending until the bytes are on disk.
*
* Suspending rather than blocking is what makes this safe to call from the main thread:
* the write itself runs there, so a blocking bridge would deadlock against it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F08 - this KDoc is factually wrong about where the write runs

"the write itself runs there [on the main thread], so a blocking bridge would deadlock against it" - it does not. CodeEditorView.save() (CodeEditorView.kt:386) marshals text.writeTo(file, ...) into withContext(readWriteContext), and readWriteContext is newSingleThreadContext("CodeEditorView") (line 126), a dedicated worker thread.

The deadlock is real, but it comes from the Main.immediate resumption hop, not from the write executing on main. A maintainer who reads this and moves the write off main "to fix the deadlock" will change nothing and still ANR.

Note also that IdeServices.kt:185 makes the opposite claim ("the write is marshalled to the editor thread"). The two should agree; that one is the accurate half.

*/
override suspend fun saveFile(file: File): Boolean {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F06 - the only EditorProvider method with no bound on how long it waits

Every other method in this class goes through onMain { }, whose 5s CountDownLatch timeout is documented as "a deadlocked UI should not be able to take the IDE down with it". saveFile has no such bound.

If a plugin calls runBlocking { editorService.saveFile(f) } from the main thread, runBlocking installs a BlockingEventLoop that does not drain the Android Handler queue. CodeEditorView.save() hops to readWriteContext and resumes via Dispatchers.Main.immediate, whose isDispatchNeeded is true from that worker thread, so the resumption is Handler.post-ed and never runs. Hard ANR.

The KDoc's suggested withTimeout cannot rescue this either - its delay is scheduled on the same blocked event loop.

Either enforce the bound host-side (withTimeoutOrNull around saveFileResult) or state plainly in the plugin-facing KDoc that blocking bridges are unsupported.

val activity = activity() ?: return false
return activity.saveFileResult(file)
}

// --- Buffer edits -------------------------------------------------------

override fun insertTextAtCursor(text: String): Boolean =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* This file is part of AndroidIDE.
*
* AndroidIDE is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* AndroidIDE is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
*/

package com.itsaky.androidide.activities.editor

import android.os.Looper
import com.google.common.truth.Truth.assertThat
import com.itsaky.androidide.app.BaseApplication
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowLooper
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread

/**
* Overlapping saves must never leave `areFilesSaving` false while a save is still writing -
* SaveFileAction re-enables itself off that flag, so a false gap lets the user fire a second
* save into an in-flight write.
*
* The interleaving is forced, not timed. Robolectric's paused main looper holds the off-main
* save's completion hop in the queue while the main-thread save runs its own hop inline
* (`Main.immediate` skips the queue when already on main) - exactly the ordering inversion at
* issue.
*
* - BUGGED: the count moves off-main, so the finishing off-main save decrements to 0 and
* queues `false`; the main-thread save then sees 0 -> 1 and writes `true` inline; the
* queued `false` runs last -> flag false mid-save -> test FAILS.
* - FIXED: count and flag move together on main, so the queued decrement lands as 2 -> 1 and
* writes nothing -> flag stays true -> test PASSES.
*/
@RunWith(RobolectricTestRunner::class)
@Config(application = OverlappingSaveFlagTest.TestApp::class)
class OverlappingSaveFlagTest {
open class TestApp : BaseApplication()

@Test
fun givenOverlappingSaves_whenTheOffMainSaveCompletionDrains_thenTheSavingFlagStaysRaised() {
val activity = Robolectric.buildActivity(EditorHandlerActivity::class.java).get()
val mainLooper = shadowOf(Looper.getMainLooper())

// Save A begins on the main thread: the hop runs inline, raising the flag.
runBlocking { activity.beginFileSave() }
assertThat(activity.editorViewModel.areFilesSaving).isTrue()

// Save A finishes off-main. Its completion hop is posted to the paused main looper and
// parks there; the worker stays blocked until we idle the looper.
val ended = CountDownLatch(1)
val worker =
thread(isDaemon = true) {
runBlocking(Dispatchers.IO) { activity.endFileSave() }
ended.countDown()
}

try {
awaitPostToMain(mainLooper)

// Save B begins on the main thread while A's hop is still queued.
runBlocking { activity.beginFileSave() }

// Drain A's queued completion. B is still writing, so the flag must stay raised.
mainLooper.idle()
assertThat(ended.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)).isTrue()
assertThat(activity.editorViewModel.areFilesSaving).isTrue()

// Only B finishing lowers it.
runBlocking { activity.endFileSave() }
assertThat(activity.editorViewModel.areFilesSaving).isFalse()
} finally {
// A failed assertion above can leave the worker parked on a main-thread hop that
// never runs; drain the queue and reap it rather than leak a blocked thread.
mainLooper.idle()
worker.join(TIMEOUT_MS)
}
}

/** Blocks until the worker's main-thread hop is sitting in the paused looper's queue. */
private fun awaitPostToMain(mainLooper: ShadowLooper) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F10 - awaitPostToMain synchronizes on the wrong condition

It only asserts that mainLooper.isIdle is false. Any message left in the paused queue by BaseApplication.onCreate or Robolectric.buildActivity satisfies that immediately - before runBlocking(Dispatchers.IO) { activity.endFileSave() } has reached its Handler.post.

The test then calls mainLooper.idle() (draining the unrelated message, not A's decrement) and blocks in ended.await(10_000). But the main thread is the only thing that can drain the worker's post, and it is sitting inside await() - so the worker never completes: 10s burned, then a failure with a misleading message.

It passes today only because the queue happens to be empty. Synchronize on the queue actually growing - snapshot ShadowLooper's queued-message count before spawning the worker and wait for an increase - rather than on mere non-idleness.

val deadline = System.currentTimeMillis() + TIMEOUT_MS
while (mainLooper.isIdle && System.currentTimeMillis() < deadline) {
Thread.sleep(1)
}
assertThat(mainLooper.isIdle).isFalse()
}

private companion object {
const val TIMEOUT_MS = 10_000L
}
}
2 changes: 2 additions & 0 deletions plugin-api/api/plugin-api.api
Original file line number Diff line number Diff line change
Expand Up @@ -1368,6 +1368,7 @@ public abstract interface class com/itsaky/androidide/plugins/services/IdeEditor
public abstract fun replaceRange (Ljava/io/File;Lcom/itsaky/androidide/plugins/services/SelectionRange;Ljava/lang/String;)Z
public abstract fun replaceSelection (Ljava/lang/String;)Z
public abstract fun saveCurrentFile ()Z
public abstract fun saveFile (Ljava/io/File;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun showInlineSuggestion (Ljava/lang/String;)V
public abstract fun showPeerCursor (Ljava/io/File;IILjava/lang/String;Ljava/lang/String;I)Z
}
Expand All @@ -1378,6 +1379,7 @@ public final class com/itsaky/androidide/plugins/services/IdeEditorService$Defau
public static fun dismissInlineSuggestion (Lcom/itsaky/androidide/plugins/services/IdeEditorService;)V
public static fun hidePeerCursor (Lcom/itsaky/androidide/plugins/services/IdeEditorService;Ljava/io/File;Ljava/lang/String;)Z
public static fun removeContentChangeListener (Lcom/itsaky/androidide/plugins/services/IdeEditorService;Lcom/itsaky/androidide/plugins/services/EditorContentChangeListener;)V
public static fun saveFile (Lcom/itsaky/androidide/plugins/services/IdeEditorService;Ljava/io/File;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static fun showInlineSuggestion (Lcom/itsaky/androidide/plugins/services/IdeEditorService;Ljava/lang/String;)V
public static fun showPeerCursor (Lcom/itsaky/androidide/plugins/services/IdeEditorService;Ljava/io/File;IILjava/lang/String;Ljava/lang/String;I)Z
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,30 @@ interface IdeEditorService {
/**
* Schedules a save of the active editor tab. Runs asynchronously; a `true` return means
* the save was dispatched, not that the buffer has been flushed to disk. Poll
* [isFileModified] on the current file to confirm completion.
* [isFileModified] on the current file to confirm completion. Prefer [saveFile] when you
* know which file you want persisted - this one follows focus, which the user controls.
*/
fun saveCurrentFile(): Boolean

/**
* Saves [file]'s open buffer to disk, whatever tab currently has focus, and suspends until
* the write completes - unlike [saveCurrentFile], which only reports that a save was
* dispatched for the focused tab.
*
* Safe to call from any dispatcher, the main one included: the write is marshalled to the
* editor thread and nothing blocks while it runs. Wrap the call in `withTimeout` if your
* plugin needs to bound how long it waits.
*
* Returns `true` when the buffer is on disk (including "was already clean"), `false` when
* the file has no open editor or the write failed. Authorization failures throw
* [SecurityException] rather than returning `false`, as they do for every other write
* method here: the caller lacks FILESYSTEM_WRITE, or [file] is outside the plugin's
* allowed roots.
*
* Default-implemented (no-op) so adding it is a backward-compatible interface extension.
*/
suspend fun saveFile(file: File): Boolean = false

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F09 - new public plugin API with no PLUGIN_API_CHANGELOG.md entry

plugin-api/api/plugin-api.api is updated, but the changelog is not. docs/plugin-api.md's "Before you change the plugin API" checklist requires "[ ] Recorded here or in a changelog so plugin authors can find it", and CLAUDE.md: "When you change code, update the docs that describe it in the same change ... so a doc never outlives the API it documents."

PLUGIN_API_CHANGELOG.md exists specifically so an author can pick a correct plugin.min_ide_version, and it already carries entries of exactly this shape (IdeEditorService.showInlineSuggestion / dismissInlineSuggestion, line 144).

Without a YY.WW row for IdeEditorService.saveFile, an author has no way to know which IDE release first accepts the call - and will ship a plugin whose min_ide_version is too low, hitting the silent = false default here instead of a real save.


fun insertTextAtCursor(text: String): Boolean

fun replaceSelection(text: String): Boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ class PluginManager private constructor(

override fun saveCurrentFile(): Boolean = current()?.saveCurrentFile() ?: false

override suspend fun saveFile(file: File): Boolean = current()?.saveFile(file) ?: false

override fun insertTextAtCursor(text: String): Boolean = current()?.insertTextAtCursor(text) ?: false

override fun replaceSelection(text: String): Boolean = current()?.replaceSelection(text) ?: false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ class IdeEditorServiceImpl(

fun saveCurrentFile(): Boolean = false

suspend fun saveFile(file: File): Boolean = false

fun insertTextAtCursor(text: String): Boolean = false

fun replaceSelection(text: String): Boolean = false
Expand Down Expand Up @@ -317,6 +319,12 @@ class IdeEditorServiceImpl(
return editorProvider.saveCurrentFile()
}

override suspend fun saveFile(file: File): Boolean {
requireWrite()
ensureFileAccessible(file)
return editorProvider.saveFile(file)
}

override fun insertTextAtCursor(text: String): Boolean {
if (!writableCurrentFile()) return false
return editorProvider.insertTextAtCursor(text)
Expand Down
Loading