From f2939c9559173586136874fe977dc29a97fb0c1a Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:43:54 +0530 Subject: [PATCH 1/7] Revert "perf: smaller startup bundle, cached encodings and faster command registration (#2931)" This reverts commit 60acb25a603420ac61609bc5a7b97ec70412e763. The startup work is being consolidated into a single PR for review. Co-Authored-By: Claude Opus 5.5 --- src/cm/commandRegistry.js | 93 +++-------------- src/cm/keyBindingUtils.js | 12 +-- src/cm/themes/index.js | 67 ++++-------- src/fileSystem/ftp.js | 4 +- src/fileSystem/sftp.js | 6 +- src/lib/commands.js | 4 +- src/lib/editorFile.js | 11 +- src/lib/runLazily.js | 9 -- src/pages/fileBrowser/fileBrowser.js | 3 +- src/pages/plugin/plugin.js | 3 +- src/sidebarApps/index.js | 14 +-- src/utils/encodings.js | 57 +---------- src/utils/mimeTypes.js | 11 -- tests/unit/commandRegistryKeymap.test.js | 124 ----------------------- tests/unit/fileSaveEvents.test.js | 2 +- tests/unit/pluginFileRestoration.test.js | 4 +- tests/unit/saveCommands.test.js | 2 +- 17 files changed, 61 insertions(+), 365 deletions(-) delete mode 100644 src/lib/runLazily.js delete mode 100644 src/utils/mimeTypes.js delete mode 100644 tests/unit/commandRegistryKeymap.test.js diff --git a/src/cm/commandRegistry.js b/src/cm/commandRegistry.js index c3f295c16d..5d53cb1747 100644 --- a/src/cm/commandRegistry.js +++ b/src/cm/commandRegistry.js @@ -151,16 +151,6 @@ 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), @@ -174,6 +164,7 @@ registerCoreCommands(); registerLspCommands(); registerLintCommands(); registerCommandsFromKeyBindings(); +rebuildKeymap(); function registerCoreCommands() { addCommand({ @@ -1523,34 +1514,19 @@ 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 keySource = syncCommandBinding(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 combos = parseKeyString(keySource); combos.forEach((combo) => { const cmKey = toCodeMirrorKey(combo); @@ -1575,15 +1551,9 @@ function rebuildKeymap() { const conflicts = []; for (const candidate of candidates) { const canonicalKey = canonicalizeKeyBinding(candidate.key); - // 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; - } - } + const claimed = Array.from(claimedKeys.entries()).find(([key]) => + keyBindingsConflict(key, canonicalKey), + ); if (claimed) { const [claimedKey, owner] = claimed; const appCommandShadowsCodeMirrorDefault = @@ -1626,7 +1596,6 @@ function rebuildKeymap() { cachedKeyBindingConflicts = conflicts; cachedKeymap = bindings; resolvedKeyBindingsVersion += 1; - keymapDirty = false; return bindings; } @@ -1674,7 +1643,6 @@ 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, @@ -1683,27 +1651,22 @@ 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)); } @@ -1791,11 +1754,9 @@ 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); } - invalidateKeymap(); + rebuildKeymap(); return stored; } @@ -1804,34 +1765,13 @@ export function removeExternalCommand(name) { const exists = commandMap.has(name); if (!exists) return false; commandMap.delete(name); - invalidateKeymap(); + rebuildKeymap(); 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); - 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); - } - } + applyCommandKeymap(resolvedView); } function normalizeExternalCommand(descriptor) { @@ -1887,9 +1827,8 @@ function normalizeExternalKey(bindKey) { return combos.length ? combos.join("|") : null; } -function applyCommandKeymap(view, bindings) { +function applyCommandKeymap(view, bindings = cachedKeymap) { 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 1ebcdeb10f..69e2a3583b 100644 --- a/src/cm/keyBindingUtils.js +++ b/src/cm/keyBindingUtils.js @@ -45,18 +45,8 @@ export function toCodeMirrorKey(combo) { return strokes.length ? strokes.join(" ") : null; } -// Conflict checks compare every binding with every other one each time the -// keymap is rebuilt (once per registered command), so cache the parsed form. -const canonicalKeyCache = new Map(); - export function canonicalizeKeyBinding(combo) { - if (typeof combo !== "string") { - return toCodeMirrorKey(combo)?.toLowerCase() || null; - } - if (canonicalKeyCache.has(combo)) return canonicalKeyCache.get(combo); - const canonicalKey = toCodeMirrorKey(combo)?.toLowerCase() || null; - canonicalKeyCache.set(combo, canonicalKey); - return canonicalKey; + return toCodeMirrorKey(combo)?.toLowerCase() || null; } /** diff --git a/src/cm/themes/index.js b/src/cm/themes/index.js index 1043b60c66..dbcd67eba1 100644 --- a/src/cm/themes/index.js +++ b/src/cm/themes/index.js @@ -116,19 +116,6 @@ 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(); @@ -142,7 +129,7 @@ function registerTheme(id, caption, isDark, getExtension, config, validate) { config: config || null, }; - if (validate && !validateThemeExtensions(key, theme.getExtension())) { + if (!validateThemeExtensions(key, theme.getExtension())) { return false; } @@ -177,127 +164,115 @@ export function removeTheme(id) { themes.delete(String(id).toLowerCase()); } -addBuiltInTheme("one_dark", "One Dark", true, () => [oneDark], oneDarkConfig); -addBuiltInTheme( - auraConfig.name, - "Aura", - !!auraConfig.dark, - () => aura(), - auraConfig, -); -addBuiltInTheme( +addTheme("one_dark", "One Dark", true, () => [oneDark], oneDarkConfig); +addTheme(auraConfig.name, "Aura", !!auraConfig.dark, () => aura(), auraConfig); +addTheme( cobaltConfig.name, "Cobalt", !!cobaltConfig.dark, () => cobalt(), cobaltConfig, ); -addBuiltInTheme( +addTheme( noctisLilacConfig.name, noctisLilacConfig.caption || "Noctis Lilac", !!noctisLilacConfig.dark, () => noctisLilac(), noctisLilacConfig, ); -addBuiltInTheme( +addTheme( draculaConfig.name, "Dracula", !!draculaConfig.dark, () => dracula(), draculaConfig, ); -addBuiltInTheme( - nordConfig.name, - "Nord", - !!nordConfig.dark, - () => nord(), - nordConfig, -); -addBuiltInTheme( +addTheme(nordConfig.name, "Nord", !!nordConfig.dark, () => nord(), nordConfig); +addTheme( gruvboxDarkConfig.name, "Gruvbox Dark", !!gruvboxDarkConfig.dark, () => gruvboxDark(), gruvboxDarkConfig, ); -addBuiltInTheme( +addTheme( ayuDarkConfig.name, "Ayu Dark", !!ayuDarkConfig.dark, () => ayuDark(), ayuDarkConfig, ); -addBuiltInTheme( +addTheme( materialPalenightConfig.name, "Material Palenight", !!materialPalenightConfig.dark, () => materialPalenight(), materialPalenightConfig, ); -addBuiltInTheme( +addTheme( githubDarkConfig.name, "GitHub Dark", !!githubDarkConfig.dark, () => githubDark(), githubDarkConfig, ); -addBuiltInTheme( +addTheme( githubLightConfig.name, "GitHub Light", !!githubLightConfig.dark, () => githubLight(), githubLightConfig, ); -addBuiltInTheme( +addTheme( solarizedDarkConfig.name, "Solarized Dark", !!solarizedDarkConfig.dark, () => solarizedDark(), solarizedDarkConfig, ); -addBuiltInTheme( +addTheme( solarizedLightConfig.name, "Solarized Light", !!solarizedLightConfig.dark, () => solarizedLight(), solarizedLightConfig, ); -addBuiltInTheme( +addTheme( tokyoNightDayConfig.name, "Tokyo Night Day", !!tokyoNightDayConfig.dark, () => tokyoNightDay(), tokyoNightDayConfig, ); -addBuiltInTheme( +addTheme( tokyoNightConfig.name, "Tokyo Night", !!tokyoNightConfig.dark, () => tokyoNight(), tokyoNightConfig, ); -addBuiltInTheme( +addTheme( tomorrowNightConfig.name, "Tomorrow Night", !!tomorrowNightConfig.dark, () => tomorrowNight(), tomorrowNightConfig, ); -addBuiltInTheme( +addTheme( tomorrowNightBrightConfig.name, "Tomorrow Night Bright", !!tomorrowNightBrightConfig.dark, () => tomorrowNightBright(), tomorrowNightBrightConfig, ); -addBuiltInTheme( +addTheme( monokaiConfig.name, "Monokai", !!monokaiConfig.dark, () => monokai(), monokaiConfig, ); -addBuiltInTheme( +addTheme( vscodeDarkConfig.name, "VS Code Dark", !!vscodeDarkConfig.dark, @@ -306,7 +281,7 @@ addBuiltInTheme( ); for (const config of catppuccinConfigs) { - addBuiltInTheme( + addTheme( config.name, config.caption, config.dark, diff --git a/src/fileSystem/ftp.js b/src/fileSystem/ftp.js index 98f4c9ef7c..8ba1ea7206 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,7 +91,6 @@ class FtpClient { async listDir() { await this.#connectIfNotConnected(); - const mimeType = await loadMimeTypes(); return new Promise((resolve, reject) => { ftp.listDirectory( this.#conId, @@ -283,7 +282,6 @@ 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 d30b50f3c0..803be8af45 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,8 +44,7 @@ class SftpClient { * @param {String} filename * @param {boolean} stat */ - async lsDir(filename = this.#path) { - const mimeType = await loadMimeTypes(); + lsDir(filename = this.#path) { return new Promise((resolve, reject) => { sftp.isConnected(async (connectionID) => { (async () => { @@ -460,7 +459,6 @@ 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/commands.js b/src/lib/commands.js index b0584442ab..de3a005a8e 100644 --- a/src/lib/commands.js +++ b/src/lib/commands.js @@ -16,7 +16,7 @@ import EditorFile from "./editorFile"; import { loadFileBrowser } from "./lazyImports"; import openFile from "./openFile"; import openFolder from "./openFolder"; -import runLazily from "./runLazily"; +import run from "./run"; import saveState from "./saveState"; import appSettings from "./settings"; import showFileInfo from "./showFileInfo"; @@ -287,7 +287,7 @@ export default { resolveReferenceFile(referenceFile)?.togglePinned?.(); }, console() { - void runLazily(true, "inapp"); + run(true, "inapp"); }, "check-files"() { if (!appSettings.value.checkFiles) return; diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index b9a736f06b..03a8c3ba0e 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -18,16 +18,16 @@ 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 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 runLazily from "./runLazily"; +import run from "./run"; import saveFile from "./saveFile"; import appSettings from "./settings"; @@ -1788,10 +1788,7 @@ export default class EditorFile { async #fileAction(action, mimeType) { try { const uri = await this.#getShareableUri(); - if (!mimeType) { - const mimeTypes = await loadMimeTypes(); - mimeType = mimeTypes.lookup(this.name) || "text/plain"; - } + if (!mimeType) mimeType = mimeTypes.lookup(this.name) || "text/plain"; system.fileAction( uri, this.filename, @@ -2031,7 +2028,7 @@ export default class EditorFile { const event = createFileEvent(this); this.#emit("run", event); if (event.defaultPrevented) return; - void runLazily(false, appSettings.value.previewMode, file); + run(false, appSettings.value.previewMode, file); } #updateTab() { diff --git a/src/lib/runLazily.js b/src/lib/runLazily.js deleted file mode 100644 index cc21326ae3..0000000000 --- a/src/lib/runLazily.js +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Runs/previews a file, loading the runner on first use. The runner pulls in - * markdown-it and the markdown preview, which are not needed at startup. - * @param {...any} args arguments for lib/run - */ -export default async function runLazily(...args) { - const { default: run } = await import(/* webpackChunkName: "run" */ "./run"); - return run(...args); -} diff --git a/src/pages/fileBrowser/fileBrowser.js b/src/pages/fileBrowser/fileBrowser.js index aabbbb19e6..30b487cdc7 100644 --- a/src/pages/fileBrowser/fileBrowser.js +++ b/src/pages/fileBrowser/fileBrowser.js @@ -21,12 +21,12 @@ 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 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"; @@ -1196,7 +1196,6 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { break; } - const mimeTypes = await loadMimeTypes(); const mimeType = mimeTypes.lookup(name) || mimeTypes.lookup(shareableUri) || diff --git a/src/pages/plugin/plugin.js b/src/pages/plugin/plugin.js index d9a74f460a..8afc97a263 100644 --- a/src/pages/plugin/plugin.js +++ b/src/pages/plugin/plugin.js @@ -18,9 +18,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"; @@ -121,7 +121,6 @@ export default async function PluginInclude( Url.join(PLUGIN_DIR, id, installedPlugin.icon), ); const iconData = await fsOperation(iconUrl).readFile(); - const mimeTypes = await loadMimeTypes(); const iconMimeType = mimeTypes.lookup(installedPlugin.icon) || "image/png"; const icon = URL.createObjectURL( diff --git a/src/sidebarApps/index.js b/src/sidebarApps/index.js index e97cc22e2e..4172f79917 100644 --- a/src/sidebarApps/index.js +++ b/src/sidebarApps/index.js @@ -86,16 +86,10 @@ function init($el) { * Loads all sidebar apps. */ async function loadApps() { - // 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); - } + add(...(await import("./files")).default); + add(...(await import("./searchInFiles")).default); + add(...(await import("./extensions")).default); + add(...(await import("./notification")).default); setSponsorSidebarAppVisibility(appSettings.value.showSponsorSidebarApp); } diff --git a/src/utils/encodings.js b/src/utils/encodings.js index 662b808f57..71a1d892e0 100644 --- a/src/utils/encodings.js +++ b/src/utils/encodings.js @@ -201,63 +201,14 @@ export function encode(text, charset) { return execEncode(text, charset); } -const ENCODINGS_CACHE_KEY = "availableEncodingsCache"; - -/** - * The available charsets only change with the Android 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?.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); - } -} - export async function initEncodings() { - const cachedMap = readCachedEncodings(); - if (cachedMap) { - setEncodings(cachedMap); - return; - } - return new Promise((resolve, reject) => { cordova.exec( (map) => { - setEncodings(map); - writeCachedEncodings(map); + Object.keys(map).forEach((key) => { + const encoding = map[key]; + encodings[key] = encoding; + }); resolve(); }, (error) => { diff --git a/src/utils/mimeTypes.js b/src/utils/mimeTypes.js deleted file mode 100644 index f5bc130432..0000000000 --- a/src/utils/mimeTypes.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * 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 deleted file mode 100644 index 429a100aa9..0000000000 --- a/tests/unit/commandRegistryKeymap.test.js +++ /dev/null @@ -1,124 +0,0 @@ -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/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 a7c6a47b2d..ad6c5aa73f 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", "utils/mimeTypes", "utils/codeHighlight", "utils/Path", "utils/remoteFilePreview", "utils/Url", "./loadPlugins", "./openFolder", "./runLazily", "cm/editorReadOnly", + "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", ].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 27a70a4432..2f33bf5940 100644 --- a/tests/unit/pluginFileRestoration.test.js +++ b/tests/unit/pluginFileRestoration.test.js @@ -153,11 +153,11 @@ function setup() { "handlers/quickTools", "handlers/tabContextMenu", "dompurify", - "utils/mimeTypes", + "mime-types", "utils/codeHighlight", "utils/Path", "./openFolder", - "./runLazily", + "./run", "cm/editorReadOnly", "lib/quickToolsAdapter", ].map((id) => [id, {}]), diff --git a/tests/unit/saveCommands.test.js b/tests/unit/saveCommands.test.js index 3414ca9b48..2576fdf490 100644 --- a/tests/unit/saveCommands.test.js +++ b/tests/unit/saveCommands.test.js @@ -5,7 +5,7 @@ function setup(files) { 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", "./runLazily", "./saveState", "./settings", "./showFileInfo", + "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", ].map(id => [id, {}])); const module = loadSourceModule("src/lib/commands.js", { ...dependencies, "dialogs/confirm": async () => true, "dialogs/select": async () => "save", "utils/helpers": { error }, From 88f067b74af3fb5d0d4a7ddbb68e385f19c58ac6 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:43:54 +0530 Subject: [PATCH 2/7] Revert "perf: speed up app startup (#2930)" This reverts commit 5a7cc934cf1ba812df41e601e66b91e338039e46. The startup work is being consolidated into a single PR for review. Co-Authored-By: Claude Opus 5.5 --- config.xml | 1 - rspack.config.js | 16 +- src/components/terminal/loader.js | 36 ---- src/lib/acode.js | 33 ++- src/main.js | 266 ++++++++++--------------- src/pages/fileBrowser/fileBrowser.js | 1 + src/pages/fontManager/fontManager.js | 2 +- src/pages/plugins/plugins.js | 2 +- src/pages/sponsor/sponsor.js | 3 +- src/pages/themeSetting/themeSetting.js | 2 +- src/settings/terminalSettings.js | 6 +- 11 files changed, 131 insertions(+), 237 deletions(-) delete mode 100644 src/components/terminal/loader.js diff --git a/config.xml b/config.xml index cb6a6aed77..0241142964 100644 --- a/config.xml +++ b/config.xml @@ -26,7 +26,6 @@ - diff --git a/rspack.config.js b/rspack.config.js index 76d5c4713f..c811c6b778 100644 --- a/rspack.config.js +++ b/rspack.config.js @@ -12,16 +12,6 @@ module.exports = (env, options) => { ? ''.concat(devProto, '://', devHost, ':', devPort) : ''; - // Match the oldest WebView the app supports (MIN_WEBVIEW_MAJOR in - // www/index.html) 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 WebView still needs. - const swcEnv = { - targets: 'chrome >= 67', - mode: 'entry', - coreJs: require('core-js/package.json').version, - }; - const rules = [ { test: /typescript[\\/]lib[\\/]lib\..*\.d\.ts$/, @@ -40,8 +30,8 @@ module.exports = (env, options) => { syntax: 'typescript', tsx: false, }, + target: 'es2015', }, - env: swcEnv, }, }, path.resolve(__dirname, 'utils/custom-loaders/html-tag-jsx-loader.js'), @@ -65,8 +55,8 @@ module.exports = (env, options) => { parser: { syntax: 'ecmascript', }, + target: 'es2015', }, - env: swcEnv, }, }, ], @@ -82,8 +72,8 @@ module.exports = (env, options) => { syntax: 'ecmascript', jsx: false, }, + target: 'es2015', }, - env: swcEnv, }, }, path.resolve(__dirname, 'utils/custom-loaders/html-tag-jsx-loader.js'), diff --git a/src/components/terminal/loader.js b/src/components/terminal/loader.js deleted file mode 100644 index 336118e1ec..0000000000 --- a/src/components/terminal/loader.js +++ /dev/null @@ -1,36 +0,0 @@ -/** - * 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) => { - loading = null; - throw error; - }, - ); - return loading; -} diff --git a/src/lib/acode.js b/src/lib/acode.js index 55785a14aa..2870fc0a98 100644 --- a/src/lib/acode.js +++ b/src/lib/acode.js @@ -34,12 +34,7 @@ import Page from "components/page"; import palette from "components/palette"; import settingsPage from "components/settingsPage"; import SideButton from "components/sideButton"; -import { - getLoadedTerminalManager, - loadTerminalManager, -} from "components/terminal/loader"; -import TerminalThemeManager from "components/terminal/terminalThemeManager"; -import TerminalTouchSelection from "components/terminal/terminalTouchSelection"; +import { TerminalManager, TerminalThemeManager } from "components/terminal"; import toast from "components/toast"; import tutorial from "components/tutorial"; import alert from "dialogs/alert"; @@ -299,24 +294,20 @@ class Acode { }; const terminalTouchSelectionMoreOptions = { - add: (option) => TerminalTouchSelection.addMoreOption(option), - remove: (id) => TerminalTouchSelection.removeMoreOption(id), - list: () => TerminalTouchSelection.getMoreOptions(), + add: (option) => TerminalManager.addTouchSelectionMoreOption(option), + remove: (id) => TerminalManager.removeTouchSelectionMoreOption(id), + list: () => TerminalManager.getTouchSelectionMoreOptions(), }; const terminalModule = { - 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(), + create: (options) => TerminalManager.createTerminal(options), + createLocal: (options) => TerminalManager.createLocalTerminal(options), + createServer: (options) => TerminalManager.createServerTerminal(options), + get: (id) => TerminalManager.getTerminal(id), + getAll: () => TerminalManager.getAllTerminals(), write: (id, data) => this.#secureTerminalWrite(id, data), - clear: (id) => getLoadedTerminalManager()?.clearTerminal(id), - close: async (id) => (await loadTerminalManager()).closeTerminal(id), + clear: (id) => TerminalManager.clearTerminal(id), + close: (id) => TerminalManager.closeTerminal(id), moreOptions: terminalTouchSelectionMoreOptions, touchSelection: { moreOptions: terminalTouchSelectionMoreOptions, @@ -560,7 +551,7 @@ class Acode { } // If all security checks pass, proceed with writing - return getLoadedTerminalManager()?.writeToTerminal(id, data); + return TerminalManager.writeToTerminal(id, data); } /** diff --git a/src/main.js b/src/main.js index bd62a94561..cf91423da2 100644 --- a/src/main.js +++ b/src/main.js @@ -8,8 +8,6 @@ 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"; @@ -28,7 +26,6 @@ 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"; @@ -76,12 +73,6 @@ import $_fileMenu from "views/file-menu.hbs"; import $_menu from "views/menu.hbs"; import auth, { loginEvents } from "./lib/auth"; -/** - * Settles once the startup purchase check can no longer change Pro status. - * Plugins wait for it so they initialize with the final value. - */ -let proStatusReady = Promise.resolve(); - const oldPreventDefault = TouchEvent.prototype.preventDefault; const previousVersionCode = Number.parseInt(localStorage.versionCode, 10); const logger = new Logger(); @@ -127,6 +118,8 @@ async function ensurePermission(permission) { } async function onDeviceReady() { + await initEncodings(); // important to load encodings before anything else + const isFreePackage = /(free)$/.test(BuildInfo.packageName); const oldResolveURL = window.resolveLocalFileSystemURL; const { @@ -152,36 +145,27 @@ async function onDeviceReady() { } } - // Start the Play Billing check first so it runs alongside the rest of - // startup instead of blocking it. - config.HAS_PRO = !isFreePackage || 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; window.editorManager = null; window.toast = toast; window.ASSETS_DIRECTORY = Url.join(cordova.file.applicationDirectory, "www"); - window.DATA_STORAGE = dataStorage; - window.CACHE_STORAGE = cacheStorage; + window.DATA_STORAGE = await resolveStorageDir( + externalDataDirectory, + dataDirectory, + ); + window.CACHE_STORAGE = await resolveStorageDir( + externalCacheDirectory, + cacheDirectory, + ); 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"}`; @@ -195,6 +179,14 @@ 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; @@ -206,7 +198,39 @@ async function onDeviceReady() { enumerable: false, }); - window.ANDROID_SDK_INT = androidSdkInt; + try { + await helpers.promisify(iap.startConnection).catch((e) => { + window.log("error", "connection error"); + window.log("error", e); + }); + + if (localStorage.acode_pro === "true") { + config.HAS_PRO = true; + } + + if (navigator.onLine) { + const purchases = await helpers.promisify(iap.getPurchases); + const isPro = purchases.find((p) => + p.productIds.includes("acode_pro_new"), + ); + if (isPro) { + config.HAS_PRO = true; + } else { + config.HAS_PRO = !isFreePackage; + } + } + } 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.DOES_SUPPORT_THEME = (() => { const $testEl = (
- requestAnimationFrame(() => void onAppRendered(proPurchaseCheck)), - ); + setTimeout(async () => { + 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); } await promptUpdateCheckConsent(); @@ -434,122 +501,6 @@ 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 Play purchases. - * Only a change made here is applied, so an upgrade from another source - * (e.g. a login that finished first) is never downgraded. - * @param {boolean} isFreePackage - */ -async function verifyProPurchase(isFreePackage) { - const initialHasPro = config.HAS_PRO; - try { - await helpers.promisify(iap.startConnection).catch((e) => { - logger.log("error", "connection error"); - logger.log("error", e); - }); - - if (!navigator.onLine) return; - - const purchases = await helpers.promisify(iap.getPurchases); - const isPro = purchases.find((p) => p.productIds.includes("acode_pro_new")); - if (isPro) { - config.HAS_PRO = true; - // Lets the next launch skip waiting for this check. - localStorage.acode_pro = "true"; - } else if (config.HAS_PRO === initialHasPro) { - config.HAS_PRO = !isFreePackage; - } - } catch (error) { - logger.log("error", "Purchase error"); - logger.log("error", error); - } -} - -/** - * 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, @@ -810,9 +761,6 @@ async function loadApp() { openWelcomeTab(); } - // Plugins read Pro status while initializing, so let it settle first. - await proStatusReady; - // load theme plugins try { await loadPlugins(true); @@ -854,8 +802,8 @@ async function loadApp() { acode.exec("save-state"); initFileList(); - loadTerminalManager().then( - (TerminalManager) => { + import(/* webpackChunkName: "terminal" */ "components/terminal").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 30b487cdc7..dcb53c4890 100644 --- a/src/pages/fileBrowser/fileBrowser.js +++ b/src/pages/fileBrowser/fileBrowser.js @@ -6,6 +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 alert from "dialogs/alert"; import confirm from "dialogs/confirm"; import loader from "dialogs/loader"; diff --git a/src/pages/fontManager/fontManager.js b/src/pages/fontManager/fontManager.js index 33c7aab3ca..d615aa1217 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/terminalDefaults"; +import { DEFAULT_TERMINAL_SETTINGS } from "components/terminal"; import toast from "components/toast"; import confirm from "dialogs/confirm"; import dialog from "dialogs/dialog"; diff --git a/src/pages/plugins/plugins.js b/src/pages/plugins/plugins.js index 52272210f0..4062a988f2 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 36c32ce08f..02b53475fd 100644 --- a/src/pages/sponsor/sponsor.js +++ b/src/pages/sponsor/sponsor.js @@ -242,7 +242,6 @@ async function handlePurchase(productId, title) { }); } - // multiPrompt rejects when the dialog is cancelled. result = await multiPrompt(onlyTitle(title), [ { placeholder: "Name", @@ -265,7 +264,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 815c44b917..e22081a407 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/terminalThemeManager"; +import { TerminalThemeManager } from "components/terminal"; 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 35a82179fd..e8e07112b6 100644 --- a/src/settings/terminalSettings.js +++ b/src/settings/terminalSettings.js @@ -1,7 +1,9 @@ import fsOperation from "fileSystem"; import settingsPage from "components/settingsPage"; -import { DEFAULT_TERMINAL_SETTINGS } from "components/terminal/terminalDefaults"; -import TerminalThemeManager from "components/terminal/terminalThemeManager"; +import { + DEFAULT_TERMINAL_SETTINGS, + TerminalThemeManager, +} from "components/terminal"; import toast from "components/toast"; import alert from "dialogs/alert"; import confirm from "dialogs/confirm"; From 5436b6158e780846141bd7d36ea01635da0871c8 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:09:31 +0530 Subject: [PATCH 3/7] perf: speed up app startup - Remove the fixed 500ms delay before hiding the splash; reveal after the app has rendered a frame. - Run independent native startup calls (encodings, storage dirs, installer, Android version) in parallel. - Run the Play Billing purchase check alongside startup. Non-Pro users still wait for it before the UI is usable so paid themes are never treated as locked; ads and the login check still wait for it. - Load the terminal manager (xterm and addons) on demand instead of in main.js; keep xterm.css in main.css and have it loaded before plugins so the synchronous plugin terminal APIs behave as before. - Target WebView 67+ instead of ES2015 and let swc reduce the core-js entry import to the polyfills that WebView needs. - Shorten the native splash fade to 150ms. - Fix disabledMap being out of scope in the owned plugins list and an unhandled rejection when the sponsor form is cancelled. - Add temporary startup timing marks (src/lib/startupPerf.js). Co-Authored-By: Claude Opus 5.5 --- config.xml | 1 + rspack.config.js | 16 +- src/boot.js | 3 + src/components/terminal/loader.js | 36 ++++ src/lib/acode.js | 33 +-- src/lib/startupPerf.js | 60 ++++++ src/main.js | 270 +++++++++++++++---------- src/pages/fileBrowser/fileBrowser.js | 1 - src/pages/fontManager/fontManager.js | 2 +- src/pages/plugins/plugins.js | 2 +- src/pages/sponsor/sponsor.js | 3 +- src/pages/themeSetting/themeSetting.js | 2 +- src/settings/terminalSettings.js | 6 +- 13 files changed, 304 insertions(+), 131 deletions(-) create mode 100644 src/components/terminal/loader.js create mode 100644 src/lib/startupPerf.js diff --git a/config.xml b/config.xml index 0241142964..cb6a6aed77 100644 --- a/config.xml +++ b/config.xml @@ -26,6 +26,7 @@ + diff --git a/rspack.config.js b/rspack.config.js index c811c6b778..76d5c4713f 100644 --- a/rspack.config.js +++ b/rspack.config.js @@ -12,6 +12,16 @@ module.exports = (env, options) => { ? ''.concat(devProto, '://', devHost, ':', devPort) : ''; + // Match the oldest WebView the app supports (MIN_WEBVIEW_MAJOR in + // www/index.html) 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 WebView still needs. + const swcEnv = { + targets: 'chrome >= 67', + mode: 'entry', + coreJs: require('core-js/package.json').version, + }; + const rules = [ { test: /typescript[\\/]lib[\\/]lib\..*\.d\.ts$/, @@ -30,8 +40,8 @@ module.exports = (env, options) => { syntax: 'typescript', tsx: false, }, - target: 'es2015', }, + env: swcEnv, }, }, path.resolve(__dirname, 'utils/custom-loaders/html-tag-jsx-loader.js'), @@ -55,8 +65,8 @@ module.exports = (env, options) => { parser: { syntax: 'ecmascript', }, - target: 'es2015', }, + env: swcEnv, }, }, ], @@ -72,8 +82,8 @@ module.exports = (env, options) => { syntax: 'ecmascript', jsx: false, }, - target: 'es2015', }, + env: swcEnv, }, }, path.resolve(__dirname, 'utils/custom-loaders/html-tag-jsx-loader.js'), diff --git a/src/boot.js b/src/boot.js index 27af18b953..6b24d5078d 100644 --- a/src/boot.js +++ b/src/boot.js @@ -3,6 +3,9 @@ // main.js is never imported directly; it's loaded dynamically so the dev server // can serve a freshly compiled version on every reload. +// TEMPORARY startup instrumentation (see src/lib/startupPerf.js) +performance.mark?.("startup:boot.js"); + const DEV_MODE = typeof __DEV_MODE__ !== "undefined" && __DEV_MODE__; const DEV_HOST = typeof __DEV_HOST__ !== "undefined" ? __DEV_HOST__ : ""; const DEV_PORT = typeof __DEV_PORT__ !== "undefined" ? __DEV_PORT__ : ""; diff --git a/src/components/terminal/loader.js b/src/components/terminal/loader.js new file mode 100644 index 0000000000..336118e1ec --- /dev/null +++ b/src/components/terminal/loader.js @@ -0,0 +1,36 @@ +/** + * 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) => { + loading = null; + throw error; + }, + ); + return loading; +} diff --git a/src/lib/acode.js b/src/lib/acode.js index 2870fc0a98..55785a14aa 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/startupPerf.js b/src/lib/startupPerf.js new file mode 100644 index 0000000000..5cee8e086f --- /dev/null +++ b/src/lib/startupPerf.js @@ -0,0 +1,60 @@ +/** + * TEMPORARY startup instrumentation for comparing launch times. + * Remove this file and every `startupPerf` call once measurements are done. + * + * Read the result with: + * adb logcat | grep startup-perf + * or from devtools: window.__startupPerf + */ + +const PREFIX = "startup:"; + +/** + * Record a startup milestone (milliseconds since the WebView started loading). + * @param {string} name + */ +function mark(name) { + try { + performance.mark(PREFIX + name); + } catch { + // performance API unavailable; measurements are best effort + } +} + +/** + * Log every milestone with its time and the gap from the previous one. + */ +function report() { + let marks = []; + try { + marks = performance + .getEntriesByType("mark") + .filter((entry) => entry.name.startsWith(PREFIX)) + .sort((a, b) => a.startTime - b.startTime); + } catch { + return; + } + + let previous = 0; + const rows = marks.map((entry) => { + const at = Math.round(entry.startTime); + const row = { + step: entry.name.slice(PREFIX.length), + at, + delta: at - previous, + }; + previous = at; + return row; + }); + + window.__startupPerf = rows; + const lines = rows.map( + ({ step, at, delta }) => + `${String(at).padStart(6)}ms (+${String(delta).padStart(5)}ms) ${step}`, + ); + const text = `[startup-perf]\n${lines.join("\n")}`; + console.info(text); + window.log?.("info", text); +} + +export default { mark, report }; diff --git a/src/main.js b/src/main.js index cf91423da2..379a7174ce 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"; @@ -61,6 +64,7 @@ import startAd, { BANNER_SUPPRESSION_REASON, setBannerSuppressed, } from "lib/startAd"; +import startupPerf from "lib/startupPerf"; import mustache from "mustache"; import themes from "theme/list"; import { initHighlighting } from "utils/codeHighlight"; @@ -73,6 +77,8 @@ import $_fileMenu from "views/file-menu.hbs"; import $_menu from "views/menu.hbs"; import auth, { loginEvents } from "./lib/auth"; +startupPerf.mark("main.js evaluated"); + const oldPreventDefault = TouchEvent.prototype.preventDefault; const previousVersionCode = Number.parseInt(localStorage.versionCode, 10); const logger = new Logger(); @@ -118,8 +124,7 @@ async function ensurePermission(permission) { } async function onDeviceReady() { - await initEncodings(); // important to load encodings before anything else - + startupPerf.mark("deviceready"); const isFreePackage = /(free)$/.test(BuildInfo.packageName); const oldResolveURL = window.resolveLocalFileSystemURL; const { @@ -145,27 +150,35 @@ async function onDeviceReady() { } } + // Start the Play Billing check first so it runs alongside the rest of + // startup instead of blocking it. + config.HAS_PRO = !isFreePackage || localStorage.acode_pro === "true"; + const proPurchaseCheck = verifyProPurchase(isFreePackage); + + // 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 + ]); + startupPerf.mark("native startup info"); + window.app = document.body; window.root = tag.get("#root"); window.addedFolder = addedFolder; window.editorManager = null; window.toast = toast; window.ASSETS_DIRECTORY = Url.join(cordova.file.applicationDirectory, "www"); - 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"}`; @@ -179,14 +192,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; @@ -198,39 +203,7 @@ async function onDeviceReady() { enumerable: false, }); - try { - await helpers.promisify(iap.startConnection).catch((e) => { - window.log("error", "connection error"); - window.log("error", e); - }); - - if (localStorage.acode_pro === "true") { - config.HAS_PRO = true; - } - - if (navigator.onLine) { - const purchases = await helpers.promisify(iap.getPurchases); - const isPro = purchases.find((p) => - p.productIds.includes("acode_pro_new"), - ); - if (isPro) { - config.HAS_PRO = true; - } else { - config.HAS_PRO = !isFreePackage; - } - } - } 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; otherwise paid themes could be treated as locked. + if (!config.HAS_PRO) { + await proPurchaseCheck; + startupPerf.mark("pro purchase check"); + } + // Reveal the app once it has rendered a frame, then load the rest. + requestAnimationFrame(() => + requestAnimationFrame(() => void onAppRendered(proPurchaseCheck)), + ); } await promptUpdateCheckConsent(); @@ -501,6 +435,124 @@ 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 Play purchases. + * Only a change made here is applied, so an upgrade from another source + * (e.g. a login that finished first) is never downgraded. + * @param {boolean} isFreePackage + */ +async function verifyProPurchase(isFreePackage) { + const initialHasPro = config.HAS_PRO; + try { + await helpers.promisify(iap.startConnection).catch((e) => { + logger.log("error", "connection error"); + logger.log("error", e); + }); + + if (!navigator.onLine) return; + + const purchases = await helpers.promisify(iap.getPurchases); + const isPro = purchases.find((p) => p.productIds.includes("acode_pro_new")); + if (isPro) { + config.HAS_PRO = true; + // Lets the next launch skip waiting for this check. + localStorage.acode_pro = "true"; + } else if (config.HAS_PRO === initialHasPro) { + config.HAS_PRO = !isFreePackage; + } + } catch (error) { + logger.log("error", "Purchase error"); + logger.log("error", error); + } +} + +/** + * 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"); + startupPerf.mark("splash hidden"); + + // 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 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); + } + startupPerf.mark("plugins loaded"); + 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(); + startupPerf.report(); +} + function showSftpMigrationReport({ failures, removedReferences, @@ -673,6 +725,7 @@ async function loadApp() { const folders = helpers.parseJSON(localStorage.folders); const files = helpers.parseJSON(localStorage.files) || []; const editorManager = await EditorManager($header, $main); + startupPerf.mark("loadApp: editor manager"); const setMainMenu = () => { if ($mainMenu) { @@ -715,6 +768,7 @@ async function loadApp() { editorManager.on("switch-file", initIconTooltips()); sidebarApps.init($sidebar); await sidebarApps.loadApps(); + startupPerf.mark("loadApp: sidebar apps"); editorManager.onupdate = onEditorUpdate; root.on("show", mainPageOnShow); app.addEventListener("click", onClickApp); @@ -769,6 +823,7 @@ async function loadApp() { window.log("error", error); toast("Failed to load theme plugins!"); } + startupPerf.mark("loadApp: theme plugins"); acode.setLoadingMessage("Loading folders..."); if (Array.isArray(folders)) { @@ -799,11 +854,12 @@ async function loadApp() { onEditorUpdate(undefined, false); } + startupPerf.mark("loadApp: files restored"); 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 dcb53c4890..30b487cdc7 100644 --- a/src/pages/fileBrowser/fileBrowser.js +++ b/src/pages/fileBrowser/fileBrowser.js @@ -6,7 +6,6 @@ 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 alert from "dialogs/alert"; import confirm from "dialogs/confirm"; import loader from "dialogs/loader"; diff --git a/src/pages/fontManager/fontManager.js b/src/pages/fontManager/fontManager.js index d615aa1217..33c7aab3ca 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/plugins/plugins.js b/src/pages/plugins/plugins.js index 4062a988f2..52272210f0 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 02b53475fd..36c32ce08f 100644 --- a/src/pages/sponsor/sponsor.js +++ b/src/pages/sponsor/sponsor.js @@ -242,6 +242,7 @@ async function handlePurchase(productId, title) { }); } + // multiPrompt rejects when the dialog is cancelled. result = await multiPrompt(onlyTitle(title), [ { placeholder: "Name", @@ -264,7 +265,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 e22081a407..815c44b917 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 e8e07112b6..35a82179fd 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"; From ac654e334f1a7c04b5178944ec0152f45cf7aed7 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:32:36 +0530 Subject: [PATCH 4/7] fix: let plugins load with the settled Pro status On free builds, a cached acode_pro value no longer waited for the Play Billing check, so plugins (including theme plugins) could initialize before a downgrade was applied. Wait for the check before loading any plugin, as before the check ran in parallel. Paid builds are always Pro and do not wait. Also remove the temporary startup timing marks. Co-Authored-By: Claude Opus 5.5 --- src/boot.js | 3 --- src/lib/startupPerf.js | 60 ------------------------------------------ src/main.js | 26 ++++++++---------- 3 files changed, 11 insertions(+), 78 deletions(-) delete mode 100644 src/lib/startupPerf.js diff --git a/src/boot.js b/src/boot.js index 6b24d5078d..27af18b953 100644 --- a/src/boot.js +++ b/src/boot.js @@ -3,9 +3,6 @@ // main.js is never imported directly; it's loaded dynamically so the dev server // can serve a freshly compiled version on every reload. -// TEMPORARY startup instrumentation (see src/lib/startupPerf.js) -performance.mark?.("startup:boot.js"); - const DEV_MODE = typeof __DEV_MODE__ !== "undefined" && __DEV_MODE__; const DEV_HOST = typeof __DEV_HOST__ !== "undefined" ? __DEV_HOST__ : ""; const DEV_PORT = typeof __DEV_PORT__ !== "undefined" ? __DEV_PORT__ : ""; diff --git a/src/lib/startupPerf.js b/src/lib/startupPerf.js deleted file mode 100644 index 5cee8e086f..0000000000 --- a/src/lib/startupPerf.js +++ /dev/null @@ -1,60 +0,0 @@ -/** - * TEMPORARY startup instrumentation for comparing launch times. - * Remove this file and every `startupPerf` call once measurements are done. - * - * Read the result with: - * adb logcat | grep startup-perf - * or from devtools: window.__startupPerf - */ - -const PREFIX = "startup:"; - -/** - * Record a startup milestone (milliseconds since the WebView started loading). - * @param {string} name - */ -function mark(name) { - try { - performance.mark(PREFIX + name); - } catch { - // performance API unavailable; measurements are best effort - } -} - -/** - * Log every milestone with its time and the gap from the previous one. - */ -function report() { - let marks = []; - try { - marks = performance - .getEntriesByType("mark") - .filter((entry) => entry.name.startsWith(PREFIX)) - .sort((a, b) => a.startTime - b.startTime); - } catch { - return; - } - - let previous = 0; - const rows = marks.map((entry) => { - const at = Math.round(entry.startTime); - const row = { - step: entry.name.slice(PREFIX.length), - at, - delta: at - previous, - }; - previous = at; - return row; - }); - - window.__startupPerf = rows; - const lines = rows.map( - ({ step, at, delta }) => - `${String(at).padStart(6)}ms (+${String(delta).padStart(5)}ms) ${step}`, - ); - const text = `[startup-perf]\n${lines.join("\n")}`; - console.info(text); - window.log?.("info", text); -} - -export default { mark, report }; diff --git a/src/main.js b/src/main.js index 379a7174ce..bd62a94561 100644 --- a/src/main.js +++ b/src/main.js @@ -64,7 +64,6 @@ import startAd, { BANNER_SUPPRESSION_REASON, setBannerSuppressed, } from "lib/startAd"; -import startupPerf from "lib/startupPerf"; import mustache from "mustache"; import themes from "theme/list"; import { initHighlighting } from "utils/codeHighlight"; @@ -77,7 +76,11 @@ import $_fileMenu from "views/file-menu.hbs"; import $_menu from "views/menu.hbs"; import auth, { loginEvents } from "./lib/auth"; -startupPerf.mark("main.js evaluated"); +/** + * Settles once the startup purchase check can no longer change Pro status. + * Plugins wait for it so they initialize with the final value. + */ +let proStatusReady = Promise.resolve(); const oldPreventDefault = TouchEvent.prototype.preventDefault; const previousVersionCode = Number.parseInt(localStorage.versionCode, 10); @@ -124,7 +127,6 @@ async function ensurePermission(permission) { } async function onDeviceReady() { - startupPerf.mark("deviceready"); const isFreePackage = /(free)$/.test(BuildInfo.packageName); const oldResolveURL = window.resolveLocalFileSystemURL; const { @@ -154,6 +156,8 @@ async function onDeviceReady() { // startup instead of blocking it. config.HAS_PRO = !isFreePackage || 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] = @@ -164,7 +168,6 @@ async function onDeviceReady() { getAndroidSdkInt(), initEncodings(), // important to load encodings before anything else ]); - startupPerf.mark("native startup info"); window.app = document.body; window.root = tag.get("#root"); @@ -286,7 +289,6 @@ async function onDeviceReady() { acode.setLoadingMessage("Loading settings..."); await settings.init(); - startupPerf.mark("settings"); fileIcons.bindSettings(settings); fileIcons.syncFromSettings(); themes.init(); @@ -299,7 +301,6 @@ async function onDeviceReady() { acode.setLoadingMessage("Loading language..."); await lang.set(settings.value.lang); - startupPerf.mark("language"); acode.setLoadingMessage("Securing SFTP profiles..."); const sftpMigration = await migrateLegacySftpProfiles(); @@ -323,7 +324,6 @@ async function onDeviceReady() { try { await loadApp(); - startupPerf.mark("loadApp"); if (sftpMigration.failures.length) { showSftpMigrationReport(sftpMigration); } @@ -335,7 +335,6 @@ async function onDeviceReady() { // the UI is usable; otherwise paid themes could be treated as locked. if (!config.HAS_PRO) { await proPurchaseCheck; - startupPerf.mark("pro purchase check"); } // Reveal the app once it has rendered a frame, then load the rest. requestAnimationFrame(() => @@ -493,7 +492,6 @@ async function verifyProPurchase(isFreePackage) { async function onAppRendered(proPurchaseCheck) { document.body.removeAttribute("data-small-msg"); app.classList.remove("loading", "splash"); - startupPerf.mark("splash hidden"); // load plugins try { @@ -501,6 +499,7 @@ async function onAppRendered(proPurchaseCheck) { 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 @@ -529,7 +528,6 @@ async function onAppRendered(proPurchaseCheck) { } finally { void processPendingIntents().catch(intentHandler.onError); } - startupPerf.mark("plugins loaded"); applySettings.afterRender(); // The purchase result must be applied before login can upgrade to Pro. @@ -550,7 +548,6 @@ async function onAppRendered(proPurchaseCheck) { fetchPromotions(); startAd(); - startupPerf.report(); } function showSftpMigrationReport({ @@ -725,7 +722,6 @@ async function loadApp() { const folders = helpers.parseJSON(localStorage.folders); const files = helpers.parseJSON(localStorage.files) || []; const editorManager = await EditorManager($header, $main); - startupPerf.mark("loadApp: editor manager"); const setMainMenu = () => { if ($mainMenu) { @@ -768,7 +764,6 @@ async function loadApp() { editorManager.on("switch-file", initIconTooltips()); sidebarApps.init($sidebar); await sidebarApps.loadApps(); - startupPerf.mark("loadApp: sidebar apps"); editorManager.onupdate = onEditorUpdate; root.on("show", mainPageOnShow); app.addEventListener("click", onClickApp); @@ -815,6 +810,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); @@ -823,7 +821,6 @@ async function loadApp() { window.log("error", error); toast("Failed to load theme plugins!"); } - startupPerf.mark("loadApp: theme plugins"); acode.setLoadingMessage("Loading folders..."); if (Array.isArray(folders)) { @@ -854,7 +851,6 @@ async function loadApp() { onEditorUpdate(undefined, false); } - startupPerf.mark("loadApp: files restored"); acode.exec("save-state"); initFileList(); From 92ce8c893e7de4f9a26d2d395ea048dcaef4b052 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:33:50 +0530 Subject: [PATCH 5/7] perf: shrink startup bundle and cache native encodings - Load lib/run (markdown-it, its plugins and the markdown preview) on first run/preview instead of in main.js. - Load mime-types and its MIME database on demand at each lookup site. - Skip load-time validation for bundled editor themes; they are still validated when applied, and plugin themes are still validated when registered. - Cache the Android charset list per app build, OS version and device model instead of rebuilding it natively on every launch. - Fetch the sidebar app chunks in parallel while adding them in their original order. Co-Authored-By: Claude Opus 5.5 --- src/cm/themes/index.js | 67 ++++++++++++++++-------- src/fileSystem/ftp.js | 4 +- src/fileSystem/sftp.js | 6 ++- src/lib/commands.js | 4 +- src/lib/editorFile.js | 11 ++-- src/lib/runLazily.js | 9 ++++ src/pages/fileBrowser/fileBrowser.js | 3 +- src/pages/plugin/plugin.js | 3 +- src/sidebarApps/index.js | 14 +++-- src/utils/encodings.js | 57 ++++++++++++++++++-- src/utils/mimeTypes.js | 11 ++++ tests/unit/fileSaveEvents.test.js | 2 +- tests/unit/pluginFileRestoration.test.js | 4 +- tests/unit/saveCommands.test.js | 2 +- 14 files changed, 153 insertions(+), 44 deletions(-) create mode 100644 src/lib/runLazily.js create mode 100644 src/utils/mimeTypes.js diff --git a/src/cm/themes/index.js b/src/cm/themes/index.js index dbcd67eba1..1043b60c66 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/fileSystem/ftp.js b/src/fileSystem/ftp.js index 8ba1ea7206..98f4c9ef7c 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 803be8af45..d30b50f3c0 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/commands.js b/src/lib/commands.js index de3a005a8e..b0584442ab 100644 --- a/src/lib/commands.js +++ b/src/lib/commands.js @@ -16,7 +16,7 @@ import EditorFile from "./editorFile"; import { loadFileBrowser } from "./lazyImports"; import openFile from "./openFile"; import openFolder from "./openFolder"; -import run from "./run"; +import runLazily from "./runLazily"; import saveState from "./saveState"; import appSettings from "./settings"; import showFileInfo from "./showFileInfo"; @@ -287,7 +287,7 @@ export default { resolveReferenceFile(referenceFile)?.togglePinned?.(); }, console() { - run(true, "inapp"); + void runLazily(true, "inapp"); }, "check-files"() { if (!appSettings.value.checkFiles) return; diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index 03a8c3ba0e..b9a736f06b 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -18,16 +18,16 @@ 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 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"; @@ -1788,7 +1788,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, @@ -2028,7 +2031,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/runLazily.js b/src/lib/runLazily.js new file mode 100644 index 0000000000..cc21326ae3 --- /dev/null +++ b/src/lib/runLazily.js @@ -0,0 +1,9 @@ +/** + * Runs/previews a file, loading the runner on first use. The runner pulls in + * markdown-it and the markdown preview, which are not needed at startup. + * @param {...any} args arguments for lib/run + */ +export default async function runLazily(...args) { + const { default: run } = await import(/* webpackChunkName: "run" */ "./run"); + return run(...args); +} diff --git a/src/pages/fileBrowser/fileBrowser.js b/src/pages/fileBrowser/fileBrowser.js index 30b487cdc7..aabbbb19e6 100644 --- a/src/pages/fileBrowser/fileBrowser.js +++ b/src/pages/fileBrowser/fileBrowser.js @@ -21,12 +21,12 @@ 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 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"; @@ -1196,6 +1196,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/plugin/plugin.js b/src/pages/plugin/plugin.js index 8afc97a263..d9a74f460a 100644 --- a/src/pages/plugin/plugin.js +++ b/src/pages/plugin/plugin.js @@ -18,9 +18,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"; @@ -121,6 +121,7 @@ export default async function PluginInclude( Url.join(PLUGIN_DIR, id, installedPlugin.icon), ); const iconData = await fsOperation(iconUrl).readFile(); + const mimeTypes = await loadMimeTypes(); const iconMimeType = mimeTypes.lookup(installedPlugin.icon) || "image/png"; const icon = URL.createObjectURL( diff --git a/src/sidebarApps/index.js b/src/sidebarApps/index.js index 4172f79917..e97cc22e2e 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 71a1d892e0..662b808f57 100644 --- a/src/utils/encodings.js +++ b/src/utils/encodings.js @@ -201,14 +201,63 @@ export function encode(text, charset) { return execEncode(text, charset); } +const ENCODINGS_CACHE_KEY = "availableEncodingsCache"; + +/** + * The available charsets only change with the Android 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?.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); + } +} + export async function initEncodings() { + const cachedMap = readCachedEncodings(); + if (cachedMap) { + setEncodings(cachedMap); + return; + } + return new Promise((resolve, reject) => { cordova.exec( (map) => { - Object.keys(map).forEach((key) => { - const encoding = map[key]; - encodings[key] = encoding; - }); + setEncodings(map); + writeCachedEncodings(map); resolve(); }, (error) => { diff --git a/src/utils/mimeTypes.js b/src/utils/mimeTypes.js new file mode 100644 index 0000000000..f5bc130432 --- /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/fileSaveEvents.test.js b/tests/unit/fileSaveEvents.test.js index ad6c5aa73f..a7c6a47b2d 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 2f33bf5940..27a70a4432 100644 --- a/tests/unit/pluginFileRestoration.test.js +++ b/tests/unit/pluginFileRestoration.test.js @@ -153,11 +153,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/saveCommands.test.js b/tests/unit/saveCommands.test.js index 2576fdf490..3414ca9b48 100644 --- a/tests/unit/saveCommands.test.js +++ b/tests/unit/saveCommands.test.js @@ -5,7 +5,7 @@ function setup(files) { 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", "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", { ...dependencies, "dialogs/confirm": async () => true, "dialogs/select": async () => "save", "utils/helpers": { error }, From 0df993d1627b5570f616ce855538107fb5082162 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:27:43 +0530 Subject: [PATCH 6/7] perf: make command registration and keymap rebuilds cheap Each command registration (including every plugin addCommand) rebuilt the whole keymap, re-parsing both key strings for every pairwise conflict check, and immediately reconfigured every editor pane. Profiling on a device showed ~1.8s of main-thread CPU spent here while plugins loaded. - Cache canonicalized key strings; the result depends only on the input. - Find the first conflicting claim without copying the claimed-keys map for every candidate. - Rebuild the keymap lazily: registration marks it dirty and every reader rebuilds first, so reads stay current. The returned command still has its final key and description immediately. - Apply keymap refreshes once per burst in a microtask, which always runs before the next key event. - Drop the module-load rebuild that setKeyBindings immediately repeated. Co-Authored-By: Claude Opus 5.5 --- src/cm/commandRegistry.js | 93 ++++++++++++++--- src/cm/keyBindingUtils.js | 12 ++- tests/unit/commandRegistryKeymap.test.js | 124 +++++++++++++++++++++++ 3 files changed, 212 insertions(+), 17 deletions(-) create mode 100644 tests/unit/commandRegistryKeymap.test.js diff --git a/src/cm/commandRegistry.js b/src/cm/commandRegistry.js index 5d53cb1747..c3f295c16d 100644 --- a/src/cm/commandRegistry.js +++ b/src/cm/commandRegistry.js @@ -151,6 +151,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), @@ -164,7 +174,6 @@ registerCoreCommands(); registerLspCommands(); registerLintCommands(); registerCommandsFromKeyBindings(); -rebuildKeymap(); function registerCoreCommands() { addCommand({ @@ -1514,19 +1523,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); @@ -1551,9 +1575,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 = @@ -1596,6 +1626,7 @@ function rebuildKeymap() { cachedKeyBindingConflicts = conflicts; cachedKeymap = bindings; resolvedKeyBindingsVersion += 1; + keymapDirty = false; return bindings; } @@ -1643,6 +1674,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, @@ -1651,22 +1683,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)); } @@ -1754,9 +1791,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; } @@ -1765,13 +1804,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) { @@ -1827,8 +1887,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 69e2a3583b..1ebcdeb10f 100644 --- a/src/cm/keyBindingUtils.js +++ b/src/cm/keyBindingUtils.js @@ -45,8 +45,18 @@ export function toCodeMirrorKey(combo) { return strokes.length ? strokes.join(" ") : null; } +// Conflict checks compare every binding with every other one each time the +// keymap is rebuilt (once per registered command), so cache the parsed form. +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; + canonicalKeyCache.set(combo, canonicalKey); + return canonicalKey; } /** diff --git a/tests/unit/commandRegistryKeymap.test.js b/tests/unit/commandRegistryKeymap.test.js new file mode 100644 index 0000000000..429a100aa9 --- /dev/null +++ b/tests/unit/commandRegistryKeymap.test.js @@ -0,0 +1,124 @@ +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/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", + }, + ]); +}); From b1e77080c1d5c9c07fb21a04068b0ce99a51e5e0 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:45:48 +0530 Subject: [PATCH 7/7] fix: run the requested file and bound the key binding cache - runLazily captures the active file before loading the runner chunk and passes it to run(), so switching tabs during the first load cannot run or preview a different file. - Cap the canonical key binding cache so repeated registrations with new keys cannot grow it without limit. Co-Authored-By: Claude Opus 5.5 --- src/cm/keyBindingUtils.js | 9 +++++++-- src/lib/run.js | 4 +++- src/lib/runLazily.js | 15 ++++++++++----- tests/unit/runLazily.test.js | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 tests/unit/runLazily.test.js diff --git a/src/cm/keyBindingUtils.js b/src/cm/keyBindingUtils.js index 1ebcdeb10f..06bf26e2d2 100644 --- a/src/cm/keyBindingUtils.js +++ b/src/cm/keyBindingUtils.js @@ -45,8 +45,10 @@ export function toCodeMirrorKey(combo) { return strokes.length ? strokes.join(" ") : null; } -// Conflict checks compare every binding with every other one each time the -// keymap is rebuilt (once per registered command), so cache the parsed form. +// 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) { @@ -55,6 +57,9 @@ export function canonicalizeKeyBinding(combo) { } 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/lib/run.js b/src/lib/run.js index beb5346433..fcab38cb13 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 index cc21326ae3..27537daf16 100644 --- a/src/lib/runLazily.js +++ b/src/lib/runLazily.js @@ -1,9 +1,14 @@ /** - * Runs/previews a file, loading the runner on first use. The runner pulls in - * markdown-it and the markdown preview, which are not needed at startup. - * @param {...any} args arguments for lib/run + * 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(...args) { +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(...args); + return run(isConsole, target, runFile, file); } diff --git a/tests/unit/runLazily.test.js b/tests/unit/runLazily.test.js new file mode 100644 index 0000000000..5cbc248f7f --- /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); +});