From 7679fc63d6b598b9820a661153d8241fcf5dea29 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:54:38 +0530 Subject: [PATCH 01/13] fix(editor): use versioned dirty state and debounced cache writes - track editor dirty state with version metadata and CodeMirror Text equality - debounce cache snapshots and flush pending writes on switch/pause/save - avoid full doc string conversion for tab switches and equality checks - use mtime metadata for external file change detection --- src/lib/checkFiles.js | 34 ++++++- src/lib/editorFile.js | 197 ++++++++++++++++++++++++++++++++++++++- src/lib/editorManager.js | 28 +++++- src/lib/openFile.js | 8 +- src/lib/saveFile.js | 8 +- src/lib/saveState.js | 6 ++ src/main.js | 3 +- src/utils/helpers.js | 10 ++ 8 files changed, 276 insertions(+), 18 deletions(-) diff --git a/src/lib/checkFiles.js b/src/lib/checkFiles.js index e22dcafc6..1f60d55fc 100644 --- a/src/lib/checkFiles.js +++ b/src/lib/checkFiles.js @@ -1,6 +1,8 @@ import fsOperation from "fileSystem"; +import { Text } from "@codemirror/state"; import alert from "dialogs/alert"; import confirm from "dialogs/confirm"; +import helpers from "utils/helpers"; let checkFileEnabled = true; @@ -49,8 +51,7 @@ export default async function checkFiles() { * @returns {Promise} */ async function checkFile(file) { - if (file === undefined || file.isUnsaved || !file.loaded || file.loading) - return; + if (file === undefined || !file.loaded || file.loading) return; if (file.uri) { const fs = fsOperation(file.uri); @@ -71,10 +72,26 @@ export default async function checkFiles() { return; } + const stat = await fs.stat().catch(() => null); + const mtime = helpers.getStatMtime(stat); + if (file.hasVersionMetadata && file.savedMtime != null && mtime != null) { + if (mtime === file.savedMtime) return; + file.markDiskChanged({ mtime }); + if (file.isUnsaved) { + editorManager.onupdate("file-changed"); + editorManager.emit("update", "file-changed"); + console.warn(`File changed on disk while unsaved: ${file.filename}`); + return; + } + } + + if (file.isUnsaved) return; + const text = await fs.readFile(file.encoding); - const loadedText = file.session.doc.toString(); + const diskDoc = Text.of(String(text ?? "").split("\n")); + const currentDoc = file.session?.doc; - if (text !== loadedText) { + if (!currentDoc?.eq?.(diskDoc)) { try { const confirmation = await confirm( strings.warning.toUpperCase(), @@ -87,11 +104,18 @@ export default async function checkFiles() { editorManager.getFile(file.id, "id")?.makeActive(); file.markChanged = false; - file.session.setValue(text); + try { + file.session.setValue(text); + file.markLoaded({ mtime }); + } finally { + file.markChanged = true; + } editor.gotoLine(cursorPos.row, cursorPos.column); } catch (error) { // ignore } + } else if (mtime != null && file.hasVersionMetadata) { + file.markLoaded({ mtime }); } } } diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index 2d3be54e1..a85de60be 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -1,6 +1,6 @@ import fsOperation from "fileSystem"; // CodeMirror imports for document state management -import { EditorState, Text } from "@codemirror/state"; +import { EditorState } from "@codemirror/state"; import { clearSelection, restoreFolds, @@ -63,6 +63,15 @@ function createSessionProxy(state, file) { } } + function recordInactiveEdit() { + if (file.markChanged === false) return; + file.markEdited(); + file.scheduleCacheWrite(); + editorManager.emit("file-content-changed", file); + editorManager.onupdate("file-changed"); + editorManager.emit("update", "file-changed"); + } + return new Proxy(state, { get(target, prop) { // Ace-compatible method: getValue() @@ -91,6 +100,7 @@ function createSessionProxy(state, file) { changes: { from: 0, to: target.doc.length, insert: newText }, }).state, ); + recordInactiveEdit(); } }; } @@ -140,6 +150,7 @@ function createSessionProxy(state, file) { changes: { from: offset, insert: String(text ?? "") }, }).state, ); + recordInactiveEdit(); } }; } @@ -158,6 +169,7 @@ function createSessionProxy(state, file) { file._setRawSession( target.update({ changes: { from, to, insert: "" } }).state, ); + recordInactiveEdit(); } return removed; }; @@ -180,6 +192,7 @@ function createSessionProxy(state, file) { changes: { from, to, insert: String(text ?? "") }, }).state, ); + recordInactiveEdit(); } }; } @@ -229,6 +242,12 @@ function createSessionProxy(state, file) { * @property {number} [scrollTop] scroll top * @property {Array} [folds] folds * @property {boolean} [pinned] pin the tab to prevent accidental closing + * @property {number} [docVersion] current document version for dirty tracking + * @property {number} [savedVersion] document version last saved or loaded from disk + * @property {number} [cacheVersion] document version last written to crash cache + * @property {number} [savedMtime] file mtime last saved or loaded from disk + * @property {number} [diskMtime] latest known file mtime on disk + * @property {boolean} [hasDiskConflict] whether editor and disk both changed */ export default class EditorFile { @@ -348,6 +367,10 @@ export default class EditorFile { * @type {boolean} */ #isUnsaved = false; + #hasVersionMetadata = false; + #cacheWriteTimer = null; + #cacheWritePromise = null; + #savedDoc = null; /** * Whether to show run button or not */ @@ -387,6 +410,13 @@ export default class EditorFile { oncanrun; onpinstatechange; + docVersion = 0; + savedVersion = 0; + cacheVersion = 0; + savedMtime = null; + diskMtime = null; + hasDiskConflict = false; + /** * * @param {string} [filename] name of file. @@ -491,7 +521,31 @@ export default class EditorFile { const editable = options?.editable ?? true; this.#SAFMode = options?.SAFMode; - this.isUnsaved = options?.isUnsaved ?? false; + this.docVersion = Number.isFinite(options?.docVersion) + ? options.docVersion + : options?.isUnsaved + ? 1 + : 0; + this.savedVersion = Number.isFinite(options?.savedVersion) + ? options.savedVersion + : options?.isUnsaved + ? 0 + : this.docVersion; + this.cacheVersion = Number.isFinite(options?.cacheVersion) + ? options.cacheVersion + : this.savedVersion; + this.savedMtime = helpers.normalizeMtime(options?.savedMtime); + this.diskMtime = helpers.normalizeMtime( + options?.diskMtime ?? options?.savedMtime, + ); + this.hasDiskConflict = !!options?.hasDiskConflict; + this.#hasVersionMetadata = + options?.docVersion !== undefined || + options?.savedVersion !== undefined || + options?.text !== undefined || + options?.isUnsaved !== undefined || + this.#id === config.DEFAULT_FILE_SESSION; + this.isUnsaved = options?.isUnsaved ?? this.hasUnsavedChanges(); if (options?.encoding) { this.encoding = options.encoding; @@ -541,6 +595,9 @@ export default class EditorFile { this.#rawSession = EditorState.create({ doc: options?.text || "", }); + if (!this.#isUnsaved) { + this.#savedDoc = this.#rawSession.doc; + } this.setMode(); this.#setupSession(); } @@ -716,7 +773,11 @@ export default class EditorFile { * End of line */ get eol() { - return /\r/.test(this.session.doc.toString()) ? "windows" : "unix"; + const { doc } = this.session; + for (let lineNumber = 1; lineNumber <= doc.lines; lineNumber++) { + if (doc.line(lineNumber).text.includes("\r")) return "windows"; + } + return "unix"; } /** @@ -762,6 +823,12 @@ export default class EditorFile { } set isUnsaved(value) { + value = !!value; + if (!value && this.#hasVersionMetadata) { + this.savedVersion = this.docVersion; + this.hasDiskConflict = false; + this.#savedDoc = this.#rawSession?.doc || this.#savedDoc; + } if (this.#isUnsaved === value) return; this.#isUnsaved = value; @@ -833,6 +900,108 @@ export default class EditorFile { return this.#SAFMode; } + get hasVersionMetadata() { + return this.#hasVersionMetadata; + } + + hasUnsavedChanges() { + if (this.type !== "editor") return false; + const currentDoc = this.#rawSession?.doc; + if (currentDoc && this.#savedDoc) { + return ( + this.hasDiskConflict || + this.deletedFile || + !currentDoc.eq(this.#savedDoc) + ); + } + return ( + this.hasDiskConflict || + this.deletedFile || + this.docVersion !== this.savedVersion + ); + } + + markLoaded({ mtime, isUnsaved = false } = {}) { + const normalizedMtime = helpers.normalizeMtime(mtime); + this.docVersion = isUnsaved ? 1 : 0; + this.savedVersion = isUnsaved ? 0 : this.docVersion; + this.cacheVersion = this.savedVersion; + this.savedMtime = normalizedMtime; + this.diskMtime = normalizedMtime; + this.hasDiskConflict = false; + this.#hasVersionMetadata = true; + this.#savedDoc = isUnsaved ? null : this.#rawSession?.doc || null; + this.isUnsaved = isUnsaved || this.hasUnsavedChanges(); + } + + markEdited() { + if (this.type !== "editor") return; + if (this.id === config.DEFAULT_FILE_SESSION) { + this.id = helpers.uuid(); + } + this.docVersion += 1; + this.#hasVersionMetadata = true; + this.isUnsaved = this.hasUnsavedChanges(); + } + + markSaved({ mtime } = {}) { + const normalizedMtime = helpers.normalizeMtime(mtime); + this.savedVersion = this.docVersion; + this.savedMtime = normalizedMtime; + this.diskMtime = normalizedMtime; + this.hasDiskConflict = false; + this.#hasVersionMetadata = true; + this.#savedDoc = this.#rawSession?.doc || null; + this.isUnsaved = false; + } + + markDiskChanged({ mtime, deleted = false } = {}) { + this.diskMtime = helpers.normalizeMtime(mtime); + this.#hasVersionMetadata = true; + if (deleted) { + this.deletedFile = true; + this.isUnsaved = true; + return; + } + this.hasDiskConflict = + this.docVersion !== this.savedVersion && + this.diskMtime !== this.savedMtime; + this.isUnsaved = this.hasUnsavedChanges(); + } + + scheduleCacheWrite(delay = 1500) { + if (this.type !== "editor") return Promise.resolve(); + if (this.cacheVersion === this.docVersion && this.#hasVersionMetadata) { + return this.#cacheWritePromise || Promise.resolve(); + } + if (this.#cacheWriteTimer) clearTimeout(this.#cacheWriteTimer); + if (delay <= 0) { + this.#cacheWriteTimer = null; + this.#cacheWritePromise = this.writeToCache().finally(() => { + this.#cacheWritePromise = null; + }); + return this.#cacheWritePromise; + } + this.#cacheWriteTimer = setTimeout(() => { + this.#cacheWriteTimer = null; + this.#cacheWritePromise = this.writeToCache().finally(() => { + this.#cacheWritePromise = null; + }); + }, delay); + return Promise.resolve(); + } + + async flushCacheWrite() { + if (this.#cacheWriteTimer) { + clearTimeout(this.#cacheWriteTimer); + this.#cacheWriteTimer = null; + this.#cacheWritePromise = this.writeToCache().finally(() => { + this.#cacheWritePromise = null; + }); + } + if (this.#cacheWritePromise) await this.#cacheWritePromise; + } + async writeToCache() { const text = this.session.doc.toString(); const fs = fsOperation(this.cacheFile); @@ -840,10 +1009,14 @@ export default class EditorFile { try { if (!(await fs.exists())) { await fsOperation(CACHE_STORAGE).createFile(this.id, text); + this.cacheVersion = this.docVersion; + this.#hasVersionMetadata = true; return; } await fs.writeFile(text); + this.cacheVersion = this.docVersion; + this.#hasVersionMetadata = true; } catch (error) { window.log("error", "Writing to cache failed:"); window.log("error", error); @@ -856,6 +1029,9 @@ export default class EditorFile { if (!this.loaded || this.loading) { return false; } + if (this.#hasVersionMetadata) { + return this.hasUnsavedChanges(); + } // is changed is called when session text is changed // if file has no uri or is readonly that means file is change // and need to saved to a location. @@ -1389,6 +1565,7 @@ export default class EditorFile { try { const cacheFs = fsOperation(this.cacheFile); const cacheExists = await cacheFs.exists(); + let loadedMtime = this.savedMtime; if (cacheExists) { value = await cacheFs.readFile(this.encoding); @@ -1401,15 +1578,23 @@ export default class EditorFile { this.deletedFile = true; this.isUnsaved = true; } else if (!cacheExists && fileExists) { + const stat = await file.stat().catch(() => null); + loadedMtime = helpers.getStatMtime(stat); value = await file.readFile(this.encoding); + } else if (fileExists) { + const stat = await file.stat().catch(() => null); + loadedMtime = helpers.getStatMtime(stat); } else if (!cacheExists && !fileExists) { window.log("error", "unable to load file"); throw new Error("Unable to load file"); } } + const isUnsaved = this.isUnsaved; this.markChanged = false; this.session.setValue(value); + this.markLoaded({ mtime: loadedMtime, isUnsaved }); + this.markChanged = true; this.loaded = true; this.loading = false; @@ -1499,6 +1684,12 @@ export default class EditorFile { #destroy() { this.#emit("close", createFileEvent(this)); appSettings.off("update:openFileListPos", this.#onFilePosChange); + if (this.#cacheWriteTimer) { + clearTimeout(this.#cacheWriteTimer); + this.#cacheWriteTimer = null; + } + this.#cacheWritePromise = null; + this.#savedDoc = null; if (this.type === "editor") { this.#removeCache(); // CodeMirror EditorState doesn't need explicit cleanup diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index 21154444a..399c5cc8d 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -1226,7 +1226,7 @@ async function EditorManager($header, $body) { // Preserve previous state for restoring selection/folds after swap const prevState = file.session || null; - const doc = prevState ? prevState.doc.toString() : ""; + const doc = prevState ? prevState.doc : ""; const state = EditorState.create({ doc, extensions: exts }); file.session = state; editor.setState(state); @@ -1400,6 +1400,13 @@ async function EditorManager($header, $body) { void configureLspForFile(activeFile); } }, + flushCacheWrites() { + return Promise.all( + manager.files + .filter((file) => file?.type === "editor") + .map((file) => file.flushCacheWrite?.()), + ); + }, }; if (typeof document !== "undefined") { @@ -1694,15 +1701,19 @@ async function EditorManager($header, $body) { // Mirror latest state only on doc changes to avoid clobbering async loads file.session = update.state; + if (file.markChanged === false) { + return; + } + + file.markEdited(); + // Debounced change handling (unsaved flag, cache, autosave) if (checkTimeout) clearTimeout(checkTimeout); if (autosaveTimeout) clearTimeout(autosaveTimeout); checkTimeout = setTimeout(async () => { - const changed = await file.isChanged(); - file.isUnsaved = changed; try { - await file.writeToCache(); + file.scheduleCacheWrite(); } catch (error) { warnRecoverable( `Failed to write cache for ${file.filename || file.uri}`, @@ -1717,7 +1728,7 @@ async function EditorManager($header, $body) { toggleProblemButton(); const { autosave } = appSettings.value; - if (file.uri && changed && autosave) { + if (file.uri && file.isUnsaved && autosave) { autosaveTimeout = setTimeout(() => { acode.exec("save", false); }, autosave); @@ -2307,6 +2318,13 @@ async function EditorManager($header, $body) { prev.session = editor.state; prev.lastScrollTop = editor.scrollDOM?.scrollTop || 0; prev.lastScrollLeft = editor.scrollDOM?.scrollLeft || 0; + prev.flushCacheWrite?.().catch((error) => { + warnRecoverable( + `Failed to flush cache for ${prev.filename || prev.uri}`, + error, + `cache-flush-${prev.id}`, + ); + }); } manager.activeFile = file; diff --git a/src/lib/openFile.js b/src/lib/openFile.js index 1d716ed88..d6d8e0b64 100644 --- a/src/lib/openFile.js +++ b/src/lib/openFile.js @@ -1,4 +1,5 @@ import fsOperation from "fileSystem"; +import { Text } from "@codemirror/state"; import AudioPlayer from "components/audioPlayer"; import alert from "dialogs/alert"; import confirm from "dialogs/confirm"; @@ -39,7 +40,8 @@ export default async function openFile(file, options = {}) { if (existingFile) { // If file is already opened and new text is provided - const existingText = existingFile.session.doc.toString() ?? ""; + const incomingDoc = + text != null ? Text.of(String(text).split("\n")) : null; // If file is already opened existingFile.makeActive(); @@ -50,7 +52,7 @@ export default async function openFile(file, options = {}) { existingFile.onsave = onsave; } - if (text && existingText !== text) { + if (incomingDoc && !existingFile.session?.doc?.eq?.(incomingDoc)) { // let confirmation = true; // if (existingFile.isUnsaved) { // const message = strings['reopen file'].replace('{file}', existingFile.filename); @@ -105,6 +107,8 @@ export default async function openFile(file, options = {}) { readOnly, encoding: detectedEncoding || encoding, SAFMode: mode, + savedMtime: helpers.getStatMtime(fileInfo), + diskMtime: helpers.getStatMtime(fileInfo), }); }; diff --git a/src/lib/saveFile.js b/src/lib/saveFile.js index 83a89cdae..b3699b803 100644 --- a/src/lib/saveFile.js +++ b/src/lib/saveFile.js @@ -86,7 +86,9 @@ async function saveFile(file, isSaveAs = false) { } // in case if user cancels the dialog - if (!filename) return; + if (!filename) { + return; + } } if (filename !== file.filename) { @@ -122,9 +124,12 @@ async function saveFile(file, isSaveAs = false) { if (appSettings.value.formatOnSave) { editorManager.activeFile.markChanged = false; acode.exec("format", false); + editorManager.activeFile.markChanged = true; } await fileOnDevice.writeFile(data, encoding); + const stat = await fileOnDevice.stat().catch(() => null); + file.markSaved({ mtime: helpers.getStatMtime(stat) }); if (file.location) { recents.addFolder(file.location); @@ -133,7 +138,6 @@ async function saveFile(file, isSaveAs = false) { clearTimeout(saveTimeout); saveTimeout = setTimeout(() => { file.isSaving = false; - file.isUnsaved = false; if (newUrl) recents.addFile(file.uri); editorManager.onupdate("save-file"); editorManager.emit("update", "save-file"); diff --git a/src/lib/saveState.js b/src/lib/saveState.js index c442d1f7a..94b0ffffb 100644 --- a/src/lib/saveState.js +++ b/src/lib/saveState.js @@ -56,6 +56,12 @@ export default () => { filename: file.filename, pinned: file.pinned, isUnsaved: file.isUnsaved, + docVersion: file.docVersion, + savedVersion: file.savedVersion, + cacheVersion: file.cacheVersion, + savedMtime: file.savedMtime, + diskMtime: file.diskMtime, + hasDiskConflict: file.hasDiskConflict, readOnly: file.readOnly, SAFMode: file.SAFMode, deletedFile: file.deletedFile, diff --git a/src/main.js b/src/main.js index f18cae9bb..646381f3f 100644 --- a/src/main.js +++ b/src/main.js @@ -816,8 +816,9 @@ function menuButtonHandler() { acode?.exec("toggle-sidebar"); } -function pauseHandler() { +async function pauseHandler() { const { acode } = window; + await window.editorManager?.flushCacheWrites?.(); acode?.exec("save-state"); } diff --git a/src/utils/helpers.js b/src/utils/helpers.js index 85790193e..c2d0488d8 100644 --- a/src/utils/helpers.js +++ b/src/utils/helpers.js @@ -518,6 +518,16 @@ export default { return `${trimmedCountStr}${units[index]}`; }, + normalizeMtime(value) { + if (value == null) return null; + const time = value instanceof Date ? value.getTime() : Number(value); + return Number.isFinite(time) ? time : null; + }, + getStatMtime(stat) { + return this.normalizeMtime( + stat?.modifiedDate ?? stat?.lastModified ?? stat?.mtime, + ); + }, isBinary(file) { // binary file extensions const binaryExtensions = [ From 3b59348959810b29b78bc1a447d73bffb1a78a6a Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:54:48 +0530 Subject: [PATCH 02/13] fix --- src/lib/editorFile.js | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index a85de60be..2734e13ca 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -533,7 +533,9 @@ export default class EditorFile { : this.docVersion; this.cacheVersion = Number.isFinite(options?.cacheVersion) ? options.cacheVersion - : this.savedVersion; + : options?.isUnsaved + ? this.docVersion + : this.savedVersion; this.savedMtime = helpers.normalizeMtime(options?.savedMtime); this.diskMtime = helpers.normalizeMtime( options?.diskMtime ?? options?.savedMtime, @@ -925,7 +927,7 @@ export default class EditorFile { const normalizedMtime = helpers.normalizeMtime(mtime); this.docVersion = isUnsaved ? 1 : 0; this.savedVersion = isUnsaved ? 0 : this.docVersion; - this.cacheVersion = this.savedVersion; + this.cacheVersion = isUnsaved ? this.docVersion : this.savedVersion; this.savedMtime = normalizedMtime; this.diskMtime = normalizedMtime; this.hasDiskConflict = false; @@ -995,28 +997,43 @@ export default class EditorFile { if (this.#cacheWriteTimer) { clearTimeout(this.#cacheWriteTimer); this.#cacheWriteTimer = null; + if (!this.#cacheWritePromise) { + this.#cacheWritePromise = this.writeToCache().finally(() => { + this.#cacheWritePromise = null; + }); + } + } + if (this.#cacheWritePromise) await this.#cacheWritePromise; + if (this.cacheVersion !== this.docVersion) { + if (this.#cacheWriteTimer) { + clearTimeout(this.#cacheWriteTimer); + this.#cacheWriteTimer = null; + } this.#cacheWritePromise = this.writeToCache().finally(() => { this.#cacheWritePromise = null; }); + await this.#cacheWritePromise; } - if (this.#cacheWritePromise) await this.#cacheWritePromise; } async writeToCache() { + const writeVersion = this.docVersion; const text = this.session.doc.toString(); const fs = fsOperation(this.cacheFile); try { if (!(await fs.exists())) { await fsOperation(CACHE_STORAGE).createFile(this.id, text); - this.cacheVersion = this.docVersion; + this.cacheVersion = writeVersion; this.#hasVersionMetadata = true; + if (this.docVersion !== writeVersion) this.scheduleCacheWrite(); return; } await fs.writeFile(text); - this.cacheVersion = this.docVersion; + this.cacheVersion = writeVersion; this.#hasVersionMetadata = true; + if (this.docVersion !== writeVersion) this.scheduleCacheWrite(); } catch (error) { window.log("error", "Writing to cache failed:"); window.log("error", error); @@ -1644,7 +1661,7 @@ export default class EditorFile { this.#emit("save", event); if (event.defaultPrevented) return Promise.resolve(false); - return Promise.all([this.writeToCache(), saveFile(this, as)]); + return Promise.all([this.flushCacheWrite(), saveFile(this, as)]); } #run(file) { From a591880309fbb7cf649bd4f34e501ed02add6325 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:45:59 +0530 Subject: [PATCH 03/13] fix(editor): preserve save and cache snapshots --- src/lib/checkFiles.js | 25 +++++++++++++++---------- src/lib/editorFile.js | 10 ++++++---- src/lib/saveFile.js | 15 +++++++++------ 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/src/lib/checkFiles.js b/src/lib/checkFiles.js index 1f60d55fc..ba81645f2 100644 --- a/src/lib/checkFiles.js +++ b/src/lib/checkFiles.js @@ -72,16 +72,21 @@ export default async function checkFiles() { return; } - const stat = await fs.stat().catch(() => null); - const mtime = helpers.getStatMtime(stat); - if (file.hasVersionMetadata && file.savedMtime != null && mtime != null) { - if (mtime === file.savedMtime) return; - file.markDiskChanged({ mtime }); - if (file.isUnsaved) { - editorManager.onupdate("file-changed"); - editorManager.emit("update", "file-changed"); - console.warn(`File changed on disk while unsaved: ${file.filename}`); - return; + let mtime = null; + if (file.hasVersionMetadata && file.savedMtime != null) { + const stat = await fs.stat().catch(() => null); + mtime = helpers.getStatMtime(stat); + if (mtime != null) { + if (mtime === file.savedMtime) return; + file.markDiskChanged({ mtime }); + if (file.isUnsaved) { + editorManager.onupdate("file-changed"); + editorManager.emit("update", "file-changed"); + console.warn( + `File changed on disk while unsaved: ${file.filename}`, + ); + return; + } } } diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index 2734e13ca..9794f6e4a 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -946,15 +946,17 @@ export default class EditorFile { this.isUnsaved = this.hasUnsavedChanges(); } - markSaved({ mtime } = {}) { + markSaved({ mtime, savedDoc, savedVersion } = {}) { const normalizedMtime = helpers.normalizeMtime(mtime); - this.savedVersion = this.docVersion; + this.savedVersion = Number.isFinite(savedVersion) + ? savedVersion + : this.docVersion; this.savedMtime = normalizedMtime; this.diskMtime = normalizedMtime; this.hasDiskConflict = false; this.#hasVersionMetadata = true; - this.#savedDoc = this.#rawSession?.doc || null; - this.isUnsaved = false; + this.#savedDoc = savedDoc || this.#rawSession?.doc || null; + this.isUnsaved = this.hasUnsavedChanges(); } markDiskChanged({ mtime, deleted = false } = {}) { diff --git a/src/lib/saveFile.js b/src/lib/saveFile.js index b3699b803..813eb85b6 100644 --- a/src/lib/saveFile.js +++ b/src/lib/saveFile.js @@ -49,11 +49,6 @@ async function saveFile(file, isSaveAs = false) { * @type {string} */ const { encoding } = file; - /** - * File data - * @type {string} - */ - const data = file.session ? file.session.doc.toString() : ""; /** * File tab bar text element, used to show saving status * @type {HTMLElement} @@ -127,9 +122,17 @@ async function saveFile(file, isSaveAs = false) { editorManager.activeFile.markChanged = true; } + const savedDoc = file.session?.doc || null; + const savedVersion = file.docVersion; + const data = savedDoc ? savedDoc.toString() : ""; + await fileOnDevice.writeFile(data, encoding); const stat = await fileOnDevice.stat().catch(() => null); - file.markSaved({ mtime: helpers.getStatMtime(stat) }); + file.markSaved({ + mtime: helpers.getStatMtime(stat), + savedDoc, + savedVersion, + }); if (file.location) { recents.addFolder(file.location); From 9609116a24a0597e6001b00b4ab04bdc129b5d98 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:03:30 +0530 Subject: [PATCH 04/13] fix(editor): surface disk conflicts safely --- src/lib/checkFiles.js | 13 ++++++++++++- src/lib/editorFile.js | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/lib/checkFiles.js b/src/lib/checkFiles.js index ba81645f2..4b4c94919 100644 --- a/src/lib/checkFiles.js +++ b/src/lib/checkFiles.js @@ -78,13 +78,24 @@ export default async function checkFiles() { mtime = helpers.getStatMtime(stat); if (mtime != null) { if (mtime === file.savedMtime) return; + const alreadyWarnedConflict = + file.hasDiskConflict && file.diskMtime === mtime; file.markDiskChanged({ mtime }); - if (file.isUnsaved) { + if (file.hasDiskConflict) { editorManager.onupdate("file-changed"); editorManager.emit("update", "file-changed"); console.warn( `File changed on disk while unsaved: ${file.filename}`, ); + if (!alreadyWarnedConflict) { + await new Promise((resolve) => { + alert( + strings.warning.toUpperCase(), + `${file.filename} changed on disk while you have unsaved edits. Saving now may overwrite the external changes.`, + resolve, + ); + }); + } return; } } diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index 9794f6e4a..bc1933ec4 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -826,12 +826,12 @@ export default class EditorFile { set isUnsaved(value) { value = !!value; + if (this.#isUnsaved === value) return; if (!value && this.#hasVersionMetadata) { this.savedVersion = this.docVersion; this.hasDiskConflict = false; this.#savedDoc = this.#rawSession?.doc || this.#savedDoc; } - if (this.#isUnsaved === value) return; this.#isUnsaved = value; this.#updateTab(); From 81173232feec3391500bf51c8791b9098099093e Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:24:35 +0530 Subject: [PATCH 05/13] fix(editor): refresh cache after disk reload --- src/lib/checkFiles.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/checkFiles.js b/src/lib/checkFiles.js index 4b4c94919..ab808210f 100644 --- a/src/lib/checkFiles.js +++ b/src/lib/checkFiles.js @@ -126,6 +126,7 @@ export default async function checkFiles() { } finally { file.markChanged = true; } + await file.scheduleCacheWrite(0); editor.gotoLine(cursorPos.row, cursorPos.column); } catch (error) { // ignore From 971a1bb6e7ce6f437f202aec804c6592f57df57b Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:17:22 +0530 Subject: [PATCH 06/13] fix(editor): restore format save guard --- src/lib/saveFile.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/saveFile.js b/src/lib/saveFile.js index 813eb85b6..44d067fcc 100644 --- a/src/lib/saveFile.js +++ b/src/lib/saveFile.js @@ -118,8 +118,11 @@ async function saveFile(file, isSaveAs = false) { if (appSettings.value.formatOnSave) { editorManager.activeFile.markChanged = false; - acode.exec("format", false); - editorManager.activeFile.markChanged = true; + try { + acode.exec("format", false); + } finally { + editorManager.activeFile.markChanged = true; + } } const savedDoc = file.session?.doc || null; From 1151cf2269f73f4f0061f223251f766b2cf14d80 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:29:56 +0530 Subject: [PATCH 07/13] fix: disable native webview selection menu in terminal --- src/components/terminal/terminal.js | 42 +++++++++++++++++++ .../terminal/terminalTouchSelection.css | 9 ++++ 2 files changed, 51 insertions(+) diff --git a/src/components/terminal/terminal.js b/src/components/terminal/terminal.js index 6a043c85b..6b3f65e5f 100644 --- a/src/components/terminal/terminal.js +++ b/src/components/terminal/terminal.js @@ -67,6 +67,7 @@ export default class TerminalComponent { this.touchSelection = null; this.parsedAppKeybindings = []; this.parsedAppKeybindingsVersion = -1; + this.boundNativeSelectionMenuHandler = null; this.init(); } @@ -508,6 +509,7 @@ export default class TerminalComponent { overflow: hidden; box-sizing: border-box; `; + this.disableNativeSelectionMenu(this.container); return this.container; } @@ -525,6 +527,7 @@ export default class TerminalComponent { // Apply terminal background color to container to match theme this.container.style.background = this.options.theme.background; + this.disableNativeSelectionMenu(this.container); try { // Open first to ensure a stable renderer is attached @@ -577,6 +580,36 @@ export default class TerminalComponent { return container; } + /** + * Disable the platform/browser text-selection menu in terminal views. + * Terminal selection is handled by TerminalTouchSelection and xterm APIs. + */ + disableNativeSelectionMenu(container) { + if (!container) return; + + container.classList.add("terminal-native-selection-disabled"); + + if (this.boundNativeSelectionMenuHandler) { + container.removeEventListener( + "contextmenu", + this.boundNativeSelectionMenuHandler, + true, + ); + } + + this.boundNativeSelectionMenuHandler = (event) => { + if (event.target?.closest?.(".terminal-context-menu")) return; + event.preventDefault(); + event.stopPropagation(); + }; + + container.addEventListener( + "contextmenu", + this.boundNativeSelectionMenuHandler, + true, + ); + } + /** * Create new terminal session using global Terminal API * @returns {Promise} Terminal PID @@ -1083,6 +1116,15 @@ export default class TerminalComponent { this.terminal.dispose(); } + if (this.container && this.boundNativeSelectionMenuHandler) { + this.container.removeEventListener( + "contextmenu", + this.boundNativeSelectionMenuHandler, + true, + ); + this.boundNativeSelectionMenuHandler = null; + } + if (this.container) { this.container.remove(); } diff --git a/src/components/terminal/terminalTouchSelection.css b/src/components/terminal/terminalTouchSelection.css index 9217f3d2f..baaf83e29 100644 --- a/src/components/terminal/terminalTouchSelection.css +++ b/src/components/terminal/terminalTouchSelection.css @@ -13,6 +13,15 @@ overflow: hidden; } +.terminal-native-selection-disabled, +.terminal-native-selection-disabled .xterm, +.terminal-native-selection-disabled .xterm-screen, +.terminal-native-selection-disabled .xterm-helper-textarea { + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; +} + .terminal-selection-handle { position: absolute; background: #2196f3; From 0b6d02da545b5f17dff00005f817ab729ab4e351 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:39:09 +0530 Subject: [PATCH 08/13] feat: set default cursor width to 2 instead of 1 --- src/lib/editorManager.js | 24 ++++++++++++++++++++++++ src/lib/settings.js | 1 + 2 files changed, 25 insertions(+) diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index 51ee5ff10..5614d50f0 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -259,6 +259,8 @@ async function EditorManager($header, $body) { const indentGuidesCompartment = new Compartment(); // Compartment for line break marker const lineBreakMarkerCompartment = new Compartment(); + // Compartment for cursor appearance + const cursorThemeCompartment = new Compartment(); // Compartment for read-only toggling const readOnlyCompartment = new Compartment(); // Compartment for language mode (allows async loading/reconfigure) @@ -289,6 +291,17 @@ async function EditorManager($header, $body) { }); } + function makeCursorTheme() { + const width = Number(appSettings?.value?.cursorWidth); + const cursorWidth = + Number.isFinite(width) && width > 0 ? Math.min(width, 10) : 2; + return EditorView.theme({ + ".cm-cursor": { + borderLeftWidth: `${cursorWidth}px`, + }, + }); + } + function makeWrapExtension() { return appSettings?.value?.textWrap ? EditorView.lineWrapping : []; } @@ -414,6 +427,13 @@ async function EditorManager($header, $body) { return makeFontTheme(); }, }, + { + keys: ["cursorWidth"], + compartments: [cursorThemeCompartment], + build() { + return makeCursorTheme(); + }, + }, { keys: ["textWrap"], compartments: [wrapCompartment], @@ -1635,6 +1655,10 @@ async function EditorManager($header, $body) { updateEditorStyleFromSettings(); }); + appSettings.on("update:cursorWidth", function () { + applyOptions(["cursorWidth"]); + }); + appSettings.on("update:relativeLineNumbers", function () { updateEditorLineNumbersFromSettings(); }); diff --git a/src/lib/settings.js b/src/lib/settings.js index be6684348..cedb6f5ca 100644 --- a/src/lib/settings.js +++ b/src/lib/settings.js @@ -130,6 +130,7 @@ class Settings { lang: "en-us", uiZoom: 100, fontSize: "12px", + cursorWidth: 2, editorTheme: "one_dark", textWrap: true, softTab: true, From 7085ef6e88077c77e7b46911e2fd03c13b579f3a Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:41:58 +0530 Subject: [PATCH 09/13] don't rebuild editor state on every tab switch --- src/lib/editorFile.js | 4 ++ src/lib/editorManager.js | 120 +++++++++++++++++++++++++++++++-------- 2 files changed, 101 insertions(+), 23 deletions(-) diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index bc1933ec4..1a6c43a97 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -74,6 +74,10 @@ function createSessionProxy(state, file) { return new Proxy(state, { get(target, prop) { + if (prop === "__rawState") { + return target; + } + // Ace-compatible method: getValue() if (prop === "getValue") { return () => target.doc.toString(); diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index 3894170ed..6064eac48 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -1189,9 +1189,76 @@ async function EditorManager($header, $body) { touchSelectionController?.setMenu(!!value); }; + function getEditorExtensionSignature(file) { + return JSON.stringify({ + syntax: getEmmetSyntaxForFile(file), + colorPreview: !!appSettings.value.colorPreview, + autoCloseTags: appSettings.value.autoCloseTags !== false, + }); + } + + function getRawEditorState(state) { + return state?.__rawState || state || null; + } + + function isReusableEditorState(file, signature) { + const session = getRawEditorState(file?.session); + return ( + !!session && + !!file.__cmSessionReady && + file.__cmExtensionSignature === signature && + !!session.doc && + typeof session.update === "function" && + typeof session.facet === "function" + ); + } + + function scheduleLspForFile(file) { + const fileId = file?.id; + window.setTimeout(() => { + if (!fileId || manager.activeFile?.id !== fileId) return; + void configureLspForFile(file); + }, 80); + } + + function applyCurrentEditorOptions(file) { + touchSelectionController?.onSessionChanged(); + const desiredTheme = appSettings?.value?.editorTheme; + if (desiredTheme) editor.setTheme(desiredTheme); + applyOptions(); + try { + const ro = !file.editable || !!file.loading; + editor.dispatch({ + effects: readOnlyCompartment.reconfigure(EditorState.readOnly.of(ro)), + }); + } catch (error) { + warnRecoverable( + "Failed to apply read-only compartment update.", + error, + "readonly-reconfigure", + ); + } + } + // Helper: apply a file's content and language to the editor view - function applyFileToEditor(file) { + function applyFileToEditor(file, options = {}) { if (!file || file.type !== "editor") return; + const { forceRecreate = false } = options; + const extensionSignature = getEditorExtensionSignature(file); + + if (!forceRecreate && isReusableEditorState(file, extensionSignature)) { + editor.setState(getRawEditorState(file.session)); + applyCurrentEditorOptions(file); + if ( + typeof file.lastScrollTop === "number" || + typeof file.lastScrollLeft === "number" + ) { + setScrollPosition(editor, file.lastScrollTop, file.lastScrollLeft); + } + scheduleLspForFile(file); + return; + } + const syntax = getEmmetSyntaxForFile(file); const baseExtensions = createMainEditorExtensions({ // Emmet needs to precede default keymaps so tracker Tab wins over indent @@ -1222,8 +1289,16 @@ async function EditorManager($header, $body) { // If the loader returns a Promise, reconfigure when it resolves if (result && typeof result.then === "function") { initialLang = []; + const fileId = file.id; + const expectedSignature = extensionSignature; result .then((ext) => { + if ( + manager.activeFile?.id !== fileId || + file.__cmExtensionSignature !== expectedSignature + ) { + return; + } try { editor.dispatch({ effects: languageCompartment.reconfigure(ext || []), @@ -1267,19 +1342,15 @@ async function EditorManager($header, $body) { exts.push(lspCompartment.of([])); // Preserve previous state for restoring selection/folds after swap - const prevState = file.session || null; + const prevState = getRawEditorState(file.session); const doc = prevState ? prevState.doc : ""; const state = EditorState.create({ doc, extensions: exts }); file.session = state; + file.__cmSessionReady = true; + file.__cmExtensionSignature = extensionSignature; editor.setState(state); - touchSelectionController?.onSessionChanged(); - // Re-apply selected theme after state replacement - const desiredTheme = appSettings?.value?.editorTheme; - if (desiredTheme) editor.setTheme(desiredTheme); - - // Ensure dynamic compartments reflect current settings - applyOptions(); + applyCurrentEditorOptions(file); // Restore selection from previous state if available try { @@ -1319,7 +1390,7 @@ async function EditorManager($header, $body) { setScrollPosition(editor, file.lastScrollTop, file.lastScrollLeft); } - void configureLspForFile(file); + scheduleLspForFile(file); } function getEmmetSyntaxForFile(file) { @@ -1658,7 +1729,8 @@ async function EditorManager($header, $body) { appSettings.on("update:autoCloseTags", function () { const file = manager.activeFile; - if (file?.type === "editor") applyFileToEditor(file); + if (file?.type === "editor") + applyFileToEditor(file, { forceRecreate: true }); }); appSettings.on("update:linenumbers", function () { @@ -1713,7 +1785,8 @@ async function EditorManager($header, $body) { appSettings.on("update:colorPreview", function () { const file = manager.activeFile; - if (file?.type === "editor") applyFileToEditor(file); + if (file?.type === "editor") + applyFileToEditor(file, { forceRecreate: true }); }); appSettings.on("update:showSideButtons", function () { @@ -1814,7 +1887,7 @@ async function EditorManager($header, $body) { "readonly-reconfigure", ); // Fallback: full re-apply - applyFileToEditor(file); + applyFileToEditor(file, { forceRecreate: true }); } }); @@ -1827,8 +1900,7 @@ async function EditorManager($header, $body) { if (file?.type !== "editor") return; if (manager.activeFile?.id === file.id) { // Re-apply file to editor to update language/syntax highlighting - applyFileToEditor(file); - void configureLspForFile(file); + applyFileToEditor(file, { forceRecreate: true }); } }); @@ -2366,16 +2438,18 @@ async function EditorManager($header, $body) { // Persist the previous editor's state before switching away const prev = manager.activeFile; if (prev?.type === "editor") { - prev.session = editor.state; + prev.session = getRawEditorState(editor.state); prev.lastScrollTop = editor.scrollDOM?.scrollTop || 0; prev.lastScrollLeft = editor.scrollDOM?.scrollLeft || 0; - prev.flushCacheWrite?.().catch((error) => { - warnRecoverable( - `Failed to flush cache for ${prev.filename || prev.uri}`, - error, - `cache-flush-${prev.id}`, - ); - }); + window.setTimeout(() => { + prev.flushCacheWrite?.().catch((error) => { + warnRecoverable( + `Failed to flush cache for ${prev.filename || prev.uri}`, + error, + `cache-flush-${prev.id}`, + ); + }); + }, 250); } manager.activeFile = file; From 18c365f8b24cbf964c4966392b8c86f5e27216c2 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:42:10 +0530 Subject: [PATCH 10/13] improve color view --- src/cm/colorView.ts | 219 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 195 insertions(+), 24 deletions(-) diff --git a/src/cm/colorView.ts b/src/cm/colorView.ts index 94029d7ad..bd38f7657 100644 --- a/src/cm/colorView.ts +++ b/src/cm/colorView.ts @@ -8,28 +8,33 @@ import { } from "@codemirror/view"; import pickColor from "dialogs/color"; import color from "utils/color"; -import { colorRegex, HEX } from "utils/color/regex"; +import { colorRegex, isValidColor } from "utils/color/regex"; interface ColorWidgetState { from: number; to: number; colorType: string; alpha?: string; + colorRaw: string; } interface ColorWidgetParams extends ColorWidgetState { color: string; - colorRaw: string; +} + +interface DocRange { + from: number; + to: number; } // WeakMap to carry state from widget DOM back into handler const colorState = new WeakMap(); -const HEX_RE = new RegExp(HEX, "gi"); - const RGBG = new RegExp(colorRegex.anyGlobal); const enumColorType = { hex: "hex", rgb: "rgb", hsl: "hsl", named: "named" }; +const MAX_SCAN_CHARS = 20000; +const MAX_COLOR_CHIPS = 150; const disallowedBoundaryBefore = new Set(["-", ".", "/", "#"]); const disallowedBoundaryAfter = new Set(["-", ".", "/"]); @@ -119,15 +124,16 @@ class ColorWidget extends WidgetType { constructor({ color, colorRaw, ...state }: ColorWidgetParams) { super(); - this.state = state; // from, to, colorType, alpha + this.state = { ...state, colorRaw }; // from, to, colorType, alpha, original text this.color = color; // hex for input value - this.colorRaw = colorRaw; // original css color string + this.colorRaw = this.state.colorRaw; // original css color string } eq(other: ColorWidget): boolean { return ( other.state.colorType === this.state.colorType && other.color === this.color && + other.colorRaw === this.colorRaw && other.state.from === this.state.from && other.state.to === this.state.to && (other.state.alpha || "") === (this.state.alpha || "") @@ -158,15 +164,118 @@ class ColorWidget extends WidgetType { } } -function colorDecorations(view: EditorView): DecorationSet { +function normalizeRanges(ranges: readonly DocRange[]): DocRange[] { + if (!ranges.length) return []; + const sorted = [...ranges] + .map(({ from, to }) => { + const rangeFrom = Math.max(0, from); + return { from: rangeFrom, to: Math.max(rangeFrom, to) }; + }) + .sort((a, b) => a.from - b.from || a.to - b.to); + const merged: DocRange[] = []; + + for (const range of sorted) { + const last = merged[merged.length - 1]; + if (last && range.from <= last.to) { + last.to = Math.max(last.to, range.to); + continue; + } + merged.push(range); + } + + return merged; +} + +function mapRanges( + ranges: readonly DocRange[], + changes: ViewUpdate["changes"], +): DocRange[] { + return normalizeRanges( + ranges.map(({ from, to }) => ({ + from: changes.mapPos(from, -1), + to: changes.mapPos(to, 1), + })), + ); +} + +function expandRangeToLines(doc: Text, from: number, to: number): DocRange { + if (doc.length === 0) return { from: 0, to: 0 }; + const rangeFrom = Math.max(0, Math.min(doc.length, from)); + const rangeTo = Math.max(rangeFrom, Math.min(doc.length, to)); + const startLine = doc.lineAt(rangeFrom); + const endLine = doc.lineAt(rangeTo); + return { from: startLine.from, to: endLine.to }; +} + +function intersectsRange(from: number, to: number, range: DocRange): boolean { + if (from === to) return from >= range.from && from <= range.to; + return from < range.to && to > range.from; +} + +function intersectsRanges( + from: number, + to: number, + ranges: readonly DocRange[], +): boolean { + return ranges.some((range) => intersectsRange(from, to, range)); +} + +function intersectRanges( + ranges: readonly DocRange[], + bounds: readonly DocRange[], +): DocRange[] { + const intersections: DocRange[] = []; + for (const range of ranges) { + for (const bound of bounds) { + const from = Math.max(range.from, bound.from); + const to = Math.min(range.to, bound.to); + if (from <= to) intersections.push({ from, to }); + } + } + return normalizeRanges(intersections); +} + +function subtractRanges( + ranges: readonly DocRange[], + coveredRanges: readonly DocRange[], +): DocRange[] { + const result: DocRange[] = []; + const covered = normalizeRanges(coveredRanges); + + for (const range of normalizeRanges(ranges)) { + let from = range.from; + for (const cover of covered) { + if (cover.to <= from) continue; + if (cover.from >= range.to) break; + if (cover.from > from) { + result.push({ from, to: Math.min(cover.from, range.to) }); + } + from = Math.max(from, cover.to); + if (from >= range.to) break; + } + if (from < range.to) result.push({ from, to: range.to }); + } + + return result; +} + +function colorRanges( + view: EditorView, + ranges: readonly DocRange[], +): Range[] { const deco: Range[] = []; - const ranges = view.visibleRanges; const doc = view.state.doc; + let scannedChars = 0; for (const { from, to } of ranges) { - const text = doc.sliceString(from, to); + if (deco.length >= MAX_COLOR_CHIPS || scannedChars >= MAX_SCAN_CHARS) break; + const scanTo = Math.min(to, from + (MAX_SCAN_CHARS - scannedChars)); + if (scanTo <= from) continue; + const text = doc.sliceString(from, scanTo); + scannedChars += text.length; // Any color using global matcher from utils (captures named/rgb/rgba/hsl/hsla/hex) RGBG.lastIndex = 0; for (let m: RegExpExecArray | null; (m = RGBG.exec(text)); ) { + if (deco.length >= MAX_COLOR_CHIPS) break; const raw = m[2]; const start = from + m.index + m[1].length; const end = start + raw.length; @@ -188,21 +297,25 @@ function colorDecorations(view: EditorView): DecorationSet { } } - return Decoration.set(deco, true); + return deco; } class ColorViewPlugin { decorations: DecorationSet; - raf = 0; + visibleRanges: DocRange[]; + flushTimer = 0; pendingView: EditorView | null = null; + pendingDirtyRanges: DocRange[] = []; constructor(view: EditorView) { - this.decorations = colorDecorations(view); + this.decorations = Decoration.none; + this.visibleRanges = normalizeRanges(view.visibleRanges); + this.scheduleVisibleRanges(view); } update(update: ViewUpdate): void { - if (update.docChanged || update.viewportChanged) { - this.scheduleDecorations(update.view); + if (update.docChanged || update.viewportChanged || update.geometryChanged) { + this.scheduleDecorations(update); } const readOnly = update.view.contentDOM.ariaReadOnly === "true"; const editable = update.view.contentDOM.contentEditable === "true"; @@ -210,18 +323,71 @@ class ColorViewPlugin { this.changePicker(update.view, canBeEdited); } - scheduleDecorations(view: EditorView): void { + scheduleVisibleRanges(view: EditorView): void { this.pendingView = view; - if (this.raf) return; - // Color chips are decorative, so batch rapid viewport/doc changes into - // one animation frame instead of rebuilding on every intermediate update. - this.raf = requestAnimationFrame(() => { - this.raf = 0; + this.pendingDirtyRanges = normalizeRanges([ + ...this.pendingDirtyRanges, + ...view.visibleRanges, + ]); + this.scheduleFlush(); + } + + scheduleDecorations(update: ViewUpdate): void { + const view = update.view; + const doc = view.state.doc; + const visibleRanges = normalizeRanges(view.visibleRanges); + const mappedPreviousVisible = update.docChanged + ? mapRanges(this.visibleRanges, update.changes) + : this.visibleRanges; + const dirtyRanges: DocRange[] = []; + + if (update.docChanged) { + this.decorations = this.decorations.map(update.changes); + update.changes.iterChangedRanges((_fromA, _toA, fromB, toB) => { + dirtyRanges.push(expandRangeToLines(doc, fromB, toB)); + }); + } + + if (update.viewportChanged || update.geometryChanged) { + dirtyRanges.push(...subtractRanges(visibleRanges, mappedPreviousVisible)); + } + + this.pendingView = view; + this.pendingDirtyRanges = normalizeRanges([ + ...this.pendingDirtyRanges, + ...dirtyRanges, + ]); + this.visibleRanges = visibleRanges; + + this.scheduleFlush(); + } + + scheduleFlush(): void { + if (this.flushTimer) return; + this.flushTimer = window.setTimeout(() => { + this.flushTimer = 0; const pendingView = this.pendingView; + const dirtyRanges = this.pendingDirtyRanges; this.pendingView = null; + this.pendingDirtyRanges = []; if (!pendingView) return; - this.decorations = colorDecorations(pendingView); + this.flushDecorations(pendingView, dirtyRanges); + }, 80); + } + + flushDecorations(view: EditorView, dirtyRanges: readonly DocRange[]): void { + const visibleRanges = normalizeRanges(view.visibleRanges); + const dirtyVisibleRanges = intersectRanges(dirtyRanges, visibleRanges); + const add = colorRanges(view, dirtyVisibleRanges); + + this.decorations = this.decorations.update({ + filter: (from, to) => + intersectsRanges(from, to, visibleRanges) && + !intersectsRanges(from, to, dirtyVisibleRanges), + add, + sort: true, }); + this.visibleRanges = visibleRanges; } changePicker(view: EditorView, canBeEdited: boolean): void { @@ -237,11 +403,13 @@ class ColorViewPlugin { } destroy(): void { - if (this.raf) { - cancelAnimationFrame(this.raf); - this.raf = 0; + if (this.flushTimer) { + window.clearTimeout(this.flushTimer); + this.flushTimer = 0; } this.pendingView = null; + this.pendingDirtyRanges = []; + this.visibleRanges = []; } } @@ -253,6 +421,7 @@ export const colorView = (showPicker = true) => const target = e.target as HTMLElement | null; const chip = target?.closest?.(".cm-color-chip") as HTMLElement | null; if (!chip) return false; + if (!showPicker) return true; // Respect read-only and setting toggle const readOnly = view.contentDOM.ariaReadOnly === "true"; const editable = view.contentDOM.contentEditable === "true"; @@ -264,6 +433,8 @@ export const colorView = (showPicker = true) => pickColor(chip.dataset.colorraw || chip.dataset.color || "") .then((picked: string | null) => { if (!picked) return; + const current = view.state.doc.sliceString(data.from, data.to); + if (current !== data.colorRaw || !isValidColor(current)) return; view.dispatch({ changes: { from: data.from, to: data.to, insert: picked }, }); From 4bd7725922f677426af87985aa5534d3d4b51db1 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:50:21 +0530 Subject: [PATCH 11/13] make indentguides less intensive --- src/cm/indentGuides.ts | 312 ++++++++++++--------------------------- src/lib/editorManager.js | 2 +- 2 files changed, 99 insertions(+), 215 deletions(-) diff --git a/src/cm/indentGuides.ts b/src/cm/indentGuides.ts index 61ac48eed..730a0fe69 100644 --- a/src/cm/indentGuides.ts +++ b/src/cm/indentGuides.ts @@ -1,4 +1,4 @@ -import { getIndentUnit, syntaxTree } from "@codemirror/language"; +import { getIndentUnit } from "@codemirror/language"; import type { Extension } from "@codemirror/state"; import { EditorState, RangeSetBuilder } from "@codemirror/state"; import { @@ -8,24 +8,37 @@ import { ViewPlugin, type ViewUpdate, } from "@codemirror/view"; -import type { SyntaxNode } from "@lezer/common"; /** * Configuration options for indent guides */ export interface IndentGuidesConfig { - /** Whether to highlight the guide at the cursor's indent level */ + /** Deprecated: active guide highlighting is disabled for performance. */ highlightActiveGuide?: boolean; /** Whether to hide guides on blank lines */ hideOnBlankLines?: boolean; } const defaultConfig: Required = { - highlightActiveGuide: true, + highlightActiveGuide: false, hideOnBlankLines: false, }; const GUIDE_MARK_CLASS = "cm-indent-guides"; +const MAX_VISIBLE_GUIDE_LINES = 1200; +const MAX_GUIDE_LEVELS = 40; +const REBUILD_DELAY_MS = 50; + +interface IndentLineInfo { + text: string; + tabSize: number; + indentColumns: number; + leadingWhitespaceLength: number; + blank: boolean; +} + +type IndentLineCache = Map; +type GuideStyleCache = Map; /** * Get the tab size from editor state @@ -83,182 +96,13 @@ function getLeadingWhitespaceLength(line: string): number { return count; } -/** - * Node types that represent scope blocks in various languages - */ -const SCOPE_NODE_TYPES = new Set([ - "Block", - "ObjectExpression", - "ArrayExpression", - "ArrowFunction", - "FunctionDeclaration", - "FunctionExpression", - "ClassBody", - "ClassDeclaration", - "MethodDeclaration", - "SwitchBody", - "IfStatement", - "WhileStatement", - "ForStatement", - "ForInStatement", - "ForOfStatement", - "TryStatement", - "CatchClause", - "Object", - "Array", - "Element", - "SelfClosingTag", - "RuleSet", - "DeclarationList", - "Body", - "Suite", - "Program", - "Script", - "Module", -]); - -/** - * Information about the active scope for highlighting - */ -interface ActiveScope { - level: number; - startLine: number; - endLine: number; -} - -/** - * Find the active scope using syntax tree analysis - */ -function getActiveScope( - view: EditorView, - indentUnit: number, -): ActiveScope | null { - const { state } = view; - const { main } = state.selection; - const cursorPos = main.head; - - const tree = syntaxTree(state); - if (!tree || tree.length === 0) { - return getActiveScopeByIndentation(state, indentUnit); - } - - let scopeNode: SyntaxNode | null = null; - let node: SyntaxNode | null = tree.resolveInner(cursorPos, 0); - - while (node) { - if (SCOPE_NODE_TYPES.has(node.name)) { - scopeNode = node; - break; - } - node = node.parent; - } - - if (!scopeNode) { - return null; - } - - const startLine = state.doc.lineAt(scopeNode.from); - const endLine = state.doc.lineAt(scopeNode.to); - let contentStartLine = startLine.number; - if (startLine.number < endLine.number) { - contentStartLine = startLine.number + 1; - } - - const tabSize = getTabSize(state); - let level = 0; - - for (let ln = contentStartLine; ln <= endLine.number; ln++) { - const line = state.doc.line(ln); - if (!isBlankLine(line.text)) { - const indent = getLineIndentation(line.text, tabSize); - level = Math.floor(indent / indentUnit); - break; - } - } - - if (level <= 0) { - return null; - } - - return { - level, - startLine: startLine.number, - endLine: endLine.number, - }; -} - -/** - * Fallback: Find active scope by indentation when no syntax tree is available - */ -function getActiveScopeByIndentation( - state: EditorState, - indentUnit: number, -): ActiveScope | null { - const { main } = state.selection; - const cursorLine = state.doc.lineAt(main.head); - const tabSize = getTabSize(state); - - let cursorIndent = getLineIndentation(cursorLine.text, tabSize); - - if (isBlankLine(cursorLine.text)) { - for (let lineNum = cursorLine.number - 1; lineNum >= 1; lineNum--) { - const prevLine = state.doc.line(lineNum); - if (!isBlankLine(prevLine.text)) { - cursorIndent = getLineIndentation(prevLine.text, tabSize); - break; - } - } - } - - const cursorLevel = Math.floor(cursorIndent / indentUnit); - if (cursorLevel <= 0) return null; - - let startLine = cursorLine.number; - for (let lineNum = cursorLine.number - 1; lineNum >= 1; lineNum--) { - const line = state.doc.line(lineNum); - if (isBlankLine(line.text)) continue; - const lineLevel = Math.floor( - getLineIndentation(line.text, tabSize) / indentUnit, - ); - if (lineLevel < cursorLevel) break; - startLine = lineNum; - } - - let endLine = cursorLine.number; - for ( - let lineNum = cursorLine.number + 1; - lineNum <= state.doc.lines; - lineNum++ - ) { - const line = state.doc.line(lineNum); - if (isBlankLine(line.text)) { - endLine = lineNum; - continue; - } - const lineLevel = Math.floor( - getLineIndentation(line.text, tabSize) / indentUnit, - ); - if (lineLevel < cursorLevel) break; - endLine = lineNum; - } - - return { level: cursorLevel, startLine, endLine }; -} - -function buildGuideStyle( - levels: number, - guideStepPx: number, - activeGuideIndex: number, -): string { +function buildGuideStyle(levels: number, guideStepPx: number): string { const images = []; const positions = []; const sizes = []; for (let i = 0; i < levels; i++) { - const color = - i === activeGuideIndex - ? "var(--indent-guide-active-color)" - : "var(--indent-guide-color)"; + const color = "var(--indent-guide-color)"; images.push(`linear-gradient(${color}, ${color})`); positions.push(`${i * guideStepPx}px 0`); sizes.push("1px 100%"); @@ -272,58 +116,87 @@ function buildGuideStyle( ].join(";"); } +function getGuideStyle( + levels: number, + guideStepPx: number, + styleCache: GuideStyleCache, +): string { + const key = `${levels}:${guideStepPx}`; + let style = styleCache.get(key); + if (!style) { + style = buildGuideStyle(levels, guideStepPx); + styleCache.set(key, style); + } + return style; +} + +function getCachedLineInfo( + lineNumber: number, + lineText: string, + tabSize: number, + cache: IndentLineCache, +): IndentLineInfo { + const cached = cache.get(lineNumber); + if (cached && cached.text === lineText && cached.tabSize === tabSize) { + return cached; + } + + const info = { + text: lineText, + tabSize, + indentColumns: getLineIndentation(lineText, tabSize), + leadingWhitespaceLength: getLeadingWhitespaceLength(lineText), + blank: isBlankLine(lineText), + }; + cache.set(lineNumber, info); + return info; +} + /** * Build decorations for indent guides */ function buildDecorations( view: EditorView, config: Required, + lineCache: IndentLineCache, + styleCache: GuideStyleCache, ): DecorationSet { const builder = new RangeSetBuilder(); const { state } = view; const tabSize = getTabSize(state); const indentUnit = getIndentUnitColumns(state); const guideStepPx = Math.max(view.defaultCharacterWidth * indentUnit, 1); - - const activeScope = config.highlightActiveGuide - ? getActiveScope(view, indentUnit) - : null; + let processedLines = 0; for (const { from: blockFrom, to: blockTo } of view.visibleRanges) { const startLine = state.doc.lineAt(blockFrom); const endLine = state.doc.lineAt(blockTo); for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) { + if (processedLines >= MAX_VISIBLE_GUIDE_LINES) return builder.finish(); + processedLines++; + const line = state.doc.line(lineNum); - const lineText = line.text; + const info = getCachedLineInfo(lineNum, line.text, tabSize, lineCache); - if (config.hideOnBlankLines && isBlankLine(lineText)) { + if (config.hideOnBlankLines && info.blank) { continue; } - const indentColumns = getLineIndentation(lineText, tabSize); - const levels = Math.floor(indentColumns / indentUnit); + const levels = Math.min( + Math.floor(info.indentColumns / indentUnit), + MAX_GUIDE_LEVELS, + ); if (levels <= 0) continue; - const leadingWhitespaceLength = getLeadingWhitespaceLength(lineText); - if (leadingWhitespaceLength <= 0) continue; - - let activeGuideIndex = -1; - if ( - activeScope && - lineNum >= activeScope.startLine && - lineNum <= activeScope.endLine && - levels >= activeScope.level - ) { - activeGuideIndex = activeScope.level - 1; - } + if (info.leadingWhitespaceLength <= 0) continue; builder.add( line.from, - line.from + leadingWhitespaceLength, + line.from + info.leadingWhitespaceLength, Decoration.mark({ attributes: { class: GUIDE_MARK_CLASS, - style: buildGuideStyle(levels, guideStepPx, activeGuideIndex), + style: getGuideStyle(levels, guideStepPx, styleCache), }, }), ); @@ -345,45 +218,59 @@ function createIndentGuidesPlugin( return ViewPlugin.fromClass( class { decorations: DecorationSet; - raf = 0; + rebuildTimer = 0; pendingView: EditorView | null = null; + lineCache: IndentLineCache = new Map(); + styleCache: GuideStyleCache = new Map(); constructor(view: EditorView) { - this.decorations = buildDecorations(view, config); + this.decorations = Decoration.none; + this.scheduleBuild(view); } update(update: ViewUpdate): void { if ( !update.docChanged && !update.viewportChanged && - !update.geometryChanged && - !(config.highlightActiveGuide && update.selectionSet) + !update.geometryChanged ) { return; } + if (update.docChanged) { + this.decorations = this.decorations.map(update.changes); + this.lineCache.clear(); + } + if (update.geometryChanged) { + this.styleCache.clear(); + } this.scheduleBuild(update.view); } scheduleBuild(view: EditorView): void { this.pendingView = view; - if (this.raf) return; - // Guide rebuilding is cosmetic and can be expensive on large - // viewports, so we intentionally collapse bursts into one frame. - this.raf = requestAnimationFrame(() => { - this.raf = 0; + if (this.rebuildTimer) return; + this.rebuildTimer = window.setTimeout(() => { + this.rebuildTimer = 0; const pendingView = this.pendingView; this.pendingView = null; if (!pendingView) return; - this.decorations = buildDecorations(pendingView, config); - }); + this.decorations = buildDecorations( + pendingView, + config, + this.lineCache, + this.styleCache, + ); + }, REBUILD_DELAY_MS); } destroy(): void { - if (this.raf) { - cancelAnimationFrame(this.raf); - this.raf = 0; + if (this.rebuildTimer) { + window.clearTimeout(this.rebuildTimer); + this.rebuildTimer = 0; } this.pendingView = null; + this.lineCache.clear(); + this.styleCache.clear(); } }, { @@ -403,15 +290,12 @@ const indentGuidesTheme = EditorView.baseTheme({ }, "&": { "--indent-guide-color": "rgba(128, 128, 128, 0.25)", - "--indent-guide-active-color": "rgba(128, 128, 128, 0.7)", }, "&light": { "--indent-guide-color": "rgba(0, 0, 0, 0.1)", - "--indent-guide-active-color": "rgba(0, 0, 0, 0.4)", }, "&dark": { "--indent-guide-color": "rgba(255, 255, 255, 0.1)", - "--indent-guide-active-color": "rgba(255, 255, 255, 0.4)", }, }); diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index 6064eac48..b99391c77 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -418,7 +418,7 @@ async function EditorManager($header, $body) { const enabled = appSettings?.value?.indentGuides ?? false; if (!enabled) return []; return indentGuides({ - highlightActiveGuide: true, + highlightActiveGuide: false, hideOnBlankLines: false, }); }, From 0889c906dcc56f2d2caa7a4334d592eff1827c7d Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:55:47 +0530 Subject: [PATCH 12/13] Fixed the terminal touch-selection handle oscillation --- .../terminal/terminalTouchSelection.js | 43 ++++++++++++++++--- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/src/components/terminal/terminalTouchSelection.js b/src/components/terminal/terminalTouchSelection.js index a75789a5b..edaadb0f1 100644 --- a/src/components/terminal/terminalTouchSelection.js +++ b/src/components/terminal/terminalTouchSelection.js @@ -7,6 +7,8 @@ import "./terminalTouchSelection.css"; const DEFAULT_MORE_OPTION_ID = "__acode_terminal_select_all__"; const terminalMoreOptions = new Map(); let terminalMoreOptionCounter = 0; +const HANDLE_EDGE_FLIP_MARGIN = 4; +const HANDLE_EDGE_RESTORE_MARGIN = 12; function ensureDefaultMoreOption() { if (terminalMoreOptions.has(DEFAULT_MORE_OPTION_ID)) return; @@ -550,6 +552,8 @@ export default class TerminalTouchSelection { const targetHandle = handleType === "start" ? this.startHandle : this.endHandle; targetHandle.style.cursor = "grabbing"; + this.startHandle.style.transition = "none"; + this.endHandle.style.transition = "none"; if (!targetHandle.style.transform.includes("scale")) { targetHandle.style.transform += " scale(1.2)"; } @@ -635,6 +639,7 @@ export default class TerminalTouchSelection { const handles = [this.startHandle, this.endHandle]; handles.forEach((handle) => { handle.style.cursor = "grab"; + handle.style.transition = ""; // More robust transform cleanup handle.style.transform = handle.style.transform .replace(/\s*scale\([^)]*\)/g, "") @@ -875,6 +880,7 @@ export default class TerminalTouchSelection { setHandleOrientation(handle, orientation) { if (!handle) return; + if (handle.dataset.orientation === orientation) return; const baseTransform = this.getHandleBaseTransform(orientation); const hasScale = /\bscale\(/.test(handle.style.transform || ""); @@ -884,22 +890,45 @@ export default class TerminalTouchSelection { : baseTransform; } + getHandleAnchorX(handle) { + const left = Number.parseFloat(handle.style.left || "0"); + return Number.isFinite(left) ? left : 0; + } + updateHandleOrientationForViewportEdges() { - const overlayRect = this.selectionOverlay.getBoundingClientRect(); + const overlayWidth = + this.selectionOverlay.clientWidth || this.container.clientWidth; + const handleSize = this.options.handleSize; if (this.startHandle.style.display !== "none") { - this.setHandleOrientation(this.startHandle, "start"); - const startRect = this.startHandle.getBoundingClientRect(); - if (startRect.left < overlayRect.left) { + const anchorX = this.getHandleAnchorX(this.startHandle); + const orientation = this.startHandle.dataset.orientation || "start"; + if ( + orientation === "start" && + anchorX < handleSize + HANDLE_EDGE_FLIP_MARGIN + ) { this.setHandleOrientation(this.startHandle, "end"); + } else if ( + orientation === "end" && + anchorX > handleSize + HANDLE_EDGE_RESTORE_MARGIN + ) { + this.setHandleOrientation(this.startHandle, "start"); } } if (this.endHandle.style.display !== "none") { - this.setHandleOrientation(this.endHandle, "end"); - const endRect = this.endHandle.getBoundingClientRect(); - if (endRect.right > overlayRect.right) { + const anchorX = this.getHandleAnchorX(this.endHandle); + const orientation = this.endHandle.dataset.orientation || "end"; + if ( + orientation === "end" && + anchorX > overlayWidth - handleSize - HANDLE_EDGE_FLIP_MARGIN + ) { this.setHandleOrientation(this.endHandle, "start"); + } else if ( + orientation === "start" && + anchorX < overlayWidth - handleSize - HANDLE_EDGE_RESTORE_MARGIN + ) { + this.setHandleOrientation(this.endHandle, "end"); } } } From 6fdbb1cc9dae86e49c43172d8e9eabe958d5bed4 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:20:52 +0530 Subject: [PATCH 13/13] fix(editor): force cache refresh after reload --- src/lib/checkFiles.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/checkFiles.js b/src/lib/checkFiles.js index ab808210f..6044498b2 100644 --- a/src/lib/checkFiles.js +++ b/src/lib/checkFiles.js @@ -126,7 +126,7 @@ export default async function checkFiles() { } finally { file.markChanged = true; } - await file.scheduleCacheWrite(0); + await file.writeToCache(); editor.gotoLine(cursorPos.row, cursorPos.column); } catch (error) { // ignore