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
52 changes: 48 additions & 4 deletions src/lib/editorFile.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import tag from "html-tag-js";
import mimeTypes from "mime-types";
import helpers from "utils/helpers";
import Path from "utils/Path";
import { readRemoteFilePreview } from "utils/remoteFilePreview";
import Url from "utils/Url";
import config from "./config";
import { isInitialPluginLoadComplete } from "./loadPlugins";
Expand Down Expand Up @@ -1762,6 +1763,8 @@ export default class EditorFile {
async #loadText() {
if (this.#type !== "editor") return;
let value = "";
const protocol = this.uri ? Url.getProtocol(this.uri) : "";
const isRemoteFile = protocol === "ftp:" || protocol === "sftp:";

const { cursorPos, scrollLeft, scrollTop, folds, editable } =
this.#loadOptions;
Expand All @@ -1774,20 +1777,49 @@ export default class EditorFile {
}
this.loading = true;
this.markChanged = false;
if (isRemoteFile) this.#setRemoteLoading(true);
this.#emit("loadstart", createFileEvent(this));

try {
const cacheFs = fsOperation(this.cacheFile);
const cacheExists = await cacheFs.exists();
let file = null;
let cacheExists;
let loadedMtime = this.savedMtime;
let savedDoc = null;

if (cacheExists) {
value = await cacheFs.readFile(this.encoding);
if (isRemoteFile) {
file = fsOperation(this.uri);
let transportCache = null;
try {
const localName = file?.localName;
if (localName) {
transportCache = fsOperation(localName);
}
} catch (_error) {
// Transport cache access is optional; continue with the remote load.
}

const preview = await readRemoteFilePreview({
editorCache: cacheFs,
transportCache,
encoding: this.encoding,
});
cacheExists = preview.editorCacheExists;
if (cacheExists) value = preview.text;

if (preview.text !== null) {
this.session = EditorState.create({ doc: preview.text });
editorManager.emit("file-loading-preview", this, preview.text);
}
} else {
cacheExists = await cacheFs.exists();
if (cacheExists) {
value = await cacheFs.readFile(this.encoding);
}
}

if (this.uri) {
const file = fsOperation(this.uri);
file ||= fsOperation(this.uri);
const fileExists = await file.exists();
if (!fileExists && cacheExists) {
this.deletedFile = true;
Expand Down Expand Up @@ -1842,10 +1874,22 @@ export default class EditorFile {
window.log("error", "Unable to load: " + this.filename);
window.log("error", error);
} finally {
if (isRemoteFile) this.#setRemoteLoading(false);
this.#emit("loadend", createFileEvent(this));
}
}

#setRemoteLoading(loading) {
if (!this.#tab) return;

this.#tab.classList.toggle("loading", loading);
if (loading) {
this.#tab.setAttribute("aria-busy", "true");
} else {
this.#tab.removeAttribute("aria-busy");
}
}

// TODO: Implement CodeMirror equivalents for folding and scroll events
// static #onfold(e) {
// editorManager.editor._emit("fold", e);
Expand Down
17 changes: 15 additions & 2 deletions src/lib/editorManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ async function EditorManager($header, $body) {
"rename-file": [],
"save-file": [],
"file-loaded": [],
"file-loading-preview": [],
"file-content-changed": [],
"add-folder": [],
"remove-folder": [],
Expand Down Expand Up @@ -2828,9 +2829,9 @@ async function EditorManager($header, $body) {
}
}

function showLoadingEditor(file) {
function showLoadingEditor(file, text = "") {
const loadingState = EditorState.create({
doc: "",
doc: text,
extensions: [
themeCompartment.of(getConfiguredThemeExtension()),
...getBaseExtensionsFromOptions(),
Expand Down Expand Up @@ -3670,6 +3671,18 @@ async function EditorManager($header, $body) {
}
});

manager.on(["file-loading-preview"], (file, text) => {
if (!file || file.type !== "editor" || !file.loading) return;
const pane = getFilePane(file);
if (!pane?.editor || pane.activeFile?.id !== file.id) return;

if (pane === getActivePane()) {
showLoadingEditor(file, text);
} else {
withPaneEditorContext(pane, () => showLoadingEditor(file, text));
}
});

manager.on(
["file-content-changed", "rename-file", "save-file", "update:pin-tab"],
markGlobalOpenFileListMirrorDirty,
Expand Down
7 changes: 6 additions & 1 deletion src/pages/fileBrowser/fileBrowser.js
Original file line number Diff line number Diff line change
Expand Up @@ -1399,11 +1399,16 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) {
list = await listAllStorages();
} else {
const id = helpers.uuid();
let loaderTimeout = 10000;

if (["ftp:", "sftp:"].includes(Url.getProtocol(url))) {
loaderTimeout = 0;
}

progress[id] = true;
const timeout = setTimeout(() => {
loader.create(name, strings.loading + "...", {
timeout: 10000,
timeout: loaderTimeout,
callback() {
loader.destroy();
navigate("/", "/");
Expand Down
6 changes: 6 additions & 0 deletions src/styles/codemirror.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@use "./mixins.scss";

.editor-container {
position: relative;
}
Expand Down Expand Up @@ -141,6 +143,10 @@ body.resizing-editor-pane {
li.tile {
min-width: min(var(--file-tab-width), 44%);
max-width: var(--file-tab-width);

&.loading {
@include mixins.bar-loader(30%, 2px);
}
}
}

Expand Down
38 changes: 38 additions & 0 deletions src/utils/remoteFilePreview.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Read the best available local preview for a remote editor file.
* Editor recovery data takes precedence because it can contain unsaved changes.
* The transport cache is optional and must never block the remote load.
*
* @param {Object} options
* @param {import("fileSystem").FileSystem} options.editorCache
* @param {import("fileSystem").FileSystem | null} options.transportCache
* @param {string} [options.encoding]
* @returns {Promise<{editorCacheExists: boolean, text: string | null}>}
*/
export async function readRemoteFilePreview({
editorCache,
transportCache,
encoding,
}) {
const editorCacheExists = await editorCache.exists();

if (editorCacheExists) {
return {
editorCacheExists,
text: await editorCache.readFile(encoding),
};
}

try {
if (transportCache && (await transportCache.exists())) {
return {
editorCacheExists,
text: await transportCache.readFile(encoding),
};
}
} catch (_error) {
// The transport cache is only a preview; continue with the remote load.
}

return { editorCacheExists, text: null };
}
70 changes: 70 additions & 0 deletions tests/unit/remoteFilePreview.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from "vitest";
import { readRemoteFilePreview } from "utils/remoteFilePreview";

function createCache({ exists = false, text = "", error = null } = {}) {
return {
exists: vi.fn().mockResolvedValue(exists),
readFile: error
? vi.fn().mockRejectedValue(error)
: vi.fn().mockResolvedValue(text),
};
}

describe("readRemoteFilePreview", () => {
it("prefers editor recovery data over the transport cache", async () => {
const editorCache = createCache({ exists: true, text: "unsaved" });
const transportCache = createCache({ exists: true, text: "downloaded" });

await expect(
readRemoteFilePreview({
editorCache,
transportCache,
encoding: "UTF-8",
}),
).resolves.toEqual({ editorCacheExists: true, text: "unsaved" });
expect(editorCache.readFile).toHaveBeenCalledWith("UTF-8");
expect(transportCache.exists).not.toHaveBeenCalled();
});

it("falls back to the transport cache", async () => {
const editorCache = createCache();
const transportCache = createCache({ exists: true, text: "downloaded" });

await expect(
readRemoteFilePreview({ editorCache, transportCache }),
).resolves.toEqual({ editorCacheExists: false, text: "downloaded" });
});

it("returns no preview when the transport cache is missing", async () => {
const editorCache = createCache();
const transportCache = createCache();

await expect(
readRemoteFilePreview({ editorCache, transportCache }),
).resolves.toEqual({ editorCacheExists: false, text: null });
expect(transportCache.readFile).not.toHaveBeenCalled();
});

it("ignores an unreadable transport cache", async () => {
const editorCache = createCache();
const transportCache = createCache({
exists: true,
error: new Error("unreadable"),
});

await expect(
readRemoteFilePreview({ editorCache, transportCache }),
).resolves.toEqual({ editorCacheExists: false, text: null });
});

it("preserves editor recovery cache read failures", async () => {
const error = new Error("recovery cache failed");
const editorCache = createCache({ exists: true, error });
const transportCache = createCache({ exists: true, text: "downloaded" });

await expect(
readRemoteFilePreview({ editorCache, transportCache }),
).rejects.toBe(error);
expect(transportCache.exists).not.toHaveBeenCalled();
});
});