From f2c313cc489a75bd76454259110e76fd1acf3085 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:32:51 +0530 Subject: [PATCH 1/2] fix(editor): restore cursor state and prevent tab-switch flash --- src/lib/editorFile.js | 74 +++++++++++++++++++-------- src/lib/editorManager.js | 5 ++ src/lib/restoreFiles.js | 34 ++++++------ src/lib/saveState.js | 2 +- tests/unit/restoreFiles.test.js | 55 ++++++++++++++++++++ tests/unit/sessionPersistence.test.js | 12 +++++ 6 files changed, 141 insertions(+), 41 deletions(-) create mode 100644 tests/unit/restoreFiles.test.js diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index df7abed821..51ce68b712 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -1,17 +1,11 @@ import fsOperation from "fileSystem"; // CodeMirror imports for document state management -import { EditorState } from "@codemirror/state"; +import { EditorSelection, EditorState } from "@codemirror/state"; import { focusEditorIfEditable, reconfigureEditorReadOnly, } from "cm/editorReadOnly"; -import { - clearSelection, - getDocText, - restoreFolds, - restoreSelection, - setScrollPosition, -} from "cm/editorUtils"; +import { clearSelection, getDocText } from "cm/editorUtils"; import { getMode, getModeForPath } from "cm/modelist"; import quickTools from "components/quickTools"; import Sidebar from "components/sidebar"; @@ -37,6 +31,25 @@ import appSettings from "./settings"; let mainCSSStyleSheet = null; +function restoreSessionSelection(state, selection) { + if (!selection?.ranges?.length) return state; + + const docLength = state.doc.length; + const ranges = selection.ranges.map((range) => { + const from = Math.max(0, Math.min(docLength, range.from | 0)); + const to = Math.max(0, Math.min(docLength, range.to | 0)); + return EditorSelection.range(from, to); + }); + const mainIndex = + selection.mainIndex >= 0 && selection.mainIndex < ranges.length + ? selection.mainIndex + : 0; + + return state.update({ + selection: EditorSelection.create(ranges, mainIndex), + }).state; +} + function getMainCSSStyleSheet() { if (mainCSSStyleSheet) return mainCSSStyleSheet; for (const sheet of document.styleSheets) { @@ -446,6 +459,7 @@ export default class EditorFile { * contains information about cursor position, scroll left, scroll top, folds. */ #loadOptions; + #loadPromise = null; /** * Weather file is changed and needs to be saved * @type {boolean} @@ -502,6 +516,9 @@ export default class EditorFile { hasDiskConflict = false; isPanePlaceholder = false; persistInSession = true; + lastScrollTop = 0; + lastScrollLeft = 0; + restoredFolds = null; /** * @@ -702,6 +719,13 @@ export default class EditorFile { folds: options?.folds, editable, }; + this.lastScrollTop = Number.isFinite(options?.scrollTop) + ? options.scrollTop + : 0; + this.lastScrollLeft = Number.isFinite(options?.scrollLeft) + ? options.scrollLeft + : 0; + this.restoredFolds = options?.folds || null; } this.#onFilePosChange = () => { @@ -1557,8 +1581,8 @@ export default class EditorFile { this.#tab.classList.add("active"); this.#tab.scrollIntoView(); - if (this.type === "editor" && !this.loaded && !this.loading) { - this.#loadText(); + if (this.type === "editor" && !this.loaded) { + void this.load(); } syncQuickToolsVisibility(this); @@ -1640,6 +1664,20 @@ export default class EditorFile { } } + /** + * Load this file's document into its stored editor session. + * Reuses an in-flight load so session restoration can safely preload tabs. + */ + load() { + if (this.type !== "editor" || this.loaded) return Promise.resolve(this); + if (this.#loadPromise) return this.#loadPromise; + + this.#loadPromise = this.#loadText().finally(() => { + this.#loadPromise = null; + }); + return this.#loadPromise; + } + /** * Add event listener * @param {string} event @@ -1781,9 +1819,7 @@ export default class EditorFile { const protocol = this.uri ? Url.getProtocol(this.uri) : ""; const isRemoteFile = protocol === "ftp:" || protocol === "sftp:"; - const { cursorPos, scrollLeft, scrollTop, folds, editable } = - this.#loadOptions; - const { editor } = editorManager; + const { cursorPos, editable } = this.#loadOptions; this.#loadOptions = null; @@ -1855,7 +1891,10 @@ export default class EditorFile { const isUnsaved = this.isUnsaved; this.markChanged = false; - this.session = EditorState.create({ doc: value }); + this.session = restoreSessionSelection( + EditorState.create({ doc: value }), + cursorPos, + ); this.__cmSessionReady = false; this.__cmLanguageReady = false; this.__cmLanguageSignature = null; @@ -1874,13 +1913,6 @@ export default class EditorFile { setTimeout(() => { this.#emit("load", createFileEvent(this)); - if (cursorPos) { - restoreSelection(editor, cursorPos); - } - if (scrollTop || scrollLeft) { - setScrollPosition(editor, scrollTop, scrollLeft); - } - restoreFolds(editor, folds); }, 0); } catch (error) { this.#emit("loaderror", createFileEvent(this)); diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index d719add426..92313e3291 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -3018,9 +3018,14 @@ async function EditorManager($header, $body) { // Restore folds from previous state if available try { const folds = prevState ? getAllFolds(prevState) : []; + if (!folds.length && file.restoredFolds?.length) { + folds.push(...file.restoredFolds); + } if (folds && folds.length) { restoreFolds(editor, folds); + file.session = editor.state; } + file.restoredFolds = null; } catch (error) { warnRecoverable( "Failed to restore folded regions from previous session state.", diff --git a/src/lib/restoreFiles.js b/src/lib/restoreFiles.js index 6f58c88285..0786d56e14 100644 --- a/src/lib/restoreFiles.js +++ b/src/lib/restoreFiles.js @@ -3,26 +3,22 @@ import EditorFile from "./editorFile"; /** * * @param {import('./editorFile').FileOptions[]} files - * @param {(count: number)=>void} callback */ export default async function restoreFiles(files) { - let rendered = false; + const hasRenderedFile = files.some((file) => file.render); + const restoredFiles = files.map((file, index) => { + const render = + file.render || (!hasRenderedFile && index === files.length - 1); + const options = { + ...file, + render, + emitUpdate: false, + }; + return new EditorFile(file.filename, options); + }); - await Promise.all( - files.map(async (file, i) => { - rendered ||= !!file.render; - - if (i === files.length - 1 && !rendered) { - file.render = true; - } - - const { filename, render = false } = file; - const options = { - ...file, - render, - emitUpdate: false, - }; - new EditorFile(filename, options); - }), - ); + // Finish restoring every document before startup persistence is enabled. + // Otherwise the temporary empty sessions can overwrite saved cursor state, + // and the first visit to an inactive tab visibly flashes a loading editor. + await Promise.all(restoredFiles.map((file) => file.load?.())); } diff --git a/src/lib/saveState.js b/src/lib/saveState.js index 7e0ea6790b..05307c55ec 100644 --- a/src/lib/saveState.js +++ b/src/lib/saveState.js @@ -72,7 +72,7 @@ export default () => { editable: file.editable, encoding: file.encoding, render: activeFile?.id === file.id, - folds: getAllFolds(file.session), + folds: file.restoredFolds ?? getAllFolds(file.session), }; if (settings.rememberFiles || fileJson.isUnsaved) diff --git a/tests/unit/restoreFiles.test.js b/tests/unit/restoreFiles.test.js new file mode 100644 index 0000000000..8afab0dbb7 --- /dev/null +++ b/tests/unit/restoreFiles.test.js @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const runtime = vi.hoisted(() => ({ + instances: [], +})); + +vi.mock("lib/editorFile", () => ({ + default: class MockEditorFile { + constructor(filename, options) { + this.filename = filename; + this.options = options; + this.loaded = new Promise((resolve) => { + this.resolveLoad = resolve; + }); + runtime.instances.push(this); + } + + load() { + return this.loaded; + } + }, +})); + +import restoreFiles from "lib/restoreFiles"; + +describe("restored file loading", () => { + beforeEach(() => { + runtime.instances.length = 0; + }); + + it("waits for every restored tab and activates the last tab by default", async () => { + let completed = false; + const restoration = restoreFiles([ + { id: "one", filename: "one.js" }, + { id: "two", filename: "two.js" }, + ]).then(() => { + completed = true; + }); + + expect(runtime.instances).toHaveLength(2); + expect(runtime.instances.map((file) => file.options.render)).toEqual([ + false, + true, + ]); + expect(completed).toBe(false); + + runtime.instances[0].resolveLoad(); + await Promise.resolve(); + expect(completed).toBe(false); + + runtime.instances[1].resolveLoad(); + await restoration; + expect(completed).toBe(true); + }); +}); diff --git a/tests/unit/sessionPersistence.test.js b/tests/unit/sessionPersistence.test.js index 938cc63a60..b2843e92aa 100644 --- a/tests/unit/sessionPersistence.test.js +++ b/tests/unit/sessionPersistence.test.js @@ -134,6 +134,18 @@ describe("file session persistence", () => { uri, ]); }); + + it("keeps restored folds until an inactive tab is first rendered", () => { + const file = createOpenFile("file:///restored.js", {}); + file.restoredFolds = [ + { fromLine: 2, fromCol: 0, toLine: 5, toCol: 1 }, + ]; + globalThis.editorManager.files.push(file); + + saveState(); + + expect(JSON.parse(localStorage.files)[0].folds).toEqual(file.restoredFolds); + }); }); function createOpenFile(uri, options) { From d83b7025f0468226c01460e8f6da753f1c265f75 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:43:14 +0530 Subject: [PATCH 2/2] fix --- src/lib/editorFile.js | 3 +++ src/lib/editorManager.js | 9 +++++++-- src/lib/restoreFiles.js | 27 ++++++++++++++++++++++----- src/lib/saveState.js | 11 +++++++++-- tests/unit/restoreFiles.test.js | 21 ++++++++++++++++++--- tests/unit/sessionPersistence.test.js | 21 +++++++++++++++++++++ 6 files changed, 80 insertions(+), 12 deletions(-) diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index 51ce68b712..e638cdb84b 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -518,6 +518,7 @@ export default class EditorFile { persistInSession = true; lastScrollTop = 0; lastScrollLeft = 0; + restoredSelection = null; restoredFolds = null; /** @@ -725,6 +726,7 @@ export default class EditorFile { this.lastScrollLeft = Number.isFinite(options?.scrollLeft) ? options.scrollLeft : 0; + this.restoredSelection = options?.cursorPos || null; this.restoredFolds = options?.folds || null; } @@ -1895,6 +1897,7 @@ export default class EditorFile { EditorState.create({ doc: value }), cursorPos, ); + this.restoredSelection = null; this.__cmSessionReady = false; this.__cmLanguageReady = false; this.__cmLanguageSignature = null; diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index 92313e3291..d216b4b933 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -3018,14 +3018,19 @@ async function EditorManager($header, $body) { // Restore folds from previous state if available try { const folds = prevState ? getAllFolds(prevState) : []; - if (!folds.length && file.restoredFolds?.length) { + const canConsumeRestoredFolds = file.loaded && !file.loading; + if ( + !folds.length && + canConsumeRestoredFolds && + file.restoredFolds?.length + ) { folds.push(...file.restoredFolds); } if (folds && folds.length) { restoreFolds(editor, folds); file.session = editor.state; } - file.restoredFolds = null; + if (canConsumeRestoredFolds) file.restoredFolds = null; } catch (error) { warnRecoverable( "Failed to restore folded regions from previous session state.", diff --git a/src/lib/restoreFiles.js b/src/lib/restoreFiles.js index 0786d56e14..4f844ac678 100644 --- a/src/lib/restoreFiles.js +++ b/src/lib/restoreFiles.js @@ -6,7 +6,9 @@ import EditorFile from "./editorFile"; */ export default async function restoreFiles(files) { const hasRenderedFile = files.some((file) => file.render); - const restoredFiles = files.map((file, index) => { + const localLoads = []; + + files.forEach((file, index) => { const render = file.render || (!hasRenderedFile && index === files.length - 1); const options = { @@ -14,11 +16,26 @@ export default async function restoreFiles(files) { render, emitUpdate: false, }; - return new EditorFile(file.filename, options); + const restoredFile = new EditorFile(file.filename, options); + const load = Promise.resolve(restoredFile.load?.()); + + if (isRemoteUri(file.uri)) { + void load.catch((error) => { + console.warn(`Failed to preload restored file: ${file.uri}`, error); + }); + return; + } + + localLoads.push(load); }); - // Finish restoring every document before startup persistence is enabled. + // Finish restoring local documents before startup persistence is enabled. // Otherwise the temporary empty sessions can overwrite saved cursor state, - // and the first visit to an inactive tab visibly flashes a loading editor. - await Promise.all(restoredFiles.map((file) => file.load?.())); + // and the first visit to an inactive local tab visibly flashes a loading editor. + // Remote tabs keep preloading without blocking the rest of app startup. + await Promise.all(localLoads); +} + +function isRemoteUri(uri) { + return /^(?:https?|s?ftp):/i.test(uri || ""); } diff --git a/src/lib/saveState.js b/src/lib/saveState.js index 05307c55ec..5d31b37324 100644 --- a/src/lib/saveState.js +++ b/src/lib/saveState.js @@ -20,7 +20,9 @@ export default () => { // - Active file uses live EditorView selection // - Inactive files use their persisted EditorState selection let cursorPos; - if (activeFile?.id === file.id) { + if (!file.loaded && file.restoredSelection) { + cursorPos = file.restoredSelection; + } else if (activeFile?.id === file.id) { cursorPos = getSelection(editor); } else { const sel = file.session?.selection; @@ -39,7 +41,12 @@ export default () => { // - Active file uses live scroll from EditorView // - Inactive files use lastScrollTop/Left captured on tab switch let scrollTop, scrollLeft; - if (activeFile?.id === file.id) { + if (!file.loaded) { + scrollTop = + typeof file.lastScrollTop === "number" ? file.lastScrollTop : 0; + scrollLeft = + typeof file.lastScrollLeft === "number" ? file.lastScrollLeft : 0; + } else if (activeFile?.id === file.id) { const sp = getScrollPosition(editor); scrollTop = sp.scrollTop; scrollLeft = sp.scrollLeft; diff --git a/tests/unit/restoreFiles.test.js b/tests/unit/restoreFiles.test.js index 8afab0dbb7..339a347111 100644 --- a/tests/unit/restoreFiles.test.js +++ b/tests/unit/restoreFiles.test.js @@ -28,11 +28,11 @@ describe("restored file loading", () => { runtime.instances.length = 0; }); - it("waits for every restored tab and activates the last tab by default", async () => { + it("waits for every local tab and activates the last tab by default", async () => { let completed = false; const restoration = restoreFiles([ - { id: "one", filename: "one.js" }, - { id: "two", filename: "two.js" }, + { id: "one", filename: "one.js", uri: "file:///one.js" }, + { id: "two", filename: "two.js", uri: "file:///two.js" }, ]).then(() => { completed = true; }); @@ -52,4 +52,19 @@ describe("restored file loading", () => { await restoration; expect(completed).toBe(true); }); + + it.each(["ftp", "sftp", "http", "https"])( + "does not block startup on an unresolved %s tab", + async (protocol) => { + await restoreFiles([ + { + id: "remote", + filename: "remote.js", + uri: `${protocol}://example.com/remote.js`, + }, + ]); + + expect(runtime.instances).toHaveLength(1); + }, + ); }); diff --git a/tests/unit/sessionPersistence.test.js b/tests/unit/sessionPersistence.test.js index b2843e92aa..e096148b65 100644 --- a/tests/unit/sessionPersistence.test.js +++ b/tests/unit/sessionPersistence.test.js @@ -146,6 +146,27 @@ describe("file session persistence", () => { expect(JSON.parse(localStorage.files)[0].folds).toEqual(file.restoredFolds); }); + + it("keeps the restored cursor while a remote tab is still loading", () => { + const file = createOpenFile("sftp://example.com/restored.js", {}); + file.loaded = false; + file.loading = true; + file.restoredSelection = { + ranges: [{ from: 25, to: 25 }], + mainIndex: 0, + }; + file.lastScrollTop = 120; + file.lastScrollLeft = 8; + globalThis.editorManager.files.push(file); + globalThis.editorManager.activeFile = file; + + saveState(); + + const savedFile = JSON.parse(localStorage.files)[0]; + expect(savedFile.cursorPos).toEqual(file.restoredSelection); + expect(savedFile.scrollTop).toBe(120); + expect(savedFile.scrollLeft).toBe(8); + }); }); function createOpenFile(uri, options) {