diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index df7abed82..e638cdb84 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,10 @@ export default class EditorFile { hasDiskConflict = false; isPanePlaceholder = false; persistInSession = true; + lastScrollTop = 0; + lastScrollLeft = 0; + restoredSelection = null; + restoredFolds = null; /** * @@ -702,6 +720,14 @@ 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.restoredSelection = options?.cursorPos || null; + this.restoredFolds = options?.folds || null; } this.#onFilePosChange = () => { @@ -1557,8 +1583,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 +1666,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 +1821,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 +1893,11 @@ 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.restoredSelection = null; this.__cmSessionReady = false; this.__cmLanguageReady = false; this.__cmLanguageSignature = null; @@ -1874,13 +1916,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 d719add42..d216b4b93 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -3018,9 +3018,19 @@ async function EditorManager($header, $body) { // Restore folds from previous state if available try { const folds = prevState ? getAllFolds(prevState) : []; + 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; } + 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 6f58c8828..4f844ac67 100644 --- a/src/lib/restoreFiles.js +++ b/src/lib/restoreFiles.js @@ -3,26 +3,39 @@ 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 localLoads = []; - await Promise.all( - files.map(async (file, i) => { - rendered ||= !!file.render; + files.forEach((file, index) => { + const render = + file.render || (!hasRenderedFile && index === files.length - 1); + const options = { + ...file, + render, + emitUpdate: false, + }; + const restoredFile = new EditorFile(file.filename, options); + const load = Promise.resolve(restoredFile.load?.()); - if (i === files.length - 1 && !rendered) { - file.render = true; - } + if (isRemoteUri(file.uri)) { + void load.catch((error) => { + console.warn(`Failed to preload restored file: ${file.uri}`, error); + }); + return; + } - const { filename, render = false } = file; - const options = { - ...file, - render, - emitUpdate: false, - }; - new EditorFile(filename, options); - }), - ); + localLoads.push(load); + }); + + // 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 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 7e0ea6790..5d31b3732 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; @@ -72,7 +79,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 000000000..339a34711 --- /dev/null +++ b/tests/unit/restoreFiles.test.js @@ -0,0 +1,70 @@ +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 local tab and activates the last tab by default", async () => { + let completed = false; + const restoration = restoreFiles([ + { id: "one", filename: "one.js", uri: "file:///one.js" }, + { id: "two", filename: "two.js", uri: "file:///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); + }); + + 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 938cc63a6..e096148b6 100644 --- a/tests/unit/sessionPersistence.test.js +++ b/tests/unit/sessionPersistence.test.js @@ -134,6 +134,39 @@ 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); + }); + + 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) {