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