diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index da9527798e..7bdceb4be5 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -1091,18 +1091,24 @@ open class EditorHandlerActivity : @Subscribe(threadMode = ThreadMode.MAIN) fun onDocumentChange(event: DocumentChangeEvent) { if (contentOrNull == null) return - editorViewModel.areFilesModified = true val fileIndex = findIndexOfEditorByFile(event.file.toFile()) if (fileIndex == -1) return + // The editor recomputes its modified flag on every content change, so a change may + // have returned the file to its saved state (e.g. the user undid all their edits). + val isModified = getEditorAtIndex(fileIndex)?.isModified ?: false + editorViewModel.areFilesModified = hasUnsavedFiles() + val tabPosition = getTabPositionForFileIndex(fileIndex) if (tabPosition < 0) return val tab = content.tabs.getTabAt(tabPosition) ?: return - if (tab.text?.startsWith('*') == true) return + val hasIndicator = tab.text?.startsWith('*') == true + if (isModified == hasIndicator) return - tab.text = "*${tab.text}" + val baseName = tab.text?.removePrefix("*") ?: return + tab.text = if (isModified) "*$baseName" else baseName } @Subscribe(threadMode = ThreadMode.MAIN) diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt b/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt index 89d4be990f..bb2f369f1a 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt @@ -142,6 +142,13 @@ constructor( private var fileVersion = 0 internal var isModified = false + // Length and content hash of the content the last time the file was loaded or saved. + // Used to detect when edits (e.g. undoing every change) return the content to its + // saved state, so the modified indicator can be cleared. The length is compared first + // as an O(1) guard so the content hash is only computed when the lengths match. + private var savedContentLength = 0 + private var savedContentHash = 0L + private val selectionChangeHandler = Handler(Looper.getMainLooper()) private var selectionChangeRunner: Runnable? = Runnable { @@ -661,9 +668,13 @@ constructor( /** * Mark this editor as NOT modified. + * + * Snapshots the current content so that later edits which return the content to this + * state (for example, undoing every change) can clear the modified flag again. */ open fun markUnmodified() { this.isModified = false + snapshotSavedContent() } /** @@ -673,6 +684,33 @@ constructor( this.isModified = true } + /** + * Recomputes [isModified] by comparing the current content against the snapshot captured + * the last time the file was loaded or saved. The content length is + * checked first as a cheap guard so the full content hash is only computed when the lengths + * match - i.e. when the edits may have restored the saved state. + */ + private fun refreshModifiedState() { + val content = text + isModified = content.length != savedContentLength || + computeContentHash(content) != savedContentHash + } + + private fun snapshotSavedContent() { + val content = text + savedContentLength = content.length + savedContentHash = computeContentHash(content) + } + + private fun computeContentHash(content: CharSequence): Long { + var hash = -3750763034362895579L // FNV_offset_basis + for (i in 0 until content.length) { + hash = hash xor content[i].code.toLong() + hash *= 1099511628211L // FNV_prime + } + return hash + } + /** * Notify the language server that the file in this editor is about to be closed. */ @@ -890,7 +928,7 @@ constructor( return@subscribeEvent } - markModified() + refreshModifiedState() file ?: return@subscribeEvent editorScope.launch {