From 4304bcfec9eb0c28dec2c2b7b1678bf78b0a862d Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:00:27 +0530 Subject: [PATCH 1/7] perf: faster app startup - Reveal the app after its first rendered frame instead of a fixed 500 ms delay - Run independent native startup calls and the purchase check in parallel - Bound the startup purchase check so an unresponsive store cannot hold the splash screen - Load the terminal, run/preview and mime-types code on demand - Target the oldest supported WebView/Safari instead of ES2015 - Cache the native encoding list, skip load-time validation of built-in themes - Rebuild the command keymap lazily and apply it once per burst - Load the built-in sidebar app chunks in parallel - Fix the owned plugins list crash and the unhandled sponsor form cancellation --- rspack.config.js | 16 +- src/cm/commandRegistry.js | 93 +++++-- src/cm/keyBindingUtils.js | 17 +- src/cm/themes/index.js | 67 +++-- src/components/terminal/loader.js | 37 +++ src/fileSystem/ftp.js | 4 +- src/fileSystem/sftp.js | 6 +- src/lib/acode.js | 33 ++- src/lib/commands.js | 9 +- src/lib/editorFile.js | 11 +- src/lib/openFolder.js | 9 +- src/lib/run.js | 4 +- src/lib/runLazily.js | 14 + src/main.js | 318 +++++++++++++++-------- src/pages/fileBrowser/fileBrowser.js | 9 +- src/pages/fontManager/fontManager.js | 2 +- src/pages/plugin/plugin.js | 3 +- src/pages/plugins/plugins.js | 2 +- src/pages/sponsor/sponsor.js | 3 +- src/pages/themeSetting/themeSetting.js | 2 +- src/settings/terminalSettings.js | 6 +- src/sidebarApps/index.js | 14 +- src/utils/encodings.js | 58 ++++- src/utils/mimeTypes.js | 11 + tests/unit/commandRegistryKeymap.test.js | 125 +++++++++ tests/unit/fileSaveEvents.test.js | 2 +- tests/unit/pluginFileRestoration.test.js | 4 +- tests/unit/runLazily.test.js | 34 +++ tests/unit/saveCommands.test.js | 2 +- 29 files changed, 706 insertions(+), 209 deletions(-) create mode 100644 src/components/terminal/loader.js create mode 100644 src/lib/runLazily.js create mode 100644 src/utils/mimeTypes.js create mode 100644 tests/unit/commandRegistryKeymap.test.js create mode 100644 tests/unit/runLazily.test.js diff --git a/rspack.config.js b/rspack.config.js index 4ca2ac2..9c2b71a 100644 --- a/rspack.config.js +++ b/rspack.config.js @@ -14,9 +14,19 @@ module.exports = (env, options) => { const devPort = process.env.DEV_PORT || ''; const devProto = isDev ? (process.env.DEV_PROTO || '') : ''; + // Match the oldest engine each platform supports (MIN_WEBVIEW_MAJOR in + // src/index.html, IPHONEOS_DEPLOYMENT_TARGET in Xcode) instead of plain + // ES2015, so native async/await, classes and spread are kept. `entry` mode + // rewrites `import "core-js/stable"` to only the polyfills that engine still + // needs, so plugins keep every global they could rely on. + const swcEnv = { + targets: platform === 'ios' ? 'ios_saf >= 18' : 'chrome >= 67', + mode: 'entry', + coreJs: require('core-js/package.json').version, + }; const typescriptLoader = { loader: 'builtin:swc-loader', - options: { jsc: { parser: { syntax: 'typescript', tsx: false }, target: 'es2015' } }, + options: { jsc: { parser: { syntax: 'typescript', tsx: false } }, env: swcEnv }, }; const rules = [ { @@ -50,8 +60,8 @@ module.exports = (env, options) => { parser: { syntax: 'ecmascript', }, - target: 'es2015', }, + env: swcEnv, }, }, ], @@ -67,8 +77,8 @@ module.exports = (env, options) => { syntax: 'ecmascript', jsx: false, }, - target: 'es2015', }, + env: swcEnv, }, }, path.resolve(__dirname, 'dev/custom-loaders/html-tag-jsx-loader.js'), diff --git a/src/cm/commandRegistry.js b/src/cm/commandRegistry.js index b905091..c2a6a90 100644 --- a/src/cm/commandRegistry.js +++ b/src/cm/commandRegistry.js @@ -152,6 +152,16 @@ let cachedKeymap = []; /** @type {Set} */ const commandViews = new Set(); +/** + * Commands are often registered in bursts (a plugin adding several at once), + * so the keymap is rebuilt lazily on the next read instead of per command. + */ +let keymapDirty = true; + +/** @type {Set} views waiting for the updated keymap */ +const pendingKeymapViews = new Set(); +let keymapRefreshScheduled = false; + const CODEMIRROR_COMMAND_ENTRIES = Object.entries(cmCommands).filter( ([name, value]) => typeof value === "function" && CODEMIRROR_COMMAND_NAMES.has(name), @@ -165,7 +175,6 @@ registerCoreCommands(); registerLspCommands(); registerLintCommands(); registerCommandsFromKeyBindings(); -rebuildKeymap(); function registerCoreCommands() { addCommand({ @@ -1516,19 +1525,34 @@ function buildResolvedKeyBindingsSnapshot() { ); } +/** + * Resolve a command's effective description and key from the bindings. + * @returns {string|null} the key source + */ +function syncCommandBinding(command, name) { + const bindingInfo = resolveBindingInfo(name); + command.description = bindingInfo?.description || command.defaultDescription; + command.key = + bindingInfo && Object.prototype.hasOwnProperty.call(bindingInfo, "key") + ? bindingInfo.key + : (command.defaultKey ?? null); + return command.key; +} + +function invalidateKeymap() { + keymapDirty = true; +} + +function ensureKeymap() { + if (keymapDirty) rebuildKeymap(); +} + function rebuildKeymap() { cachedResolvedKeyBindings = buildResolvedKeyBindingsSnapshot(); const candidates = []; let order = 0; commandMap.forEach((command, name) => { - const bindingInfo = resolveBindingInfo(name); - command.description = - bindingInfo?.description || command.defaultDescription; - const keySource = - bindingInfo && Object.prototype.hasOwnProperty.call(bindingInfo, "key") - ? bindingInfo.key - : (command.defaultKey ?? null); - command.key = keySource; + const keySource = syncCommandBinding(command, name); const combos = parseKeyString(keySource); combos.forEach((combo) => { const cmKey = toCodeMirrorKey(combo); @@ -1553,9 +1577,15 @@ function rebuildKeymap() { const conflicts = []; for (const candidate of candidates) { const canonicalKey = canonicalizeKeyBinding(candidate.key); - const claimed = Array.from(claimedKeys.entries()).find(([key]) => - keyBindingsConflict(key, canonicalKey), - ); + // First conflicting claim in insertion order, without copying the map + // for every candidate. + let claimed = null; + for (const entry of claimedKeys) { + if (keyBindingsConflict(entry[0], canonicalKey)) { + claimed = entry; + break; + } + } if (claimed) { const [claimedKey, owner] = claimed; const appCommandShadowsCodeMirrorDefault = @@ -1598,6 +1628,7 @@ function rebuildKeymap() { cachedKeyBindingConflicts = conflicts; cachedKeymap = bindings; resolvedKeyBindingsVersion += 1; + keymapDirty = false; return bindings; } @@ -1645,6 +1676,7 @@ export function executeCommand(name, view, args) { } export function getRegisteredCommands() { + ensureKeymap(); return Array.from(commandMap.values()).map((command) => ({ name: command.name, description: command.description || command.defaultDescription, @@ -1653,22 +1685,27 @@ export function getRegisteredCommands() { } export function getResolvedKeyBindings() { + ensureKeymap(); return cachedResolvedKeyBindings; } export function getEffectiveKeyBindings() { + ensureKeymap(); return cachedEffectiveKeyBindings; } export function getKeyBindingConflicts() { + ensureKeymap(); return cachedKeyBindingConflicts.map((conflict) => ({ ...conflict })); } export function getResolvedKeyBindingsVersion() { + ensureKeymap(); return resolvedKeyBindingsVersion; } export function getCommandKeymapExtension() { + ensureKeymap(); return commandKeymapCompartment.of(keymap.of(cachedKeymap)); } @@ -1756,9 +1793,11 @@ export function registerExternalCommand(descriptor = {}) { const stored = commandMap.get(name); if (stored) { stored.key = normalized.key ?? stored.key; + // The returned command reflects its final binding right away. + syncCommandBinding(stored, name); } - rebuildKeymap(); + invalidateKeymap(); return stored; } @@ -1767,13 +1806,34 @@ export function removeExternalCommand(name) { const exists = commandMap.has(name); if (!exists) return false; commandMap.delete(name); - rebuildKeymap(); + invalidateKeymap(); return true; } +/** + * Apply the current keymap to a view. Calls made in the same task are applied + * together in a microtask, which always runs before the next key event. + */ export function refreshCommandKeymap(view) { const resolvedView = resolveView(view); - applyCommandKeymap(resolvedView); + if (!resolvedView) return; + pendingKeymapViews.add(resolvedView); + if (keymapRefreshScheduled) return; + keymapRefreshScheduled = true; + Promise.resolve().then(flushKeymapRefresh); +} + +function flushKeymapRefresh() { + keymapRefreshScheduled = false; + const views = Array.from(pendingKeymapViews); + pendingKeymapViews.clear(); + for (const view of views) { + try { + applyCommandKeymap(view); + } catch (error) { + console.error("Failed to apply command keymap", error); + } + } } function normalizeExternalCommand(descriptor) { @@ -1829,8 +1889,9 @@ function normalizeExternalKey(bindKey) { return combos.length ? combos.join("|") : null; } -function applyCommandKeymap(view, bindings = cachedKeymap) { +function applyCommandKeymap(view, bindings) { if (!view) return; + ensureKeymap(); commandViews.add(view); view.dispatch({ effects: commandKeymapCompartment.reconfigure( diff --git a/src/cm/keyBindingUtils.js b/src/cm/keyBindingUtils.js index 69e2a35..06bf26e 100644 --- a/src/cm/keyBindingUtils.js +++ b/src/cm/keyBindingUtils.js @@ -45,8 +45,23 @@ export function toCodeMirrorKey(combo) { return strokes.length ? strokes.join(" ") : null; } +// Conflict checks compare every binding with every other one on each keymap +// rebuild, so cache the parsed form. The app has a few hundred binding strings; +// the cap only stops unbounded growth from repeated registrations of new keys. +const MAX_CANONICAL_KEY_CACHE_SIZE = 1000; +const canonicalKeyCache = new Map(); + export function canonicalizeKeyBinding(combo) { - return toCodeMirrorKey(combo)?.toLowerCase() || null; + if (typeof combo !== "string") { + return toCodeMirrorKey(combo)?.toLowerCase() || null; + } + if (canonicalKeyCache.has(combo)) return canonicalKeyCache.get(combo); + const canonicalKey = toCodeMirrorKey(combo)?.toLowerCase() || null; + if (canonicalKeyCache.size >= MAX_CANONICAL_KEY_CACHE_SIZE) { + canonicalKeyCache.clear(); + } + canonicalKeyCache.set(combo, canonicalKey); + return canonicalKey; } /** diff --git a/src/cm/themes/index.js b/src/cm/themes/index.js index dbcd67e..1043b60 100644 --- a/src/cm/themes/index.js +++ b/src/cm/themes/index.js @@ -116,6 +116,19 @@ function resolveThemeEntryExtensions(theme, fallbackExtensions) { } export function addTheme(id, caption, isDark, getExtension, config = null) { + return registerTheme(id, caption, isDark, getExtension, config, true); +} + +/** + * Registers a bundled theme. These are known to be valid, so the up-front + * validation (which builds every theme's extensions at startup) is skipped; + * they are still validated when applied. + */ +function addBuiltInTheme(id, caption, isDark, getExtension, config = null) { + return registerTheme(id, caption, isDark, getExtension, config, false); +} + +function registerTheme(id, caption, isDark, getExtension, config, validate) { const key = String(id || "") .trim() .toLowerCase(); @@ -129,7 +142,7 @@ export function addTheme(id, caption, isDark, getExtension, config = null) { config: config || null, }; - if (!validateThemeExtensions(key, theme.getExtension())) { + if (validate && !validateThemeExtensions(key, theme.getExtension())) { return false; } @@ -164,115 +177,127 @@ export function removeTheme(id) { themes.delete(String(id).toLowerCase()); } -addTheme("one_dark", "One Dark", true, () => [oneDark], oneDarkConfig); -addTheme(auraConfig.name, "Aura", !!auraConfig.dark, () => aura(), auraConfig); -addTheme( +addBuiltInTheme("one_dark", "One Dark", true, () => [oneDark], oneDarkConfig); +addBuiltInTheme( + auraConfig.name, + "Aura", + !!auraConfig.dark, + () => aura(), + auraConfig, +); +addBuiltInTheme( cobaltConfig.name, "Cobalt", !!cobaltConfig.dark, () => cobalt(), cobaltConfig, ); -addTheme( +addBuiltInTheme( noctisLilacConfig.name, noctisLilacConfig.caption || "Noctis Lilac", !!noctisLilacConfig.dark, () => noctisLilac(), noctisLilacConfig, ); -addTheme( +addBuiltInTheme( draculaConfig.name, "Dracula", !!draculaConfig.dark, () => dracula(), draculaConfig, ); -addTheme(nordConfig.name, "Nord", !!nordConfig.dark, () => nord(), nordConfig); -addTheme( +addBuiltInTheme( + nordConfig.name, + "Nord", + !!nordConfig.dark, + () => nord(), + nordConfig, +); +addBuiltInTheme( gruvboxDarkConfig.name, "Gruvbox Dark", !!gruvboxDarkConfig.dark, () => gruvboxDark(), gruvboxDarkConfig, ); -addTheme( +addBuiltInTheme( ayuDarkConfig.name, "Ayu Dark", !!ayuDarkConfig.dark, () => ayuDark(), ayuDarkConfig, ); -addTheme( +addBuiltInTheme( materialPalenightConfig.name, "Material Palenight", !!materialPalenightConfig.dark, () => materialPalenight(), materialPalenightConfig, ); -addTheme( +addBuiltInTheme( githubDarkConfig.name, "GitHub Dark", !!githubDarkConfig.dark, () => githubDark(), githubDarkConfig, ); -addTheme( +addBuiltInTheme( githubLightConfig.name, "GitHub Light", !!githubLightConfig.dark, () => githubLight(), githubLightConfig, ); -addTheme( +addBuiltInTheme( solarizedDarkConfig.name, "Solarized Dark", !!solarizedDarkConfig.dark, () => solarizedDark(), solarizedDarkConfig, ); -addTheme( +addBuiltInTheme( solarizedLightConfig.name, "Solarized Light", !!solarizedLightConfig.dark, () => solarizedLight(), solarizedLightConfig, ); -addTheme( +addBuiltInTheme( tokyoNightDayConfig.name, "Tokyo Night Day", !!tokyoNightDayConfig.dark, () => tokyoNightDay(), tokyoNightDayConfig, ); -addTheme( +addBuiltInTheme( tokyoNightConfig.name, "Tokyo Night", !!tokyoNightConfig.dark, () => tokyoNight(), tokyoNightConfig, ); -addTheme( +addBuiltInTheme( tomorrowNightConfig.name, "Tomorrow Night", !!tomorrowNightConfig.dark, () => tomorrowNight(), tomorrowNightConfig, ); -addTheme( +addBuiltInTheme( tomorrowNightBrightConfig.name, "Tomorrow Night Bright", !!tomorrowNightBrightConfig.dark, () => tomorrowNightBright(), tomorrowNightBrightConfig, ); -addTheme( +addBuiltInTheme( monokaiConfig.name, "Monokai", !!monokaiConfig.dark, () => monokai(), monokaiConfig, ); -addTheme( +addBuiltInTheme( vscodeDarkConfig.name, "VS Code Dark", !!vscodeDarkConfig.dark, @@ -281,7 +306,7 @@ addTheme( ); for (const config of catppuccinConfigs) { - addTheme( + addBuiltInTheme( config.name, config.caption, config.dark, diff --git a/src/components/terminal/loader.js b/src/components/terminal/loader.js new file mode 100644 index 0000000..c1d3ab8 --- /dev/null +++ b/src/components/terminal/loader.js @@ -0,0 +1,37 @@ +/** + * Loads the terminal manager (and xterm) on demand so it stays out of the + * startup bundle. + */ + +/** @type {import("./terminalManager").default | null} */ +let manager = null; +/** @type {Promise | null} */ +let loading = null; + +/** + * The terminal manager if it has already been loaded, otherwise null. + */ +export function getLoadedTerminalManager() { + return manager; +} + +/** + * Loads the terminal manager, reusing the pending load if one is in flight. + * @returns {Promise} + */ +export function loadTerminalManager() { + loading ??= import( + /* webpackChunkName: "terminal" */ "./terminalManager" + ).then( + ({ default: terminalManager }) => { + manager = terminalManager; + return terminalManager; + }, + (error) => { + // Allow a later call to retry instead of caching the failure. + loading = null; + throw error; + }, + ); + return loading; +} diff --git a/src/fileSystem/ftp.js b/src/fileSystem/ftp.js index 8ba1ea7..98f4c9e 100644 --- a/src/fileSystem/ftp.js +++ b/src/fileSystem/ftp.js @@ -1,7 +1,7 @@ import settings from "lib/settings"; -import mimeType from "mime-types"; import { decode, encode } from "utils/encodings"; import helpers from "utils/helpers"; +import loadMimeTypes from "utils/mimeTypes"; import Path from "utils/Path"; import Url from "utils/Url"; import internalFs from "./internalFs"; @@ -91,6 +91,7 @@ class FtpClient { async listDir() { await this.#connectIfNotConnected(); + const mimeType = await loadMimeTypes(); return new Promise((resolve, reject) => { ftp.listDirectory( this.#conId, @@ -282,6 +283,7 @@ class FtpClient { } async #getStat(url = this.#path) { + const mimeType = await loadMimeTypes(); return new Promise((resolve, reject) => { ftp.getStat( this.#conId, diff --git a/src/fileSystem/sftp.js b/src/fileSystem/sftp.js index 803be8a..d30b50f 100644 --- a/src/fileSystem/sftp.js +++ b/src/fileSystem/sftp.js @@ -1,7 +1,7 @@ import settings from "lib/settings"; -import mimeType from "mime-types"; import { decode, encode } from "utils/encodings"; import helpers from "utils/helpers"; +import loadMimeTypes from "utils/mimeTypes"; import Path from "utils/Path"; import Url from "utils/Url"; import internalFs from "./internalFs"; @@ -44,7 +44,8 @@ class SftpClient { * @param {String} filename * @param {boolean} stat */ - lsDir(filename = this.#path) { + async lsDir(filename = this.#path) { + const mimeType = await loadMimeTypes(); return new Promise((resolve, reject) => { sftp.isConnected(async (connectionID) => { (async () => { @@ -459,6 +460,7 @@ class SftpClient { async stat() { if (this.#stat) return this.#stat; + const mimeType = await loadMimeTypes(); return new Promise((resolve, reject) => { sftp.isConnected(async (connectionID) => { (async () => { diff --git a/src/lib/acode.js b/src/lib/acode.js index d421bae..76f8f80 100644 --- a/src/lib/acode.js +++ b/src/lib/acode.js @@ -34,7 +34,12 @@ import Page from "components/page"; import palette from "components/palette"; import settingsPage from "components/settingsPage"; import SideButton from "components/sideButton"; -import { TerminalManager, TerminalThemeManager } from "components/terminal"; +import { + getLoadedTerminalManager, + loadTerminalManager, +} from "components/terminal/loader"; +import TerminalThemeManager from "components/terminal/terminalThemeManager"; +import TerminalTouchSelection from "components/terminal/terminalTouchSelection"; import toast from "components/toast"; import tutorial from "components/tutorial"; import alert from "dialogs/alert"; @@ -294,20 +299,24 @@ class Acode { }; const terminalTouchSelectionMoreOptions = { - add: (option) => TerminalManager.addTouchSelectionMoreOption(option), - remove: (id) => TerminalManager.removeTouchSelectionMoreOption(id), - list: () => TerminalManager.getTouchSelectionMoreOptions(), + add: (option) => TerminalTouchSelection.addMoreOption(option), + remove: (id) => TerminalTouchSelection.removeMoreOption(id), + list: () => TerminalTouchSelection.getMoreOptions(), }; const terminalModule = { - create: (options) => TerminalManager.createTerminal(options), - createLocal: (options) => TerminalManager.createLocalTerminal(options), - createServer: (options) => TerminalManager.createServerTerminal(options), - get: (id) => TerminalManager.getTerminal(id), - getAll: () => TerminalManager.getAllTerminals(), + create: async (options) => + (await loadTerminalManager()).createTerminal(options), + createLocal: async (options) => + (await loadTerminalManager()).createLocalTerminal(options), + createServer: async (options) => + (await loadTerminalManager()).createServerTerminal(options), + // No terminal can exist before the manager has loaded. + get: (id) => getLoadedTerminalManager()?.getTerminal(id) ?? null, + getAll: () => getLoadedTerminalManager()?.getAllTerminals() ?? new Map(), write: (id, data) => this.#secureTerminalWrite(id, data), - clear: (id) => TerminalManager.clearTerminal(id), - close: (id) => TerminalManager.closeTerminal(id), + clear: (id) => getLoadedTerminalManager()?.clearTerminal(id), + close: async (id) => (await loadTerminalManager()).closeTerminal(id), moreOptions: terminalTouchSelectionMoreOptions, touchSelection: { moreOptions: terminalTouchSelectionMoreOptions, @@ -551,7 +560,7 @@ class Acode { } // If all security checks pass, proceed with writing - return TerminalManager.writeToTerminal(id, data); + return getLoadedTerminalManager()?.writeToTerminal(id, data); } /** diff --git a/src/lib/commands.js b/src/lib/commands.js index 8cd5740..a00d055 100644 --- a/src/lib/commands.js +++ b/src/lib/commands.js @@ -2,6 +2,7 @@ import fsOperation from "fileSystem"; import { selectAll } from "@codemirror/commands"; import { focusEditorIfEditable } from "cm/editorReadOnly"; import Sidebar from "components/sidebar"; +import { loadTerminalManager } from "components/terminal/loader"; import confirm from "dialogs/confirm"; import prompt from "dialogs/prompt"; import select from "dialogs/select"; @@ -17,7 +18,7 @@ import { loadFileBrowser } from "./lazyImports"; import openFile from "./openFile"; import openFolder from "./openFolder"; import platform from "./platform"; -import run from "./run"; +import runLazily from "./runLazily"; import saveState from "./saveState"; import appSettings from "./settings"; import showFileInfo from "./showFileInfo"; @@ -288,7 +289,7 @@ const commands = { resolveReferenceFile(referenceFile)?.togglePinned?.(); }, console() { - run(true, "inapp"); + void runLazily(true, "inapp"); }, "check-files"() { if (!appSettings.value.checkFiles) return; @@ -809,9 +810,7 @@ Additional Info: }, async "new-terminal"() { try { - const { TerminalManager } = await import( - /* webpackChunkName: "terminal" */ "components/terminal" - ); + const TerminalManager = await loadTerminalManager(); await TerminalManager.createServerTerminal(); } catch (error) { console.error("Failed to create terminal:", error); diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index 6e462f6..1851543 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -18,17 +18,17 @@ import actions from "handlers/quickTools"; import { openTabContextMenuOnRelease } from "handlers/tabContextMenu"; import tag from "html-tag-js"; import quickToolsAdapters from "lib/quickToolsAdapter"; -import mimeTypes from "mime-types"; import { applyHighlightStyles } from "utils/codeHighlight"; import haptic from "utils/haptic"; import helpers from "utils/helpers"; +import loadMimeTypes from "utils/mimeTypes"; import Path from "utils/Path"; import { readRemoteFilePreview } from "utils/remoteFilePreview"; import Url from "utils/Url"; import config from "./config"; import { isInitialPluginLoadComplete } from "./loadPlugins"; import openFolder from "./openFolder"; -import run from "./run"; +import runLazily from "./runLazily"; import saveFile from "./saveFile"; import appSettings from "./settings"; @@ -1789,7 +1789,10 @@ export default class EditorFile { async #fileAction(action, mimeType) { try { const uri = await this.#getShareableUri(); - if (!mimeType) mimeType = mimeTypes.lookup(this.name) || "text/plain"; + if (!mimeType) { + const mimeTypes = await loadMimeTypes(); + mimeType = mimeTypes.lookup(this.name) || "text/plain"; + } system.fileAction( uri, this.filename, @@ -2029,7 +2032,7 @@ export default class EditorFile { const event = createFileEvent(this); this.#emit("run", event); if (event.defaultPrevented) return; - run(false, appSettings.value.previewMode, file); + void runLazily(false, appSettings.value.previewMode, file); } #updateTab() { diff --git a/src/lib/openFolder.js b/src/lib/openFolder.js index cce1d6a..6942ef2 100644 --- a/src/lib/openFolder.js +++ b/src/lib/openFolder.js @@ -3,6 +3,7 @@ import sidebarApps from "sidebarApps"; import collapsableList from "components/collapsableList"; import FileTree from "components/fileTree"; import Sidebar from "components/sidebar"; +import { loadTerminalManager } from "components/terminal/loader"; import tile from "components/tile"; import toast from "components/toast"; import alert from "dialogs/alert"; @@ -546,9 +547,7 @@ function execOperation(type, action, url, $target, name) { async function openInTerminal() { try { - const { TerminalManager } = await import( - /* webpackChunkName: "terminal" */ "components/terminal" - ); + const TerminalManager = await loadTerminalManager(); const prootPath = convertToProotPath(url); const terminal = await TerminalManager.createTerminal({ name: `Terminal - ${name}`, @@ -582,9 +581,7 @@ function execOperation(type, action, url, $target, name) { async function openSshTerminal() { try { - const { TerminalManager } = await import( - /* webpackChunkName: "terminal" */ "components/terminal" - ); + const TerminalManager = await loadTerminalManager(); await TerminalManager.createRemoteTerminal({ url, name }); Sidebar.hide(); } catch (error) { diff --git a/src/lib/run.js b/src/lib/run.js index 507385b..791425d 100644 --- a/src/lib/run.js +++ b/src/lib/run.js @@ -30,14 +30,16 @@ const CONSOLE_THEME_STATE = "__acode_console_theme.json"; * @param {Boolean} isConsole * @param {"inapp"|"browser"} target * @param {Boolean} runFile + * @param {EditorFile} [file] file to run, captured when the run was requested */ async function run( isConsole = false, target = appSettings.value.previewMode, runFile = false, + file = editorManager.activeFile, ) { /** @type {EditorFile} */ - const activeFile = isConsole ? null : editorManager.activeFile; + const activeFile = isConsole ? null : file; if (!isConsole && Url.extname(activeFile?.filename || "") === ".md") { if (!(await activeFile?.canRun())) return; diff --git a/src/lib/runLazily.js b/src/lib/runLazily.js new file mode 100644 index 0000000..27537da --- /dev/null +++ b/src/lib/runLazily.js @@ -0,0 +1,14 @@ +/** + * Runs/previews the active file, loading the runner on first use. The runner + * pulls in markdown-it and the markdown preview, which are not needed at + * startup. + * @param {boolean} [isConsole] + * @param {"inapp"|"browser"} [target] + * @param {boolean} [runFile] + */ +export default async function runLazily(isConsole, target, runFile) { + // Capture the file now: the user may switch tabs while the runner loads. + const file = isConsole ? null : editorManager.activeFile; + const { default: run } = await import(/* webpackChunkName: "run" */ "./run"); + return run(isConsole, target, runFile, file); +} diff --git a/src/main.js b/src/main.js index 1ccd453..a3f21b9 100644 --- a/src/main.js +++ b/src/main.js @@ -8,6 +8,8 @@ import "styles/overrideAceStyle.scss"; import "styles/wideScreen.scss"; // Editor tabs use a shadow root that only links build/main.css. import "pages/welcome/welcome.scss"; +// Terminal code loads on demand; keep its styles in main.css as before. +import "@xterm/xterm/css/xterm.css"; import "lib/polyfill"; import "cm/supportedModes"; @@ -26,6 +28,7 @@ import { } from "cm/modelist"; import Contextmenu from "components/contextmenu"; import Sidebar from "components/sidebar"; +import { loadTerminalManager } from "components/terminal/loader"; import tile from "components/tile"; import toast from "components/toast"; import { initIconTooltips } from "components/tooltip"; @@ -74,6 +77,18 @@ import $_fileMenu from "views/file-menu.hbs"; import $_menu from "views/menu.hbs"; import auth, { loginEvents } from "./lib/auth"; +/** + * A billing service that never answers must not keep the app on the splash + * screen, so startup stops waiting for the purchase check after this long. + */ +const PRO_CHECK_TIMEOUT_MS = 5000; + +/** + * Settles once the startup purchase check can no longer change Pro status + * (or has timed out). Plugins wait for it so they initialize with that value. + */ +let proStatusReady = Promise.resolve(); + const oldPreventDefault = TouchEvent.prototype.preventDefault; const previousVersionCode = Number.parseInt(localStorage.versionCode, 10); const logger = new Logger(); @@ -119,8 +134,6 @@ async function ensurePermission(permission) { } async function onDeviceReady() { - await initEncodings(); // important to load encodings before anything else - const isFreePackage = platform.isIOS ? __FREE__ : /(free)$/.test(BuildInfo.packageName); @@ -148,6 +161,25 @@ async function onDeviceReady() { } } + // Start the purchase check first so it runs alongside the rest of startup + // instead of blocking it. StoreKit verifies cached entitlements even when + // the device is offline, so iOS never trusts the stored flag. + config.HAS_PRO = + !isFreePackage || (!platform.isIOS && localStorage.acode_pro === "true"); + const proPurchaseCheck = verifyProPurchase(isFreePackage); + // Paid builds are always Pro, so only a free build's check can change it. + proStatusReady = isFreePackage ? proPurchaseCheck : Promise.resolve(); + + // These native calls are independent, so run them together. + const [dataStorage, cacheStorage, installSource, androidSdkInt] = + await Promise.all([ + resolveStorageDir(externalDataDirectory, dataDirectory), + resolveStorageDir(externalCacheDirectory, cacheDirectory), + getInstallSource(), + getAndroidSdkInt(), + initEncodings(), // important to load encodings before anything else + ]); + window.app = document.body; window.root = tag.get("#root"); window.addedFolder = addedFolder; @@ -157,21 +189,13 @@ async function onDeviceReady() { Bridge.file.applicationDirectory, "bundle", ); - window.DATA_STORAGE = await resolveStorageDir( - externalDataDirectory, - dataDirectory, - ); - window.CACHE_STORAGE = await resolveStorageDir( - externalCacheDirectory, - cacheDirectory, - ); + window.DATA_STORAGE = dataStorage; + window.CACHE_STORAGE = cacheStorage; window.PLUGIN_DIR = Url.join(DATA_STORAGE, "plugins"); window.KEYBINDING_FILE = Url.join(DATA_STORAGE, ".key-bindings.json"); window.log = logger.log.bind(logger); - config.HAS_PRO = !isFreePackage; - // Capture synchronous errors window.addEventListener("error", (event) => { const errorMsg = `Error: ${event.message}, Source: ${event.filename}, Line: ${event.lineno}, Column: ${event.colno}, Stack: ${event.error?.stack || "N/A"}`; @@ -185,14 +209,6 @@ async function onDeviceReady() { ); }); - let installSource = INSTALL_SOURCE_PLAY; - - try { - installSource = await helpers.promisify(system.getInstaller); - } catch (error) { - console.error(error); - } - Object.defineProperty(window, "appInstallSource", { get() { return installSource; @@ -204,43 +220,7 @@ async function onDeviceReady() { enumerable: false, }); - try { - await helpers.promisify(iap.startConnection).catch((e) => { - window.log("error", "connection error"); - window.log("error", e); - }); - - // StoreKit verifies cached entitlements even when the device is offline. - if (!platform.isIOS && localStorage.acode_pro === "true") { - config.HAS_PRO = true; - } - - if (platform.isIOS || navigator.onLine) { - const purchases = await helpers.promisify(iap.getPurchases); - const isPro = purchases.some( - (purchase) => - purchase.purchaseState === iap.PURCHASE_STATE_PURCHASED && - purchase.productIds.includes("acode_pro_new"), - ); - if (isPro) { - config.HAS_PRO = true; - } else { - config.HAS_PRO = !isFreePackage; - localStorage.removeItem("acode_pro"); - } - } - } catch (error) { - window.log("error", "Purchase error"); - window.log("error", error); - } - - try { - window.ANDROID_SDK_INT = await new Promise((resolve, reject) => - system.getAndroidVersion(resolve, reject), - ); - } catch (error) { - window.ANDROID_SDK_INT = Number.parseInt(device.version); - } + window.ANDROID_SDK_INT = androidSdkInt; window.DOES_SUPPORT_THEME = (() => { const $testEl = (
{ - document.body.removeAttribute("data-small-msg"); - app.classList.remove("loading", "splash"); - - // load plugins - try { - await loadPlugins(); - fileIcons.refreshRenderedIcons(); - // Ensure at least one sidebar app is active after all plugins are loaded - // This handles cases where the stored section was from an uninstalled plugin - sidebarApps.ensureActiveApp(); - - // Re-emit events for active file after plugins are loaded - const { activeFile } = editorManager; - for (const file of editorManager.files) { - if (file?.type === "editor") { - file.setMode(); - } - } - editorManager.reapplyActiveFile(); - if (activeFile?.uri) { - if (activeFile.loaded && !activeFile.loading) { - editorManager.emit("file-loaded", activeFile); - } - // Re-emit switch-file event - editorManager.emit("switch-file", activeFile); - } - } catch (error) { - window.log("error", "Failed to load plugins!"); - window.log("error", error); - toast("Failed to load plugins!"); - } finally { - void processPendingIntents().catch(intentHandler.onError); - } - applySettings.afterRender(); - - // Check login status before emitting events - try { - const user = await auth.getLoggedInUser(); - if (user) { - if (Boolean(user.acode_pro)) { - config.HAS_PRO = true; - } - loginEvents.emit(); - } - } catch (error) { - console.error("Error checking login status:", error); - } - - fetchPromotions(); - startAd(); - }, 500); + // Only the purchase check can upgrade a non-Pro user, so settle it before + // the UI is usable: restoreTheme() resets a paid theme while HAS_PRO is + // false. + if (!config.HAS_PRO) { + await proPurchaseCheck; + } + // Reveal the app once it has rendered a frame, then load the rest. + requestAnimationFrame(() => + requestAnimationFrame(() => void onAppRendered(proPurchaseCheck)), + ); } await promptUpdateCheckConsent(); @@ -517,6 +455,161 @@ async function onDeviceReady() { .catch(console.error); } +async function getInstallSource() { + try { + return await helpers.promisify(system.getInstaller); + } catch (error) { + console.error(error); + return INSTALL_SOURCE_PLAY; + } +} + +async function getAndroidSdkInt() { + try { + return await new Promise((resolve, reject) => + system.getAndroidVersion(resolve, reject), + ); + } catch (error) { + return Number.parseInt(device.version); + } +} + +/** + * Confirms Pro status against the store's purchases, waiting at most + * PRO_CHECK_TIMEOUT_MS. Only a change made here is applied, so an upgrade + * from another source (e.g. a login) is never downgraded. A result that + * arrives after the timeout can only upgrade, because startup has already + * moved on with the current status. + * @param {boolean} isFreePackage + */ +async function verifyProPurchase(isFreePackage) { + const initialHasPro = config.HAS_PRO; + let timedOut = false; + const check = findProPurchase().then((isPro) => { + if (isPro) { + config.HAS_PRO = true; + // Lets the next Android launch skip waiting for this check. + localStorage.setItem("acode_pro", "true"); + } else if ( + isPro === false && + !timedOut && + config.HAS_PRO === initialHasPro + ) { + config.HAS_PRO = !isFreePackage; + // A refunded or revoked purchase must not come back offline. + localStorage.removeItem("acode_pro"); + } + }); + await new Promise((resolve) => { + const timer = setTimeout(() => { + timedOut = true; + logger.log( + "warn", + `Purchase check still pending after ${PRO_CHECK_TIMEOUT_MS}ms`, + ); + resolve(); + }, PRO_CHECK_TIMEOUT_MS); + const settle = (error) => { + if (error) console.error(error); + clearTimeout(timer); + resolve(); + }; + check.then(() => settle(), settle); + }); +} + +/** + * @returns {Promise} whether Pro was purchased, or null when + * the store could not tell + */ +async function findProPurchase() { + try { + await helpers.promisify(iap.startConnection).catch((e) => { + logger.log("error", "connection error"); + logger.log("error", e); + }); + + if (!platform.isIOS && !navigator.onLine) return null; + + const purchases = await helpers.promisify(iap.getPurchases); + return purchases.some( + (purchase) => + purchase.purchaseState === iap.PURCHASE_STATE_PURCHASED && + purchase.productIds.includes("acode_pro_new"), + ); + } catch (error) { + logger.log("error", "Purchase error"); + logger.log("error", error); + return null; + } +} + +/** + * Hides the splash and loads everything that is not needed for the first + * frame: plugins, login state and ads. + * @param {Promise} proPurchaseCheck + */ +async function onAppRendered(proPurchaseCheck) { + document.body.removeAttribute("data-small-msg"); + app.classList.remove("loading", "splash"); + + // load plugins + try { + // Plugins may use the synchronous terminal APIs, so have them ready. + await loadTerminalManager().catch((error) => { + console.error("Failed to load terminal module:", error); + }); + await proStatusReady; + await loadPlugins(); + fileIcons.refreshRenderedIcons(); + // Ensure at least one sidebar app is active after all plugins are loaded + // This handles cases where the stored section was from an uninstalled plugin + sidebarApps.ensureActiveApp(); + + // Re-emit events for active file after plugins are loaded + const { activeFile } = editorManager; + for (const file of editorManager.files) { + if (file?.type === "editor") { + file.setMode(); + } + } + editorManager.reapplyActiveFile(); + if (activeFile?.uri) { + if (activeFile.loaded && !activeFile.loading) { + editorManager.emit("file-loaded", activeFile); + } + // Re-emit switch-file event + editorManager.emit("switch-file", activeFile); + } + } catch (error) { + window.log("error", "Failed to load plugins!"); + window.log("error", error); + toast("Failed to load plugins!"); + } finally { + void processPendingIntents().catch(intentHandler.onError); + } + applySettings.afterRender(); + + // The purchase result must be applied before login can upgrade to Pro. + await proPurchaseCheck; + + // Check login status before emitting events + try { + const user = await auth.getLoggedInUser(); + if (user) { + if (Boolean(user.acode_pro)) { + config.HAS_PRO = true; + } + loginEvents.emit(); + } + } catch (error) { + console.error("Error checking login status:", error); + } + + fetchPromotions(); + startAd(); +} + function showSftpMigrationReport({ failures, removedReferences, @@ -794,6 +887,9 @@ async function loadApp() { openWelcomeTab(); } + // Plugins read Pro status while initializing, so let it settle first. + await proStatusReady; + // load theme plugins try { await loadPlugins(true); @@ -835,8 +931,8 @@ async function loadApp() { acode.exec("save-state"); initFileList(); - import(/* webpackChunkName: "terminal" */ "components/terminal").then( - ({ TerminalManager }) => { + loadTerminalManager().then( + (TerminalManager) => { TerminalManager.restorePersistedSessions().catch((error) => { console.error("Terminal restoration failed:", error); }); diff --git a/src/pages/fileBrowser/fileBrowser.js b/src/pages/fileBrowser/fileBrowser.js index a730ff1..ef7b9d6 100644 --- a/src/pages/fileBrowser/fileBrowser.js +++ b/src/pages/fileBrowser/fileBrowser.js @@ -6,7 +6,7 @@ import Checkbox from "components/checkbox"; import Contextmenu from "components/contextmenu"; import Page from "components/page"; import searchBar from "components/searchbar"; -import terminalManager from "components/terminal/terminalManager"; +import { loadTerminalManager } from "components/terminal/loader"; import alert from "dialogs/alert"; import confirm from "dialogs/confirm"; import loader from "dialogs/loader"; @@ -23,13 +23,13 @@ import recents from "lib/recents"; import remoteStorage from "lib/remoteStorage"; import appSettings from "lib/settings"; import { deleteSftpProfile, getSftpProfileId } from "lib/sftpProfiles"; -import mimeTypes from "mime-types"; import mustache from "mustache"; import filesSettings from "settings/filesSettings"; import URLParse from "url-parse"; import copyEntry from "utils/copyEntry"; import haptic from "utils/haptic"; import helpers from "utils/helpers"; +import loadMimeTypes from "utils/mimeTypes"; import Url from "utils/Url"; import _addMenu from "./add-menu.hbs"; import _addMenuHome from "./add-menu-home.hbs"; @@ -1408,9 +1408,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { } case "ssh_terminal": { - const { TerminalManager } = await import( - /* webpackChunkName: "terminal" */ "components/terminal" - ); + const TerminalManager = await loadTerminalManager(); await TerminalManager.createRemoteTerminal({ url, name }); $page.hide(); break; @@ -1443,6 +1441,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { break; } + const mimeTypes = await loadMimeTypes(); const mimeType = mimeTypes.lookup(name) || mimeTypes.lookup(shareableUri) || diff --git a/src/pages/fontManager/fontManager.js b/src/pages/fontManager/fontManager.js index d615aa1..33c7aab 100644 --- a/src/pages/fontManager/fontManager.js +++ b/src/pages/fontManager/fontManager.js @@ -2,7 +2,7 @@ import "./style.scss"; import fsOperation from "fileSystem"; import Page from "components/page"; import searchBar from "components/searchbar"; -import { DEFAULT_TERMINAL_SETTINGS } from "components/terminal"; +import { DEFAULT_TERMINAL_SETTINGS } from "components/terminal/terminalDefaults"; import toast from "components/toast"; import confirm from "dialogs/confirm"; import dialog from "dialogs/dialog"; diff --git a/src/pages/plugin/plugin.js b/src/pages/plugin/plugin.js index a887e9e..74312e1 100644 --- a/src/pages/plugin/plugin.js +++ b/src/pages/plugin/plugin.js @@ -19,9 +19,9 @@ import anchor from "markdown-it-anchor"; import markdownItFootnote from "markdown-it-footnote"; import MarkdownItGitHubAlerts from "markdown-it-github-alerts"; import markdownItTaskLists from "markdown-it-task-lists"; -import mimeTypes from "mime-types"; import { highlightCodeBlock, initHighlighting } from "utils/codeHighlight"; import helpers from "utils/helpers"; +import loadMimeTypes from "utils/mimeTypes"; import Url from "utils/Url"; import { isVersionGreater } from "utils/version"; import view, { cleanups } from "./plugin.view.js"; @@ -122,6 +122,7 @@ export default async function PluginInclude( if (installedPlugin.icon) { const iconPath = Url.join(PLUGIN_DIR, id, installedPlugin.icon); const iconData = await fsOperation(iconPath).readFile(); + const mimeTypes = await loadMimeTypes(); const iconMimeType = mimeTypes.lookup(installedPlugin.icon) || "image/png"; icon = URL.createObjectURL( diff --git a/src/pages/plugins/plugins.js b/src/pages/plugins/plugins.js index 4062a98..5227221 100644 --- a/src/pages/plugins/plugins.js +++ b/src/pages/plugins/plugins.js @@ -673,9 +673,9 @@ export default function PluginsInclude(updates) { $list.owned.setAttribute("empty-msg", strings["loading..."]); let iapPurchases = []; + const disabledMap = settings.value.pluginsDisabled || {}; if (helpers.isIapAvailable()) { iapPurchases = await helpers.promisify(iap.getPurchases); - const disabledMap = settings.value.pluginsDisabled || {}; iapPurchases.forEach(async ({ productIds }) => { const [sku] = productIds; diff --git a/src/pages/sponsor/sponsor.js b/src/pages/sponsor/sponsor.js index ffb6e7d..7fa5dda 100644 --- a/src/pages/sponsor/sponsor.js +++ b/src/pages/sponsor/sponsor.js @@ -247,6 +247,7 @@ async function handlePurchase(productId, title) { }); } + // multiPrompt rejects when the dialog is cancelled. result = await multiPrompt(onlyTitle(title), [ { placeholder: "Name", @@ -269,7 +270,7 @@ async function handlePurchase(productId, title) { type: "checkbox", value: true, }, - ]); + ]).catch(() => null); if (!result) { return; diff --git a/src/pages/themeSetting/themeSetting.js b/src/pages/themeSetting/themeSetting.js index e22081a..815c44b 100644 --- a/src/pages/themeSetting/themeSetting.js +++ b/src/pages/themeSetting/themeSetting.js @@ -8,7 +8,7 @@ import { basicSetup, EditorView } from "codemirror"; import Page from "components/page"; import searchBar from "components/searchbar"; import TabView from "components/tabView"; -import { TerminalThemeManager } from "components/terminal"; +import TerminalThemeManager from "components/terminal/terminalThemeManager"; import alert from "dialogs/alert"; import Ref from "html-tag-js/ref"; import actionStack from "lib/actionStack"; diff --git a/src/settings/terminalSettings.js b/src/settings/terminalSettings.js index 0b2d085..246d131 100644 --- a/src/settings/terminalSettings.js +++ b/src/settings/terminalSettings.js @@ -1,9 +1,7 @@ import fsOperation from "fileSystem"; import settingsPage from "components/settingsPage"; -import { - DEFAULT_TERMINAL_SETTINGS, - TerminalThemeManager, -} from "components/terminal"; +import { DEFAULT_TERMINAL_SETTINGS } from "components/terminal/terminalDefaults"; +import TerminalThemeManager from "components/terminal/terminalThemeManager"; import toast from "components/toast"; import alert from "dialogs/alert"; import confirm from "dialogs/confirm"; diff --git a/src/sidebarApps/index.js b/src/sidebarApps/index.js index 4172f79..e97cc22 100644 --- a/src/sidebarApps/index.js +++ b/src/sidebarApps/index.js @@ -86,10 +86,16 @@ function init($el) { * Loads all sidebar apps. */ async function loadApps() { - add(...(await import("./files")).default); - add(...(await import("./searchInFiles")).default); - add(...(await import("./extensions")).default); - add(...(await import("./notification")).default); + // Fetch the app chunks in parallel but add them in their original order. + const appModules = [ + import("./files"), + import("./searchInFiles"), + import("./extensions"), + import("./notification"), + ]; + for (const appModule of appModules) { + add(...(await appModule).default); + } setSponsorSidebarAppVisibility(appSettings.value.showSponsorSidebarApp); } diff --git a/src/utils/encodings.js b/src/utils/encodings.js index cda6ff7..eaaa8ce 100644 --- a/src/utils/encodings.js +++ b/src/utils/encodings.js @@ -201,14 +201,20 @@ export function encode(text, charset) { return execEncode(text, charset); } +const ENCODINGS_CACHE_KEY = "availableEncodingsCache"; + export async function initEncodings() { + const cachedMap = readCachedEncodings(); + if (cachedMap) { + setEncodings(cachedMap); + return; + } + return new Promise((resolve, reject) => { Bridge.exec( (map) => { - Object.keys(map).forEach((key) => { - const encoding = map[key]; - encodings[key] = encoding; - }); + setEncodings(map); + writeCachedEncodings(map); resolve(); }, (error) => { @@ -267,3 +273,47 @@ function execEncode(text, charset) { } export default encodings; + +/** + * The available charsets only change with the native runtime, so the list is + * cached per app build and OS version instead of being rebuilt natively (and + * sent over the bridge) on every launch. + */ +function getEncodingsCacheId() { + return [ + globalThis.BuildInfo?.versionCode, + globalThis.device?.platform, + globalThis.device?.version, + globalThis.device?.model, + ].join("|"); +} + +function setEncodings(map) { + Object.keys(map).forEach((key) => { + const encoding = map[key]; + encodings[key] = encoding; + }); +} + +function readCachedEncodings() { + try { + const cached = JSON.parse(localStorage.getItem(ENCODINGS_CACHE_KEY)); + if (cached?.id !== getEncodingsCacheId()) return null; + const { map } = cached; + if (!map || typeof map !== "object" || !map["UTF-8"]) return null; + return map; + } catch { + return null; + } +} + +function writeCachedEncodings(map) { + try { + localStorage.setItem( + ENCODINGS_CACHE_KEY, + JSON.stringify({ id: getEncodingsCacheId(), map }), + ); + } catch (error) { + console.warn("Unable to cache available encodings", error); + } +} diff --git a/src/utils/mimeTypes.js b/src/utils/mimeTypes.js new file mode 100644 index 0000000..f5bc130 --- /dev/null +++ b/src/utils/mimeTypes.js @@ -0,0 +1,11 @@ +/** + * Loads the mime-types package on demand. Its MIME database is large and is + * never needed during startup. + * @returns {Promise} + */ +export default async function loadMimeTypes() { + const { default: mimeTypes } = await import( + /* webpackChunkName: "mimeTypes" */ "mime-types" + ); + return mimeTypes; +} diff --git a/tests/unit/commandRegistryKeymap.test.js b/tests/unit/commandRegistryKeymap.test.js new file mode 100644 index 0000000..92d08ae --- /dev/null +++ b/tests/unit/commandRegistryKeymap.test.js @@ -0,0 +1,125 @@ +import { expect, it, vi } from "vitest"; +import { loadSourceModule } from "../helpers/loadSourceModule"; + +async function loadRegistry() { + const keyBindingUtils = await import("cm/keyBindingUtils"); + const stubs = Object.fromEntries( + [ + "fileSystem", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/lsp-client", + "cm/editorReadOnly", + "cm/foldAwareLineCommands", + "cm/foldingCommands", + "cm/lsp", + "cm/lsp/references", + "components/symbolsPanel", + "components/toast", + "dialogs/prompt", + "handlers/quickTools", + "lib/platform", + "lib/settings", + "utils/Url", + ].map((id) => [id, {}]), + ); + return loadSourceModule( + "src/cm/commandRegistry.js", + { + ...stubs, + "@codemirror/state": { + Compartment: class { + of(extension) { + return extension; + } + reconfigure(extension) { + return { reconfigure: extension }; + } + }, + EditorSelection: {}, + }, + "@codemirror/view": { keymap: { of: (bindings) => ({ bindings }) } }, + "cm/keyBindingUtils": keyBindingUtils, + "lib/keyBindings": { + __esModule: true, + default: {}, + APP_KEY_BINDING_NAMES: new Set(), + CODEMIRROR_COMMAND_NAMES: new Set(), + }, + }, + { editorManager: {} }, + ); +} + +const appliedKeys = (view, call) => + view.dispatch.mock.calls[call][0].effects.reconfigure.bindings.map( + (binding) => binding.key, + ); + +it("applies a burst of command registrations to the editor once, keeping reads current", async () => { + const registry = await loadRegistry(); + const view = { dispatch: vi.fn() }; + + const first = registry.registerExternalCommand({ + name: "pluginFirst", + exec() {}, + bindKey: "Ctrl-Alt-A", + }); + registry.refreshCommandKeymap(view); + registry.registerExternalCommand({ + name: "pluginSecond", + exec() {}, + bindKey: { win: "Ctrl-Alt-B" }, + }); + registry.refreshCommandKeymap(view); + + // The returned command and registry reads are up to date immediately. + expect(first.key).toBe("Ctrl-Alt-A"); + expect( + registry + .getRegisteredCommands() + .filter(({ name }) => name.startsWith("plugin")) + .map(({ name, key }) => [name, key]), + ).toEqual([ + ["pluginFirst", "Ctrl-Alt-A"], + ["pluginSecond", "Ctrl-Alt-B"], + ]); + + // The editor is reconfigured once, before any later event can run. + expect(view.dispatch).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(view.dispatch).toHaveBeenCalledOnce(); + expect(appliedKeys(view, 0)).toEqual( + expect.arrayContaining(["Mod-Alt-a", "Mod-Alt-b"]), + ); + + registry.removeExternalCommand("pluginFirst"); + registry.refreshCommandKeymap(view); + await Promise.resolve(); + expect(view.dispatch).toHaveBeenCalledTimes(2); + expect(appliedKeys(view, 1)).not.toContain("Mod-Alt-a"); + expect(appliedKeys(view, 1)).toContain("Mod-Alt-b"); +}); + +it("reports a shortcut conflict with the earlier registered command", async () => { + const registry = await loadRegistry(); + registry.registerExternalCommand({ + name: "pluginOwner", + exec() {}, + bindKey: "Ctrl-K", + }); + registry.registerExternalCommand({ + name: "pluginChord", + exec() {}, + bindKey: "Ctrl-K Ctrl-X", + }); + + expect(registry.getKeyBindingConflicts()).toEqual([ + { + key: "Mod-k Mod-x", + command: "pluginChord", + shadowedBy: "pluginOwner", + }, + ]); +}); diff --git a/tests/unit/fileSaveEvents.test.js b/tests/unit/fileSaveEvents.test.js index d7e0eb1..6ad1582 100644 --- a/tests/unit/fileSaveEvents.test.js +++ b/tests/unit/fileSaveEvents.test.js @@ -9,7 +9,7 @@ function setup() { const write = vi.fn(async () => true); const manager = { files: [], getFile: () => null, addFile: file => manager.files.push(file), emit: vi.fn(), onupdate: vi.fn(), activeFile: null }; const defaults = Object.fromEntries([ - "fileSystem", "components/quickTools", "components/sidebar", "components/toast", "dialogs/confirm", "handlers/editorFileTab", "handlers/quickTools", "lib/quickToolsAdapter", "handlers/tabContextMenu", "dompurify", "mime-types", "utils/codeHighlight", "utils/Path", "utils/remoteFilePreview", "utils/Url", "./loadPlugins", "./openFolder", "./run", "cm/editorReadOnly", + "fileSystem", "components/quickTools", "components/sidebar", "components/toast", "dialogs/confirm", "handlers/editorFileTab", "handlers/quickTools", "lib/quickToolsAdapter", "handlers/tabContextMenu", "dompurify", "utils/mimeTypes", "utils/codeHighlight", "utils/Path", "utils/remoteFilePreview", "utils/Url", "./loadPlugins", "./openFolder", "./runLazily", "cm/editorReadOnly", ].map(id => [id, {}])); const { default: EditorFile } = loadSourceModule("src/lib/editorFile.js", { ...defaults, diff --git a/tests/unit/pluginFileRestoration.test.js b/tests/unit/pluginFileRestoration.test.js index f1ca8aa..d8a4619 100644 --- a/tests/unit/pluginFileRestoration.test.js +++ b/tests/unit/pluginFileRestoration.test.js @@ -154,11 +154,11 @@ function setup() { "handlers/quickTools", "handlers/tabContextMenu", "dompurify", - "mime-types", + "utils/mimeTypes", "utils/codeHighlight", "utils/Path", "./openFolder", - "./run", + "./runLazily", "cm/editorReadOnly", "lib/quickToolsAdapter", ].map((id) => [id, {}]), diff --git a/tests/unit/runLazily.test.js b/tests/unit/runLazily.test.js new file mode 100644 index 0000000..5cbc248 --- /dev/null +++ b/tests/unit/runLazily.test.js @@ -0,0 +1,34 @@ +import { expect, it, vi } from "vitest"; +import { loadSourceModule } from "../helpers/loadSourceModule"; + +function setup() { + const run = vi.fn(); + const editorManager = { activeFile: { id: "requested" } }; + const { default: runLazily } = loadSourceModule( + "src/lib/runLazily.js", + { "./run": { __esModule: true, default: run } }, + { editorManager }, + ); + return { run, editorManager, runLazily }; +} + +it("runs the file that was active when the run was requested", async () => { + const { run, editorManager, runLazily } = setup(); + + const pending = runLazily(false, "inapp", true); + // The user switches tabs while the runner chunk is still loading. + editorManager.activeFile = { id: "switched" }; + await pending; + + expect(run).toHaveBeenCalledExactlyOnceWith(false, "inapp", true, { + id: "requested", + }); +}); + +it("does not capture a file for the console", async () => { + const { run, runLazily } = setup(); + + await runLazily(true, "inapp"); + + expect(run).toHaveBeenCalledExactlyOnceWith(true, "inapp", undefined, null); +}); diff --git a/tests/unit/saveCommands.test.js b/tests/unit/saveCommands.test.js index 4d629d4..7902949 100644 --- a/tests/unit/saveCommands.test.js +++ b/tests/unit/saveCommands.test.js @@ -6,7 +6,7 @@ function setup(files, capabilities = platform) { const manager = { files, activeFile: files[0], getFile: id => files.find(file => file.id === id) }; const toast = vi.fn(), error = vi.fn(); const dependencies = Object.fromEntries([ - "fileSystem", "@codemirror/commands", "cm/editorReadOnly", "components/sidebar", "dialogs/prompt", "handlers/quickTools", "lib/recents", "utils/color/regex", "utils/Url", "./checkFiles", "./config", "./editorFile", "./lazyImports", "./openFile", "./openFolder", "./run", "./saveState", "./settings", "./showFileInfo", + "fileSystem", "@codemirror/commands", "cm/editorReadOnly", "components/sidebar", "components/terminal/loader", "dialogs/prompt", "handlers/quickTools", "lib/recents", "utils/color/regex", "utils/Url", "./checkFiles", "./config", "./editorFile", "./lazyImports", "./openFile", "./openFolder", "./runLazily", "./saveState", "./settings", "./showFileInfo", ].map(id => [id, {}])); const module = loadSourceModule("src/lib/commands.js", { "./platform": capabilities, From 986d87248aac2b49d0f5e1fe73b60f0395813010 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:26:00 +0530 Subject: [PATCH 2/7] perf: load core-js polyfills once native.js already installs core-js/stable and boot.js always loads it before main.js, so main.js no longer bundles a second copy (-113 KB). --- src/main.js | 2 +- src/native/index.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main.js b/src/main.js index a3f21b9..ab904a2 100644 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,4 @@ -import "core-js/stable"; +// core-js/stable is installed by native.js, which boot.js always loads first. import "html-tag-js/dist/polyfill"; import "./main.scss"; diff --git a/src/native/index.ts b/src/native/index.ts index 356adcc..b3f8671 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -1,3 +1,5 @@ +// Installs the polyfills for the whole app and its plugins; main.js relies on +// this bundle loading first. import "core-js/stable"; import buildInfo, { initializeBuildInfo } from "./buildInfo"; import clipboard from "./clipboard"; From fbb231589e2dc5c7095e8137ed0b42950a5401b1 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:37:07 +0530 Subject: [PATCH 3/7] fix: typos --- licenses/ftp-LICENSE.md | 2 +- licenses/sftp-LICENSE.md | 2 +- src/native/file/types.d.ts | 2 +- src/native/http/messages.ts | 2 +- src/native/terminal/Terminal.ts | 6 +++--- src/native/types/sftp.d.ts | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/licenses/ftp-LICENSE.md b/licenses/ftp-LICENSE.md index 7e63c3c..b14abd6 100644 --- a/licenses/ftp-LICENSE.md +++ b/licenses/ftp-LICENSE.md @@ -1,3 +1,3 @@ - GoldRaccoon is under [original author's license](https://github.com/albertodebortoli/GoldRaccoon/blob/master/LICENSE.markdown) - ftp4j is under [LGPL](http://opensource.org/licenses/LGPL-2.1) -- All other codes (writen by me) are under [Apache License 2.0](http://www.apache.org/licenses/LICENSE-2.0) +- All other codes (written by me) are under [Apache License 2.0](http://www.apache.org/licenses/LICENSE-2.0) diff --git a/licenses/sftp-LICENSE.md b/licenses/sftp-LICENSE.md index 7e63c3c..b14abd6 100644 --- a/licenses/sftp-LICENSE.md +++ b/licenses/sftp-LICENSE.md @@ -1,3 +1,3 @@ - GoldRaccoon is under [original author's license](https://github.com/albertodebortoli/GoldRaccoon/blob/master/LICENSE.markdown) - ftp4j is under [LGPL](http://opensource.org/licenses/LGPL-2.1) -- All other codes (writen by me) are under [Apache License 2.0](http://www.apache.org/licenses/LICENSE-2.0) +- All other codes (written by me) are under [Apache License 2.0](http://www.apache.org/licenses/LICENSE-2.0) diff --git a/src/native/file/types.d.ts b/src/native/file/types.d.ts index b40ea46..553262c 100644 --- a/src/native/file/types.d.ts +++ b/src/native/file/types.d.ts @@ -389,7 +389,7 @@ interface NativeBridge { tempDirectory: string; /* iOS: Holds app-specific files that should be synced (e.g. to iCloud). */ syncedDataDirectory: string; - /* iOS: Files private to the app, but that are meaningful to other applciations (e.g. Office files) */ + /* iOS: Files private to the app, but that are meaningful to other applications (e.g. Office files) */ documentsDirectory: string; /* BlackBerry10: Files globally available to all apps */ sharedDirectory: string; diff --git a/src/native/http/messages.ts b/src/native/http/messages.ts index 9ebc35f..5b05cbd 100644 --- a/src/native/http/messages.ts +++ b/src/native/http/messages.ts @@ -47,7 +47,7 @@ export default { MISSING_TEXT_ENCODER_API: 'advanced-http: TextEncoder API is not supported in this webview. If you want to use "multipart/form-data" requests, you need to load a polyfill library before loading this plugin.', POST_PROCESSING_FAILED: - "advanced-http: an error occured during post processing response:", + "advanced-http: an error occurred during post processing response:", TYPE_MISMATCH_DATA: 'advanced-http: "data" option is configured to support only following data types:', TYPE_MISMATCH_FILE_PATHS: diff --git a/src/native/terminal/Terminal.ts b/src/native/terminal/Terminal.ts index e5a22de..e1b6c1a 100644 --- a/src/native/terminal/Terminal.ts +++ b/src/native/terminal/Terminal.ts @@ -139,10 +139,10 @@ const Terminal = { const isFdroid = await Executor.execute("echo $FDROID"); this.lastInstallError = ""; try { - //cleanup before insatll + //cleanup before install await this.uninstall(); } catch (e) { - //supress error + //suppress error } const filesDir = await new Promise((resolve, reject) => { system.getFilesDir(resolve, reject); @@ -321,7 +321,7 @@ const Terminal = { await setExec(`${alpineDir}/bin/rm`, true); logger("✅ Extraction complete"); await ensureDir(`${filesDir}/.extracted`); - logger("⚙️ Updating sandbox enviroment..."); + logger("⚙️ Updating sandbox environment..."); const installResult = await this.startAxs(true, logger, errorLogger); if (!installResult) { throw new Error( diff --git a/src/native/types/sftp.d.ts b/src/native/types/sftp.d.ts index 9a1775e..9c92cd2 100644 --- a/src/native/types/sftp.d.ts +++ b/src/native/types/sftp.d.ts @@ -135,7 +135,7 @@ interface Sftp { close(onSuccess: () => void, onFail: (err: any) => void): void; /** - * Gets wether server is connected or not. + * checks whether server is connected or not. * @param onSuccess * @param onFail */ From c3927f55d2e120936f0914370efe19e6e8734508 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:06:43 +0530 Subject: [PATCH 4/7] perf: stop plugins and file I/O from slowing startup - Decode and encode UTF-8 in the WebView instead of round-tripping base64 through native; invalid UTF-8, lone surrogates and other charsets still use native so behaviour is unchanged (BOM kept on Android, stripped on iOS) - Format Android internal file URLs in JS when the result provably matches native, skipping a resolve round trip per read and per plugin path - Load plugins with fewer native round trips: no exists() check for main, and the cache file and plugin token are prepared while the script loads - Open files shared from other apps after a short plugin grace period instead of waiting for every plugin; files a plugin may handle (remembered handler extensions, documents) still wait as before --- src/fileSystem/internalFs.js | 16 +++- src/handlers/intent.js | 65 ++++++++++++-- src/lib/fileTypeHandler.js | 38 ++++++++ src/lib/loadPlugin.js | 145 ++++++++++++++++++------------- src/lib/openFile.js | 7 +- src/main.js | 6 +- src/native/file/index.ts | 43 +++++---- src/native/file/internalUrl.ts | 42 +++++++++ src/native/types.ts | 4 +- src/utils/encodings.js | 45 +++++++++- tests/unit/encodingsUtf8.test.js | 94 ++++++++++++++++++++ tests/unit/fileIntents.test.js | 75 +++++++++++++++- tests/unit/internalUrl.test.ts | 84 ++++++++++++++++++ 13 files changed, 573 insertions(+), 91 deletions(-) create mode 100644 src/native/file/internalUrl.ts create mode 100644 tests/unit/encodingsUtf8.test.js create mode 100644 tests/unit/internalUrl.test.ts diff --git a/src/fileSystem/internalFs.js b/src/fileSystem/internalFs.js index 49c2c13..230736d 100644 --- a/src/fileSystem/internalFs.js +++ b/src/fileSystem/internalFs.js @@ -95,7 +95,21 @@ const internalFs = { * @param {string} encoding * @returns {Promise} */ - readFile(filename) { + async readFile(filename) { + // Skips the native resolve round trip when the WebView URL is known. + const internalUrl = globalThis.Bridge?.file?.toInternalURL?.(filename); + if (internalUrl) { + try { + const data = await ajax({ + url: internalUrl, + responseType: "arraybuffer", + }); + return { data }; + } catch { + // Missing files and other failures keep the native path's errors. + } + } + return new Promise((resolve, reject) => { reject = setMessage(reject, filename); window.resolveLocalFileSystemURL( diff --git a/src/handlers/intent.js b/src/handlers/intent.js index 22c30ac..1d2b635 100644 --- a/src/handlers/intent.js +++ b/src/handlers/intent.js @@ -2,11 +2,19 @@ import fsOperation from "fileSystem"; import select from "dialogs/select"; import auth from "lib/auth"; import config from "lib/config"; +import fileTypeHandler from "lib/fileTypeHandler"; import { isInitialPluginLoadComplete } from "lib/loadPlugins"; -import openFile from "lib/openFile"; +import openFile, { EXTERNAL_DOCUMENT_PATTERN } from "lib/openFile"; import { BANNER_SUPPRESSION_REASON, setBannerSuppressed } from "lib/startAd"; import helpers from "utils/helpers"; +/** + * How long files opened from other apps wait for plugins at startup. After + * this, only files a plugin may handle keep waiting for every plugin, so one + * slow plugin no longer delays everything else. + */ +const PLUGIN_GRACE_MS = 1000; + const handlers = []; /** * Batches wait for restored files and plugin handlers, then open sequentially. @@ -14,6 +22,7 @@ const handlers = []; */ const pendingIntents = []; let opening; +let pluginGraceElapsed = false; /** * @@ -112,16 +121,33 @@ export function removeIntentHandler(handler) { if (index > -1) handlers.splice(index, 1); } -/** Drain only after both startup phases, including a partially failed plugin load. */ +/** + * Starts the plugin grace period. Call it when the initial plugin load starts. + */ +export function startPluginGracePeriod() { + setTimeout(() => { + pluginGraceElapsed = true; + void processPendingIntents().catch(HandleIntent.onError); + }, PLUGIN_GRACE_MS); +} + +/** + * Drain once files are restored and either every plugin has loaded (even + * partially failed) or the grace period is over and no pending file may need + * a plugin handler. + */ export async function processPendingIntents() { - if ( - sessionStorage.getItem("isfilesRestored") !== "true" || - !isInitialPluginLoadComplete() - ) - return; + if (sessionStorage.getItem("isfilesRestored") !== "true") return; + if (!isInitialPluginLoadComplete() && !pluginGraceElapsed) return; if (opening) return opening; opening = (async () => { while (pendingIntents.length) { + // Keep the order: a file that may need a plugin holds back the rest. + if ( + !isInitialPluginLoadComplete() && + (await mayNeedPluginHandler(pendingIntents[0])) + ) + break; const { uris, invalid } = pendingIntents.shift(); const failures = invalid ? [{ filename: strings["invalid shared file"] }] @@ -147,6 +173,9 @@ export async function processPendingIntents() { } })().finally(() => { opening = undefined; + // Plugins may have finished while a held-back file was being checked. + if (pendingIntents.length && isInitialPluginLoadComplete()) + void processPendingIntents().catch(HandleIntent.onError); }); return opening; } @@ -217,3 +246,25 @@ class IntentEvent { return this.#propagationStopped; } } + +/** + * @param {{uris: string[]}} intent + */ +async function mayNeedPluginHandler({ uris }) { + for (const uri of uris) { + let name; + try { + // Content URIs rarely carry the file name; the provider knows it. + ({ name } = await fsOperation(uri).stat()); + } catch { + return true; + } + if ( + !name || + EXTERNAL_DOCUMENT_PATTERN.test(name) || + fileTypeHandler.mayHavePluginHandler(name) + ) + return true; + } + return false; +} diff --git a/src/lib/fileTypeHandler.js b/src/lib/fileTypeHandler.js index f67770d..14d5352 100644 --- a/src/lib/fileTypeHandler.js +++ b/src/lib/fileTypeHandler.js @@ -14,6 +14,8 @@ * @property {Object} options - Additional options passed during file open */ +const HANDLED_EXTENSIONS_KEY = "pluginHandledExtensions"; + class FileTypeHandlerRegistry { #handlers = new Map(); @@ -47,6 +49,19 @@ class FileTypeHandlerRegistry { extensions: normalizedExts, handleFile, }); + rememberHandledExtensions(normalizedExts); + } + + /** + * Whether a plugin has registered a handler for this file name in any + * session, so opening it should wait until plugins have loaded. Extensions + * are never forgotten, which only means waiting as before. + * @param {string} filename + */ + mayHavePluginHandler(filename) { + const extensions = readHandledExtensions(); + const ext = filename.split(".").pop().toLowerCase(); + return extensions.includes("*") || extensions.includes(ext); } /** @@ -91,3 +106,26 @@ class FileTypeHandlerRegistry { export const fileTypeHandler = new FileTypeHandlerRegistry(); export default fileTypeHandler; + +function readHandledExtensions() { + try { + const extensions = JSON.parse(localStorage.getItem(HANDLED_EXTENSIONS_KEY)); + return Array.isArray(extensions) ? extensions : []; + } catch { + return []; + } +} + +function rememberHandledExtensions(extensions) { + const known = readHandledExtensions(); + const added = extensions.filter((ext) => !known.includes(ext)); + if (!added.length) return; + try { + localStorage.setItem( + HANDLED_EXTENSIONS_KEY, + JSON.stringify([...known, ...added]), + ); + } catch (error) { + console.warn("Unable to remember plugin file handlers", error); + } +} diff --git a/src/lib/loadPlugin.js b/src/lib/loadPlugin.js index e719965..1e8c2c8 100644 --- a/src/lib/loadPlugin.js +++ b/src/lib/loadPlugin.js @@ -14,7 +14,8 @@ export default async function loadPlugin(pluginId, justInstalled = false) { // single choke point through which all plugin loads flow. await connect(); - const baseUrl = await helpers.toInternalUri(Url.join(PLUGIN_DIR, pluginId)); + const pluginDir = Url.join(PLUGIN_DIR, pluginId); + const baseUrl = await toInternalUri(pluginDir, true); const cacheFile = Url.join(CACHE_STORAGE, pluginId); // Unmount the old version before loading the new one. @@ -32,72 +33,100 @@ export default async function loadPlugin(pluginId, justInstalled = false) { if (oldScript) oldScript.remove(); const pluginJson = await fsOperation( - Url.join(PLUGIN_DIR, pluginId, "plugin.json"), + Url.join(pluginDir, "plugin.json"), ).readFile("json"); - let mainUrl; - if ( - await fsOperation(Url.join(PLUGIN_DIR, pluginId, pluginJson.main)).exists() - ) { - mainUrl = Url.join(baseUrl, pluginJson.main); - } else { - mainUrl = Url.join(baseUrl, "main.js"); - } - - return new Promise((resolve, reject) => { - const $script = ( - - ); - - const iconApi = fileIcons.bindPlugin($script, pluginId); - - $script.onerror = (error) => { - fileIcons.unregisterByPlugin(pluginId); - reject( - new Error( - `Failed to load script for plugin ${pluginId}: ${error.message || error}`, - ), + // Neither is needed until init, so prepare them while the script loads. + const initInputs = Promise.all([ + prepareCacheFile(cacheFile, pluginId), + generatePluginContext(pluginId, JSON.stringify(pluginJson)), + ]); + initInputs.catch(() => {}); + + // A missing `main` falls back to main.js, as the old exists() check did. + const mainFiles = [...new Set([pluginJson.main, "main.js"].filter(Boolean))]; + + await new Promise((resolve, reject) => { + const load = (index) => { + const $script = ( + ); - }; - $script.onload = async () => { - const $page = Page("Plugin"); - $page.show = () => { - actionStack.push({ - id: pluginId, - action: $page.hide, - }); + const iconApi = fileIcons.bindPlugin($script, pluginId); - app.append($page); - }; - - $page.onhide = function () { - actionStack.remove(pluginId); + $script.onerror = (error) => { + fileIcons.unregisterByPlugin(pluginId); + if (index + 1 < mainFiles.length) { + $script.remove(); + load(index + 1); + return; + } + reject( + new Error( + `Failed to load script for plugin ${pluginId}: ${error.message || error}`, + ), + ); }; - try { - if (!(await fsOperation(cacheFile).exists())) { - await fsOperation(CACHE_STORAGE).createFile(pluginId); + $script.onload = async () => { + const $page = Page("Plugin"); + $page.show = () => { + actionStack.push({ + id: pluginId, + action: $page.hide, + }); + + app.append($page); + }; + + $page.onhide = function () { + actionStack.remove(pluginId); + }; + + try { + const [cacheFileUrl, ctx] = await initInputs; + await acode.initPlugin(pluginId, baseUrl, $page, { + fileIcons: iconApi, + cacheFileUrl, + cacheFile: fsOperation(cacheFile), + firstInit: justInstalled, + ctx, + }); + + resolve(); + } catch (error) { + fileIcons.unregisterByPlugin(pluginId); + reject(error); } + }; - await acode.initPlugin(pluginId, baseUrl, $page, { - fileIcons: iconApi, - cacheFileUrl: await helpers.toInternalUri(cacheFile), - cacheFile: fsOperation(cacheFile), - firstInit: justInstalled, - ctx: await generatePluginContext( - pluginId, - JSON.stringify(pluginJson), - ), - }); - - resolve(); - } catch (error) { - fileIcons.unregisterByPlugin(pluginId); - reject(error); - } + document.head.append($script); }; - - document.head.append($script); + load(0); }); } + +/** + * Creates the plugin's cache file if needed. + * @returns {Promise} its WebView URL + */ +async function prepareCacheFile(cacheFile, pluginId) { + if (!(await fsOperation(cacheFile).exists())) { + await fsOperation(CACHE_STORAGE).createFile(pluginId); + } + return toInternalUri(cacheFile); +} + +/** + * The WebView URL of an existing local path, without a native round trip when + * the native file layer can format it directly. + */ +async function toInternalUri(url, isDirectory = false) { + return ( + Bridge.file?.toInternalURL?.(url, isDirectory) ?? + (await helpers.toInternalUri(url)) + ); +} diff --git a/src/lib/openFile.js b/src/lib/openFile.js index c84cee1..1382da4 100644 --- a/src/lib/openFile.js +++ b/src/lib/openFile.js @@ -13,6 +13,10 @@ import fileTypeHandler from "./fileTypeHandler"; import recents from "./recents"; import appSettings from "./settings"; +/** Documents opened from other apps that only a plugin handler can show. */ +export const EXTERNAL_DOCUMENT_PATTERN = + /\.(pdf|docx|dotx|xlsx|xls|ods|pptx|ppsx|potx)$/i; + let loadingFileCount = 0; /** @@ -148,8 +152,7 @@ export default async function openFile(file, options = {}) { // Check for registered file handlers const customHandler = fileTypeHandler.getFileHandler(name); const needsDocumentHandler = - options.external && - /\.(pdf|docx|dotx|xlsx|xls|ods|pptx|ppsx|potx)$/i.test(name); + options.external && EXTERNAL_DOCUMENT_PATTERN.test(name); if (needsDocumentHandler && !customHandler) { throw Object.assign(new Error("Document handler unavailable"), { code: "DOCUMENT_HANDLER_UNAVAILABLE", diff --git a/src/main.js b/src/main.js index ab904a2..cee9545 100644 --- a/src/main.js +++ b/src/main.js @@ -34,7 +34,10 @@ import toast from "components/toast"; import { initIconTooltips } from "components/tooltip"; import alert from "dialogs/alert"; import confirm from "dialogs/confirm"; -import intentHandler, { processPendingIntents } from "handlers/intent"; +import intentHandler, { + processPendingIntents, + startPluginGracePeriod, +} from "handlers/intent"; import keyboardHandler, { keydownState } from "handlers/keyboard"; import quickToolsInit from "handlers/quickToolsInit"; import windowResize from "handlers/windowResize"; @@ -554,6 +557,7 @@ async function onAppRendered(proPurchaseCheck) { app.classList.remove("loading", "splash"); // load plugins + startPluginGracePeriod(); try { // Plugins may use the synchronous terminal APIs, so have them ready. await loadTerminalManager().catch((error) => { diff --git a/src/native/file/index.ts b/src/native/file/index.ts index eeb6b1c..f0ba647 100644 --- a/src/native/file/index.ts +++ b/src/native/file/index.ts @@ -13,6 +13,7 @@ import NativeFile from "./File"; import FileError from "./FileError"; import FileReader from "./FileReader"; import FileWriter from "./FileWriter"; +import toInternalURL from "./internalUrl"; import Metadata from "./Metadata"; import ProgressEvent from "./ProgressEvent"; import restorePaths from "./restorePaths"; @@ -20,22 +21,32 @@ import { FileUploadOptions, FileUploadResult, Flags } from "./transferTypes"; const call = bridge("File"); const fileSystems = new Map(); -export const file = Object.fromEntries( - [ - "applicationDirectory", - "applicationStorageDirectory", - "dataDirectory", - "cacheDirectory", - "externalApplicationStorageDirectory", - "externalDataDirectory", - "externalCacheDirectory", - "externalRootDirectory", - "tempDirectory", - "syncedDataDirectory", - "documentsDirectory", - "sharedDirectory", - ].map((name) => [name, null]), -) as Record; +export const file = Object.assign( + Object.fromEntries( + [ + "applicationDirectory", + "applicationStorageDirectory", + "dataDirectory", + "cacheDirectory", + "externalApplicationStorageDirectory", + "externalDataDirectory", + "externalCacheDirectory", + "externalRootDirectory", + "tempDirectory", + "syncedDataDirectory", + "documentsDirectory", + "sharedDirectory", + ].map((name) => [name, null]), + ) as Record, + { + /** + * The WebView URL of a local path without a native round trip, or null + * when only `resolveLocalFileSystemURL` can tell. + */ + toInternalURL: (url: string, isDirectory?: boolean) => + toInternalURL(fileSystems, url, isDirectory), + }, +); export default function installFileAPI( expose: (name: string, value: unknown) => void, diff --git a/src/native/file/internalUrl.ts b/src/native/file/internalUrl.ts new file mode 100644 index 0000000..eda2197 --- /dev/null +++ b/src/native/file/internalUrl.ts @@ -0,0 +1,42 @@ +import type { FileSystem } from "./entries"; + +// Paths outside this set (encoded, unicode, special characters) could be +// normalised differently by Android, so they are left to the native resolver. +const SAFE_PATH = /^file:\/\/\/[\w\-.~ /]*$/; + +/** + * Formats the WebView URL of an Android `file://` path the same way the native + * `resolveLocalFileSystemURI` does, without a bridge round trip: the matching + * filesystem with the shortest local URL wins and directories end with `/`. + * Returns null whenever the result could differ from native, so callers fall + * back to it. iOS resolves symlinks and remapped containers natively, so it + * always uses the native path. + */ +export default function toInternalURL( + fileSystems: Map, + url: string, + isDirectory = false, +): string | null { + if (Bridge.platformId !== "android" || !fileSystems.size) return null; + if (typeof url !== "string" || !SAFE_PATH.test(url)) return null; + + const path = url.slice("file://".length); + if (/\/\/|\/\.\.?(\/|$)/.test(path)) return null; + + let best: { fs: FileSystem; fullPath: string; length: number } | null = null; + for (const fs of fileSystems.values()) { + const rootURL = fs.root.nativeURL; + if (!rootURL?.startsWith("file:///")) continue; + const root = rootURL.slice("file://".length).replace(/\/$/, ""); + if (!path.startsWith(root)) continue; + const rest = path.slice(root.length); + // Native accepts a root that is only a string prefix (e.g. ".../files" + // for ".../files-x"); that case is too unusual to replicate. + if (rest && !rest.startsWith("/")) return null; + let fullPath = rest.replace(/\/$/, "") || "/"; + if (isDirectory && !fullPath.endsWith("/")) fullPath += "/"; + const length = `cdvfile://localhost/${fs.name}${fullPath}`.length; + if (!best || length < best.length) best = { fs, fullPath, length }; + } + return best ? best.fs.format(best.fullPath, url) : null; +} diff --git a/src/native/types.ts b/src/native/types.ts index 029f7b5..909117b 100644 --- a/src/native/types.ts +++ b/src/native/types.ts @@ -11,7 +11,9 @@ export interface NativeBridge { addDocumentEventHandler(name: string): Channel; addStickyDocumentEventHandler(name: string): Channel; removeDocumentEventHandler(name: string): void; - file?: Record; + file?: Record & { + toInternalURL(url: string, isDirectory?: boolean): string | null; + }; clipboard?: typeof import("./clipboard").default; http?: typeof import("./http/advanced-http").default; websocket?: typeof import("./websocket").default; diff --git a/src/utils/encodings.js b/src/utils/encodings.js index eaaa8ce..5b22699 100644 --- a/src/utils/encodings.js +++ b/src/utils/encodings.js @@ -1,8 +1,23 @@ import alert from "dialogs/alert"; +import platform from "lib/platform"; import settings from "lib/settings"; let encodings = {}; +/** + * Sending text through the native bridge base64-encodes it twice (about 0.8 s + * for a 3.7 MB file on a mid-range phone), while the WebView decodes UTF-8 in + * milliseconds. Only input the native decoder would treat identically is + * handled here: Android keeps a UTF-8 BOM and iOS strips it, and invalid bytes + * or lone surrogates still go to native so their replacement/error behaviour + * is unchanged. + */ +const UTF8 = "UTF-8"; +const LONE_SURROGATE = + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?} */ -export function encode(text, charset) { +export async function encode(text, charset) { charset = getEncodingName(charset); + if (charset === UTF8 && !LONE_SURROGATE.test(text)) { + utf8Encoder ??= new TextEncoder(); + const bytes = utf8Encoder.encode(text); + // The bridge sends whole ArrayBuffers, so pass one sized to the text. + return bytes.byteLength === bytes.buffer.byteLength + ? bytes.buffer + : bytes.slice().buffer; + } return execEncode(text, charset); } @@ -317,3 +342,19 @@ function writeCachedEncodings(map) { console.warn("Unable to cache available encodings", error); } } + +/** + * @param {ArrayBuffer} buffer + * @returns {string|null} null when the bytes are not valid UTF-8 + */ +function decodeUtf8(buffer) { + try { + utf8Decoder ??= new TextDecoder(UTF8, { + fatal: true, + ignoreBOM: !platform.isIOS, + }); + return utf8Decoder.decode(buffer); + } catch { + return null; + } +} diff --git a/tests/unit/encodingsUtf8.test.js b/tests/unit/encodingsUtf8.test.js new file mode 100644 index 0000000..f42a1bb --- /dev/null +++ b/tests/unit/encodingsUtf8.test.js @@ -0,0 +1,94 @@ +import { expect, it, vi } from "vitest"; +import { loadSourceModule } from "../helpers/loadSourceModule"; + +const NATIVE_TEXT = "decoded natively"; +const NATIVE_BYTES = new Uint8Array([1, 2, 3]).buffer; + +async function setup({ isIOS = false } = {}) { + const exec = vi.fn((success, error, service, action, args) => { + if (action === "get-available-encodings") { + success({ + "UTF-8": { name: "UTF-8", label: "UTF-8", aliases: ["utf8"] }, + "ISO-8859-1": { name: "ISO-8859-1", label: "ISO-8859-1", aliases: [] }, + }); + } else if (action === "decode") success(NATIVE_TEXT); + else if (action === "encode") success(NATIVE_BYTES); + }); + const storage = new Map(); + const encodings = loadSourceModule( + "src/utils/encodings.js", + { + "dialogs/alert": { __esModule: true, default: vi.fn() }, + "lib/platform": { __esModule: true, default: { isIOS } }, + "lib/settings": { + __esModule: true, + default: { value: { defaultFileEncoding: "UTF-8" } }, + }, + }, + { + Bridge: { exec }, + TextDecoder, + TextEncoder, + localStorage: { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, value), + }, + }, + ); + await encodings.initEncodings(); + exec.mockClear(); + const nativeCalls = (action) => + exec.mock.calls.filter((call) => call[3] === action).length; + return { ...encodings, nativeCalls }; +} + +const bytes = (...values) => new Uint8Array(values).buffer; +const utf8 = (text) => new TextEncoder().encode(text).buffer; + +it("decodes valid UTF-8 without the native bridge", async () => { + const { decode, nativeCalls } = await setup(); + + expect(await decode(utf8("héllo ✓ 😀"), "utf-8")).toBe("héllo ✓ 😀"); + expect(await decode(utf8('{"a":[1]}'), "json")).toEqual({ a: [1] }); + expect(nativeCalls("decode")).toBe(0); +}); + +it("keeps the UTF-8 BOM on Android and strips it on iOS, like native", async () => { + const withBom = bytes(0xef, 0xbb, 0xbf, 0x68, 0x69); + + expect(await (await setup()).decode(withBom, "UTF-8")).toBe("hi"); + expect(await (await setup({ isIOS: true })).decode(withBom, "UTF-8")).toBe( + "hi", + ); +}); + +it("leaves invalid UTF-8 and other charsets to the native decoder", async () => { + const { decode, nativeCalls } = await setup(); + + expect(await decode(bytes(0x68, 0xff, 0x69), "UTF-8")).toBe(NATIVE_TEXT); + expect(await decode(utf8("abc"), "ISO-8859-1")).toBe(NATIVE_TEXT); + expect(nativeCalls("decode")).toBe(2); +}); + +it("encodes UTF-8 locally into an exactly sized buffer", async () => { + const { encode, nativeCalls } = await setup(); + + const buffer = await encode("héllo ✓ 😀", "UTF-8"); + + expect(buffer).toBeInstanceOf(ArrayBuffer); + expect(new TextDecoder().decode(buffer)).toBe("héllo ✓ 😀"); + expect(buffer.byteLength).toBe(utf8("héllo ✓ 😀").byteLength); + expect(nativeCalls("encode")).toBe(0); +}); + +it("leaves lone surrogates and other charsets to the native encoder", async () => { + const { encode, nativeCalls } = await setup(); + + expect(await encode("a\uD800b", "UTF-8")).toBe(NATIVE_BYTES); + expect(await encode("a\uDC00", "UTF-8")).toBe(NATIVE_BYTES); + expect(await encode("abc", "ISO-8859-1")).toBe(NATIVE_BYTES); + expect(nativeCalls("encode")).toBe(3); + // A valid surrogate pair stays local. + await encode("😀", "UTF-8"); + expect(nativeCalls("encode")).toBe(3); +}); diff --git a/tests/unit/fileIntents.test.js b/tests/unit/fileIntents.test.js index 1e156f2..2800f19 100644 --- a/tests/unit/fileIntents.test.js +++ b/tests/unit/fileIntents.test.js @@ -5,7 +5,7 @@ import { loadSourceModule } from "../helpers/loadSourceModule"; afterEach(() => vi.restoreAllMocks()); -function setup() { +function setup({ pluginHandled = [] } = {}) { let restored = false, pluginsReady = false; const open = vi.fn(async () => {}); @@ -15,11 +15,27 @@ function setup() { const handler = loadSourceModule( "src/handlers/intent.js", { - fileSystem: {}, + fileSystem: { + __esModule: true, + default: (uri) => ({ + stat: async () => ({ name: uri.split("/").pop() }), + }), + }, "lib/auth": {}, "lib/config": {}, "lib/startAd": {}, - "lib/openFile": open, + "lib/fileTypeHandler": { + __esModule: true, + default: { + mayHavePluginHandler: (name) => + pluginHandled.includes(name.split(".").pop()), + }, + }, + "lib/openFile": { + __esModule: true, + default: open, + EXTERNAL_DOCUMENT_PATTERN: /\.(pdf|docx)$/i, + }, "lib/loadPlugins": { isInitialPluginLoadComplete: () => pluginsReady }, "dialogs/select": select, "utils/helpers": { error: reportError }, @@ -29,6 +45,7 @@ function setup() { strings, acode: { exec }, sessionStorage: { getItem: () => String(restored) }, + setTimeout, }, ); return { @@ -177,3 +194,55 @@ it.each(["cancel", "reject"])( expect(f.exec).not.toHaveBeenCalled(); }, ); + +it("opens files no plugin handles after the grace period, holding back the rest until plugins load", async () => { + vi.useFakeTimers(); + try { + const f = setup({ pluginHandled: ["epub"] }); + await f.send(["content://docs/notes.txt"]); + await f.send(["content://docs/book.epub"]); + await f.send(["content://docs/report.pdf"]); + await f.send(["content://docs/later.md"]); + f.ready(true, false); + f.startPluginGracePeriod(); + await f.processPendingIntents(); + expect(f.open).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1000); + // The handled extension holds back everything after it, keeping order. + expect(f.open.mock.calls.map(([uri]) => uri)).toEqual([ + "content://docs/notes.txt", + ]); + + f.ready(); + await f.processPendingIntents(); + expect(f.open.mock.calls.map(([uri]) => uri)).toEqual([ + "content://docs/notes.txt", + "content://docs/book.epub", + "content://docs/report.pdf", + "content://docs/later.md", + ]); + } finally { + vi.useRealTimers(); + } +}); + +it("keeps waiting for plugins when a document needs a plugin handler", async () => { + vi.useFakeTimers(); + try { + const f = setup(); + f.ready(true, false); + f.startPluginGracePeriod(); + await f.send(["content://docs/report.pdf"]); + await vi.advanceTimersByTimeAsync(1000); + expect(f.open).not.toHaveBeenCalled(); + + f.ready(); + await f.processPendingIntents(); + expect(f.open.mock.calls.map(([uri]) => uri)).toEqual([ + "content://docs/report.pdf", + ]); + } finally { + vi.useRealTimers(); + } +}); diff --git a/tests/unit/internalUrl.test.ts b/tests/unit/internalUrl.test.ts new file mode 100644 index 0000000..77a996b --- /dev/null +++ b/tests/unit/internalUrl.test.ts @@ -0,0 +1,84 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { FileSystem } from "../../src/native/file/entries"; +import toInternalURL from "../../src/native/file/internalUrl"; + +const PKG = "com.foxdebug.acode"; +// Registration order of Android's filesystems (requestAllFileSystems). +const ROOTS: [string, string][] = [ + ["temporary", `file:///data/user/0/${PKG}/cache/`], + ["persistent", `file:///storage/emulated/0/`], + ["content", "content://"], + ["assets", "file:///android_asset/"], + ["files", `file:///data/user/0/${PKG}/files/files/`], + ["files-external", `file:///storage/emulated/0/Android/data/${PKG}/files/`], + ["sdcard", "file:///storage/emulated/0/"], + ["cache", `file:///data/user/0/${PKG}/cache/`], + ["cache-external", `file:///storage/emulated/0/Android/data/${PKG}/cache/`], + ["root", "file:///"], +]; + +let fileSystems: Map; + +beforeEach(() => { + vi.stubGlobal("Bridge", { platformId: "android" }); + vi.stubGlobal("location", { + origin: "https://localhost", + protocol: "https:", + host: "localhost", + }); + fileSystems = new Map( + ROOTS.map(([name, nativeURL]) => [ + name, + new FileSystem(name, { name, fullPath: "/", nativeURL }), + ]), + ); +}); +afterEach(() => vi.unstubAllGlobals()); + +const external = `file:///storage/emulated/0/Android/data/${PKG}/files`; + +it("formats files and directories like Android's shortest-match resolver", () => { + expect(toInternalURL(fileSystems, `${external}/plugins/acode.git/main.js`)).toBe( + "https://localhost/__cdvfile_files-external__/plugins/acode.git/main.js", + ); + expect(toInternalURL(fileSystems, `${external}/plugins/acode.git`, true)).toBe( + "https://localhost/__cdvfile_files-external__/plugins/acode.git/", + ); + expect( + toInternalURL(fileSystems, `file:///data/user/0/${PKG}/cache/x.json`), + ).toBe("https://localhost/__cdvfile_cache__/x.json"); + expect(toInternalURL(fileSystems, "file:///storage/emulated/0/My Code/a.js")).toBe( + "https://localhost/__cdvfile_sdcard__/My%20Code/a.js", + ); +}); + +it("defers to the native resolver when the result could differ", () => { + for (const url of [ + `${external}/plugins/%E2%9C%93/a.js`, + `${external}/plugins/ünï/a.js`, + `${external}/plugins/../settings.json`, + `${external}//plugins/a.js`, + `${external}/a.js?x=1`, + "content://com.android.providers/document/1", + "file:///storage/emulated/0x/a.js", + ]) + expect(toInternalURL(fileSystems, url), url).toBeNull(); + + vi.stubGlobal("Bridge", { platformId: "ios" }); + expect(toInternalURL(fileSystems, `${external}/a.js`)).toBeNull(); + vi.stubGlobal("Bridge", { platformId: "android" }); + expect(toInternalURL(new Map(), `${external}/a.js`)).toBeNull(); +}); + +it("defers when a root matches only as a string prefix of a sibling", () => { + fileSystems.set( + "docs", + new FileSystem("docs", { + name: "docs", + fullPath: "/", + nativeURL: "file:///storage/emulated/0/Doc/", + }), + ); + expect(toInternalURL(fileSystems, "file:///storage/emulated/0/Docs/a.js")).toBeNull(); +}); From a44fea511424e8c911dac2c12067013a01c7f21d Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:55:32 +0530 Subject: [PATCH 5/7] perf: load LSP client, Emmet and developer tools after startup - Move the language-server client (clientManager, editor features, @codemirror/lsp-client, marked, protocol types) into a chunk that loads only when an enabled server handles an open file, after the editor is visible; the plugin-facing registry and diagnostics UI stay in main.js - Load Emmet through a compartment only when a file uses an Emmet syntax - Start developer tools (eruda) after the editor is visible and replay the console output and errors captured during startup into its console - main.js: 1,895 KB -> 1,515 KB --- src/cm/colorChip.ts | 4 +- src/cm/commandRegistry.js | 69 +++----- src/cm/emmet/extensions.js | 26 +++ src/cm/emmet/index.js | 60 +++++++ src/cm/lsp/client.ts | 26 +++ src/cm/lsp/clientLoader.ts | 65 ++++++++ src/cm/lsp/diagnostics.ts | 10 +- src/cm/lsp/documentColors.ts | 16 +- src/cm/lsp/formatter.ts | 5 +- src/cm/touchSelectionMenu.js | 8 +- src/components/lspInfoDialog/index.js | 8 +- src/lib/acode.js | 8 +- src/lib/devTools.js | 146 ++++++++++------- src/lib/editorManager.js | 198 +++++++++++++++-------- src/lib/startupLogBuffer.js | 65 ++++++++ src/main.js | 24 ++- src/settings/lspServerDetail.js | 6 +- src/utils/whenAppVisible.js | 19 +++ tests/unit/commandRegistryKeymap.test.js | 5 +- tests/unit/lspMultiClient.test.js | 11 ++ tests/unit/pluginFileRestoration.test.js | 1 + tests/unit/quickToolsAdapterUi.test.js | 5 +- tests/unit/startupLogBuffer.test.js | 68 ++++++++ 23 files changed, 633 insertions(+), 220 deletions(-) create mode 100644 src/cm/emmet/extensions.js create mode 100644 src/cm/emmet/index.js create mode 100644 src/cm/lsp/client.ts create mode 100644 src/cm/lsp/clientLoader.ts create mode 100644 src/lib/startupLogBuffer.js create mode 100644 src/utils/whenAppVisible.js create mode 100644 tests/unit/startupLogBuffer.test.js diff --git a/src/cm/colorChip.ts b/src/cm/colorChip.ts index 3f055cf..a8c0d30 100644 --- a/src/cm/colorChip.ts +++ b/src/cm/colorChip.ts @@ -1,5 +1,5 @@ import type { LSPClient } from "@codemirror/lsp-client"; -import { LSPPlugin } from "@codemirror/lsp-client"; +import { getAllLspPlugins } from "./lsp/clientLoader"; import type { Range } from "@codemirror/state"; import { Decoration, EditorView, WidgetType } from "@codemirror/view"; import pickColor from "dialogs/color"; @@ -94,7 +94,7 @@ export function isViewEditable(view: EditorView): boolean { } export function hasLspColorProvider(view: EditorView): boolean { - return LSPPlugin.getAll(view, "documentColor").some( + return getAllLspPlugins(view, "documentColor").some( (lsp) => lsp.client.connected && !!lsp.client.serverCapabilities?.colorProvider, diff --git a/src/cm/commandRegistry.js b/src/cm/commandRegistry.js index c2a6a90..a37ff8a 100644 --- a/src/cm/commandRegistry.js +++ b/src/cm/commandRegistry.js @@ -61,10 +61,6 @@ import { openLintPanel, previousDiagnostic, } from "@codemirror/lint"; -import { - LSPPlugin, - formatDocument as lspFormatDocument, -} from "@codemirror/lsp-client"; import { Compartment, EditorSelection } from "@codemirror/state"; import { keymap } from "@codemirror/view"; import { focusEditorIfEditable } from "cm/editorReadOnly"; @@ -81,24 +77,7 @@ import { keyBindingsConflict, toCodeMirrorKey, } from "cm/keyBindingUtils"; -import { - renameSymbol as acodeRenameSymbol, - clearDiagnosticsEffect, - clientManager, - jumpToDeclaration as lspJumpToDeclaration, - jumpToDefinition as lspJumpToDefinition, - jumpToImplementation as lspJumpToImplementation, - jumpToTypeDefinition as lspJumpToTypeDefinition, - nextSignature as lspNextSignature, - prevSignature as lspPrevSignature, - showSignatureHelp as lspShowSignatureHelp, -} from "cm/lsp"; -import { - closeReferencesPanel as acodeCloseReferencesPanel, - findAllReferences as acodeFindAllReferences, - findAllReferencesInTab as acodeFindAllReferencesInTab, -} from "cm/lsp/references"; -import { showDocumentSymbols } from "components/symbolsPanel"; +import { getLoadedLspClient, getLspPlugin } from "cm/lsp/clientLoader"; import toast from "components/toast"; import prompt from "dialogs/prompt"; import actions from "handlers/quickTools"; @@ -1071,63 +1050,63 @@ function registerLspCommands() { description: "Format document (Language Server)", readOnly: false, requiresView: true, - run: runLspCommand(lspFormatDocument), + run: runLspCommand("formatDocument"), }); addCommand({ name: "renameSymbol", description: "Rename symbol (Language Server)", readOnly: false, requiresView: true, - run: runLspCommand(acodeRenameSymbol), + run: runLspCommand("renameSymbol"), }); addCommand({ name: "showSignatureHelp", description: "Show signature help", readOnly: true, requiresView: true, - run: runLspCommand(lspShowSignatureHelp), + run: runLspCommand("showSignatureHelp"), }); addCommand({ name: "nextSignature", description: "Next signature", readOnly: true, requiresView: true, - run: runLspCommand(lspNextSignature, { silentOnMissing: true }), + run: runLspCommand("nextSignature", { silentOnMissing: true }), }); addCommand({ name: "prevSignature", description: "Previous signature", readOnly: true, requiresView: true, - run: runLspCommand(lspPrevSignature, { silentOnMissing: true }), + run: runLspCommand("prevSignature", { silentOnMissing: true }), }); addCommand({ name: "jumpToDefinition", description: "Go to definition (Language Server)", readOnly: true, requiresView: true, - run: runLspCommand(lspJumpToDefinition), + run: runLspCommand("jumpToDefinition"), }); addCommand({ name: "jumpToDeclaration", description: "Go to declaration (Language Server)", readOnly: true, requiresView: true, - run: runLspCommand(lspJumpToDeclaration), + run: runLspCommand("jumpToDeclaration"), }); addCommand({ name: "jumpToTypeDefinition", description: "Go to type definition (Language Server)", readOnly: true, requiresView: true, - run: runLspCommand(lspJumpToTypeDefinition), + run: runLspCommand("jumpToTypeDefinition"), }); addCommand({ name: "jumpToImplementation", description: "Go to implementation (Language Server)", readOnly: true, requiresView: true, - run: runLspCommand(lspJumpToImplementation), + run: runLspCommand("jumpToImplementation"), }); addCommand({ name: "findReferences", @@ -1137,12 +1116,11 @@ function registerLspCommands() { async run(view) { const resolvedView = resolveView(view); if (!resolvedView) return false; - const plugin = LSPPlugin.get(resolvedView); - if (!plugin) { + if (!getLspPlugin(resolvedView)) { notifyLspUnavailable(); return false; } - return acodeFindAllReferences(resolvedView); + return getLoadedLspClient().findAllReferences(resolvedView); }, }); addCommand({ @@ -1151,7 +1129,8 @@ function registerLspCommands() { readOnly: true, requiresView: false, run() { - return acodeCloseReferencesPanel(); + // The panel can only be open once the client has loaded. + return getLoadedLspClient()?.closeReferencesPanel() ?? false; }, }); addCommand({ @@ -1162,12 +1141,11 @@ function registerLspCommands() { async run(view) { const resolvedView = resolveView(view); if (!resolvedView) return false; - const plugin = LSPPlugin.get(resolvedView); - if (!plugin) { + if (!getLspPlugin(resolvedView)) { notifyLspUnavailable(); return false; } - return acodeFindAllReferencesInTab(resolvedView); + return getLoadedLspClient().findAllReferencesInTab(resolvedView); }, }); addCommand({ @@ -1176,7 +1154,8 @@ function registerLspCommands() { readOnly: true, requiresView: false, async run() { - const activeClients = clientManager.getActiveClients(); + const clientManager = getLoadedLspClient()?.clientManager; + const activeClients = clientManager?.getActiveClients() ?? []; if (!activeClients.length) { toast("No LSP servers are currently running"); return true; @@ -1198,7 +1177,8 @@ function registerLspCommands() { readOnly: true, requiresView: false, async run() { - const activeClients = clientManager.getActiveClients(); + const clientManager = getLoadedLspClient()?.clientManager; + const activeClients = clientManager?.getActiveClients() ?? []; if (!activeClients.length) { toast("No LSP servers are currently running"); return true; @@ -1219,6 +1199,7 @@ function registerLspCommands() { async run(view) { const resolvedView = resolveView(view); if (!resolvedView) return false; + const { showDocumentSymbols } = await import("components/symbolsPanel"); return showDocumentSymbols(resolvedView); }, }); @@ -1336,18 +1317,18 @@ function notifyLspUnavailable() { toast?.("Language server not available"); } -function runLspCommand(commandFn, options = {}) { +function runLspCommand(commandName, options = {}) { return (view) => { const resolvedView = resolveView(view); if (!resolvedView) return false; - const plugin = LSPPlugin.get(resolvedView); - if (!plugin) { + // Without a loaded client no editor has a language server attached. + if (!getLspPlugin(resolvedView)) { if (!options?.silentOnMissing) { notifyLspUnavailable(); } return false; } - const result = commandFn(resolvedView); + const result = getLoadedLspClient()[commandName](resolvedView); return result !== false; }; } diff --git a/src/cm/emmet/extensions.js b/src/cm/emmet/extensions.js new file mode 100644 index 0000000..7c1cab1 --- /dev/null +++ b/src/cm/emmet/extensions.js @@ -0,0 +1,26 @@ +import { Prec } from "@codemirror/state"; +import { keymap } from "@codemirror/view"; +import { + abbreviationTracker, + emmetConfig, + expandAbbreviation, + wrapWithAbbreviation, +} from "@emmetio/codemirror6-plugin"; + +export { emmetCompletionSource } from "@emmetio/codemirror6-plugin"; + +/** + * Emmet's editor extensions for one syntax. + * @param {string} syntax + * @param {{tracker?: object, config?: object}} [options] + */ +export function createEmmetExtensions(syntax, { tracker = {}, config } = {}) { + const { autocompleteTab = ["markup", "stylesheet"], ...restOverrides } = + config || {}; + return [ + Prec.high(abbreviationTracker({ syntax, ...tracker })), + wrapWithAbbreviation(), + keymap.of([{ key: "Mod-e", run: expandAbbreviation }]), + emmetConfig.of({ syntax, autocompleteTab, ...restOverrides }), + ]; +} diff --git a/src/cm/emmet/index.js b/src/cm/emmet/index.js new file mode 100644 index 0000000..73c3526 --- /dev/null +++ b/src/cm/emmet/index.js @@ -0,0 +1,60 @@ +import { Compartment } from "@codemirror/state"; +import whenAppVisible from "utils/whenAppVisible"; + +/** + * Emmet's syntaxes (EmmetKnownSyntax), kept here so choosing one does not load + * the Emmet package. + */ +export const EmmetSyntax = Object.freeze({ + html: "html", + xml: "xml", + xsl: "xsl", + jsx: "jsx", + tsx: "tsx", + vue: "vue", + haml: "haml", + jade: "jade", + pug: "pug", + slim: "slim", + css: "css", + scss: "scss", + less: "less", + sass: "sass", + sss: "sss", + stylus: "stylus", + postcss: "postcss", +}); + +/** Holds Emmet's extensions; empty until the Emmet chunk has loaded. */ +export const emmetCompartment = new Compartment(); + +/** @type {typeof import("./extensions") | null} */ +let emmet = null; +/** @type {Promise | null} */ +let loading = null; + +/** The loaded Emmet module, or null while it is not needed or still loading. */ +export function getLoadedEmmet() { + return emmet; +} + +/** + * Loads Emmet once the editor is visible; repeated calls share one load. + * @returns {Promise} + */ +export function loadEmmet() { + loading ??= whenAppVisible() + .then(() => import(/* webpackChunkName: "emmet" */ "./extensions")) + .then( + (module) => { + emmet = module; + return module; + }, + (error) => { + loading = null; + console.error("Failed to load Emmet", error); + throw error; + }, + ); + return loading; +} diff --git a/src/cm/lsp/client.ts b/src/cm/lsp/client.ts new file mode 100644 index 0000000..c3fa680 --- /dev/null +++ b/src/cm/lsp/client.ts @@ -0,0 +1,26 @@ +/** + * The language-server client and the editor features built on it. Loaded on + * demand through clientLoader, so editors without a language server never + * parse it. + */ +export { + formatDocument, + LSPPlugin, + serverCompletionSource, +} from "@codemirror/lsp-client"; +export { default as clientManager, lspCompletionEnabled } from "./clientManager"; +export { + jumpToDeclaration, + jumpToDefinition, + jumpToImplementation, + jumpToTypeDefinition, + nextSignature, + prevSignature, + renameSymbol, + showSignatureHelp, +} from "./index"; +export { + closeReferencesPanel, + findAllReferences, + findAllReferencesInTab, +} from "./references"; diff --git a/src/cm/lsp/clientLoader.ts b/src/cm/lsp/clientLoader.ts new file mode 100644 index 0000000..bf99f83 --- /dev/null +++ b/src/cm/lsp/clientLoader.ts @@ -0,0 +1,65 @@ +import type { EditorView } from "@codemirror/view"; +import whenAppVisible from "utils/whenAppVisible"; +import type * as LspClient from "./client"; +import type { ClientManagerOptions } from "./types"; + +type LspClientModule = typeof LspClient; +type LspPluginInstance = ReturnType; + +let client: LspClientModule | null = null; +let loading: Promise | null = null; +let pendingOptions: Partial = {}; + +/** + * The client module once loaded, otherwise null. While it is null no editor + * can have a language server attached. + */ +export function getLoadedLspClient(): LspClientModule | null { + return client; +} + +/** Loads the client once the editor is visible; calls share one load. */ +export function loadLspClient(): Promise { + loading ??= whenAppVisible() + .then(() => import(/* webpackChunkName: "lspClient" */ "./client")) + .then( + (module) => { + module.clientManager.setOptions(pendingOptions); + pendingOptions = {}; + client = module; + return module; + }, + (error) => { + loading = null; + throw error; + }, + ); + return loading; +} + +/** Client options, kept until the client loads. */ +export function setLspOptions(options: Partial): void { + if (client) client.clientManager.setOptions(options); + else pendingOptions = { ...pendingOptions, ...options }; +} + +export function getLspPlugin( + view: EditorView, + lspClient?: Parameters[1], +): LspPluginInstance | null { + return client?.LSPPlugin.get(view, lspClient) ?? null; +} + +export function getAllLspPlugins( + view: EditorView, + feature?: Parameters[1], +): ReturnType { + return client?.LSPPlugin.getAll(view, feature) ?? []; +} + +export function getLspPluginForFeature( + view: EditorView, + feature: Parameters[1], +): ReturnType | null { + return client?.LSPPlugin.getForFeature(view, feature) ?? null; +} diff --git a/src/cm/lsp/diagnostics.ts b/src/cm/lsp/diagnostics.ts index 61e670a..65679d6 100644 --- a/src/cm/lsp/diagnostics.ts +++ b/src/cm/lsp/diagnostics.ts @@ -1,6 +1,6 @@ import { Diagnostic, linter, lintGutter } from "@codemirror/lint"; import type { LSPClient } from "@codemirror/lsp-client"; -import { LSPPlugin } from "@codemirror/lsp-client"; +import { getAllLspPlugins, getLspPlugin } from "./clientLoader"; import type { Extension } from "@codemirror/state"; import { EditorState, @@ -65,7 +65,7 @@ function supportsPullDiagnostics(client: LSPClient): boolean { function diagnosticsEnabledForFile(client: LSPClient, uri: string): boolean { const file = client.workspace.getFile(uri); const view = file?.getView(); - const plugin = view && LSPPlugin.get(view, client); + const plugin = view && getLspPlugin(view, client); return !!plugin?.featureEnabled("diagnostics"); } @@ -263,7 +263,7 @@ function applyDiagnostics( } const view = file.getView(); if (!view) return false; - const plugin = LSPPlugin.get(view, client); + const plugin = getLspPlugin(view, client); if (!plugin || !plugin.featureEnabled("diagnostics")) return false; const diagnostics = collectLspDiagnostics( @@ -452,7 +452,7 @@ export function lspDiagnosticsAutoSyncExtension(): Extension { flush(): void { this.pending = null; - for (const plugin of LSPPlugin.getAll(this.view, "diagnostics")) { + for (const plugin of getAllLspPlugins(this.view, "diagnostics")) { plugin.client.sync(); schedulePullDiagnostics(plugin.client, plugin.uri, 0); } @@ -494,7 +494,7 @@ function lspLinterSource(view: EditorView): Diagnostic[] { const stored = view.state.field(lspPublishedDiagnostics); const diagnostics: Diagnostic[] = []; for (const [client, items] of stored) { - const plugin = LSPPlugin.get(view, client); + const plugin = getLspPlugin(view, client); if (plugin?.featureEnabled("diagnostics")) diagnostics.push(...items); } return diagnostics; diff --git a/src/cm/lsp/documentColors.ts b/src/cm/lsp/documentColors.ts index 6c64c75..f16ecc5 100644 --- a/src/cm/lsp/documentColors.ts +++ b/src/cm/lsp/documentColors.ts @@ -2,7 +2,11 @@ import type { LSPClient, LSPClientExtension, } from "@codemirror/lsp-client"; -import { LSPPlugin } from "@codemirror/lsp-client"; +import { + getAllLspPlugins, + getLspPlugin, + getLspPluginForFeature, +} from "./clientLoader"; import type { Extension, Range } from "@codemirror/state"; import { MapMode, StateEffect, StateField } from "@codemirror/state"; import { @@ -295,7 +299,7 @@ function createPlugin(config: DocumentColorsConfig) { return; } - const bindings = LSPPlugin.getAll(this.view, "documentColor") as + const bindings = getAllLspPlugins(this.view, "documentColor") as readonly LSPPluginAPI[]; const connected = bindings.filter((lsp) => lsp.client.connected); if (!connected.length) { @@ -440,8 +444,8 @@ async function handleColorPick( payload: ColorChipPayload, ): Promise { const lsp = (payload.lspClient - ? LSPPlugin.get(view, payload.lspClient) - : LSPPlugin.getForFeature(view, "documentColor")) as LSPPluginAPI | null; + ? getLspPlugin(view, payload.lspClient) + : getLspPluginForFeature(view, "documentColor")) as LSPPluginAPI | null; if (!lsp?.client.connected) return; const doc = view.state.doc; @@ -456,8 +460,8 @@ async function handleColorPick( if (!picked) return; const lsp2 = (payload.lspClient - ? LSPPlugin.get(view, payload.lspClient) - : LSPPlugin.getForFeature(view, "documentColor")) as LSPPluginAPI | null; + ? getLspPlugin(view, payload.lspClient) + : getLspPluginForFeature(view, "documentColor")) as LSPPluginAPI | null; if (!lsp2?.client.connected) return; const live = findLiveColor(view, payload, currentText); diff --git a/src/cm/lsp/formatter.ts b/src/cm/lsp/formatter.ts index 440f1e2..5ed527c 100644 --- a/src/cm/lsp/formatter.ts +++ b/src/cm/lsp/formatter.ts @@ -1,7 +1,7 @@ import type { EditorView } from "@codemirror/view"; import { getModes } from "cm/modelist"; import toast from "components/toast"; -import lspClientManager from "./clientManager"; +import { loadLspClient } from "./clientLoader"; import { supportsBuiltinFormatting } from "./formattingSupport"; import serverRegistry from "./serverRegistry"; import type { AcodeApi, FileMetadata } from "./types"; @@ -69,7 +69,8 @@ export function registerLspFormatter(acode: AcodeApi): void { ...metadata, languageName: metadata.languageName || languageId, }; - const success = await lspClientManager.formatDocument(fullMetadata); + const { clientManager } = await loadLspClient(); + const success = await clientManager.formatDocument(fullMetadata); if (!success) { toast("LSP formatter failed"); } diff --git a/src/cm/touchSelectionMenu.js b/src/cm/touchSelectionMenu.js index b017acb..7304711 100644 --- a/src/cm/touchSelectionMenu.js +++ b/src/cm/touchSelectionMenu.js @@ -1,4 +1,3 @@ -import { LSPPlugin } from "@codemirror/lsp-client"; import { EditorSelection } from "@codemirror/state"; import { focusEditorIfEditable, @@ -6,6 +5,7 @@ import { resolveReadOnlyContextSelection, shouldCommitReadOnlyTap, } from "cm/editorReadOnly"; +import { getAllLspPlugins } from "cm/lsp/clientLoader"; import { bindSelectionMenuButton, filterSelectionMenuItems, @@ -135,7 +135,7 @@ function clamp(value, min, max) { } function hasCodeActionProvider(view) { - return LSPPlugin.getAll(view, "codeAction").some( + return getAllLspPlugins(view, "codeAction").some( (plugin) => !!plugin.client.serverCapabilities?.codeActionProvider, ); } @@ -150,7 +150,7 @@ function hasLspActions(view) { ]; if ( capabilities.some(([feature, capability]) => - LSPPlugin.getAll(view, feature).some( + getAllLspPlugins(view, feature).some( (plugin) => !!plugin.client.serverCapabilities?.[capability], ), ) @@ -159,7 +159,7 @@ function hasLspActions(view) { } return ( !view.state.readOnly && - LSPPlugin.getAll(view, "rename").some( + getAllLspPlugins(view, "rename").some( (plugin) => !!plugin.client.serverCapabilities?.renameProvider, ) ); diff --git a/src/components/lspInfoDialog/index.js b/src/components/lspInfoDialog/index.js index 6b97190..12549a0 100644 --- a/src/components/lspInfoDialog/index.js +++ b/src/components/lspInfoDialog/index.js @@ -1,5 +1,5 @@ import "./styles.scss"; -import lspClientManager from "cm/lsp/clientManager"; +import { getLoadedLspClient } from "cm/lsp/clientLoader"; import { getCurrentFileLanguage, getServersForCurrentFile, @@ -16,7 +16,7 @@ let dialogInstance = null; function getActiveClients() { try { - return lspClientManager.getActiveClients(); + return getLoadedLspClient()?.clientManager.getActiveClients() ?? []; } catch { return []; } @@ -146,7 +146,7 @@ async function restartAllServers() { toast(`Restarting ${count} LSP server${count > 1 ? "s" : ""}...`); try { - await lspClientManager.dispose(); + await getLoadedLspClient()?.clientManager.dispose(); window.editorManager?.restartLsp?.(); toast("All servers restarted"); } catch (err) { @@ -164,7 +164,7 @@ async function stopAllServers() { const count = activeClients.length; try { - await lspClientManager.dispose(); + await getLoadedLspClient()?.clientManager.dispose(); toast(`Stopped ${count} LSP server${count > 1 ? "s" : ""}`); } catch (err) { toast("Failed to stop servers"); diff --git a/src/lib/acode.js b/src/lib/acode.js index 76f8f80..3a9b114 100644 --- a/src/lib/acode.js +++ b/src/lib/acode.js @@ -18,7 +18,7 @@ import { executeCommand as runCommand, } from "cm/commandRegistry"; import { default as lspApi } from "cm/lsp/api"; -import lspClientManager from "cm/lsp/clientManager"; +import { getLoadedLspClient, setLspOptions } from "cm/lsp/clientLoader"; import { registerLspFormatter } from "cm/lsp/formatter"; import { addMode, @@ -252,8 +252,10 @@ class Acode { const lspModule = { ...lspApi, clientManager: { - setOptions: (options) => lspClientManager.setOptions(options), - getActiveClients: () => lspClientManager.getActiveClients(), + setOptions: (options) => setLspOptions(options), + // No client can be active before the client module has loaded. + getActiveClients: () => + getLoadedLspClient()?.clientManager.getActiveClients() ?? [], }, }; diff --git a/src/lib/devTools.js b/src/lib/devTools.js index 098e48c..ad195f4 100644 --- a/src/lib/devTools.js +++ b/src/lib/devTools.js @@ -3,9 +3,12 @@ import loader from "dialogs/loader"; import helpers from "utils/helpers"; import Url from "utils/Url"; import config from "./config"; +import { takeStartupLogs } from "./startupLogBuffer"; let erudaInstance = null; let isInitialized = false; +/** @type {Promise | null} */ +let initializing = null; /** * Developer tools module for debugging Acode @@ -32,70 +35,19 @@ const devTools = { * @param {boolean} showLoader - Whether to show a loading dialog * @returns {Promise} */ - async init(showLoader = false) { - if (isInitialized) return; - - try { - const erudaPath = Url.join(DATA_STORAGE, "eruda.js"); - const fs = fsOperation(erudaPath); - - if (!(await fs.exists())) { - if (showLoader) { - loader.create( - strings["downloading file"]?.replace("{file}", "eruda.js") || - "Downloading eruda.js...", - strings["downloading..."] || "Downloading...", - ); - } - - try { - const erudaScript = await fsOperation(config.ERUDA_CDN).readFile( - "utf-8", - ); - await fsOperation(DATA_STORAGE).createFile("eruda.js", erudaScript); - } catch { - } finally { - if (showLoader) loader.destroy(); - } - } - - const internalUri = await helpers.toInternalUri(erudaPath); - - await new Promise((resolve, reject) => { - const script = document.createElement("script"); - script.src = internalUri; - script.id = "eruda-script"; - script.onload = resolve; - script.onerror = reject; - document.head.appendChild(script); - }); - - if (window.eruda) { - window.eruda.init({ - useShadowDom: true, - autoScale: true, - defaults: { - displaySize: 50, - }, - }); - - window.eruda._shadowRoot.querySelector( - ".eruda-entry-btn", - ).style.display = "none"; - - erudaInstance = window.eruda; - isInitialized = true; - } - } catch (error) { - console.error("Failed to initialize developer tools", error); - throw error; - } + init(showLoader = false) { + if (isInitialized) return Promise.resolve(); + initializing ??= initEruda(showLoader).finally(() => { + initializing = null; + }); + return initializing; }, /** * Show the inspector panel */ - show() { + async show() { + await initializing?.catch(() => {}); if (!isInitialized) { window.toast?.("Developer mode is not enabled"); return; @@ -120,7 +72,8 @@ const devTools = { /** * Toggle the inspector panel visibility */ - toggle() { + async toggle() { + await initializing?.catch(() => {}); if (!isInitialized) { window.toast?.("Developer mode is not enabled"); return; @@ -146,3 +99,76 @@ const devTools = { }; export default devTools; + +/** + * @param {boolean} showLoader + */ +async function initEruda(showLoader) { + try { + const erudaPath = Url.join(DATA_STORAGE, "eruda.js"); + const fs = fsOperation(erudaPath); + + if (!(await fs.exists())) { + if (showLoader) { + loader.create( + strings["downloading file"]?.replace("{file}", "eruda.js") || + "Downloading eruda.js...", + strings["downloading..."] || "Downloading...", + ); + } + + try { + const erudaScript = await fsOperation(config.ERUDA_CDN).readFile( + "utf-8", + ); + await fsOperation(DATA_STORAGE).createFile("eruda.js", erudaScript); + } catch { + } finally { + if (showLoader) loader.destroy(); + } + } + + const internalUri = await helpers.toInternalUri(erudaPath); + + await new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = internalUri; + script.id = "eruda-script"; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); + + if (window.eruda) { + window.eruda.init({ + useShadowDom: true, + autoScale: true, + defaults: { + displaySize: 50, + }, + }); + + window.eruda._shadowRoot.querySelector(".eruda-entry-btn").style.display = + "none"; + + erudaInstance = window.eruda; + isInitialized = true; + replayStartupLogs(erudaInstance); + } + } catch (error) { + console.error("Failed to initialize developer tools", error); + throw error; + } +} + +/** + * Eruda starts after the editor is visible; show what was logged before it. + */ +function replayStartupLogs(eruda) { + const erudaConsole = eruda.get?.("console"); + if (!erudaConsole) return; + for (const { method, args } of takeStartupLogs()) { + const write = erudaConsole[method] ?? erudaConsole.log; + write.apply(erudaConsole, args); + } +} diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index ea2b573..d771790 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -20,14 +20,6 @@ import { lineNumbers, placeholder, } from "@codemirror/view"; -import { - abbreviationTracker, - EmmetKnownSyntax, - emmetCompletionSource, - emmetConfig, - expandAbbreviation, - wrapWithAbbreviation, -} from "@emmetio/codemirror6-plugin"; import createBaseExtensions from "cm/baseExtensions"; import { setKeyBindings as applyKeyBindings, @@ -44,12 +36,22 @@ import { focusEditorIfEditable, reconfigureEditorReadOnly, } from "cm/editorReadOnly"; +import { + EmmetSyntax, + emmetCompartment, + getLoadedEmmet, + loadEmmet, +} from "cm/emmet"; import { handleLineNumberClick } from "cm/lineNumberSelection"; import localWordCompletions, { localWordCompletionSource, } from "cm/localWordCompletions"; import lspApi from "cm/lsp/api"; -import lspClientManager, { lspCompletionEnabled } from "cm/lsp/clientManager"; +import { + getLoadedLspClient, + loadLspClient, + setLspOptions, +} from "cm/lsp/clientLoader"; import { getLspDiagnostics, LSP_DIAGNOSTICS_EVENT, @@ -57,6 +59,7 @@ import { lspDiagnosticsUiExtension, } from "cm/lsp/diagnostics"; import { stopManagedServer } from "cm/lsp/serverLauncher"; +import serverRegistry from "cm/lsp/serverRegistry"; import createMainEditorExtensions from "cm/mainEditorExtensions"; // CodeMirror mode management import { @@ -70,7 +73,6 @@ import createTouchSelectionMenu from "cm/touchSelectionMenu"; import "cm/supportedModes"; import { onProviderRegistered } from "fileSystem"; import { autocompletion } from "@codemirror/autocomplete"; -import { serverCompletionSource } from "@codemirror/lsp-client"; import colorView from "cm/colorView"; import { getAllFolds, @@ -924,6 +926,7 @@ async function EditorManager($header, $body) { // Compartment to swap editor theme dynamically const themeCompartment = new Compartment(); + let emmetRequested = false; // Compartments to control indentation, tab width, and font styling dynamically const indentUnitCompartment = new Compartment(); const tabSizeCompartment = new Compartment(); @@ -971,13 +974,14 @@ async function EditorManager($header, $body) { } function getLspCompletionSource(context) { - if (!context.state.facet(lspCompletionEnabled)) return null; - return serverCompletionSource(context); + const lsp = getLoadedLspClient(); + if (!lsp || !context.state.facet(lsp.lspCompletionEnabled)) return null; + return lsp.serverCompletionSource(context); } function getEmmetCompletionSource(context) { try { - return emmetCompletionSource(context); + return getLoadedEmmet()?.emmetCompletionSource(context) ?? null; } catch { return null; } @@ -1326,34 +1330,65 @@ async function EditorManager($header, $body) { return exts; } - function createEmmetExtensionSet({ - syntax, - tracker = {}, - config: emmetOverrides = {}, - } = {}) { + /** + * Emmet sits in a compartment so its package loads only when a file needs + * it, after the editor is visible; the compartment keeps its precedence. + */ + function createEmmetExtensionSet({ syntax, request = true } = {}) { if (appSettings.value.useEmmet === false) return []; - const resolvedSyntax = - syntax === undefined ? EmmetKnownSyntax.html : syntax; + const resolvedSyntax = syntax === undefined ? EmmetSyntax.html : syntax; if (!resolvedSyntax) return []; - const trackerExtension = abbreviationTracker({ - syntax: resolvedSyntax, - ...tracker, - }); - const { autocompleteTab = ["markup", "stylesheet"], ...restOverrides } = - emmetOverrides || {}; - const emmetConfigExtension = emmetConfig.of({ - syntax: resolvedSyntax, - autocompleteTab, - ...restOverrides, - }); + const emmet = getLoadedEmmet(); + if (!emmet && request) requestEmmet(); return [ - Prec.high(trackerExtension), - wrapWithAbbreviation(), - keymap.of([{ key: "Mod-e", run: expandAbbreviation }]), - emmetConfigExtension, + emmetCompartment.of( + emmet ? emmet.createEmmetExtensions(resolvedSyntax) : [], + ), ]; } + function requestEmmet() { + if (emmetRequested) return; + emmetRequested = true; + loadEmmet().then( + () => { + for (const pane of panes) { + if (pane.editor) fillEmmetSlot(pane.editor, pane.activeFile); + } + }, + () => { + emmetRequested = false; + }, + ); + } + + /** + * Gives a reused file state Emmet, loading it first if needed. + */ + function syncEmmetForFile(file, view) { + if (!getEmmetSyntaxForFile(file)) return; + if (getLoadedEmmet()) fillEmmetSlot(view, file); + else if (appSettings.value.useEmmet !== false) requestEmmet(); + } + + /** + * Adds Emmet to a view whose state was created before Emmet loaded. + * @param {EditorView} view + * @param {object} [file] the file shown, or none for the empty editor + */ + function fillEmmetSlot(view, file) { + const emmet = getLoadedEmmet(); + const slot = emmetCompartment.get(view.state); + if (!emmet || !Array.isArray(slot) || slot.length) return; + const syntax = file ? getEmmetSyntaxForFile(file) : EmmetSyntax.html; + if (!syntax) return; + view.dispatch({ + effects: emmetCompartment.reconfigure( + emmet.createEmmetExtensions(syntax), + ), + }); + } + function applyOptions(keys, targetEditor = null) { const filter = keys ? new Set(keys) : null; const targetEditors = targetEditor @@ -1422,8 +1457,15 @@ async function EditorManager($header, $body) { detachActiveLsp(pane, { invalidate: false }); } try { - const extensions = - (await lspClientManager.getExtensionsForFile(metadata)) || []; + // The client only loads when a server could handle this file. + const extensions = hasLspServerFor( + metadata.languageId, + metadata.languageName, + ) + ? (await ( + await loadLspClient() + ).clientManager.getExtensionsForFile(metadata)) || [] + : []; if (token !== pane.lspRequestToken) return; if (!isFileActiveInEditor(file, targetEditor)) return; if (!extensions.length) { @@ -1457,6 +1499,18 @@ async function EditorManager($header, $body) { ); } + /** + * Whether an enabled server handles this language, matching the client + * manager's own check, so the client only loads when it could attach. + */ + function hasLspServerFor(languageId, languageName) { + const language = String(languageId ?? languageName ?? "").toLowerCase(); + return ( + Boolean(language) && + serverRegistry.getServersForLanguage(language).length > 0 + ); + } + function detachLspForFile(file) { if (!file || file.type !== "editor") return; const uri = getFileLspUri(file); @@ -1465,7 +1519,7 @@ async function EditorManager($header, $body) { if (!pane) return; const targetEditor = pane?.editor || editor; try { - lspClientManager.detach(uri, targetEditor); + getLoadedLspClient()?.clientManager.detach(uri, targetEditor); } catch (error) { console.warn(`Failed to detach LSP client for ${uri}`, error); } @@ -1479,12 +1533,12 @@ async function EditorManager($header, $body) { // Plugin already wires CSS completions; attach extras for related syntaxes. const emmetCompletionSyntaxes = new Set([ - EmmetKnownSyntax.scss, - EmmetKnownSyntax.less, - EmmetKnownSyntax.sass, - EmmetKnownSyntax.sss, - EmmetKnownSyntax.stylus, - EmmetKnownSyntax.postcss, + EmmetSyntax.scss, + EmmetSyntax.less, + EmmetSyntax.sass, + EmmetSyntax.sss, + EmmetSyntax.stylus, + EmmetSyntax.postcss, ]); function maybeAttachEmmetCompletions(targetExtensions, syntax) { @@ -1492,7 +1546,7 @@ async function EditorManager($header, $body) { if (emmetCompletionSyntaxes.has(syntax)) { targetExtensions.push( EditorState.languageData.of(() => [ - { autocomplete: emmetCompletionSource }, + { autocomplete: getEmmetCompletionSource }, ]), ); } @@ -1542,7 +1596,7 @@ async function EditorManager($header, $body) { if (!pane.lastLspUri) return; const targetEditor = pane.editor || editor; try { - lspClientManager.detach(pane.lastLspUri, targetEditor); + getLoadedLspClient()?.clientManager.detach(pane.lastLspUri, targetEditor); } catch (error) { console.warn( `Failed to detach LSP session for ${pane.lastLspUri}`, @@ -1555,7 +1609,7 @@ async function EditorManager($header, $body) { function applyLspSettings() { const { lsp } = appSettings.value || {}; if (!lsp) return; - lspClientManager.setOptions({ + setLspOptions({ allowNonTerminalWorkspace: lsp.allowNonTerminalWorkspace === true, }); const overrides = lsp.servers || {}; @@ -1662,7 +1716,8 @@ async function EditorManager($header, $body) { extensions: createMainEditorExtensions({ // Emmet needs highest precedence so place before default keymaps emmetExtensions: createEmmetExtensionSet({ - syntax: EmmetKnownSyntax.html, + syntax: EmmetSyntax.html, + request: false, }), baseExtensions: createConfiguredBaseExtensions(), commandKeymapExtension: getCommandKeymapExtension(), @@ -2916,6 +2971,7 @@ async function EditorManager($header, $body) { const reusedState = getRawEditorState(file.session); editor.setState(reusedState); applyCurrentEditorOptions(file, { targetEditor: editor }); + syncEmmetForFile(file, editor); if (shouldApplyLanguage(file, reusedState, languageSignature)) { const ext = resolveLanguageExtension( @@ -3109,40 +3165,40 @@ async function EditorManager($header, $body) { const mode = (file?.currentMode || "").toLowerCase(); const name = (file?.filename || "").toLowerCase(); const ext = name.includes(".") ? name.split(".").pop() : ""; - if (ext === "tsx" || mode.includes("tsx")) return EmmetKnownSyntax.tsx; - if (ext === "jsx" || mode.includes("jsx")) return EmmetKnownSyntax.jsx; + if (ext === "tsx" || mode.includes("tsx")) return EmmetSyntax.tsx; + if (ext === "jsx" || mode.includes("jsx")) return EmmetSyntax.jsx; if (mode.includes("javascript") && (ext === "jsx" || ext === "tsx")) { - return ext === "tsx" ? EmmetKnownSyntax.tsx : EmmetKnownSyntax.jsx; + return ext === "tsx" ? EmmetSyntax.tsx : EmmetSyntax.jsx; } - if (ext === "css" || mode.includes("css")) return EmmetKnownSyntax.css; - if (ext === "scss" || mode.includes("scss")) return EmmetKnownSyntax.scss; - if (ext === "sass" || mode.includes("sass")) return EmmetKnownSyntax.sass; - if (ext === "less" || mode.includes("less")) return EmmetKnownSyntax.less; - if (ext === "sss" || mode.includes("sss")) return EmmetKnownSyntax.sss; + if (ext === "css" || mode.includes("css")) return EmmetSyntax.css; + if (ext === "scss" || mode.includes("scss")) return EmmetSyntax.scss; + if (ext === "sass" || mode.includes("sass")) return EmmetSyntax.sass; + if (ext === "less" || mode.includes("less")) return EmmetSyntax.less; + if (ext === "sss" || mode.includes("sss")) return EmmetSyntax.sss; if (ext === "styl" || ext === "stylus" || mode.includes("styl")) - return EmmetKnownSyntax.stylus; + return EmmetSyntax.stylus; if (ext === "postcss" || mode.includes("postcss")) - return EmmetKnownSyntax.postcss; - if (ext === "xml" || mode.includes("xml")) return EmmetKnownSyntax.xml; - if (ext === "xsl" || mode.includes("xsl")) return EmmetKnownSyntax.xsl; - if (ext === "haml" || mode.includes("haml")) return EmmetKnownSyntax.haml; + return EmmetSyntax.postcss; + if (ext === "xml" || mode.includes("xml")) return EmmetSyntax.xml; + if (ext === "xsl" || mode.includes("xsl")) return EmmetSyntax.xsl; + if (ext === "haml" || mode.includes("haml")) return EmmetSyntax.haml; if ( ext === "pug" || ext === "jade" || mode.includes("pug") || mode.includes("jade") ) - return EmmetKnownSyntax.pug; - if (ext === "slim" || mode.includes("slim")) return EmmetKnownSyntax.slim; - if (ext === "vue" || mode.includes("vue")) return EmmetKnownSyntax.vue; - if (ext === "php" || mode.includes("php")) return EmmetKnownSyntax.html; + return EmmetSyntax.pug; + if (ext === "slim" || mode.includes("slim")) return EmmetSyntax.slim; + if (ext === "vue" || mode.includes("vue")) return EmmetSyntax.vue; + if (ext === "php" || mode.includes("php")) return EmmetSyntax.html; if ( ext === "htm" || ext === "html" || ext === "xhtml" || mode.includes("html") ) - return EmmetKnownSyntax.html; + return EmmetSyntax.html; return null; } @@ -3312,7 +3368,7 @@ async function EditorManager($header, $body) { } } - lspClientManager.setOptions({ + setLspOptions({ resolveRoot: resolveRootUriForContext, onClientIdle: ({ server, dispose }) => { if (!server?.id || typeof dispose !== "function") return; @@ -3320,8 +3376,8 @@ async function EditorManager($header, $body) { // down every workspace sharing the server id (e.g. web-worker LSPs). void (async () => { await dispose(); - const stillActive = lspClientManager - .getActiveClients() + const stillActive = getLoadedLspClient() + .clientManager.getActiveClients() .some( (state) => state.server?.id?.toLowerCase() === server.id.toLowerCase(), @@ -3530,7 +3586,7 @@ async function EditorManager($header, $body) { } else { detachActiveLsp(); editor.dispatch({ effects: lspCompartment.reconfigure([]) }); - await lspClientManager.dispose(); + await getLoadedLspClient()?.clientManager.dispose(); } }); @@ -3607,7 +3663,7 @@ async function EditorManager($header, $body) { }); appSettings.on("update:lintGutter", function (value) { - lspClientManager.setOptions({ + setLspOptions({ diagnosticsUiExtension: lspDiagnosticsUiExtension(value !== false), }); const active = manager.activeFile; diff --git a/src/lib/startupLogBuffer.js b/src/lib/startupLogBuffer.js new file mode 100644 index 0000000..387a8b3 --- /dev/null +++ b/src/lib/startupLogBuffer.js @@ -0,0 +1,65 @@ +/** + * Records console output and uncaught errors from the moment main.js runs, so + * developer tools can start after the editor is visible without losing the + * startup logs. It is discarded as soon as developer mode turns out to be off. + */ + +const MAX_ENTRIES = 1000; +const METHODS = ["log", "info", "warn", "error", "debug"]; + +/** @type {Array<{method: string, args: unknown[]}> | null} */ +let entries = []; +const originals = {}; +const wrappers = {}; + +for (const method of METHODS) { + originals[method] = console[method]; + wrappers[method] = function (...args) { + record(method, args); + return originals[method].apply(this, args); + }; + console[method] = wrappers[method]; +} +window.addEventListener("error", onError); +window.addEventListener("unhandledrejection", onRejection); + +/** + * Stops recording and returns what was captured, oldest first. + * @returns {Array<{method: string, args: unknown[]}>} + */ +export function takeStartupLogs() { + const captured = entries ?? []; + stop(); + return captured; +} + +/** Stops recording and drops everything captured. */ +export function discardStartupLogs() { + stop(); +} + +function stop() { + if (!entries) return; + entries = null; + for (const method of METHODS) { + // If something wrapped console after us, ours stays as a pass-through. + if (console[method] === wrappers[method]) + console[method] = originals[method]; + } + window.removeEventListener("error", onError); + window.removeEventListener("unhandledrejection", onRejection); +} + +function record(method, args) { + if (!entries) return; + if (entries.length >= MAX_ENTRIES) entries.shift(); + entries.push({ method, args }); +} + +function onError(event) { + record("error", [event.error ?? event.message]); +} + +function onRejection(event) { + record("error", ["Unhandled rejection:", event.reason]); +} diff --git a/src/main.js b/src/main.js index cee9545..4c8fa3e 100644 --- a/src/main.js +++ b/src/main.js @@ -1,3 +1,5 @@ +// Must run first so developer tools can show logs from the whole startup. +import "lib/startupLogBuffer"; // core-js/stable is installed by native.js, which boot.js always loads first. import "html-tag-js/dist/polyfill"; @@ -68,6 +70,7 @@ import startAd, { BANNER_SUPPRESSION_REASON, setBannerSuppressed, } from "lib/startAd"; +import { discardStartupLogs } from "lib/startupLogBuffer"; import mustache from "mustache"; import themes from "theme/list"; import { initHighlighting } from "utils/codeHighlight"; @@ -335,14 +338,8 @@ async function onDeviceReady() { } } - if (settings.value.developerMode) { - try { - const devTools = (await import("lib/devTools")).default; - await devTools.init(false); - } catch (error) { - console.error("Failed to initialize developer tools", error); - } - } + // Developer tools start once the editor is visible (see onAppRendered). + if (!settings.value.developerMode) discardStartupLogs(); try { await loadApp(); @@ -555,6 +552,7 @@ async function findProPurchase() { async function onAppRendered(proPurchaseCheck) { document.body.removeAttribute("data-small-msg"); app.classList.remove("loading", "splash"); + if (settings.value.developerMode) setTimeout(startDevTools); // load plugins startPluginGracePeriod(); @@ -614,6 +612,16 @@ async function onAppRendered(proPurchaseCheck) { startAd(); } +async function startDevTools() { + try { + const devTools = (await import("lib/devTools")).default; + await devTools.init(false); + } catch (error) { + discardStartupLogs(); + console.error("Failed to initialize developer tools", error); + } +} + function showSftpMigrationReport({ failures, removedReferences, diff --git a/src/settings/lspServerDetail.js b/src/settings/lspServerDetail.js index c84a2a2..8b89eea 100644 --- a/src/settings/lspServerDetail.js +++ b/src/settings/lspServerDetail.js @@ -1,5 +1,5 @@ import lspApi from "cm/lsp/api"; -import lspClientManager from "cm/lsp/clientManager"; +import { getLoadedLspClient } from "cm/lsp/clientLoader"; import { checkRuntimeServerInstallation, getRuntimeInstallCommand, @@ -597,7 +597,7 @@ export default function lspServerDetail(serverId) { case "enabled": await persistEnabled(serverId, value); if (!value) { - await lspClientManager.disposeServer(serverId); + await getLoadedLspClient()?.clientManager.disposeServer(serverId); stopManagedServer(serverId); } toast( @@ -620,7 +620,7 @@ export default function lspServerDetail(serverId) { break; } $loader?.show(); - await lspClientManager.disposeServer(serverId); + await getLoadedLspClient()?.clientManager.disposeServer(serverId); stopManagedServer(serverId); await removeCustomServer(serverId); toast(strings["lsp-custom-server-removed"]); diff --git a/src/utils/whenAppVisible.js b/src/utils/whenAppVisible.js new file mode 100644 index 0000000..c7d59aa --- /dev/null +++ b/src/utils/whenAppVisible.js @@ -0,0 +1,19 @@ +/** + * Resolves once the startup splash is gone, so optional code loads after the + * editor is visible instead of competing with startup. + * @returns {Promise} + */ +export default function whenAppVisible() { + if (!document.body.classList.contains("loading")) return Promise.resolve(); + return new Promise((resolve) => { + const observer = new MutationObserver(() => { + if (document.body.classList.contains("loading")) return; + observer.disconnect(); + resolve(); + }); + observer.observe(document.body, { + attributes: true, + attributeFilter: ["class"], + }); + }); +} diff --git a/tests/unit/commandRegistryKeymap.test.js b/tests/unit/commandRegistryKeymap.test.js index 92d08ae..c83bc85 100644 --- a/tests/unit/commandRegistryKeymap.test.js +++ b/tests/unit/commandRegistryKeymap.test.js @@ -9,13 +9,10 @@ async function loadRegistry() { "@codemirror/commands", "@codemirror/language", "@codemirror/lint", - "@codemirror/lsp-client", "cm/editorReadOnly", "cm/foldAwareLineCommands", "cm/foldingCommands", - "cm/lsp", - "cm/lsp/references", - "components/symbolsPanel", + "cm/lsp/clientLoader", "components/toast", "dialogs/prompt", "handlers/quickTools", diff --git a/tests/unit/lspMultiClient.test.js b/tests/unit/lspMultiClient.test.js index d118a31..f46f597 100644 --- a/tests/unit/lspMultiClient.test.js +++ b/tests/unit/lspMultiClient.test.js @@ -20,6 +20,17 @@ import { } from "cm/lsp/diagnostics"; import {afterEach, describe, expect, it, vi} from "vitest"; +// The editor loads the LSP client lazily; here it is always present. +vi.mock("cm/lsp/clientLoader", async () => { + const { LSPPlugin } = await import("@codemirror/lsp-client"); + return { + getLspPlugin: (view, client) => LSPPlugin.get(view, client), + getAllLspPlugins: (view, feature) => LSPPlugin.getAll(view, feature), + getLspPluginForFeature: (view, feature) => + LSPPlugin.getForFeature(view, feature), + }; +}); + const views = []; afterEach(() => { diff --git a/tests/unit/pluginFileRestoration.test.js b/tests/unit/pluginFileRestoration.test.js index d8a4619..84364fb 100644 --- a/tests/unit/pluginFileRestoration.test.js +++ b/tests/unit/pluginFileRestoration.test.js @@ -642,6 +642,7 @@ it.each(["cached text", "", undefined])( shouldApplyLanguage: () => false, restoreFileScrollPosition: vi.fn(), scheduleLspForFile: vi.fn(), + syncEmmetForFile: vi.fn(), }); // Run the actual render functions with a real EditorView; omit the unrelated app shell. vm.runInContext( diff --git a/tests/unit/quickToolsAdapterUi.test.js b/tests/unit/quickToolsAdapterUi.test.js index c32d741..8b156ff 100644 --- a/tests/unit/quickToolsAdapterUi.test.js +++ b/tests/unit/quickToolsAdapterUi.test.js @@ -129,14 +129,11 @@ function hostCommands(manager, exec) { "@codemirror/commands", "@codemirror/language", "@codemirror/lint", - "@codemirror/lsp-client", "@codemirror/view", "cm/editorReadOnly", "cm/foldAwareLineCommands", "cm/foldingCommands", - "cm/lsp", - "cm/lsp/references", - "components/symbolsPanel", + "cm/lsp/clientLoader", "components/toast", "dialogs/prompt", "handlers/quickTools", diff --git a/tests/unit/startupLogBuffer.test.js b/tests/unit/startupLogBuffer.test.js new file mode 100644 index 0000000..f875387 --- /dev/null +++ b/tests/unit/startupLogBuffer.test.js @@ -0,0 +1,68 @@ +import { expect, it, vi } from "vitest"; +import { loadSourceModule } from "../helpers/loadSourceModule"; + +function setup() { + const original = { + log: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + const fakeConsole = { ...original }; + const window = new EventTarget(); + const buffer = loadSourceModule( + "src/lib/startupLogBuffer.js", + {}, + { console: fakeConsole, window }, + ); + return { buffer, original, fakeConsole, window }; +} + +it("records console calls and uncaught errors while still printing them", () => { + const { buffer, original, fakeConsole, window } = setup(); + fakeConsole.log("a", 1); + fakeConsole.warn("careful"); + const error = new Error("boom"); + window.dispatchEvent(Object.assign(new Event("error"), { error })); + const rejection = new Event("unhandledrejection"); + rejection.reason = "nope"; + window.dispatchEvent(rejection); + + expect(original.log).toHaveBeenCalledWith("a", 1); + expect(buffer.takeStartupLogs()).toEqual([ + { method: "log", args: ["a", 1] }, + { method: "warn", args: ["careful"] }, + { method: "error", args: [error] }, + { method: "error", args: ["Unhandled rejection:", "nope"] }, + ]); +}); + +it("restores console and stops recording once taken or discarded", () => { + const { buffer, original, fakeConsole } = setup(); + buffer.discardStartupLogs(); + expect(fakeConsole.log).toBe(original.log); + fakeConsole.log("later"); + expect(buffer.takeStartupLogs()).toEqual([]); +}); + +it("leaves a later console wrapper in place and becomes a pass-through", () => { + const { buffer, original, fakeConsole } = setup(); + const ours = fakeConsole.log; + const later = (...args) => ours(...args); + fakeConsole.log = later; + buffer.takeStartupLogs(); + + expect(fakeConsole.log).toBe(later); + fakeConsole.log("still printed"); + expect(original.log).toHaveBeenCalledWith("still printed"); + expect(buffer.takeStartupLogs()).toEqual([]); +}); + +it("keeps only the most recent 1000 entries", () => { + const { buffer, fakeConsole } = setup(); + for (let i = 0; i < 1005; i++) fakeConsole.log(i); + const logs = buffer.takeStartupLogs(); + expect(logs).toHaveLength(1000); + expect(logs[0].args).toEqual([5]); +}); From e90054888dcd364d1a83cc06e7b5321340b140e9 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:57:39 +0530 Subject: [PATCH 6/7] fix: keep terminal focused during touch-and-hold selection Android's native long-press fires after our tap-hold timer and blurs the terminal, which closed the keyboard, resized the grid and made the selection menu disappear and reappear. Refocus in the same task so the IME never hides. Co-Authored-By: Claude Opus 5.5 --- .../terminal/terminalTouchSelection.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/components/terminal/terminalTouchSelection.js b/src/components/terminal/terminalTouchSelection.js index 0edb599..62ca06e 100644 --- a/src/components/terminal/terminalTouchSelection.js +++ b/src/components/terminal/terminalTouchSelection.js @@ -347,6 +347,15 @@ export default class TerminalTouchSelection { // Terminal resize listener (for keyboard events) this.boundHandlers.terminalResize = this.onTerminalResize.bind(this); this.terminal.onResize(this.boundHandlers.terminalResize); + + // Chromium's native long-press (~500 ms) lands after our tap-hold timer and + // blurs the terminal, closing the keyboard and resizing the grid under the + // menu. Refocusing in the same task keeps the IME from ever hiding. + this.boundHandlers.textareaBlur = this.onTextareaBlur.bind(this); + this.terminal.textarea?.addEventListener( + "blur", + this.boundHandlers.textareaBlur, + ); } onTerminalTouchStart(event) { @@ -683,6 +692,11 @@ export default class TerminalTouchSelection { }, this.resizeSettleDelay); } + onTextareaBlur(event) { + if (!this.isSelectionTouchActive || event.relatedTarget) return; + this.terminal.textarea?.focus({ preventScroll: true }); + } + onTerminalScroll() { if (!this.isSelecting || this.isHandleDragging) return; @@ -1600,6 +1614,10 @@ export default class TerminalTouchSelection { this.terminalScrollDisposable?.dispose(); this.terminalScrollDisposable = null; + this.terminal.textarea?.removeEventListener( + "blur", + this.boundHandlers.textareaBlur, + ); window.removeEventListener( "orientationchange", this.boundHandlers.orientationChange, From f99a15e97571113c9c90e7315f1182e75df4da06 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:22:50 +0530 Subject: [PATCH 7/7] fix: address review findings in startup changes - Hide banners when a Pro purchase is confirmed after the startup check timed out, as purchase restoration does - Clear the cached Pro flag when the store reports no purchase, even if the result is too late to change this session - Wait for plugins before opening shared files until a full plugin load has recorded which extensions plugins handle (first launch after updating) - Cancel a pending developer tools initialization when developer mode is turned off, so Eruda does not activate afterwards - Move the startup purchase check into lib/verifyProPurchase.js --- src/lib/devTools.js | 26 ++++-- src/lib/fileTypeHandler.js | 35 +++++-- src/lib/loadPlugins.js | 3 + src/lib/verifyProPurchase.js | 85 +++++++++++++++++ src/main.js | 79 +--------------- tests/unit/devToolsCancel.test.js | 95 +++++++++++++++++++ tests/unit/fileTypeHandlerHistory.test.js | 54 +++++++++++ tests/unit/verifyProPurchase.test.js | 106 ++++++++++++++++++++++ 8 files changed, 394 insertions(+), 89 deletions(-) create mode 100644 src/lib/verifyProPurchase.js create mode 100644 tests/unit/devToolsCancel.test.js create mode 100644 tests/unit/fileTypeHandlerHistory.test.js create mode 100644 tests/unit/verifyProPurchase.test.js diff --git a/src/lib/devTools.js b/src/lib/devTools.js index ad195f4..1e055bc 100644 --- a/src/lib/devTools.js +++ b/src/lib/devTools.js @@ -3,12 +3,14 @@ import loader from "dialogs/loader"; import helpers from "utils/helpers"; import Url from "utils/Url"; import config from "./config"; -import { takeStartupLogs } from "./startupLogBuffer"; +import { discardStartupLogs, takeStartupLogs } from "./startupLogBuffer"; let erudaInstance = null; let isInitialized = false; /** @type {Promise | null} */ let initializing = null; +/** Bumped by destroy() so a pending initialization does not activate Eruda. */ +let generation = 0; /** * Developer tools module for debugging Acode @@ -37,10 +39,12 @@ const devTools = { */ init(showLoader = false) { if (isInitialized) return Promise.resolve(); - initializing ??= initEruda(showLoader).finally(() => { - initializing = null; + if (initializing) return initializing; + const pending = initEruda(showLoader, generation).finally(() => { + if (initializing === pending) initializing = null; }); - return initializing; + initializing = pending; + return pending; }, /** @@ -89,6 +93,9 @@ const devTools = { * Destroy Eruda instance */ destroy() { + generation++; + initializing = null; + discardStartupLogs(); if (!isInitialized) return; erudaInstance?.destroy(); erudaInstance = null; @@ -103,7 +110,7 @@ export default devTools; /** * @param {boolean} showLoader */ -async function initEruda(showLoader) { +async function initEruda(showLoader, initGeneration) { try { const erudaPath = Url.join(DATA_STORAGE, "eruda.js"); const fs = fsOperation(erudaPath); @@ -130,8 +137,9 @@ async function initEruda(showLoader) { const internalUri = await helpers.toInternalUri(erudaPath); + if (initGeneration !== generation) return; + const script = document.createElement("script"); await new Promise((resolve, reject) => { - const script = document.createElement("script"); script.src = internalUri; script.id = "eruda-script"; script.onload = resolve; @@ -139,6 +147,12 @@ async function initEruda(showLoader) { document.head.appendChild(script); }); + // Developer mode was turned off while Eruda was loading. + if (initGeneration !== generation) { + script.remove(); + return; + } + if (window.eruda) { window.eruda.init({ useShadowDom: true, diff --git a/src/lib/fileTypeHandler.js b/src/lib/fileTypeHandler.js index 14d5352..64064f4 100644 --- a/src/lib/fileTypeHandler.js +++ b/src/lib/fileTypeHandler.js @@ -55,15 +55,28 @@ class FileTypeHandlerRegistry { /** * Whether a plugin has registered a handler for this file name in any * session, so opening it should wait until plugins have loaded. Extensions - * are never forgotten, which only means waiting as before. + * are never forgotten, which only means waiting as before. Until a full + * plugin load has recorded them, every file may have a handler. * @param {string} filename */ mayHavePluginHandler(filename) { const extensions = readHandledExtensions(); + if (!extensions) return true; const ext = filename.split(".").pop().toLowerCase(); return extensions.includes("*") || extensions.includes(ext); } + /** + * Marks the handled extensions as known. Call it after a full plugin load, + * once every installed plugin has had the chance to register handlers. + */ + markHandledExtensionsKnown() { + const registered = [...this.#handlers.values()].flatMap( + (handler) => handler.extensions, + ); + writeHandledExtensions([...(readHandledExtensions() ?? []), ...registered]); + } + /** * Unregister a file type handler * @param {string} id - The handler id to remove @@ -107,23 +120,33 @@ class FileTypeHandlerRegistry { export const fileTypeHandler = new FileTypeHandlerRegistry(); export default fileTypeHandler; +/** + * @returns {string[] | null} null until a full plugin load has recorded them + */ function readHandledExtensions() { try { const extensions = JSON.parse(localStorage.getItem(HANDLED_EXTENSIONS_KEY)); - return Array.isArray(extensions) ? extensions : []; + return Array.isArray(extensions) ? extensions : null; } catch { - return []; + return null; } } +/** + * Adds extensions to the history. Before the first full plugin load the + * history is left unset; markHandledExtensionsKnown records everything then. + */ function rememberHandledExtensions(extensions) { const known = readHandledExtensions(); - const added = extensions.filter((ext) => !known.includes(ext)); - if (!added.length) return; + if (!known || extensions.every((ext) => known.includes(ext))) return; + writeHandledExtensions([...known, ...extensions]); +} + +function writeHandledExtensions(extensions) { try { localStorage.setItem( HANDLED_EXTENSIONS_KEY, - JSON.stringify([...known, ...added]), + JSON.stringify([...new Set(extensions)]), ); } catch (error) { console.warn("Unable to remember plugin file handlers", error); diff --git a/src/lib/loadPlugins.js b/src/lib/loadPlugins.js index c3475fd..a1aae71 100644 --- a/src/lib/loadPlugins.js +++ b/src/lib/loadPlugins.js @@ -1,5 +1,6 @@ import fsOperation from "../fileSystem"; import Url from "../utils/Url"; +import fileTypeHandler from "./fileTypeHandler"; import loadPlugin from "./loadPlugin"; import settings from "./settings"; @@ -112,6 +113,8 @@ export default async function loadPlugins(loadOnlyTheme = false) { }); await Promise.allSettled(loadPromises); + // Every installed plugin has now had the chance to register a handler. + if (!loadOnlyTheme) fileTypeHandler.markHandledExtensionsKnown(); acode[onPluginsLoadCompleteCallback](); return results.filter(Boolean).length; diff --git a/src/lib/verifyProPurchase.js b/src/lib/verifyProPurchase.js new file mode 100644 index 0000000..0c3ad21 --- /dev/null +++ b/src/lib/verifyProPurchase.js @@ -0,0 +1,85 @@ +import helpers from "utils/helpers"; +import config from "./config"; +import platform from "./platform"; +import { BANNER_SUPPRESSION_REASON, setBannerSuppressed } from "./startAd"; + +/** + * A billing service that never answers must not keep the app on the splash + * screen, so startup stops waiting for the purchase check after this long. + */ +export const PRO_CHECK_TIMEOUT_MS = 5000; + +/** + * Confirms Pro status against the store's purchases, waiting at most + * PRO_CHECK_TIMEOUT_MS. Only a change made here is applied, so an upgrade + * from another source (e.g. a login) is never downgraded. A result that + * arrives after the timeout can only upgrade this session, because startup + * has already moved on with the current status. + * @param {boolean} isFreePackage + * @param {{log(level: string, message: unknown): void}} logger + */ +export default async function verifyProPurchase(isFreePackage, logger) { + const initialHasPro = config.HAS_PRO; + let timedOut = false; + const check = findProPurchase(logger).then((isPro) => { + if (isPro) { + config.HAS_PRO = true; + // Ads may have started while a slow check was still pending. + setBannerSuppressed(BANNER_SUPPRESSION_REASON.PRO, true); + // Lets the next Android launch skip waiting for this check. + localStorage.setItem("acode_pro", "true"); + } else if (isPro === false) { + // A refunded or revoked purchase must not come back offline, even + // when the result is too late to change this session. A purchase or + // login that upgraded the session meanwhile keeps its cache. + if (!config.HAS_PRO || initialHasPro) { + localStorage.removeItem("acode_pro"); + } + if (!timedOut && config.HAS_PRO === initialHasPro) { + config.HAS_PRO = !isFreePackage; + } + } + }); + await new Promise((resolve) => { + const timer = setTimeout(() => { + timedOut = true; + logger.log( + "warn", + `Purchase check still pending after ${PRO_CHECK_TIMEOUT_MS}ms`, + ); + resolve(); + }, PRO_CHECK_TIMEOUT_MS); + const settle = (error) => { + if (error) console.error(error); + clearTimeout(timer); + resolve(); + }; + check.then(() => settle(), settle); + }); +} + +/** + * @returns {Promise} whether Pro was purchased, or null when + * the store could not tell + */ +async function findProPurchase(logger) { + try { + await helpers.promisify(iap.startConnection).catch((e) => { + logger.log("error", "connection error"); + logger.log("error", e); + }); + + if (!platform.isIOS && !navigator.onLine) return null; + + const purchases = await helpers.promisify(iap.getPurchases); + return purchases.some( + (purchase) => + purchase.purchaseState === iap.PURCHASE_STATE_PURCHASED && + purchase.productIds.includes("acode_pro_new"), + ); + } catch (error) { + logger.log("error", "Purchase error"); + logger.log("error", error); + return null; + } +} diff --git a/src/main.js b/src/main.js index 4c8fa3e..9efcd87 100644 --- a/src/main.js +++ b/src/main.js @@ -71,6 +71,7 @@ import startAd, { setBannerSuppressed, } from "lib/startAd"; import { discardStartupLogs } from "lib/startupLogBuffer"; +import verifyProPurchase from "lib/verifyProPurchase"; import mustache from "mustache"; import themes from "theme/list"; import { initHighlighting } from "utils/codeHighlight"; @@ -83,12 +84,6 @@ import $_fileMenu from "views/file-menu.hbs"; import $_menu from "views/menu.hbs"; import auth, { loginEvents } from "./lib/auth"; -/** - * A billing service that never answers must not keep the app on the splash - * screen, so startup stops waiting for the purchase check after this long. - */ -const PRO_CHECK_TIMEOUT_MS = 5000; - /** * Settles once the startup purchase check can no longer change Pro status * (or has timed out). Plugins wait for it so they initialize with that value. @@ -172,7 +167,7 @@ async function onDeviceReady() { // the device is offline, so iOS never trusts the stored flag. config.HAS_PRO = !isFreePackage || (!platform.isIOS && localStorage.acode_pro === "true"); - const proPurchaseCheck = verifyProPurchase(isFreePackage); + const proPurchaseCheck = verifyProPurchase(isFreePackage, logger); // Paid builds are always Pro, so only a free build's check can change it. proStatusReady = isFreePackage ? proPurchaseCheck : Promise.resolve(); @@ -474,76 +469,6 @@ async function getAndroidSdkInt() { } } -/** - * Confirms Pro status against the store's purchases, waiting at most - * PRO_CHECK_TIMEOUT_MS. Only a change made here is applied, so an upgrade - * from another source (e.g. a login) is never downgraded. A result that - * arrives after the timeout can only upgrade, because startup has already - * moved on with the current status. - * @param {boolean} isFreePackage - */ -async function verifyProPurchase(isFreePackage) { - const initialHasPro = config.HAS_PRO; - let timedOut = false; - const check = findProPurchase().then((isPro) => { - if (isPro) { - config.HAS_PRO = true; - // Lets the next Android launch skip waiting for this check. - localStorage.setItem("acode_pro", "true"); - } else if ( - isPro === false && - !timedOut && - config.HAS_PRO === initialHasPro - ) { - config.HAS_PRO = !isFreePackage; - // A refunded or revoked purchase must not come back offline. - localStorage.removeItem("acode_pro"); - } - }); - await new Promise((resolve) => { - const timer = setTimeout(() => { - timedOut = true; - logger.log( - "warn", - `Purchase check still pending after ${PRO_CHECK_TIMEOUT_MS}ms`, - ); - resolve(); - }, PRO_CHECK_TIMEOUT_MS); - const settle = (error) => { - if (error) console.error(error); - clearTimeout(timer); - resolve(); - }; - check.then(() => settle(), settle); - }); -} - -/** - * @returns {Promise} whether Pro was purchased, or null when - * the store could not tell - */ -async function findProPurchase() { - try { - await helpers.promisify(iap.startConnection).catch((e) => { - logger.log("error", "connection error"); - logger.log("error", e); - }); - - if (!platform.isIOS && !navigator.onLine) return null; - - const purchases = await helpers.promisify(iap.getPurchases); - return purchases.some( - (purchase) => - purchase.purchaseState === iap.PURCHASE_STATE_PURCHASED && - purchase.productIds.includes("acode_pro_new"), - ); - } catch (error) { - logger.log("error", "Purchase error"); - logger.log("error", error); - return null; - } -} - /** * Hides the splash and loads everything that is not needed for the first * frame: plugins, login state and ads. diff --git a/tests/unit/devToolsCancel.test.js b/tests/unit/devToolsCancel.test.js new file mode 100644 index 0000000..4373fb6 --- /dev/null +++ b/tests/unit/devToolsCancel.test.js @@ -0,0 +1,95 @@ +import { expect, it, vi } from "vitest"; +import { loadSourceModule } from "../helpers/loadSourceModule"; + +function setup() { + const scripts = []; + const eruda = { + init: vi.fn(), + destroy: vi.fn(), + get: () => null, + _shadowRoot: { querySelector: () => ({ style: {} }) }, + }; + const window = { toast: vi.fn() }; + const document = { + createElement: () => { + const script = { remove: vi.fn() }; + scripts.push(script); + return script; + }, + head: { appendChild: () => {} }, + getElementById: () => null, + }; + const discardStartupLogs = vi.fn(); + const { default: devTools } = loadSourceModule( + "src/lib/devTools.js", + { + fileSystem: { + __esModule: true, + default: () => ({ exists: async () => true }), + }, + "dialogs/loader": { __esModule: true, default: {} }, + "utils/helpers": { + __esModule: true, + default: { toInternalUri: async (url) => url }, + }, + "utils/Url": { __esModule: true, default: { join: (...p) => p.join("/") } }, + "./config": { __esModule: true, default: {} }, + "./startupLogBuffer": { + discardStartupLogs, + takeStartupLogs: () => [], + }, + }, + { window, document, DATA_STORAGE: "data", strings: {} }, + ); + const waitForScript = async () => { + for (let i = 0; i < 100 && !scripts.at(-1)?.onload; i++) { + await new Promise((resolve) => setTimeout(resolve)); + } + }; + /** Finishes loading the latest eruda script. */ + const finishScript = async () => { + await waitForScript(); + window.eruda = eruda; + scripts.at(-1).onload(); + await new Promise((resolve) => setTimeout(resolve)); + }; + return { + devTools, + eruda, + window, + scripts, + waitForScript, + finishScript, + discardStartupLogs, + }; +} + +it("does not activate Eruda when developer mode is turned off while it loads", async () => { + const f = setup(); + const pending = f.devTools.init(); + await f.waitForScript(); + f.devTools.destroy(); + await f.finishScript(); + await pending; + + expect(f.eruda.init).not.toHaveBeenCalled(); + expect(f.devTools.isInitialized).toBe(false); + expect(f.scripts[0].remove).toHaveBeenCalled(); + expect(f.discardStartupLogs).toHaveBeenCalled(); + await f.devTools.show(); + expect(f.window.toast).toHaveBeenCalledWith("Developer mode is not enabled"); +}); + +it("can be enabled again after a cancelled initialization", async () => { + const f = setup(); + const cancelled = f.devTools.init(); + f.devTools.destroy(); + const enabled = f.devTools.init(); + await f.finishScript(); + await Promise.all([cancelled, enabled]); + + // The cancelled run stops before loading a script of its own. + expect(f.scripts).toHaveLength(1); + expect(f.eruda.init).toHaveBeenCalledOnce(); + expect(f.devTools.isInitialized).toBe(true); +}); diff --git a/tests/unit/fileTypeHandlerHistory.test.js b/tests/unit/fileTypeHandlerHistory.test.js new file mode 100644 index 0000000..572c28a --- /dev/null +++ b/tests/unit/fileTypeHandlerHistory.test.js @@ -0,0 +1,54 @@ +import { expect, it } from "vitest"; +import { loadSourceModule } from "../helpers/loadSourceModule"; + +function setup(stored) { + const storage = new Map( + stored ? [["pluginHandledExtensions", JSON.stringify(stored)]] : [], + ); + const { default: registry } = loadSourceModule( + "src/lib/fileTypeHandler.js", + {}, + { + localStorage: { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, value), + }, + }, + ); + const register = (id, extensions) => + registry.registerFileHandler(id, { extensions, handleFile() {} }); + return { registry, storage, register }; +} + +it("treats every file as possibly plugin-handled until a full load records history", () => { + const { registry, register } = setup(); + expect(registry.mayHavePluginHandler("book.epub")).toBe(true); + expect(registry.mayHavePluginHandler("notes.txt")).toBe(true); + + // A fast plugin registering first must not make other types look unhandled. + register("epub", ["epub"]); + expect(registry.mayHavePluginHandler("comic.cbz")).toBe(true); + + register("cbz", [".CBZ"]); + registry.markHandledExtensionsKnown(); + expect(registry.mayHavePluginHandler("book.epub")).toBe(true); + expect(registry.mayHavePluginHandler("comic.cbz")).toBe(true); + expect(registry.mayHavePluginHandler("notes.txt")).toBe(false); +}); + +it("adds handlers registered after the history exists and keeps old ones", () => { + const { registry, register, storage } = setup(["epub"]); + expect(registry.mayHavePluginHandler("notes.txt")).toBe(false); + register("sheets", ["csv"]); + expect(registry.mayHavePluginHandler("data.csv")).toBe(true); + expect(registry.mayHavePluginHandler("book.epub")).toBe(true); + expect(JSON.parse(storage.get("pluginHandledExtensions"))).toEqual([ + "epub", + "csv", + ]); +}); + +it("treats a wildcard handler as handling everything", () => { + const { registry } = setup(["*"]); + expect(registry.mayHavePluginHandler("anything.txt")).toBe(true); +}); diff --git a/tests/unit/verifyProPurchase.test.js b/tests/unit/verifyProPurchase.test.js new file mode 100644 index 0000000..315b29b --- /dev/null +++ b/tests/unit/verifyProPurchase.test.js @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { loadSourceModule } from "../helpers/loadSourceModule"; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +/** + * @param {{hasPro: boolean, cached?: boolean}} start + */ +function setup({ hasPro, cached = false }) { + let answer; + const storage = new Map(cached ? [["acode_pro", "true"]] : []); + const config = { HAS_PRO: hasPro }; + const setBannerSuppressed = vi.fn(); + const { default: verifyProPurchase, PRO_CHECK_TIMEOUT_MS } = + loadSourceModule( + "src/lib/verifyProPurchase.js", + { + "utils/helpers": { + __esModule: true, + default: { + promisify: (fn) => new Promise((resolve) => fn(resolve)), + }, + }, + "./config": { __esModule: true, default: config }, + "./platform": { __esModule: true, default: { isIOS: false } }, + "./startAd": { + BANNER_SUPPRESSION_REASON: { PRO: "pro" }, + setBannerSuppressed, + }, + }, + { + setTimeout, + clearTimeout, + navigator: { onLine: true }, + localStorage: { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, value), + removeItem: (key) => storage.delete(key), + }, + iap: { + PURCHASE_STATE_PURCHASED: 1, + startConnection: (resolve) => resolve(), + // The store answers whenever the test decides. + getPurchases: (resolve) => { + answer = resolve; + }, + }, + }, + ); + const logger = { log: vi.fn() }; + return { + config, + storage, + setBannerSuppressed, + check: verifyProPurchase(true, logger), + answer: async (owned) => { + while (!answer) await Promise.resolve(); + answer( + owned ? [{ purchaseState: 1, productIds: ["acode_pro_new"] }] : [], + ); + await vi.advanceTimersByTimeAsync(0); + }, + timeOut: () => vi.advanceTimersByTimeAsync(PRO_CHECK_TIMEOUT_MS), + }; +} + +it("applies a purchase that arrives after the timeout, including hiding ads", async () => { + const f = setup({ hasPro: false }); + await f.timeOut(); + await f.check; + expect(f.config.HAS_PRO).toBe(false); + + await f.answer(true); + expect(f.config.HAS_PRO).toBe(true); + expect(f.setBannerSuppressed).toHaveBeenCalledWith("pro", true); + expect(f.storage.get("acode_pro")).toBe("true"); +}); + +it("clears a revoked cached purchase even when the result is late", async () => { + const f = setup({ hasPro: true, cached: true }); + await f.timeOut(); + await f.answer(false); + + // Too late to change this session, but the next offline launch is not Pro. + expect(f.config.HAS_PRO).toBe(true); + expect(f.storage.has("acode_pro")).toBe(false); +}); + +it("downgrades a revoked cached purchase when the result is in time", async () => { + const f = setup({ hasPro: true, cached: true }); + await f.answer(false); + await f.check; + expect(f.config.HAS_PRO).toBe(false); + expect(f.storage.has("acode_pro")).toBe(false); +}); + +it("keeps Pro and its cache when a purchase or login upgraded the session meanwhile", async () => { + const f = setup({ hasPro: false }); + f.config.HAS_PRO = true; + f.storage.set("acode_pro", "true"); + await f.answer(false); + await f.check; + expect(f.config.HAS_PRO).toBe(true); + expect(f.storage.get("acode_pro")).toBe("true"); +});