diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index b151fda80..4649e3f04 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -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"; @@ -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; @@ -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; @@ -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); diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index b58231e0e..118aeef50 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -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": [], @@ -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(), @@ -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, diff --git a/src/pages/fileBrowser/fileBrowser.js b/src/pages/fileBrowser/fileBrowser.js index e977eb59d..4ee9e5bd0 100644 --- a/src/pages/fileBrowser/fileBrowser.js +++ b/src/pages/fileBrowser/fileBrowser.js @@ -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("/", "/"); diff --git a/src/styles/codemirror.scss b/src/styles/codemirror.scss index 19fd31694..959f03237 100644 --- a/src/styles/codemirror.scss +++ b/src/styles/codemirror.scss @@ -1,3 +1,5 @@ +@use "./mixins.scss"; + .editor-container { position: relative; } @@ -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); + } } } diff --git a/src/utils/remoteFilePreview.js b/src/utils/remoteFilePreview.js new file mode 100644 index 000000000..d022383ef --- /dev/null +++ b/src/utils/remoteFilePreview.js @@ -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 }; +} diff --git a/tests/unit/remoteFilePreview.test.js b/tests/unit/remoteFilePreview.test.js new file mode 100644 index 000000000..f82fe47f6 --- /dev/null +++ b/tests/unit/remoteFilePreview.test.js @@ -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(); + }); +});