Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 56 additions & 21 deletions src/lib/editorFile.js
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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) {
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -502,6 +516,10 @@ export default class EditorFile {
hasDiskConflict = false;
isPanePlaceholder = false;
persistInSession = true;
lastScrollTop = 0;
lastScrollLeft = 0;
restoredSelection = null;
restoredFolds = null;

/**
*
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand All @@ -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));
Expand Down
10 changes: 10 additions & 0 deletions src/lib/editorManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
47 changes: 30 additions & 17 deletions src/lib/restoreFiles.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || "");
}
13 changes: 10 additions & 3 deletions src/lib/saveState.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
70 changes: 70 additions & 0 deletions tests/unit/restoreFiles.test.js
Original file line number Diff line number Diff line change
@@ -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);
},
);
});
33 changes: 33 additions & 0 deletions tests/unit/sessionPersistence.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down