diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json index 78f3fac..08e3811 100644 --- a/.devcontainer/devcontainer-lock.json +++ b/.devcontainer/devcontainer-lock.json @@ -1,9 +1,9 @@ { "features": { "ghcr.io/devcontainers/features/node:2.1": { - "version": "2.1.0", - "resolved": "ghcr.io/devcontainers/features/node@sha256:586c9a6f7dd40bd3ba2cd41e7f2f88dcc31fbe5d1442afcbf07ffbc66b686857", - "integrity": "sha256:586c9a6f7dd40bd3ba2cd41e7f2f88dcc31fbe5d1442afcbf07ffbc66b686857" + "version": "2.1.1", + "resolved": "ghcr.io/devcontainers/features/node@sha256:2a19d3b0b3879437637cfd292eb29fee0afcfbe6364e33db9afc4f80c5c1f956", + "integrity": "sha256:2a19d3b0b3879437637cfd292eb29fee0afcfbe6364e33db9afc4f80c5c1f956" }, "ghcr.io/nordcominc/devcontainer-features/android-sdk:2": { "version": "2.0.0", diff --git a/_typos.toml b/_typos.toml index 35a768b..9d1b75f 100644 --- a/_typos.toml +++ b/_typos.toml @@ -57,5 +57,6 @@ multline = "multline" sav = "sav" ist = "ist" Hel = "Hel" +edn = "edn" # Apple simulator device identifier udid = "udid" diff --git a/biome.json b/biome.json index 00c5cdd..324b2ca 100644 --- a/biome.json +++ b/biome.json @@ -40,10 +40,10 @@ "files": { "includes": [ "src/**/*.js", + "src/**/*.ts", "dev/**/*.js", "src/lang/**/*.json", - "src/native/**/*.ts", - "src/platforms/**/*.ts", + "tests/**", "!fastlane/**/*", "!platforms/**/*" ] diff --git a/package-lock.json b/package-lock.json index 620d6f6..7fe4508 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "@deadlyjack/ajax": "^1.2.6", "@emmetio/codemirror6-plugin": "^0.4.1", "@lezer/common": "^1.5.2", - "@lezer/highlight": "^1.2.3", + "@lezer/highlight": "^1.2.5", "@lezer/lr": "^1.4.10", "@ungap/custom-elements": "^1.3.0", "@xterm/addon-attach": "^0.12.0", diff --git a/package.json b/package.json index 66632b2..6624f06 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,7 @@ "@deadlyjack/ajax": "^1.2.6", "@emmetio/codemirror6-plugin": "^0.4.1", "@lezer/common": "^1.5.2", - "@lezer/highlight": "^1.2.3", + "@lezer/highlight": "^1.2.5", "@lezer/lr": "^1.4.10", "@ungap/custom-elements": "^1.3.0", "@xterm/addon-attach": "^0.12.0", diff --git a/platforms/android/app/src/main/java/com/foxdebug/acode/browser/Browser.java b/platforms/android/app/src/main/java/com/foxdebug/acode/browser/Browser.java index 48eeec7..c42ca49 100644 --- a/platforms/android/app/src/main/java/com/foxdebug/acode/browser/Browser.java +++ b/platforms/android/app/src/main/java/com/foxdebug/acode/browser/Browser.java @@ -219,6 +219,7 @@ public void onDownloadStart(String url, String userAgent, createMenu(); addView(titleLayout); addView(webViewContainer); + restoreEmulator(); } private void createMenu() { @@ -228,6 +229,10 @@ private void createMenu() { menu.addItem(Ui.Icons.DEVICES, "Devices", false); menu.addItem(Ui.Icons.NO_CACHE, "Disable Cache", false); menu.addItem(Ui.Icons.TERMINAL, "Console", false); + menu.addItem(Ui.Icons.TUNE, "Set as Default"); + menu.addItem(Ui.Icons.REFRESH, "Clear Default"); + menu.setVisible("Set as Default", false); + menu.setVisible("Clear Default", false); menu.addItem(Ui.Icons.OPEN_IN_BROWSER, "Open in Browser"); menu.addItem(Ui.Icons.EXIT, "Exit"); @@ -242,6 +247,7 @@ public void onSelect(String action, Boolean checked) { } emulator = checked; + updateDefaultItems(checked); if (checked) { setDesktopMode(true); setConsoleVisible(false); @@ -276,6 +282,26 @@ public void onGlobalLayout() { case "Console": setConsoleVisible(checked); break; + case "Set as Default": + if (deviceEmulator != null) { + deviceEmulator.saveAsDefault(); + updateDefaultItems(emulator); + Toast + .makeText( + context, + "Saved as default preview", + Toast.LENGTH_SHORT + ) + .show(); + } + break; + case "Clear Default": + Emulator.clearDefault(context); + updateDefaultItems(emulator); + Toast + .makeText(context, "Default cleared", Toast.LENGTH_SHORT) + .show(); + break; case "Disable Cache": webView .getSettings() @@ -300,6 +326,75 @@ public void onGlobalLayout() { ); } + private void updateDefaultItems(boolean emulatorOn) { + menu.setVisible("Set as Default", emulatorOn); + menu.setVisible( + "Clear Default", + emulatorOn && Emulator.hasDefault(context) + ); + } + + private void reloadAfterLayout() { + // If no page was requested yet, the first load will already happen at + // the saved size, so there is nothing to reload. + if (url.isEmpty()) return; + + webView + .getViewTreeObserver() + .addOnGlobalLayoutListener( + new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + webView.getViewTreeObserver().removeOnGlobalLayoutListener(this); + webView.reload(); + } + } + ); + } + + private void restoreEmulator() { + if (onlyConsole) return; + if (!Emulator.hasDefault(context)) return; + + // Apply the saved mode before the first navigation so the page is not + // loaded with the wrong user agent and then reloaded. + setDesktopMode(true, false); + + webView + .getViewTreeObserver() + .addOnGlobalLayoutListener( + new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + if (webView.getMeasuredWidth() == 0) return; + webView.getViewTreeObserver().removeOnGlobalLayoutListener(this); + + if (deviceEmulator == null) { + createDeviceEmulatorLayout(); + deviceEmulator.setRestoredListener( + new Runnable() { + @Override + public void run() { + reloadAfterLayout(); + } + } + ); + } + + emulator = true; + menu.setChecked("Devices", true); + updateDefaultItems(true); + setConsoleVisible(false); + menu.setChecked("Console", false); + menu.setVisible("Console", false); + addView(deviceEmulator); + // The emulator sets up its sliders on a later layout pass and then + // applies the saved size itself, so the webview is not resized here. + } + } + ); + } + private void createDeviceEmulatorLayout() { Browser browser = this; deviceEmulator = new Emulator(context, theme); @@ -331,6 +426,10 @@ private void setTextViewProperties(TextView textView, int height) { } private void setDesktopMode(boolean enabled) { + setDesktopMode(enabled, true); + } + + private void setDesktopMode(boolean enabled, boolean reload) { int width = 0; int height = 0; WebSettings webSettings = webView.getSettings(); @@ -352,7 +451,9 @@ private void setDesktopMode(boolean enabled) { webSettings.setLoadWithOverviewMode(enabled); webSettings.setSupportZoom(enabled); webSettings.setBuiltInZoomControls(enabled); - webView.reload(); + if (reload) { + webView.reload(); + } } public void setDesktopMode() { diff --git a/platforms/android/app/src/main/java/com/foxdebug/acode/browser/Emulator.java b/platforms/android/app/src/main/java/com/foxdebug/acode/browser/Emulator.java index 86cff6f..beeca36 100644 --- a/platforms/android/app/src/main/java/com/foxdebug/acode/browser/Emulator.java +++ b/platforms/android/app/src/main/java/com/foxdebug/acode/browser/Emulator.java @@ -161,6 +161,8 @@ public void onGlobalLayout() { if (listener != null) { listener.onChange(iWidth, correctedHeight, 1); } + + restoreDefaultDevice(); } } ); @@ -300,6 +302,108 @@ private void selectDevice(Device device) { listener.onChange(width, height, maxScale / 100f); } + public static boolean hasDefault(Context context) { + return context + .getSharedPreferences("acode_browser", Context.MODE_PRIVATE) + .getBoolean("default_set", false); + } + + public static void clearDefault(Context context) { + context + .getSharedPreferences("acode_browser", Context.MODE_PRIVATE) + .edit() + .putBoolean("default_set", false) + .apply(); + } + + public void saveAsDefault() { + context + .getSharedPreferences("acode_browser", Context.MODE_PRIVATE) + .edit() + .putBoolean("default_set", true) + .putInt("default_width", getWidthProgress()) + .putInt("default_height", getHeightProgress()) + .putInt("default_scale", seekBars.get("scale").getProgress()) + .putString( + "default_device", + selectedDevice != null ? selectedDevice.name : "Custom" + ) + .apply(); + } + + private Runnable restoredListener; + + public void setRestoredListener(Runnable listener) { + restoredListener = listener; + } + + private void restoreDefaultDevice() { + if (!hasDefault(context)) return; + + android.content.SharedPreferences prefs = context.getSharedPreferences( + "acode_browser", + Context.MODE_PRIVATE + ); + SeekBar widthSeekBar = seekBars.get("width"); + SeekBar heightSeekBar = seekBars.get("height"); + SeekBar scaleSeekBar = seekBars.get("scale"); + String name = prefs.getString("default_device", "Custom"); + + Device preset = null; + for (Device device : deviceListView.devices) { + if (device.id != customDevice.id && device.name.equals(name)) { + preset = device; + break; + } + } + + if (preset != null && listener != null) { + // A preset is re-fitted to the current screen, so it keeps its shape if + // the screen size changed since it was saved (rotation, split-screen, + // foldables). Only the scale is restored from the saved value. + if (deviceListView.selectedDeviceView != null) { + deviceListView.selectedDeviceView.deselect(); + } + deviceListView.select(preset); + selectDevice(preset); + + int scale = Math.min( + prefs.getInt("default_scale", scaleSeekBar.getMax()), + scaleSeekBar.getMax() + ); + scaleSeekBar.setProgress(scale); + listener.onChange(getWidthProgress(), getHeightProgress(), scale / 100f); + } else { + // Custom sizes are restored from the saved width/height. + int width = Math.min( + prefs.getInt("default_width", widthSeekBar.getMax()), + widthSeekBar.getMax() + ); + int height = Math.min( + prefs.getInt("default_height", heightSeekBar.getMax()), + heightSeekBar.getMax() + ); + + widthSeekBar.setProgress(width); + heightSeekBar.setProgress(height); + setMaxScale(width, height); + int scale = Math.min( + prefs.getInt("default_scale", 100), + scaleSeekBar.getMax() + ); + scaleSeekBar.setProgress(scale); + selectedDevice = customDevice; + + if (listener != null) { + listener.onChange(width, height, scale / 100f); + } + } + + if (restoredListener != null) { + restoredListener.run(); + } + } + private void setMaxScale(int width, int height) { SeekBar scaleSeekBar = seekBars.get("scale"); SeekBar widthSeekBar = seekBars.get("width"); @@ -365,6 +469,7 @@ public void deselect() { class DeviceListView extends ScrollView { DeviceView selectedDeviceView; + ArrayList devices = new ArrayList(); LinearLayout deviceListLayout; Callback callback; Context context; @@ -405,6 +510,7 @@ public void select(Device device) { } public void add(Device device) { + devices.add(device); DeviceView deviceView = new DeviceView(context, device, theme); deviceListLayout.addView(deviceView); diff --git a/readme.md b/readme.md index 180651c..f58eab7 100644 --- a/readme.md +++ b/readme.md @@ -4,7 +4,7 @@

-[![](https://img.shields.io/endpoint?logo=telegram&label=Acode&style=flat&url=https%3A%2F%2Facode.app%2Fapi%2Ftelegram-members-count)](https://t.me/foxdebug_acode) [![](https://dcbadge.vercel.app/api/server/vVxVWYUAWD?style=flat)](https://discord.gg/vVxVWYUAWD) +[![](https://img.shields.io/endpoint?logo=telegram&label=Acode&style=flat&url=https%3A%2F%2Facode.app%2Fapi%2Ftelegram-members-count)](https://t.me/foxdebug_acode) [![](https://dcbadge.limes.pink/api/server/https://discord.gg/vVxVWYUAWD?style=flat)](https://discord.gg/vVxVWYUAWD) ## • Overview diff --git a/src/cm/baseExtensions.ts b/src/cm/baseExtensions.ts index 336027f..d55a6c7 100644 --- a/src/cm/baseExtensions.ts +++ b/src/cm/baseExtensions.ts @@ -15,7 +15,6 @@ import { import { highlightSelectionMatches } from "@codemirror/search"; import type { Extension } from "@codemirror/state"; import { EditorState, Prec } from "@codemirror/state"; -import indentationFolding from "./indentationFolding"; import { crosshairCursor, drawSelection, @@ -27,6 +26,7 @@ import { rectangularSelection, tooltips, } from "@codemirror/view"; +import indentationFolding from "./indentationFolding"; export interface BaseExtensionOptions { autoIndent?: boolean; @@ -51,10 +51,7 @@ export default function createBaseExtensions( highlightActiveLine: enableHighlightActiveLine = true, highlightSelectionMatches: enableHighlightSelectionMatches = true, } = options; - const extensions: Extension[] = [ - highlightSpecialChars(), - history(), - ]; + const extensions: Extension[] = [highlightSpecialChars(), history()]; if (enableHighlightActiveLine) extensions.push(highlightActiveLineGutter()); if (codeFolding) extensions.push(foldGutter(), indentationFolding); diff --git a/src/cm/colorChip.ts b/src/cm/colorChip.ts index a8c0d30..4d29c4f 100644 --- a/src/cm/colorChip.ts +++ b/src/cm/colorChip.ts @@ -1,10 +1,10 @@ import type { LSPClient } 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"; import color from "utils/color"; import type { Color as LspColor } from "vscode-languageserver-types"; +import { getAllLspPlugins } from "./lsp/clientLoader"; export const COLOR_CHIP_CLASS = "cm-color-chip"; export const COLOR_CHIP_SOURCE_ATTR = "data-color-source"; @@ -96,8 +96,7 @@ export function isViewEditable(view: EditorView): boolean { export function hasLspColorProvider(view: EditorView): boolean { return getAllLspPlugins(view, "documentColor").some( (lsp) => - lsp.client.connected && - !!lsp.client.serverCapabilities?.colorProvider, + lsp.client.connected && !!lsp.client.serverCapabilities?.colorProvider, ); } diff --git a/src/cm/colorView.ts b/src/cm/colorView.ts index 33b58bc..4a9ae7c 100644 --- a/src/cm/colorView.ts +++ b/src/cm/colorView.ts @@ -5,13 +5,13 @@ import color from "utils/color"; import { colorRegex, isValidColor } from "utils/color/regex"; import { COLOR_CHIP_CLASS, + type ColorChipPayload, colorChipDecoration, colorChipTheme, findColorChip, getColorChipPayload, isViewEditable, openColorPicker, - type ColorChipPayload, } from "./colorChip"; import { didReplaceLspDocumentColors, @@ -45,10 +45,7 @@ function isWhitespace(char: string): boolean { function isAlpha(char: string): boolean { if (!char) return false; const code = char.charCodeAt(0); - return ( - (code >= 65 && code <= 90) || - (code >= 97 && code <= 122) - ); + return (code >= 65 && code <= 90) || (code >= 97 && code <= 122); } function charAt(doc: Text, index: number): string { diff --git a/src/cm/editorReadOnly.ts b/src/cm/editorReadOnly.ts index 927b7a8..767b740 100644 --- a/src/cm/editorReadOnly.ts +++ b/src/cm/editorReadOnly.ts @@ -2,9 +2,9 @@ import { Compartment, EditorSelection, EditorState, - Transaction, type Extension, type SelectionRange, + Transaction, } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; diff --git a/src/cm/foldAwareLineCommands.ts b/src/cm/foldAwareLineCommands.ts index eea1920..4591f9a 100644 --- a/src/cm/foldAwareLineCommands.ts +++ b/src/cm/foldAwareLineCommands.ts @@ -1,53 +1,53 @@ import { - copyLineDown, - copyLineUp, - deleteLine, - moveLineDown, - moveLineUp, + copyLineDown, + copyLineUp, + deleteLine, + moveLineDown, + moveLineUp, } from "@codemirror/commands"; import { foldEffect, foldedRanges } from "@codemirror/language"; import { - EditorSelection, - type EditorState, - type SelectionRange, - Text, + EditorSelection, + type EditorState, + type SelectionRange, + Text, } from "@codemirror/state"; import type { EditorView } from "@codemirror/view"; interface FoldRange { - from: number; - to: number; + from: number; + to: number; } interface IndexedFold extends FoldRange { - blockFrom: number; - blockTo: number; + blockFrom: number; + blockTo: number; } interface FoldGroup { - blockFrom: number; - blockTo: number; - folds: IndexedFold[]; + blockFrom: number; + blockTo: number; + folds: IndexedFold[]; } interface FoldIndex { - groups: FoldGroup[]; + groups: FoldGroup[]; } interface LineBlock { - from: number; - to: number; - rangeIndexes: number[]; + from: number; + to: number; + rangeIndexes: number[]; } interface MoveOperation { - block: LineBlock; - neighbor: LineBlock; - replaceFrom: number; - replaceTo: number; - insert: Text; - blockOffset: number; - neighborOffset: number; + block: LineBlock; + neighbor: LineBlock; + replaceFrom: number; + replaceTo: number; + insert: Text; + blockOffset: number; + neighborOffset: number; } type Direction = -1 | 1; @@ -55,378 +55,378 @@ type Direction = -1 | 1; const lineBreak = Text.of(["", ""]); function getFoldIndex(state: EditorState): FoldIndex { - const groups: FoldGroup[] = []; - foldedRanges(state).between(0, state.doc.length, (from, to) => { - const fold: IndexedFold = { - from, - to, - blockFrom: state.doc.lineAt(from).from, - blockTo: state.doc.lineAt(to).to, - }; - const previous = groups[groups.length - 1]; - if (previous && fold.blockFrom <= previous.blockTo) { - previous.blockTo = Math.max(previous.blockTo, fold.blockTo); - previous.folds.push(fold); - } else { - groups.push({ - blockFrom: fold.blockFrom, - blockTo: fold.blockTo, - folds: [fold], - }); - } - }); - - return { groups }; + const groups: FoldGroup[] = []; + foldedRanges(state).between(0, state.doc.length, (from, to) => { + const fold: IndexedFold = { + from, + to, + blockFrom: state.doc.lineAt(from).from, + blockTo: state.doc.lineAt(to).to, + }; + const previous = groups[groups.length - 1]; + if (previous && fold.blockFrom <= previous.blockTo) { + previous.blockTo = Math.max(previous.blockTo, fold.blockTo); + previous.folds.push(fold); + } else { + groups.push({ + blockFrom: fold.blockFrom, + blockTo: fold.blockTo, + folds: [fold], + }); + } + }); + + return { groups }; } function firstGroupEndingAtOrAfter(groups: FoldGroup[], position: number) { - let low = 0; - let high = groups.length; - while (low < high) { - const middle = (low + high) >> 1; - if (groups[middle].blockTo < position) low = middle + 1; - else high = middle; - } - return low; + let low = 0; + let high = groups.length; + while (low < high) { + const middle = (low + high) >> 1; + if (groups[middle].blockTo < position) low = middle + 1; + else high = middle; + } + return low; } function expandBlockToFolds( - block: Pick, - index: FoldIndex, + block: Pick, + index: FoldIndex, ): Pick { - let from = block.from; - let to = block.to; - const start = firstGroupEndingAtOrAfter(index.groups, from); - - for (let i = start; i < index.groups.length; i++) { - const group = index.groups[i]; - if (group.blockFrom > to) break; - from = Math.min(from, group.blockFrom); - to = Math.max(to, group.blockTo); - } - - return { from, to }; + let from = block.from; + let to = block.to; + const start = firstGroupEndingAtOrAfter(index.groups, from); + + for (let i = start; i < index.groups.length; i++) { + const group = index.groups[i]; + if (group.blockFrom > to) break; + from = Math.min(from, group.blockFrom); + to = Math.max(to, group.blockTo); + } + + return { from, to }; } function selectionLineBlock( - state: EditorState, - range: SelectionRange, - rangeIndex: number, - index: FoldIndex, + state: EditorState, + range: SelectionRange, + rangeIndex: number, + index: FoldIndex, ): LineBlock { - const startLine = state.doc.lineAt(range.from); - let endLine = state.doc.lineAt(range.to); - if (!range.empty && range.to === endLine.from) { - endLine = state.doc.lineAt(range.to - 1); - } - const expanded = expandBlockToFolds( - { from: startLine.from, to: endLine.to }, - index, - ); - return { ...expanded, rangeIndexes: [rangeIndex] }; + const startLine = state.doc.lineAt(range.from); + let endLine = state.doc.lineAt(range.to); + if (!range.empty && range.to === endLine.from) { + endLine = state.doc.lineAt(range.to - 1); + } + const expanded = expandBlockToFolds( + { from: startLine.from, to: endLine.to }, + index, + ); + return { ...expanded, rangeIndexes: [rangeIndex] }; } function selectedLineBlocks(state: EditorState, index: FoldIndex): LineBlock[] { - const blocks = state.selection.ranges - .map((range, rangeIndex) => - selectionLineBlock(state, range, rangeIndex, index), - ) - .sort((a, b) => a.from - b.from); - const merged: LineBlock[] = []; - - for (const block of blocks) { - const previous = merged[merged.length - 1]; - if (previous && block.from <= previous.to + 1) { - previous.to = Math.max(previous.to, block.to); - previous.rangeIndexes.push(...block.rangeIndexes); - } else { - merged.push({ ...block, rangeIndexes: [...block.rangeIndexes] }); - } - } - - return merged; + const blocks = state.selection.ranges + .map((range, rangeIndex) => + selectionLineBlock(state, range, rangeIndex, index), + ) + .sort((a, b) => a.from - b.from); + const merged: LineBlock[] = []; + + for (const block of blocks) { + const previous = merged[merged.length - 1]; + if (previous && block.from <= previous.to + 1) { + previous.to = Math.max(previous.to, block.to); + previous.rangeIndexes.push(...block.rangeIndexes); + } else { + merged.push({ ...block, rangeIndexes: [...block.rangeIndexes] }); + } + } + + return merged; } function foldsInBlock(index: FoldIndex, block: Pick) { - const folds: IndexedFold[] = []; - const start = firstGroupEndingAtOrAfter(index.groups, block.from); - for (let i = start; i < index.groups.length; i++) { - const group = index.groups[i]; - if (group.blockFrom > block.to) break; - for (const fold of group.folds) { - if (fold.from >= block.from && fold.to <= block.to) folds.push(fold); - } - } - return folds; + const folds: IndexedFold[] = []; + const start = firstGroupEndingAtOrAfter(index.groups, block.from); + for (let i = start; i < index.groups.length; i++) { + const group = index.groups[i]; + if (group.blockFrom > block.to) break; + for (const fold of group.folds) { + if (fold.from >= block.from && fold.to <= block.to) folds.push(fold); + } + } + return folds; } function blockHasFolds( - index: FoldIndex, - block: Pick, + index: FoldIndex, + block: Pick, ) { - const start = firstGroupEndingAtOrAfter(index.groups, block.from); - return ( - start < index.groups.length && index.groups[start].blockFrom <= block.to - ); + const start = firstGroupEndingAtOrAfter(index.groups, block.from); + return ( + start < index.groups.length && index.groups[start].blockFrom <= block.to + ); } function uniqueFolds(folds: FoldRange[]) { - const seen = new Set(); - return folds.filter((fold) => { - const key = `${fold.from}:${fold.to}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); + const seen = new Set(); + return folds.filter((fold) => { + const key = `${fold.from}:${fold.to}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); } function createFoldEffects(folds: FoldRange[], docLength: number) { - const validFolds = uniqueFolds(folds).filter( - (fold) => fold.from >= 0 && fold.to <= docLength && fold.to > fold.from, - ); - return validFolds.map((fold) => foldEffect.of(fold)); + const validFolds = uniqueFolds(folds).filter( + (fold) => fold.from >= 0 && fold.to <= docLength && fold.to > fold.from, + ); + return validFolds.map((fold) => foldEffect.of(fold)); } function translatedFold(fold: FoldRange, oldFrom: number, newFrom: number) { - return { - from: newFrom + fold.from - oldFrom, - to: newFrom + fold.to - oldFrom, - }; + return { + from: newFrom + fold.from - oldFrom, + to: newFrom + fold.to - oldFrom, + }; } function mapRangeIntoBlock( - range: SelectionRange, - block: LineBlock, - newBlockFrom: number, + range: SelectionRange, + block: LineBlock, + newBlockFrom: number, ) { - return EditorSelection.range( - newBlockFrom + range.anchor - block.from, - newBlockFrom + range.head - block.from, - ); + return EditorSelection.range( + newBlockFrom + range.anchor - block.from, + newBlockFrom + range.head - block.from, + ); } function copyFoldedLines(view: EditorView, direction: Direction): boolean { - const initialState = view.state; - if (initialState.readOnly) return false; - const copyDown = direction > 0; - const foldIndex = getFoldIndex(initialState); - const blocks = selectedLineBlocks(initialState, foldIndex); - if (!blocks.some((block) => blockHasFolds(foldIndex, block))) { - return copyDown ? copyLineDown(view) : copyLineUp(view); - } - - const state = initialState; - const changes = blocks.map((block) => { - const text = state.doc.slice(block.from, block.to); - return copyDown - ? { from: block.from, insert: text.append(lineBreak) } - : { from: block.to, insert: lineBreak.append(text) }; - }); - const changeSet = state.changes(changes); - const mappedRanges: SelectionRange[] = new Array( - state.selection.ranges.length, - ); - const newFolds: FoldRange[] = []; - - for (const block of blocks) { - const blockLength = block.to - block.from; - const topBlockFrom = changeSet.mapPos(block.from, -1); - const bottomBlockFrom = topBlockFrom + blockLength + 1; - // Match CodeMirror's native semantics: copy down keeps the selection in - // the bottom block, while copy up keeps it in the top block. - const selectedBlockFrom = copyDown ? bottomBlockFrom : topBlockFrom; - - for (const rangeIndex of block.rangeIndexes) { - mappedRanges[rangeIndex] = mapRangeIntoBlock( - state.selection.ranges[rangeIndex], - block, - selectedBlockFrom, - ); - } - for (const fold of foldsInBlock(foldIndex, block)) { - newFolds.push( - translatedFold(fold, block.from, topBlockFrom), - translatedFold(fold, block.from, bottomBlockFrom), - ); - } - } - - view.dispatch({ - changes: changeSet, - selection: EditorSelection.create(mappedRanges, state.selection.mainIndex), - effects: createFoldEffects(newFolds, changeSet.newLength), - scrollIntoView: true, - userEvent: "input.copyline", - }); - return true; + const initialState = view.state; + if (initialState.readOnly) return false; + const copyDown = direction > 0; + const foldIndex = getFoldIndex(initialState); + const blocks = selectedLineBlocks(initialState, foldIndex); + if (!blocks.some((block) => blockHasFolds(foldIndex, block))) { + return copyDown ? copyLineDown(view) : copyLineUp(view); + } + + const state = initialState; + const changes = blocks.map((block) => { + const text = state.doc.slice(block.from, block.to); + return copyDown + ? { from: block.from, insert: text.append(lineBreak) } + : { from: block.to, insert: lineBreak.append(text) }; + }); + const changeSet = state.changes(changes); + const mappedRanges: SelectionRange[] = new Array( + state.selection.ranges.length, + ); + const newFolds: FoldRange[] = []; + + for (const block of blocks) { + const blockLength = block.to - block.from; + const topBlockFrom = changeSet.mapPos(block.from, -1); + const bottomBlockFrom = topBlockFrom + blockLength + 1; + // Match CodeMirror's native semantics: copy down keeps the selection in + // the bottom block, while copy up keeps it in the top block. + const selectedBlockFrom = copyDown ? bottomBlockFrom : topBlockFrom; + + for (const rangeIndex of block.rangeIndexes) { + mappedRanges[rangeIndex] = mapRangeIntoBlock( + state.selection.ranges[rangeIndex], + block, + selectedBlockFrom, + ); + } + for (const fold of foldsInBlock(foldIndex, block)) { + newFolds.push( + translatedFold(fold, block.from, topBlockFrom), + translatedFold(fold, block.from, bottomBlockFrom), + ); + } + } + + view.dispatch({ + changes: changeSet, + selection: EditorSelection.create(mappedRanges, state.selection.mainIndex), + effects: createFoldEffects(newFolds, changeSet.newLength), + scrollIntoView: true, + userEvent: "input.copyline", + }); + return true; } function neighboringBlock( - state: EditorState, - block: LineBlock, - direction: Direction, - index: FoldIndex, + state: EditorState, + block: LineBlock, + direction: Direction, + index: FoldIndex, ): LineBlock | null { - const line = state.doc.lineAt(direction < 0 ? block.from : block.to); - const lineNumber = line.number + direction; - if (lineNumber < 1 || lineNumber > state.doc.lines) return null; - const neighborLine = state.doc.line(lineNumber); - const expanded = expandBlockToFolds( - { from: neighborLine.from, to: neighborLine.to }, - index, - ); - return { ...expanded, rangeIndexes: [] }; + const line = state.doc.lineAt(direction < 0 ? block.from : block.to); + const lineNumber = line.number + direction; + if (lineNumber < 1 || lineNumber > state.doc.lines) return null; + const neighborLine = state.doc.line(lineNumber); + const expanded = expandBlockToFolds( + { from: neighborLine.from, to: neighborLine.to }, + index, + ); + return { ...expanded, rangeIndexes: [] }; } function createMoveOperation( - state: EditorState, - block: LineBlock, - neighbor: LineBlock, - direction: Direction, + state: EditorState, + block: LineBlock, + neighbor: LineBlock, + direction: Direction, ): MoveOperation { - const blockText = state.doc.slice(block.from, block.to); - const neighborText = state.doc.slice(neighbor.from, neighbor.to); - if (direction < 0) { - return { - block, - neighbor, - replaceFrom: neighbor.from, - replaceTo: block.to, - insert: blockText.append(lineBreak).append(neighborText), - blockOffset: 0, - neighborOffset: block.to - block.from + 1, - }; - } - return { - block, - neighbor, - replaceFrom: block.from, - replaceTo: neighbor.to, - insert: neighborText.append(lineBreak).append(blockText), - blockOffset: neighbor.to - neighbor.from + 1, - neighborOffset: 0, - }; + const blockText = state.doc.slice(block.from, block.to); + const neighborText = state.doc.slice(neighbor.from, neighbor.to); + if (direction < 0) { + return { + block, + neighbor, + replaceFrom: neighbor.from, + replaceTo: block.to, + insert: blockText.append(lineBreak).append(neighborText), + blockOffset: 0, + neighborOffset: block.to - block.from + 1, + }; + } + return { + block, + neighbor, + replaceFrom: block.from, + replaceTo: neighbor.to, + insert: neighborText.append(lineBreak).append(blockText), + blockOffset: neighbor.to - neighbor.from + 1, + neighborOffset: 0, + }; } function moveFoldedLines(view: EditorView, direction: Direction): boolean { - const initialState = view.state; - if (initialState.readOnly) return false; - const foldIndex = getFoldIndex(initialState); - const blocks = selectedLineBlocks(initialState, foldIndex); - const operations = blocks.flatMap((block) => { - const neighbor = neighboringBlock( - initialState, - block, - direction, - foldIndex, - ); - return neighbor - ? [createMoveOperation(initialState, block, neighbor, direction)] - : []; - }); - if (!operations.length) return false; - - if ( - !operations.some( - (operation) => - blockHasFolds(foldIndex, operation.block) || - blockHasFolds(foldIndex, operation.neighbor), - ) - ) { - return direction < 0 ? moveLineUp(view) : moveLineDown(view); - } - - const state = initialState; - const changeSet = state.changes( - operations.map((operation) => ({ - from: operation.replaceFrom, - to: operation.replaceTo, - insert: operation.insert, - })), - ); - const mappedRanges = state.selection.ranges.map((range) => - EditorSelection.range( - changeSet.mapPos(range.anchor), - changeSet.mapPos(range.head), - ), - ); - const newFolds: FoldRange[] = []; - - for (const operation of operations) { - const replacementFrom = changeSet.mapPos(operation.replaceFrom, -1); - const newBlockFrom = replacementFrom + operation.blockOffset; - const newNeighborFrom = replacementFrom + operation.neighborOffset; - for (const rangeIndex of operation.block.rangeIndexes) { - mappedRanges[rangeIndex] = mapRangeIntoBlock( - state.selection.ranges[rangeIndex], - operation.block, - newBlockFrom, - ); - } - for (const fold of foldsInBlock(foldIndex, operation.block)) { - newFolds.push(translatedFold(fold, operation.block.from, newBlockFrom)); - } - for (const fold of foldsInBlock(foldIndex, operation.neighbor)) { - newFolds.push( - translatedFold(fold, operation.neighbor.from, newNeighborFrom), - ); - } - } - - view.dispatch({ - changes: changeSet, - selection: EditorSelection.create(mappedRanges, state.selection.mainIndex), - effects: createFoldEffects(newFolds, changeSet.newLength), - scrollIntoView: true, - userEvent: "move.line", - }); - return true; + const initialState = view.state; + if (initialState.readOnly) return false; + const foldIndex = getFoldIndex(initialState); + const blocks = selectedLineBlocks(initialState, foldIndex); + const operations = blocks.flatMap((block) => { + const neighbor = neighboringBlock( + initialState, + block, + direction, + foldIndex, + ); + return neighbor + ? [createMoveOperation(initialState, block, neighbor, direction)] + : []; + }); + if (!operations.length) return false; + + if ( + !operations.some( + (operation) => + blockHasFolds(foldIndex, operation.block) || + blockHasFolds(foldIndex, operation.neighbor), + ) + ) { + return direction < 0 ? moveLineUp(view) : moveLineDown(view); + } + + const state = initialState; + const changeSet = state.changes( + operations.map((operation) => ({ + from: operation.replaceFrom, + to: operation.replaceTo, + insert: operation.insert, + })), + ); + const mappedRanges = state.selection.ranges.map((range) => + EditorSelection.range( + changeSet.mapPos(range.anchor), + changeSet.mapPos(range.head), + ), + ); + const newFolds: FoldRange[] = []; + + for (const operation of operations) { + const replacementFrom = changeSet.mapPos(operation.replaceFrom, -1); + const newBlockFrom = replacementFrom + operation.blockOffset; + const newNeighborFrom = replacementFrom + operation.neighborOffset; + for (const rangeIndex of operation.block.rangeIndexes) { + mappedRanges[rangeIndex] = mapRangeIntoBlock( + state.selection.ranges[rangeIndex], + operation.block, + newBlockFrom, + ); + } + for (const fold of foldsInBlock(foldIndex, operation.block)) { + newFolds.push(translatedFold(fold, operation.block.from, newBlockFrom)); + } + for (const fold of foldsInBlock(foldIndex, operation.neighbor)) { + newFolds.push( + translatedFold(fold, operation.neighbor.from, newNeighborFrom), + ); + } + } + + view.dispatch({ + changes: changeSet, + selection: EditorSelection.create(mappedRanges, state.selection.mainIndex), + effects: createFoldEffects(newFolds, changeSet.newLength), + scrollIntoView: true, + userEvent: "move.line", + }); + return true; } export function copyLineUpFoldAware(view: EditorView): boolean { - return copyFoldedLines(view, -1); + return copyFoldedLines(view, -1); } export function copyLineDownFoldAware(view: EditorView): boolean { - return copyFoldedLines(view, 1); + return copyFoldedLines(view, 1); } export function moveLineUpFoldAware(view: EditorView): boolean { - return moveFoldedLines(view, -1); + return moveFoldedLines(view, -1); } export function moveLineDownFoldAware(view: EditorView): boolean { - return moveFoldedLines(view, 1); + return moveFoldedLines(view, 1); } export function deleteLineFoldAware(view: EditorView): boolean { - const initialState = view.state; - if (initialState.readOnly) return false; - const foldIndex = getFoldIndex(initialState); - const blocks = selectedLineBlocks(initialState, foldIndex); - if (!blocks.some((block) => blockHasFolds(foldIndex, block))) { - return deleteLine(view); - } - - const state = initialState; - const changes = blocks.map((block) => { - let { from, to } = block; - if (from > 0) from--; - else if (to < state.doc.length) to++; - return { from, to }; - }); - const changeSet = state.changes(changes); - const cursors = blocks.map((block) => - EditorSelection.cursor( - Math.min(changeSet.newLength, changeSet.mapPos(block.from, -1)), - ), - ); - - view.dispatch({ - changes: changeSet, - selection: EditorSelection.create(cursors, 0), - scrollIntoView: true, - userEvent: "delete.line", - }); - return true; + const initialState = view.state; + if (initialState.readOnly) return false; + const foldIndex = getFoldIndex(initialState); + const blocks = selectedLineBlocks(initialState, foldIndex); + if (!blocks.some((block) => blockHasFolds(foldIndex, block))) { + return deleteLine(view); + } + + const state = initialState; + const changes = blocks.map((block) => { + let { from, to } = block; + if (from > 0) from--; + else if (to < state.doc.length) to++; + return { from, to }; + }); + const changeSet = state.changes(changes); + const cursors = blocks.map((block) => + EditorSelection.cursor( + Math.min(changeSet.newLength, changeSet.mapPos(block.from, -1)), + ), + ); + + view.dispatch({ + changes: changeSet, + selection: EditorSelection.create(cursors, 0), + scrollIntoView: true, + userEvent: "delete.line", + }); + return true; } diff --git a/src/cm/highlightTags.ts b/src/cm/highlightTags.ts new file mode 100644 index 0000000..49b746d --- /dev/null +++ b/src/cm/highlightTags.ts @@ -0,0 +1,10 @@ +import { Tag, tags as t } from "@lezer/highlight"; + +/** + * Property names in JavaScript/TypeScript: `obj.foo`, object keys and class + * fields. It is a sub-tag of `propertyName`, so themes that don't style it + * keep their `propertyName` colour, and themes that do can colour JS + * properties apart from methods without changing CSS properties, JSON/YAML + * keys or other languages that share `propertyName`. + */ +export const memberProperty = Tag.define("memberProperty", t.propertyName); diff --git a/src/cm/indentationFolding.ts b/src/cm/indentationFolding.ts index 7be802c..ed876a9 100644 --- a/src/cm/indentationFolding.ts +++ b/src/cm/indentationFolding.ts @@ -1,8 +1,4 @@ -import { - foldService, - language, - StreamLanguage, -} from "@codemirror/language"; +import { foldService, language, StreamLanguage } from "@codemirror/language"; import { countColumn, type EditorState } from "@codemirror/state"; interface FoldRange { @@ -10,10 +6,7 @@ interface FoldRange { to: number; } -const foldCache = new WeakMap< - EditorState, - Map ->(); +const foldCache = new WeakMap>(); function findIndentationFold( state: EditorState, @@ -60,8 +53,7 @@ function findIndentationFold( foldEnd = nextLine.to; } - const range = - foldEnd === null ? null : { from: line.to, to: foldEnd }; + const range = foldEnd === null ? null : { from: line.to, to: foldEnd }; stateCache.set(line.from, range); return range; } diff --git a/src/cm/lineBreakMarker.ts b/src/cm/lineBreakMarker.ts index ea8eda6..74779ce 100644 --- a/src/cm/lineBreakMarker.ts +++ b/src/cm/lineBreakMarker.ts @@ -1,5 +1,12 @@ -import { Decoration, DecorationSet, ViewPlugin, WidgetType, EditorView, ViewUpdate } from "@codemirror/view"; import { RangeSetBuilder } from "@codemirror/state"; +import { + Decoration, + DecorationSet, + EditorView, + ViewPlugin, + ViewUpdate, + WidgetType, +} from "@codemirror/view"; class NewlineWidget extends WidgetType { toDOM(): HTMLElement { @@ -30,12 +37,15 @@ export const lineBreakMarkerPlugin = ViewPlugin.fromClass( getDecorations(view: EditorView): DecorationSet { let builder = new RangeSetBuilder(); let lastLineNumber = -1; - + for (let { from, to } of view.visibleRanges) { for (let pos = from; pos <= to; ) { let line = view.state.doc.lineAt(pos); - - if (line.number > lastLineNumber && line.number < view.state.doc.lines) { + + if ( + line.number > lastLineNumber && + line.number < view.state.doc.lines + ) { let deco = Decoration.widget({ widget: new NewlineWidget(), side: 1, @@ -43,7 +53,7 @@ export const lineBreakMarkerPlugin = ViewPlugin.fromClass( builder.add(line.to, line.to, deco); lastLineNumber = line.number; } - + pos = line.to + 1; } } diff --git a/src/cm/localWordCompletions.ts b/src/cm/localWordCompletions.ts index f9b6353..4be1cc6 100644 --- a/src/cm/localWordCompletions.ts +++ b/src/cm/localWordCompletions.ts @@ -1,6 +1,6 @@ import { - completeAnyWord, type CompletionSource, + completeAnyWord, } from "@codemirror/autocomplete"; import { EditorState, type Extension } from "@codemirror/state"; diff --git a/src/cm/lsp/api.ts b/src/cm/lsp/api.ts index 44103e3..91aa873 100644 --- a/src/cm/lsp/api.ts +++ b/src/cm/lsp/api.ts @@ -1,4 +1,11 @@ import { defineBundle, defineServer, installers } from "./providerUtils"; +import { + getRuntimeProvider, + listRuntimeProviders, + registerRuntimeProvider, + selectRuntimeProvider, + unregisterRuntimeProvider, +} from "./runtimeProviders"; import { getServerBundle, listServerBundles, @@ -16,13 +23,6 @@ import { unregisterServer, updateServer, } from "./serverRegistry"; -import { - getRuntimeProvider, - listRuntimeProviders, - registerRuntimeProvider, - selectRuntimeProvider, - unregisterRuntimeProvider, -} from "./runtimeProviders"; import "./runtimes/registerBuiltins"; import type { LspRuntimeContext, @@ -36,8 +36,11 @@ import { type LspWorkerTransportOptions, } from "./workerTransport"; +export type { + LspWorkerHostHandler, + LspWorkerTransportOptions, +} from "./workerTransport"; export { defineBundle, defineServer, installers }; -export type { LspWorkerHostHandler, LspWorkerTransportOptions } from "./workerTransport"; export type LspRegistrationEntry = LspServerManifest | LspServerBundle; diff --git a/src/cm/lsp/client.ts b/src/cm/lsp/client.ts index c3fa680..755b82f 100644 --- a/src/cm/lsp/client.ts +++ b/src/cm/lsp/client.ts @@ -8,7 +8,10 @@ export { LSPPlugin, serverCompletionSource, } from "@codemirror/lsp-client"; -export { default as clientManager, lspCompletionEnabled } from "./clientManager"; +export { + default as clientManager, + lspCompletionEnabled, +} from "./clientManager"; export { jumpToDeclaration, jumpToDefinition, diff --git a/src/cm/lsp/clientManager.ts b/src/cm/lsp/clientManager.ts index efd9cb2..6d08882 100644 --- a/src/cm/lsp/clientManager.ts +++ b/src/cm/lsp/clientManager.ts @@ -1,13 +1,13 @@ import { getIndentUnit, indentUnit } from "@codemirror/language"; import type { - LSPClientExtension, - LSPPluginOptions, + LSPClientExtension, + LSPPluginOptions, } from "@codemirror/lsp-client"; import { - LSPClient, - LSPPlugin, - serverCompletion, - serverDiagnostics, + LSPClient, + LSPPlugin, + serverCompletion, + serverDiagnostics, } from "@codemirror/lsp-client"; import { EditorState, Extension, Facet, MapMode } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; @@ -16,1497 +16,1495 @@ import notificationManager from "lib/notificationManager"; import Uri from "utils/Uri"; import Url from "utils/Url"; import { - clearDiagnosticsEffect, - disposePullDiagnostics, - lspDiagnosticsAutoSyncExtension, + clearDiagnosticsEffect, + disposePullDiagnostics, + lspDiagnosticsAutoSyncExtension, } from "./diagnostics"; -import { supportsBuiltinFormatting } from "./formattingSupport"; import { documentColorsExtension } from "./documentColors"; +import { supportsBuiltinFormatting } from "./formattingSupport"; import { inlayHintsExtension } from "./inlayHints"; import { addLspLog } from "./logs"; import { safeLspPositionToOffset } from "./positionUtils"; import { selectRuntimeProvider } from "./runtimeProviders"; import serverRegistry from "./serverRegistry"; import { - hoverTooltips, - resolveLspHoverHighlightLanguage, - signatureHelp, + hoverTooltips, + resolveLspHoverHighlightLanguage, + signatureHelp, } from "./tooltipExtensions"; import { createTransport } from "./transport"; import type { - BuiltinExtensionsConfig, - ClientManagerOptions, - ClientState, - DocumentUriContext, - FileMetadata, - FormattingOptions, - LspServerDefinition, - LspRuntimeConnection, - LspRuntimeProvider, - LspClientScope, - NormalizedRootUri, - ParsedUri, - RootUriContext, - TextEdit, - TransportContext, - TransportHandle, - Transport, + BuiltinExtensionsConfig, + ClientManagerOptions, + ClientState, + DocumentUriContext, + FileMetadata, + FormattingOptions, + LspClientScope, + LspRuntimeConnection, + LspRuntimeProvider, + LspServerDefinition, + NormalizedRootUri, + ParsedUri, + RootUriContext, + TextEdit, + Transport, + TransportContext, + TransportHandle, } from "./types"; import AcodeWorkspace from "./workspace"; export const DEFAULT_CLIENT_IDLE_GRACE_PERIOD_MS = 15_000; export const lspCompletionEnabled = Facet.define({ - // File-level marker used by the autocomplete override path. If any attached - // server exposes completion, keep the shared LSP completion source available. - // Per-server completion opt-outs do not make this a per-server gate. - combine: (values) => values.some(Boolean), + // File-level marker used by the autocomplete override path. If any attached + // server exposes completion, keep the shared LSP completion source available. + // Per-server completion opt-outs do not make this a per-server gate. + combine: (values) => values.some(Boolean), }); function asArray(value: T | T[] | null | undefined): T[] { - if (!value) return []; - return Array.isArray(value) ? value : [value]; + if (!value) return []; + return Array.isArray(value) ? value : [value]; } function pluginKey( - serverId: string, - rootUri: string | null | undefined, - useWorkspaceFolders?: boolean, + serverId: string, + rootUri: string | null | undefined, + useWorkspaceFolders?: boolean, ): string { - // For workspace folders mode, use just the server ID (one client per server type) - if (useWorkspaceFolders) { - return serverId; - } - return `${serverId}::${rootUri ?? "__global__"}`; + // For workspace folders mode, use just the server ID (one client per server type) + if (useWorkspaceFolders) { + return serverId; + } + return `${serverId}::${rootUri ?? "__global__"}`; } function safeString(value: unknown): string { - return value != null ? String(value) : ""; + return value != null ? String(value) : ""; } function formatInitializationError(error: unknown): string { - if (isPlainObject(error)) { - const message = safeString(error.message).trim(); - const code = - typeof error.code === "number" || typeof error.code === "string" - ? ` (${error.code})` - : ""; - if (message) return `Initialization failed${code}: ${message}`; - } - const message = - error instanceof Error ? error.message : safeString(error).trim(); - return message - ? `Initialization failed: ${message}` - : "Initialization failed"; + if (isPlainObject(error)) { + const message = safeString(error.message).trim(); + const code = + typeof error.code === "number" || typeof error.code === "string" + ? ` (${error.code})` + : ""; + if (message) return `Initialization failed${code}: ${message}`; + } + const message = + error instanceof Error ? error.message : safeString(error).trim(); + return message + ? `Initialization failed: ${message}` + : "Initialization failed"; } function isSettingsOrKeybindingsFile( - uri: string | null | undefined, - file?: { uri?: string } | null, + uri: string | null | undefined, + file?: { uri?: string } | null, ): boolean { - const fileUri = String(uri || file?.uri || "").toLowerCase(); - if (!fileUri) return false; - - try { - const dataStorage = (globalThis as any).DATA_STORAGE; - if (dataStorage) { - const settingsPath = Url.join(dataStorage, "settings.json").toLowerCase(); - const keybindingsPath = ( - (globalThis as any).KEYBINDING_FILE || - Url.join(dataStorage, ".key-bindings.json") - ).toLowerCase(); - - if (fileUri === settingsPath || fileUri === keybindingsPath) { - return true; - } - } - } catch {} - - return ( - fileUri.endsWith("/settings.json") || - fileUri.endsWith("/.key-bindings.json") || - fileUri.endsWith("/keybindings.json") || - fileUri.endsWith("/.keybindings.json") || - fileUri === "settings.json" || - fileUri === ".key-bindings.json" || - fileUri === "keybindings.json" || - fileUri === ".keybindings.json" - ); + const fileUri = String(uri || file?.uri || "").toLowerCase(); + if (!fileUri) return false; + + try { + const dataStorage = (globalThis as any).DATA_STORAGE; + if (dataStorage) { + const settingsPath = Url.join(dataStorage, "settings.json").toLowerCase(); + const keybindingsPath = ( + (globalThis as any).KEYBINDING_FILE || + Url.join(dataStorage, ".key-bindings.json") + ).toLowerCase(); + + if (fileUri === settingsPath || fileUri === keybindingsPath) { + return true; + } + } + } catch {} + + return ( + fileUri.endsWith("/settings.json") || + fileUri.endsWith("/.key-bindings.json") || + fileUri.endsWith("/keybindings.json") || + fileUri.endsWith("/.keybindings.json") || + fileUri === "settings.json" || + fileUri === ".key-bindings.json" || + fileUri === "keybindings.json" || + fileUri === ".keybindings.json" + ); } function isVerboseLspLoggingEnabled(): boolean { - const buildInfo = (globalThis as { BuildInfo?: { debug?: boolean } }) - .BuildInfo; - return !!buildInfo?.debug; + const buildInfo = (globalThis as { BuildInfo?: { debug?: boolean } }) + .BuildInfo; + return !!buildInfo?.debug; } function logLspInfo(...args: unknown[]): void { - if (!isVerboseLspLoggingEnabled()) return; - console.info(...args); + if (!isVerboseLspLoggingEnabled()) return; + console.info(...args); } function isPlainObject(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); + return !!value && typeof value === "object" && !Array.isArray(value); } function resolveInitializationOptions( - server: LspServerDefinition, - clientConfig: Record, + server: LspServerDefinition, + clientConfig: Record, ): Record | undefined { - const serverOptions = isPlainObject(server.initializationOptions) - ? server.initializationOptions - : null; - const clientOptions = isPlainObject(clientConfig.initializationOptions) - ? clientConfig.initializationOptions - : null; - - if (serverOptions && clientOptions) { - return { - ...serverOptions, - ...clientOptions, - }; - } - - return serverOptions || clientOptions || undefined; + const serverOptions = isPlainObject(server.initializationOptions) + ? server.initializationOptions + : null; + const clientOptions = isPlainObject(clientConfig.initializationOptions) + ? clientConfig.initializationOptions + : null; + + if (serverOptions && clientOptions) { + return { + ...serverOptions, + ...clientOptions, + }; + } + + return serverOptions || clientOptions || undefined; } interface InternalLSPRequest { - promise: Promise; + promise: Promise; } type RequestInnerFn = ( - method: string, - params: Params, - mapped?: boolean, + method: string, + params: Params, + mapped?: boolean, ) => InternalLSPRequest; function connectClient( - client: ExtendedLSPClient, - transport: Transport, - initializationOptions?: Record, - rootUri?: string | null, + client: ExtendedLSPClient, + transport: Transport, + initializationOptions?: Record, + rootUri?: string | null, ): void { - const hasInitializationOptions = - !!initializationOptions && Object.keys(initializationOptions).length > 0; - if (!hasInitializationOptions && !rootUri) { - client.connect(transport); - return; - } - - const patchedClient = client as unknown as { - requestInner: RequestInnerFn; - }; - const originalRequestInner = patchedClient.requestInner.bind( - patchedClient, - ) as RequestInnerFn; - - patchedClient.requestInner = function patchedRequestInner( - method: string, - params: Params, - mapped?: boolean, - ): InternalLSPRequest { - if (method === "initialize" && isPlainObject(params)) { - params = { - ...params, - ...(hasInitializationOptions ? { initializationOptions } : {}), - ...(rootUri - ? { - workspaceFolders: [ - { uri: rootUri, name: workspaceName(rootUri) }, - ], - } - : {}), - } as Params; - } - return originalRequestInner(method, params, mapped); - }; - - try { - client.connect(transport); - } finally { - patchedClient.requestInner = originalRequestInner; - } + const hasInitializationOptions = + !!initializationOptions && Object.keys(initializationOptions).length > 0; + if (!hasInitializationOptions && !rootUri) { + client.connect(transport); + return; + } + + const patchedClient = client as unknown as { + requestInner: RequestInnerFn; + }; + const originalRequestInner = patchedClient.requestInner.bind( + patchedClient, + ) as RequestInnerFn; + + patchedClient.requestInner = function patchedRequestInner( + method: string, + params: Params, + mapped?: boolean, + ): InternalLSPRequest { + if (method === "initialize" && isPlainObject(params)) { + params = { + ...params, + ...(hasInitializationOptions ? { initializationOptions } : {}), + ...(rootUri + ? { + workspaceFolders: [ + { uri: rootUri, name: workspaceName(rootUri) }, + ], + } + : {}), + } as Params; + } + return originalRequestInner(method, params, mapped); + }; + + try { + client.connect(transport); + } finally { + patchedClient.requestInner = originalRequestInner; + } } function workspaceName(rootUri: string): string { - const trimmed = rootUri.replace(/\/+$/, ""); - const encodedName = trimmed.slice(trimmed.lastIndexOf("/") + 1); - try { - return decodeURIComponent(encodedName) || "workspace"; - } catch { - return encodedName || "workspace"; - } + const trimmed = rootUri.replace(/\/+$/, ""); + const encodedName = trimmed.slice(trimmed.lastIndexOf("/") + 1); + try { + return decodeURIComponent(encodedName) || "workspace"; + } catch { + return encodedName || "workspace"; + } } interface BuiltinExtensionsResult { - extensions: Extension[]; - diagnosticsExtension: Extension | LSPClientExtension | null; + extensions: Extension[]; + diagnosticsExtension: Extension | LSPClientExtension | null; } function buildBuiltinExtensions( - config: BuiltinExtensionsConfig = {}, + config: BuiltinExtensionsConfig = {}, ): BuiltinExtensionsResult { - const { - hover: includeHover = true, - completion: includeCompletion = true, - signature: includeSignature = true, - diagnostics: includeDiagnostics = true, - inlayHints: includeInlayHints = false, - documentColors: includeDocumentColors = true, - } = config; - - const extensions: Extension[] = []; - let diagnosticsExtension: Extension | LSPClientExtension | null = null; - - if (includeCompletion) extensions.push(serverCompletion()); - if (includeHover) extensions.push(hoverTooltips()); - if (includeSignature) extensions.push(signatureHelp({ keymap: false })); - if (includeDiagnostics) { - const diagExt = serverDiagnostics(); - diagnosticsExtension = diagExt; - extensions.push(diagExt as Extension); - } - if (includeInlayHints) { - const hintsExt = inlayHintsExtension(); - extensions.push(hintsExt as LSPClientExtension as Extension); - } - if (includeDocumentColors) { - const colorsExt = documentColorsExtension(); - extensions.push(colorsExt as LSPClientExtension as Extension); - } - - return { extensions, diagnosticsExtension }; + const { + hover: includeHover = true, + completion: includeCompletion = true, + signature: includeSignature = true, + diagnostics: includeDiagnostics = true, + inlayHints: includeInlayHints = false, + documentColors: includeDocumentColors = true, + } = config; + + const extensions: Extension[] = []; + let diagnosticsExtension: Extension | LSPClientExtension | null = null; + + if (includeCompletion) extensions.push(serverCompletion()); + if (includeHover) extensions.push(hoverTooltips()); + if (includeSignature) extensions.push(signatureHelp({ keymap: false })); + if (includeDiagnostics) { + const diagExt = serverDiagnostics(); + diagnosticsExtension = diagExt; + extensions.push(diagExt as Extension); + } + if (includeInlayHints) { + const hintsExt = inlayHintsExtension(); + extensions.push(hintsExt as LSPClientExtension as Extension); + } + if (includeDocumentColors) { + const colorsExt = documentColorsExtension(); + extensions.push(colorsExt as LSPClientExtension as Extension); + } + + return { extensions, diagnosticsExtension }; } function buildPluginOptions(server: LspServerDefinition): LSPPluginOptions { - const builtin = server.clientConfig?.builtinExtensions ?? {}; - return { - priority: server.priority, - features: { - completion: builtin.completion !== false, - hover: builtin.hover !== false, - signatureHelp: builtin.signature !== false, - diagnostics: builtin.diagnostics !== false, - formatting: builtin.formatting !== false, - inlayHint: builtin.inlayHints === true, - documentColor: builtin.documentColors !== false, - }, - }; + const builtin = server.clientConfig?.builtinExtensions ?? {}; + return { + priority: server.priority, + features: { + completion: builtin.completion !== false, + hover: builtin.hover !== false, + signatureHelp: builtin.signature !== false, + diagnostics: builtin.diagnostics !== false, + formatting: builtin.formatting !== false, + inlayHint: builtin.inlayHints === true, + documentColor: builtin.documentColors !== false, + }, + }; } interface InitContext { - key: string; - normalizedRootUri: string | null; - originalRootUri: string | null; - runtimeRootUri: string | null; - runtimeOriginalRootUri: string | null; - originalDocumentUri: string; - documentUri: string; - runtimeProvider: LspRuntimeProvider; - scope: LspClientScope; - signal: AbortSignal; + key: string; + normalizedRootUri: string | null; + originalRootUri: string | null; + runtimeRootUri: string | null; + runtimeOriginalRootUri: string | null; + originalDocumentUri: string; + documentUri: string; + runtimeProvider: LspRuntimeProvider; + scope: LspClientScope; + signal: AbortSignal; } interface ResolvedRuntimeTarget { - originalDocumentUri: string; - documentUri: string; - normalizedRootUri: string | null; - originalRootUri: string | null; - runtimeProvider: LspRuntimeProvider; - scope: LspClientScope; + originalDocumentUri: string; + documentUri: string; + normalizedRootUri: string | null; + originalRootUri: string | null; + runtimeProvider: LspRuntimeProvider; + scope: LspClientScope; } interface ExtendedLSPClient extends LSPClient { - __acodeServerId?: string; - __acodeLoggedInfo?: boolean; + __acodeServerId?: string; + __acodeLoggedInfo?: boolean; } interface PendingClientInitialization { - serverId: string; - controller: AbortController; - promise: Promise; + serverId: string; + controller: AbortController; + promise: Promise; } function initializationCancelledError(serverId: string): Error { - const error = new Error(`LSP client initialization cancelled for ${serverId}`); - error.name = "AbortError"; - return error; + const error = new Error( + `LSP client initialization cancelled for ${serverId}`, + ); + error.name = "AbortError"; + return error; } function throwIfInitializationCancelled( - signal: AbortSignal, - serverId: string, + signal: AbortSignal, + serverId: string, ): void { - if (!signal.aborted) return; - throw initializationCancelledError(serverId); + if (!signal.aborted) return; + throw initializationCancelledError(serverId); } function waitForInitialization( - promise: PromiseLike, - signal: AbortSignal, - serverId: string, + promise: PromiseLike, + signal: AbortSignal, + serverId: string, ): Promise { - throwIfInitializationCancelled(signal, serverId); - return new Promise((resolve, reject) => { - const onAbort = () => { - signal.removeEventListener("abort", onAbort); - reject(initializationCancelledError(serverId)); - }; - signal.addEventListener("abort", onAbort, { once: true }); - Promise.resolve(promise).then( - (value) => { - signal.removeEventListener("abort", onAbort); - resolve(value); - }, - (error) => { - signal.removeEventListener("abort", onAbort); - reject(error); - }, - ); - }); + throwIfInitializationCancelled(signal, serverId); + return new Promise((resolve, reject) => { + const onAbort = () => { + signal.removeEventListener("abort", onAbort); + reject(initializationCancelledError(serverId)); + }; + signal.addEventListener("abort", onAbort, { once: true }); + Promise.resolve(promise).then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (error) => { + signal.removeEventListener("abort", onAbort); + reject(error); + }, + ); + }); } export class LspClientManager { - options: ClientManagerOptions; - - #clients: Map; - #pendingClients: Map; - - constructor(options: ClientManagerOptions = {}) { - this.options = { ...options }; - this.#clients = new Map(); - this.#pendingClients = new Map(); - } - - setOptions(next: Partial): void { - this.options = { ...this.options, ...next }; - } - - getActiveClients(): ClientState[] { - return Array.from(this.#clients.values()); - } - - async getExtensionsForFile(metadata: FileMetadata): Promise { - const { - uri: originalUri, - languageId, - languageName, - view, - file, - rootUri, - } = metadata; - - const effectiveLang = safeString(languageId ?? languageName).toLowerCase(); - if (!effectiveLang) return []; - - const servers = serverRegistry.getServersForLanguage(effectiveLang); - if (!servers.length) return []; - - const lspExtensions: Extension[] = []; - const diagnosticsUiExtension = this.options.diagnosticsUiExtension; - - for (const server of servers) { - if (isSettingsOrKeybindingsFile(originalUri, file)) { - continue; - } - const target = await this.#resolveRuntimeTarget(server, { - uri: originalUri, - file, - view, - languageId: effectiveLang, - rootUri, - }); - if (!target) { - console.warn( - `Cannot resolve runtime or document URI for LSP server ${server.id}: ${originalUri}`, - ); - continue; - } - const normalizedUri = target.documentUri; - let targetLanguageId = effectiveLang; - if (server.resolveLanguageId) { - try { - const resolved = server.resolveLanguageId({ - languageId: effectiveLang, - languageName, - uri: normalizedUri, - file, - }); - if (resolved) targetLanguageId = safeString(resolved); - } catch (error) { - console.warn( - `LSP server ${server.id} failed to resolve language id for ${normalizedUri}`, - error, - ); - } - } - - try { - const clientState = await this.#ensureClient( - server, - { - uri: normalizedUri, - file, - view, - languageId: targetLanguageId, - rootUri: target.normalizedRootUri ?? undefined, - }, - target, - ); - const plugin = clientState.client.plugin( - normalizedUri, - targetLanguageId, - buildPluginOptions(server), - ); - if (server.clientConfig?.builtinExtensions?.completion !== false) { - lspExtensions.push(lspCompletionEnabled.of(true)); - } - const aliases = - originalUri && originalUri !== normalizedUri ? [originalUri] : []; - clientState.attach(normalizedUri, view as EditorView, aliases); - lspExtensions.push(plugin); - } catch (error) { - console.error( - `Failed to initialize LSP client for ${server.id}`, - error, - ); - } - } - - if (diagnosticsUiExtension && lspExtensions.length) { - lspExtensions.push(lspDiagnosticsAutoSyncExtension()); - lspExtensions.push(...asArray(diagnosticsUiExtension)); - } - - return lspExtensions; - } - - async formatDocument( - metadata: FileMetadata, - options: FormattingOptions = {}, - ): Promise { - const { uri: originalUri, languageId, languageName, view, file } = metadata; - - const effectiveLang = safeString(languageId ?? languageName).toLowerCase(); - if (!effectiveLang || !view) return false; - - const servers = serverRegistry.getServersForLanguage(effectiveLang); - if (!servers.length) return false; - - for (const server of servers) { - if (isSettingsOrKeybindingsFile(originalUri, file)) { - continue; - } - if (!supportsBuiltinFormatting(server)) continue; - try { - const target = await this.#resolveRuntimeTarget(server, { - uri: originalUri, - file, - view, - languageId: effectiveLang, - rootUri: metadata.rootUri, - }); - if (!target) { - console.warn( - `Cannot resolve document URI for formatting with ${server.id}: ${originalUri}`, - ); - continue; - } - const normalizedUri = target.documentUri; - const context: RootUriContext = { - uri: normalizedUri, - languageId: effectiveLang, - view, - file, - rootUri: target.normalizedRootUri ?? undefined, - }; - const state = await this.#ensureClient(server, context, target); - const capabilities = state.client.serverCapabilities; - if (!capabilities?.documentFormattingProvider) continue; - state.attach(normalizedUri, view); - const plugin = LSPPlugin.get(view, state.client); - if (!plugin) continue; - plugin.client.sync(); - const edits = await state.client.request< - { textDocument: { uri: string }; options: FormattingOptions }, - TextEdit[] | null - >("textDocument/formatting", { - textDocument: { uri: normalizedUri }, - options: buildFormattingOptions(view, options), - }); - if (!edits || !edits.length) { - plugin.client.sync(); - return true; - } - const applied = applyTextEdits(plugin, view, edits); - if (applied) { - plugin.client.sync(); - return true; - } - } catch (error) { - console.error(`LSP formatting failed for ${server.id}`, error); - } - } - return false; - } - - detach(uri: string, view: EditorView): void { - for (const state of this.#clients.values()) { - state.detach(uri, view); - } - } - - async disposeServer(serverId: string): Promise { - const normalizedId = safeString(serverId).toLowerCase(); - if (!normalizedId) return; - const pendingDisposals: Promise[] = []; - for (const [key, pending] of this.#pendingClients.entries()) { - if (pending.serverId !== normalizedId) continue; - pending.controller.abort(); - this.#pendingClients.delete(key); - pendingDisposals.push( - pending.promise.then((state) => state.dispose()), - ); - } - const states = Array.from(this.#clients.values()).filter( - (state) => state.server.id.toLowerCase() === normalizedId, - ); - await Promise.allSettled([ - ...states.map((state) => state.dispose()), - ...pendingDisposals, - ]); - } - - async dispose(): Promise { - try { - interface FileWithSession { - id?: string; - type?: string; - session?: EditorState; - } - - interface EditorManagerLike { - files?: FileWithSession[]; - editor?: EditorView; - activeFile?: FileWithSession; - } - - const em = (globalThis as Record).editorManager as - | EditorManagerLike - | undefined; - - if (em?.editor) { - try { - em.editor.dispatch({ effects: clearDiagnosticsEffect() }); - if (em.activeFile?.type === "editor") { - em.activeFile.session = em.editor.state; - } - } catch { - /* View may be disposed */ - } - } - - if (em?.files) { - for (const file of em.files) { - if (file?.type !== "editor" || file.id === em.activeFile?.id) - continue; - const session = file.session; - if (session && typeof session.update === "function") { - try { - file.session = session.update({ - effects: clearDiagnosticsEffect(), - }).state; - } catch { - /* State update failed */ - } - } - } - } - } catch { - /* Ignore errors */ - } - - const disposeOps: Promise[] = []; - for (const [key, pending] of this.#pendingClients.entries()) { - pending.controller.abort(); - this.#pendingClients.delete(key); - disposeOps.push(pending.promise.then((state) => state.dispose())); - } - for (const [key, state] of this.#clients.entries()) { - disposeOps.push(state.dispose()); - this.#clients.delete(key); - } - await Promise.allSettled(disposeOps); - } - - async #ensureClient( - server: LspServerDefinition, - context: RootUriContext, - target: ResolvedRuntimeTarget, - ): Promise { - const { - documentUri, - normalizedRootUri, - originalRootUri, - runtimeProvider, - scope, - } = target; - const useWsFolders = - scope === "workspace" && server.useWorkspaceFolders === true; - const runtimeServerKey = `${server.id}@${runtimeProvider.id}`; - - // Workspace-folder clients are shared only within the selected runtime. - const key = - scope === "document" - ? `${runtimeServerKey}::__document__::${documentUri}` - : pluginKey(runtimeServerKey, normalizedRootUri, useWsFolders); - - // Return existing client if already initialized - if (this.#clients.has(key)) { - const existing = this.#clients.get(key)!; - // For workspace folders mode, add the new folder to the existing server - if (useWsFolders && normalizedRootUri) { - const workspace = existing.client.workspace as AcodeWorkspace | null; - if (workspace && !workspace.hasWorkspaceFolder(normalizedRootUri)) { - workspace.addWorkspaceFolder(normalizedRootUri); - } - } - return existing; - } - - // If initialization is already in progress, wait for it - if (this.#pendingClients.has(key)) { - const pending = await this.#pendingClients.get(key)!.promise; - if (useWsFolders && normalizedRootUri) { - const workspace = pending.client.workspace as AcodeWorkspace | null; - if (workspace && !workspace.hasWorkspaceFolder(normalizedRootUri)) { - workspace.addWorkspaceFolder(normalizedRootUri); - } - } - return pending; - } - - // Create and track the pending initialization - const controller = new AbortController(); - const initPromise = this.#initializeClient(server, context, { - key, - normalizedRootUri: useWsFolders ? null : normalizedRootUri, - originalRootUri: useWsFolders ? null : originalRootUri, - runtimeRootUri: normalizedRootUri, - runtimeOriginalRootUri: originalRootUri, - originalDocumentUri: target.originalDocumentUri, - documentUri, - runtimeProvider, - scope, - signal: controller.signal, - }); - const pending: PendingClientInitialization = { - serverId: server.id.toLowerCase(), - controller, - promise: initPromise, - }; - this.#pendingClients.set(key, pending); - - try { - const state = await initPromise; - if (useWsFolders && normalizedRootUri) { - const workspace = state.client.workspace as AcodeWorkspace | null; - if (workspace && !workspace.hasWorkspaceFolder(normalizedRootUri)) { - workspace.addWorkspaceFolder(normalizedRootUri); - } - } - return state; - } finally { - if (this.#pendingClients.get(key) === pending) { - this.#pendingClients.delete(key); - } - } - } - - async #initializeClient( - server: LspServerDefinition, - context: RootUriContext, - initContext: InitContext, - ): Promise { - const { - key, - normalizedRootUri, - originalRootUri, - runtimeRootUri, - runtimeOriginalRootUri, - originalDocumentUri, - documentUri, - runtimeProvider, - scope, - signal, - } = initContext; - - const workspaceOptions = { - displayFile: this.options.displayFile, - openFile: this.options.openFile, - resolveLanguageId: this.options.resolveLanguageId, - // Track the first folder advertised during `initialize` so it is not - // sent again as a workspace-folder change after the client connects. - initialFolders: runtimeRootUri ? [runtimeRootUri] : undefined, - }; - - const clientConfig = { ...(server.clientConfig ?? {}) }; - const initializationOptions = resolveInitializationOptions( - server, - clientConfig as Record, - ); - const builtinConfig = clientConfig.builtinExtensions ?? {}; - const useDefaultExtensions = clientConfig.useDefaultExtensions !== false; - const { extensions: defaultExtensions, diagnosticsExtension } = - useDefaultExtensions - ? buildBuiltinExtensions({ - hover: builtinConfig.hover !== false, - completion: builtinConfig.completion !== false, - signature: builtinConfig.signature !== false, - keymaps: builtinConfig.keymaps !== false, - diagnostics: builtinConfig.diagnostics !== false, - inlayHints: builtinConfig.inlayHints === true, - formatting: builtinConfig.formatting !== false, - documentColors: builtinConfig.documentColors !== false, - }) - : { extensions: [], diagnosticsExtension: null }; - - const extraExtensions = asArray(this.options.clientExtensions); - const serverExtensions = asArray(clientConfig.extensions); - - interface ExtensionWithCapabilities { - clientCapabilities?: { - textDocument?: { - publishDiagnostics?: unknown; - }; - }; - } - - const wantsCustomDiagnostics = [ - ...extraExtensions, - ...serverExtensions, - ].some((ext) => { - const extWithCaps = ext as ExtensionWithCapabilities; - return !!extWithCaps?.clientCapabilities?.textDocument - ?.publishDiagnostics; - }); - - const filteredBuiltins = - wantsCustomDiagnostics && diagnosticsExtension - ? defaultExtensions.filter((ext) => ext !== diagnosticsExtension) - : defaultExtensions; - - const clientCapabilities: LSPClientExtension = { - clientCapabilities: { - window: { - workDoneProgress: true, - }, - workspace: { - configuration: true, - workspaceFolders: true, - }, - textDocument: { - codeAction: { - dataSupport: true, - resolveSupport: { - properties: ["edit"], - }, - }, - }, - }, - }; - - const mergedExtensions = [ - ...filteredBuiltins, - ...extraExtensions, - ...serverExtensions, - clientCapabilities, - ]; - clientConfig.extensions = mergedExtensions; - - const existingHandlers = clientConfig.notificationHandlers ?? {}; - - type LogLevel = "error" | "warn" | "log" | "info"; - interface LogMessageParams { - type?: number; - message?: string; - } - interface ShowMessageParams { - type?: number; - message?: string; - } - - clientConfig.notificationHandlers = { - ...existingHandlers, - "window/logMessage": (_client: LSPClient, params: unknown): boolean => { - const logParams = params as LogMessageParams; - if (!logParams?.message) return false; - const { type, message } = logParams; - let level: LogLevel = "info"; - switch (type) { - case 1: - level = "error"; - break; - case 2: - level = "warn"; - break; - case 4: - level = "log"; - break; - default: - level = "info"; - } - const logFn = console[level] ?? console.info; - addLspLog(server.id, level === "log" ? "info" : level, message); - logFn(`[LSP:${server.id}] ${message}`); - return true; - }, - "window/showMessage": (_client: LSPClient, params: unknown): boolean => { - const showParams = params as ShowMessageParams; - if (!showParams?.message) return false; - const { type, message } = showParams; - const serverLabel = server.label || server.id; - - // Helper to clean and truncate message for notifications - const cleanMessage = (msg: string, maxLen = 150): string => { - // Take only first line - let cleaned = msg.split("\n")[0].trim(); - if (cleaned.length > maxLen) { - cleaned = cleaned.slice(0, maxLen - 3) + "..."; - } - return cleaned; - }; - - // Use notifications for errors and warnings - if (type === 1 || type === 2) { - notificationManager.pushNotification({ - title: serverLabel, - message: cleanMessage(message), - icon: type === 1 ? "error" : "warningreport_problem", - type: type === 1 ? "error" : "warning", - }); - addLspLog(server.id, type === 1 ? "error" : "warn", message); - logLspInfo(`[LSP:${server.id}] ${message}`); - return true; - } - - // For info/log messages, use status bar briefly - lspStatusBar.show({ - message: cleanMessage(message, 80), - title: serverLabel, - type: "info", - icon: type === 4 ? "autorenew" : "info", - duration: 5000, - }); - addLspLog(server.id, "info", message); - logLspInfo(`[LSP:${server.id}] ${message}`); - return true; - }, - "$/progress": (_client: LSPClient, params: unknown): boolean => { - interface ProgressParams { - token?: string | number; - value?: { - kind?: "begin" | "report" | "end"; - title?: string; - message?: string; - percentage?: number; - cancellable?: boolean; - }; - } - const progressParams = params as ProgressParams; - if (!progressParams?.value) return false; - - const { kind, title, message, percentage } = progressParams.value; - const displayTitle = title || server.label || server.id; - // Use server ID + token as unique status ID for concurrent progress tracking - const progressToken = progressParams.token; - const statusId = `${server.id}-progress-${progressToken ?? "default"}`; - - if (kind === "begin") { - lspStatusBar.show({ - id: statusId, - message: message || title || "Starting...", - title: displayTitle, - type: "info", - icon: "autorenew", - duration: false, - showProgress: typeof percentage === "number", - progress: percentage, - }); - } else if (kind === "report") { - lspStatusBar.update({ - id: statusId, - message: message, - progress: percentage, - }); - } else if (kind === "end") { - // Just hide the progress item silently, no "Complete" message - lspStatusBar.hideById(statusId); - } - - logLspInfo( - `[LSP:${server.id}] Progress: ${kind} - ${message || title || ""} ${typeof percentage === "number" ? `(${percentage}%)` : ""}`, - ); - return true; - }, - "$/typescriptVersion": (_client: LSPClient, params: unknown): boolean => { - interface TypeScriptVersionParams { - version?: string; - source?: string; - } - const versionParams = params as TypeScriptVersionParams; - if (!versionParams?.version) return false; - - const serverLabel = server.label || server.id; - const source = versionParams.source || "bundled"; - logLspInfo( - `[LSP:${server.id}] TypeScript ${versionParams.version} (${source})`, - ); - - // Show briefly in status bar - lspStatusBar.show({ - message: `TypeScript ${versionParams.version}`, - title: serverLabel, - type: "info", - icon: "code", - duration: 3000, - }); - return true; - }, - }; - - // Log unhandled notifications to help debug what servers are sending - const unhandledNotificationKey = - "unhandledNotification" as keyof typeof clientConfig; - if (!(unhandledNotificationKey in clientConfig)) { - ( - clientConfig as Record< - string, - (client: LSPClient, method: string, params: unknown) => void - > - ).unhandledNotification = ( - _client: LSPClient, - method: string, - params: unknown, - ) => { - logLspInfo( - `[LSP:${server.id}] Unhandled notification: ${method}`, - params, - ); - }; - } - - if (!clientConfig.workspace) { - clientConfig.workspace = (client: LSPClient) => - new AcodeWorkspace(client, workspaceOptions); - } - - if (normalizedRootUri && !clientConfig.rootUri) { - clientConfig.rootUri = normalizedRootUri; - } - - if (!normalizedRootUri && clientConfig.rootUri) { - delete clientConfig.rootUri; - } - - if (server.startupTimeout && !clientConfig.timeout) { - clientConfig.timeout = server.startupTimeout; - } - - if (!clientConfig.highlightLanguage) { - clientConfig.highlightLanguage = resolveLspHoverHighlightLanguage; - } - - let transportHandle: TransportHandle | undefined; - let client: ExtendedLSPClient | undefined; - let runtimeConnection: LspRuntimeConnection | undefined; - - try { - throwIfInitializationCancelled(signal, server.id); - const runtimeContext = { - ...context, - uri: documentUri, - documentUri, - originalDocumentUri, - rootUri: runtimeRootUri, - originalRootUri: runtimeOriginalRootUri ?? undefined, - serverId: server.id, - allowNonTerminalWorkspace: - this.options.allowNonTerminalWorkspace === true, - }; - const connection = await runtimeProvider.start(server, runtimeContext); - const connectionDispose = connection.dispose; - connection.dispose = async () => { - try { - if (connectionDispose) { - await connectionDispose(); - } - } finally { - if (runtimeProvider.stop) { - await runtimeProvider.stop(connection); - } - } - }; - runtimeConnection = connection; - throwIfInitializationCancelled(signal, server.id); - - transportHandle = createTransportFromRuntimeConnection( - server, - runtimeContext, - connection, - ); - await waitForInitialization(transportHandle.ready, signal, server.id); - client = new LSPClient(clientConfig) as ExtendedLSPClient; - client.__acodeServerId = server.id; - connectClient( - client, - transportHandle.transport, - initializationOptions, - runtimeRootUri, - ); - await waitForInitialization(client.initializing, signal, server.id); - if (!client.__acodeLoggedInfo) { - // Log root URI info to console - if (normalizedRootUri) { - if (originalRootUri && originalRootUri !== normalizedRootUri) { - logLspInfo( - `[LSP:${server.id}] root ${normalizedRootUri} (from ${originalRootUri})`, - ); - } else { - logLspInfo(`[LSP:${server.id}] root`, normalizedRootUri); - } - } else if (originalRootUri) { - logLspInfo(`[LSP:${server.id}] root ignored`, originalRootUri); - } - if (initializationOptions) { - logLspInfo( - `[LSP:${server.id}] initializationOptions keys`, - Object.keys(initializationOptions), - ); - } - logLspInfo(`[LSP:${server.id}] initialized`); - addLspLog( - server.id, - "info", - runtimeRootUri - ? `Initialized workspace ${runtimeRootUri}` - : "Initialized without a workspace root", - ); - client.__acodeLoggedInfo = true; - } - } catch (error) { - addLspLog( - server.id, - "error", - formatInitializationError(error), - error, - ); - try { - client?.disconnect(); - } catch { - /* Client may not have finished connecting */ - } - if (transportHandle) { - await transportHandle.dispose?.(); - } else { - await runtimeConnection?.dispose?.(); - } - throw error; - } - - const state = this.#createClientState({ - key, - server, - client, - transportHandle, - normalizedRootUri, - originalRootUri: scope === "document" ? null : originalRootUri, - }); - - this.#clients.set(key, state); - return state; - } - - #createClientState(params: { - key: string; - server: LspServerDefinition; - client: LSPClient; - transportHandle: TransportHandle; - normalizedRootUri: string | null; - originalRootUri: string | null; - }): ClientState { - const { - key, - server, - client, - transportHandle, - normalizedRootUri, - originalRootUri, - } = params; - const fileRefs = new Map>(); - const uriAliases = new Map(); - const effectiveRoot = normalizedRootUri ?? originalRootUri ?? null; - let disposed = false; - let idleTimer: ReturnType | null = null; - - const cancelIdleTimer = (): void => { - if (idleTimer === null) return; - clearTimeout(idleTimer); - idleTimer = null; - }; - - const attach = ( - uri: string, - view: EditorView, - aliases: string[] = [], - ): void => { - cancelIdleTimer(); - const existing = fileRefs.get(uri) ?? new Set(); - existing.add(view); - fileRefs.set(uri, existing); - uriAliases.set(uri, uri); - for (const alias of aliases) { - if (!alias || alias === uri) continue; - uriAliases.set(alias, uri); - } - const suffix = effectiveRoot ? ` (root ${effectiveRoot})` : ""; - logLspInfo(`[LSP:${server.id}] attached to ${uri}${suffix}`); - }; - - const clearClientDiagnostics = (view: EditorView): void => { - try { - view.dispatch({ effects: clearDiagnosticsEffect(client) }); - } catch { - /* View may already be destroyed or may not have diagnostics state. */ - } - }; - - const dispose = async (): Promise => { - if (disposed) return; - disposed = true; - cancelIdleTimer(); - disposePullDiagnostics(client); - this.#clients.delete(key); - for (const views of fileRefs.values()) { - for (const view of views) clearClientDiagnostics(view); - } - try { - client.disconnect(); - } catch (error) { - console.warn(`Error disconnecting LSP client ${server.id}`, error); - } - try { - await transportHandle.dispose?.(); - } catch (error) { - console.warn(`Error disposing LSP transport ${server.id}`, error); - } - }; - - const detach = (uri: string, view?: EditorView): void => { - const actualUri = uriAliases.get(uri) ?? uri; - const existing = fileRefs.get(actualUri); - if (!existing) return; - if (view) { - existing.delete(view); - clearClientDiagnostics(view); - } - if (!view || !existing.size) { - fileRefs.delete(actualUri); - for (const [alias, target] of uriAliases.entries()) { - if (target === actualUri) { - uriAliases.delete(alias); - } - } - try { - // Only pass uri to closeFile - view is not needed for closing - // and passing it may cause issues if the view is already disposed - (client.workspace as AcodeWorkspace)?.closeFile?.(actualUri); - } catch (error) { - console.warn(`Failed to close LSP file ${actualUri}`, error); - } - } - - if (fileRefs.size || idleTimer !== null) return; - - const configuredGracePeriod = this.options.clientIdleGracePeriodMs; - const gracePeriod = - typeof configuredGracePeriod === "number" && - Number.isFinite(configuredGracePeriod) - ? Math.max(0, configuredGracePeriod) - : DEFAULT_CLIENT_IDLE_GRACE_PERIOD_MS; - idleTimer = setTimeout(() => { - idleTimer = null; - if (disposed || fileRefs.size) return; - this.options.onClientIdle?.({ - server, - client, - rootUri: effectiveRoot, - dispose, - }); - }, gracePeriod); - }; - - return { - server, - client, - transport: transportHandle, - rootUri: effectiveRoot, - attach, - detach, - dispose, - }; - } - - async #resolveRootUri( - server: LspServerDefinition, - context: RootUriContext, - ): Promise { - if (typeof server.rootUri === "function") { - try { - const value = await server.rootUri(context?.uri ?? "", context); - if (value) return safeString(value); - } catch (error) { - console.warn(`Server root resolver failed for ${server.id}`, error); - } - } - - if (context?.rootUri) return safeString(context.rootUri); - - if (typeof this.options.resolveRoot === "function") { - try { - const value = await this.options.resolveRoot(context); - if (value) return safeString(value); - } catch (error) { - console.warn("Global LSP root resolver failed", error); - } - } - - return null; - } - - async #resolveRuntimeTarget( - server: LspServerDefinition, - context: RootUriContext, - ): Promise { - const originalDocumentUri = context.uri; - if (!originalDocumentUri) return null; - - const originalRootUri = await this.#resolveRootUri(server, context); - const { normalizedRootUri } = normalizeRootUriForServer( - server, - originalRootUri, - ); - const normalizedDocumentUri = await this.#resolveDocumentUri( - server, - context, - ); - const providerContext = { - ...context, - uri: originalDocumentUri, - documentUri: normalizedDocumentUri, - originalDocumentUri, - rootUri: originalRootUri, - originalRootUri: originalRootUri ?? undefined, - serverId: server.id, - allowNonTerminalWorkspace: - this.options.allowNonTerminalWorkspace === true, - }; - const runtimeProvider = await selectRuntimeProvider(server, providerContext); - if (!runtimeProvider) { - console.warn( - `No LSP runtime provider selected for ${server.id}: uri=${originalDocumentUri}, root=${originalRootUri ?? "none"}, normalizedUri=${normalizedDocumentUri ?? "none"}`, - ); - return null; - } - - let documentUri = normalizedDocumentUri; - let rootUri = normalizedRootUri; - let scope: LspClientScope = "workspace"; - - if (runtimeProvider.resolveUris) { - try { - const resolution = await runtimeProvider.resolveUris(server, { - ...providerContext, - originalRootUri, - normalizedDocumentUri, - normalizedRootUri, - }); - if (resolution) { - if (Object.prototype.hasOwnProperty.call(resolution, "documentUri")) { - documentUri = resolution.documentUri || null; - } - if (Object.prototype.hasOwnProperty.call(resolution, "rootUri")) { - rootUri = resolution.rootUri || null; - } - if (resolution.scope) scope = resolution.scope; - } - } catch (error) { - console.warn( - `LSP runtime provider ${runtimeProvider.id} failed to resolve URIs for ${server.id}`, - error, - ); - return null; - } - } - - if (!documentUri) { - console.warn( - `LSP runtime provider ${runtimeProvider.id} produced no document URI for ${server.id}: uri=${originalDocumentUri}, normalizedUri=${normalizedDocumentUri ?? "none"}`, - ); - return null; - } - return { - originalDocumentUri, - documentUri, - normalizedRootUri: rootUri, - originalRootUri, - runtimeProvider, - scope, - }; - } - - async #resolveDocumentUri( - server: LspServerDefinition, - context: RootUriContext, - ): Promise { - const originalUri = context?.uri; - if (!originalUri) return null; - - const normalizedUri = normalizeDocumentUri(originalUri); - - if (typeof server.documentUri === "function") { - try { - const value = await server.documentUri(originalUri, { - ...context, - normalizedUri, - } as DocumentUriContext); - if (value) return safeString(value); - } catch (error) { - console.warn( - `Server document URI resolver failed for ${server.id}`, - error, - ); - } - } - - return normalizedUri; - } + options: ClientManagerOptions; + + #clients: Map; + #pendingClients: Map; + + constructor(options: ClientManagerOptions = {}) { + this.options = { ...options }; + this.#clients = new Map(); + this.#pendingClients = new Map(); + } + + setOptions(next: Partial): void { + this.options = { ...this.options, ...next }; + } + + getActiveClients(): ClientState[] { + return Array.from(this.#clients.values()); + } + + async getExtensionsForFile(metadata: FileMetadata): Promise { + const { + uri: originalUri, + languageId, + languageName, + view, + file, + rootUri, + } = metadata; + + const effectiveLang = safeString(languageId ?? languageName).toLowerCase(); + if (!effectiveLang) return []; + + const servers = serverRegistry.getServersForLanguage(effectiveLang); + if (!servers.length) return []; + + const lspExtensions: Extension[] = []; + const diagnosticsUiExtension = this.options.diagnosticsUiExtension; + + for (const server of servers) { + if (isSettingsOrKeybindingsFile(originalUri, file)) { + continue; + } + const target = await this.#resolveRuntimeTarget(server, { + uri: originalUri, + file, + view, + languageId: effectiveLang, + rootUri, + }); + if (!target) { + console.warn( + `Cannot resolve runtime or document URI for LSP server ${server.id}: ${originalUri}`, + ); + continue; + } + const normalizedUri = target.documentUri; + let targetLanguageId = effectiveLang; + if (server.resolveLanguageId) { + try { + const resolved = server.resolveLanguageId({ + languageId: effectiveLang, + languageName, + uri: normalizedUri, + file, + }); + if (resolved) targetLanguageId = safeString(resolved); + } catch (error) { + console.warn( + `LSP server ${server.id} failed to resolve language id for ${normalizedUri}`, + error, + ); + } + } + + try { + const clientState = await this.#ensureClient( + server, + { + uri: normalizedUri, + file, + view, + languageId: targetLanguageId, + rootUri: target.normalizedRootUri ?? undefined, + }, + target, + ); + const plugin = clientState.client.plugin( + normalizedUri, + targetLanguageId, + buildPluginOptions(server), + ); + if (server.clientConfig?.builtinExtensions?.completion !== false) { + lspExtensions.push(lspCompletionEnabled.of(true)); + } + const aliases = + originalUri && originalUri !== normalizedUri ? [originalUri] : []; + clientState.attach(normalizedUri, view as EditorView, aliases); + lspExtensions.push(plugin); + } catch (error) { + console.error( + `Failed to initialize LSP client for ${server.id}`, + error, + ); + } + } + + if (diagnosticsUiExtension && lspExtensions.length) { + lspExtensions.push(lspDiagnosticsAutoSyncExtension()); + lspExtensions.push(...asArray(diagnosticsUiExtension)); + } + + return lspExtensions; + } + + async formatDocument( + metadata: FileMetadata, + options: FormattingOptions = {}, + ): Promise { + const { uri: originalUri, languageId, languageName, view, file } = metadata; + + const effectiveLang = safeString(languageId ?? languageName).toLowerCase(); + if (!effectiveLang || !view) return false; + + const servers = serverRegistry.getServersForLanguage(effectiveLang); + if (!servers.length) return false; + + for (const server of servers) { + if (isSettingsOrKeybindingsFile(originalUri, file)) { + continue; + } + if (!supportsBuiltinFormatting(server)) continue; + try { + const target = await this.#resolveRuntimeTarget(server, { + uri: originalUri, + file, + view, + languageId: effectiveLang, + rootUri: metadata.rootUri, + }); + if (!target) { + console.warn( + `Cannot resolve document URI for formatting with ${server.id}: ${originalUri}`, + ); + continue; + } + const normalizedUri = target.documentUri; + const context: RootUriContext = { + uri: normalizedUri, + languageId: effectiveLang, + view, + file, + rootUri: target.normalizedRootUri ?? undefined, + }; + const state = await this.#ensureClient(server, context, target); + const capabilities = state.client.serverCapabilities; + if (!capabilities?.documentFormattingProvider) continue; + state.attach(normalizedUri, view); + const plugin = LSPPlugin.get(view, state.client); + if (!plugin) continue; + plugin.client.sync(); + const edits = await state.client.request< + { textDocument: { uri: string }; options: FormattingOptions }, + TextEdit[] | null + >("textDocument/formatting", { + textDocument: { uri: normalizedUri }, + options: buildFormattingOptions(view, options), + }); + if (!edits || !edits.length) { + plugin.client.sync(); + return true; + } + const applied = applyTextEdits(plugin, view, edits); + if (applied) { + plugin.client.sync(); + return true; + } + } catch (error) { + console.error(`LSP formatting failed for ${server.id}`, error); + } + } + return false; + } + + detach(uri: string, view: EditorView): void { + for (const state of this.#clients.values()) { + state.detach(uri, view); + } + } + + async disposeServer(serverId: string): Promise { + const normalizedId = safeString(serverId).toLowerCase(); + if (!normalizedId) return; + const pendingDisposals: Promise[] = []; + for (const [key, pending] of this.#pendingClients.entries()) { + if (pending.serverId !== normalizedId) continue; + pending.controller.abort(); + this.#pendingClients.delete(key); + pendingDisposals.push(pending.promise.then((state) => state.dispose())); + } + const states = Array.from(this.#clients.values()).filter( + (state) => state.server.id.toLowerCase() === normalizedId, + ); + await Promise.allSettled([ + ...states.map((state) => state.dispose()), + ...pendingDisposals, + ]); + } + + async dispose(): Promise { + try { + interface FileWithSession { + id?: string; + type?: string; + session?: EditorState; + } + + interface EditorManagerLike { + files?: FileWithSession[]; + editor?: EditorView; + activeFile?: FileWithSession; + } + + const em = (globalThis as Record).editorManager as + | EditorManagerLike + | undefined; + + if (em?.editor) { + try { + em.editor.dispatch({ effects: clearDiagnosticsEffect() }); + if (em.activeFile?.type === "editor") { + em.activeFile.session = em.editor.state; + } + } catch { + /* View may be disposed */ + } + } + + if (em?.files) { + for (const file of em.files) { + if (file?.type !== "editor" || file.id === em.activeFile?.id) + continue; + const session = file.session; + if (session && typeof session.update === "function") { + try { + file.session = session.update({ + effects: clearDiagnosticsEffect(), + }).state; + } catch { + /* State update failed */ + } + } + } + } + } catch { + /* Ignore errors */ + } + + const disposeOps: Promise[] = []; + for (const [key, pending] of this.#pendingClients.entries()) { + pending.controller.abort(); + this.#pendingClients.delete(key); + disposeOps.push(pending.promise.then((state) => state.dispose())); + } + for (const [key, state] of this.#clients.entries()) { + disposeOps.push(state.dispose()); + this.#clients.delete(key); + } + await Promise.allSettled(disposeOps); + } + + async #ensureClient( + server: LspServerDefinition, + context: RootUriContext, + target: ResolvedRuntimeTarget, + ): Promise { + const { + documentUri, + normalizedRootUri, + originalRootUri, + runtimeProvider, + scope, + } = target; + const useWsFolders = + scope === "workspace" && server.useWorkspaceFolders === true; + const runtimeServerKey = `${server.id}@${runtimeProvider.id}`; + + // Workspace-folder clients are shared only within the selected runtime. + const key = + scope === "document" + ? `${runtimeServerKey}::__document__::${documentUri}` + : pluginKey(runtimeServerKey, normalizedRootUri, useWsFolders); + + // Return existing client if already initialized + if (this.#clients.has(key)) { + const existing = this.#clients.get(key)!; + // For workspace folders mode, add the new folder to the existing server + if (useWsFolders && normalizedRootUri) { + const workspace = existing.client.workspace as AcodeWorkspace | null; + if (workspace && !workspace.hasWorkspaceFolder(normalizedRootUri)) { + workspace.addWorkspaceFolder(normalizedRootUri); + } + } + return existing; + } + + // If initialization is already in progress, wait for it + if (this.#pendingClients.has(key)) { + const pending = await this.#pendingClients.get(key)!.promise; + if (useWsFolders && normalizedRootUri) { + const workspace = pending.client.workspace as AcodeWorkspace | null; + if (workspace && !workspace.hasWorkspaceFolder(normalizedRootUri)) { + workspace.addWorkspaceFolder(normalizedRootUri); + } + } + return pending; + } + + // Create and track the pending initialization + const controller = new AbortController(); + const initPromise = this.#initializeClient(server, context, { + key, + normalizedRootUri: useWsFolders ? null : normalizedRootUri, + originalRootUri: useWsFolders ? null : originalRootUri, + runtimeRootUri: normalizedRootUri, + runtimeOriginalRootUri: originalRootUri, + originalDocumentUri: target.originalDocumentUri, + documentUri, + runtimeProvider, + scope, + signal: controller.signal, + }); + const pending: PendingClientInitialization = { + serverId: server.id.toLowerCase(), + controller, + promise: initPromise, + }; + this.#pendingClients.set(key, pending); + + try { + const state = await initPromise; + if (useWsFolders && normalizedRootUri) { + const workspace = state.client.workspace as AcodeWorkspace | null; + if (workspace && !workspace.hasWorkspaceFolder(normalizedRootUri)) { + workspace.addWorkspaceFolder(normalizedRootUri); + } + } + return state; + } finally { + if (this.#pendingClients.get(key) === pending) { + this.#pendingClients.delete(key); + } + } + } + + async #initializeClient( + server: LspServerDefinition, + context: RootUriContext, + initContext: InitContext, + ): Promise { + const { + key, + normalizedRootUri, + originalRootUri, + runtimeRootUri, + runtimeOriginalRootUri, + originalDocumentUri, + documentUri, + runtimeProvider, + scope, + signal, + } = initContext; + + const workspaceOptions = { + displayFile: this.options.displayFile, + openFile: this.options.openFile, + resolveLanguageId: this.options.resolveLanguageId, + // Track the first folder advertised during `initialize` so it is not + // sent again as a workspace-folder change after the client connects. + initialFolders: runtimeRootUri ? [runtimeRootUri] : undefined, + }; + + const clientConfig = { ...(server.clientConfig ?? {}) }; + const initializationOptions = resolveInitializationOptions( + server, + clientConfig as Record, + ); + const builtinConfig = clientConfig.builtinExtensions ?? {}; + const useDefaultExtensions = clientConfig.useDefaultExtensions !== false; + const { extensions: defaultExtensions, diagnosticsExtension } = + useDefaultExtensions + ? buildBuiltinExtensions({ + hover: builtinConfig.hover !== false, + completion: builtinConfig.completion !== false, + signature: builtinConfig.signature !== false, + keymaps: builtinConfig.keymaps !== false, + diagnostics: builtinConfig.diagnostics !== false, + inlayHints: builtinConfig.inlayHints === true, + formatting: builtinConfig.formatting !== false, + documentColors: builtinConfig.documentColors !== false, + }) + : { extensions: [], diagnosticsExtension: null }; + + const extraExtensions = asArray(this.options.clientExtensions); + const serverExtensions = asArray(clientConfig.extensions); + + interface ExtensionWithCapabilities { + clientCapabilities?: { + textDocument?: { + publishDiagnostics?: unknown; + }; + }; + } + + const wantsCustomDiagnostics = [ + ...extraExtensions, + ...serverExtensions, + ].some((ext) => { + const extWithCaps = ext as ExtensionWithCapabilities; + return !!extWithCaps?.clientCapabilities?.textDocument + ?.publishDiagnostics; + }); + + const filteredBuiltins = + wantsCustomDiagnostics && diagnosticsExtension + ? defaultExtensions.filter((ext) => ext !== diagnosticsExtension) + : defaultExtensions; + + const clientCapabilities: LSPClientExtension = { + clientCapabilities: { + window: { + workDoneProgress: true, + }, + workspace: { + configuration: true, + workspaceFolders: true, + }, + textDocument: { + codeAction: { + dataSupport: true, + resolveSupport: { + properties: ["edit"], + }, + }, + }, + }, + }; + + const mergedExtensions = [ + ...filteredBuiltins, + ...extraExtensions, + ...serverExtensions, + clientCapabilities, + ]; + clientConfig.extensions = mergedExtensions; + + const existingHandlers = clientConfig.notificationHandlers ?? {}; + + type LogLevel = "error" | "warn" | "log" | "info"; + interface LogMessageParams { + type?: number; + message?: string; + } + interface ShowMessageParams { + type?: number; + message?: string; + } + + clientConfig.notificationHandlers = { + ...existingHandlers, + "window/logMessage": (_client: LSPClient, params: unknown): boolean => { + const logParams = params as LogMessageParams; + if (!logParams?.message) return false; + const { type, message } = logParams; + let level: LogLevel = "info"; + switch (type) { + case 1: + level = "error"; + break; + case 2: + level = "warn"; + break; + case 4: + level = "log"; + break; + default: + level = "info"; + } + const logFn = console[level] ?? console.info; + addLspLog(server.id, level === "log" ? "info" : level, message); + logFn(`[LSP:${server.id}] ${message}`); + return true; + }, + "window/showMessage": (_client: LSPClient, params: unknown): boolean => { + const showParams = params as ShowMessageParams; + if (!showParams?.message) return false; + const { type, message } = showParams; + const serverLabel = server.label || server.id; + + // Helper to clean and truncate message for notifications + const cleanMessage = (msg: string, maxLen = 150): string => { + // Take only first line + let cleaned = msg.split("\n")[0].trim(); + if (cleaned.length > maxLen) { + cleaned = cleaned.slice(0, maxLen - 3) + "..."; + } + return cleaned; + }; + + // Use notifications for errors and warnings + if (type === 1 || type === 2) { + notificationManager.pushNotification({ + title: serverLabel, + message: cleanMessage(message), + icon: type === 1 ? "error" : "warningreport_problem", + type: type === 1 ? "error" : "warning", + }); + addLspLog(server.id, type === 1 ? "error" : "warn", message); + logLspInfo(`[LSP:${server.id}] ${message}`); + return true; + } + + // For info/log messages, use status bar briefly + lspStatusBar.show({ + message: cleanMessage(message, 80), + title: serverLabel, + type: "info", + icon: type === 4 ? "autorenew" : "info", + duration: 5000, + }); + addLspLog(server.id, "info", message); + logLspInfo(`[LSP:${server.id}] ${message}`); + return true; + }, + "$/progress": (_client: LSPClient, params: unknown): boolean => { + interface ProgressParams { + token?: string | number; + value?: { + kind?: "begin" | "report" | "end"; + title?: string; + message?: string; + percentage?: number; + cancellable?: boolean; + }; + } + const progressParams = params as ProgressParams; + if (!progressParams?.value) return false; + + const { kind, title, message, percentage } = progressParams.value; + const displayTitle = title || server.label || server.id; + // Use server ID + token as unique status ID for concurrent progress tracking + const progressToken = progressParams.token; + const statusId = `${server.id}-progress-${progressToken ?? "default"}`; + + if (kind === "begin") { + lspStatusBar.show({ + id: statusId, + message: message || title || "Starting...", + title: displayTitle, + type: "info", + icon: "autorenew", + duration: false, + showProgress: typeof percentage === "number", + progress: percentage, + }); + } else if (kind === "report") { + lspStatusBar.update({ + id: statusId, + message: message, + progress: percentage, + }); + } else if (kind === "end") { + // Just hide the progress item silently, no "Complete" message + lspStatusBar.hideById(statusId); + } + + logLspInfo( + `[LSP:${server.id}] Progress: ${kind} - ${message || title || ""} ${typeof percentage === "number" ? `(${percentage}%)` : ""}`, + ); + return true; + }, + "$/typescriptVersion": (_client: LSPClient, params: unknown): boolean => { + interface TypeScriptVersionParams { + version?: string; + source?: string; + } + const versionParams = params as TypeScriptVersionParams; + if (!versionParams?.version) return false; + + const serverLabel = server.label || server.id; + const source = versionParams.source || "bundled"; + logLspInfo( + `[LSP:${server.id}] TypeScript ${versionParams.version} (${source})`, + ); + + // Show briefly in status bar + lspStatusBar.show({ + message: `TypeScript ${versionParams.version}`, + title: serverLabel, + type: "info", + icon: "code", + duration: 3000, + }); + return true; + }, + }; + + // Log unhandled notifications to help debug what servers are sending + const unhandledNotificationKey = + "unhandledNotification" as keyof typeof clientConfig; + if (!(unhandledNotificationKey in clientConfig)) { + ( + clientConfig as Record< + string, + (client: LSPClient, method: string, params: unknown) => void + > + ).unhandledNotification = ( + _client: LSPClient, + method: string, + params: unknown, + ) => { + logLspInfo( + `[LSP:${server.id}] Unhandled notification: ${method}`, + params, + ); + }; + } + + if (!clientConfig.workspace) { + clientConfig.workspace = (client: LSPClient) => + new AcodeWorkspace(client, workspaceOptions); + } + + if (normalizedRootUri && !clientConfig.rootUri) { + clientConfig.rootUri = normalizedRootUri; + } + + if (!normalizedRootUri && clientConfig.rootUri) { + delete clientConfig.rootUri; + } + + if (server.startupTimeout && !clientConfig.timeout) { + clientConfig.timeout = server.startupTimeout; + } + + if (!clientConfig.highlightLanguage) { + clientConfig.highlightLanguage = resolveLspHoverHighlightLanguage; + } + + let transportHandle: TransportHandle | undefined; + let client: ExtendedLSPClient | undefined; + let runtimeConnection: LspRuntimeConnection | undefined; + + try { + throwIfInitializationCancelled(signal, server.id); + const runtimeContext = { + ...context, + uri: documentUri, + documentUri, + originalDocumentUri, + rootUri: runtimeRootUri, + originalRootUri: runtimeOriginalRootUri ?? undefined, + serverId: server.id, + allowNonTerminalWorkspace: + this.options.allowNonTerminalWorkspace === true, + }; + const connection = await runtimeProvider.start(server, runtimeContext); + const connectionDispose = connection.dispose; + connection.dispose = async () => { + try { + if (connectionDispose) { + await connectionDispose(); + } + } finally { + if (runtimeProvider.stop) { + await runtimeProvider.stop(connection); + } + } + }; + runtimeConnection = connection; + throwIfInitializationCancelled(signal, server.id); + + transportHandle = createTransportFromRuntimeConnection( + server, + runtimeContext, + connection, + ); + await waitForInitialization(transportHandle.ready, signal, server.id); + client = new LSPClient(clientConfig) as ExtendedLSPClient; + client.__acodeServerId = server.id; + connectClient( + client, + transportHandle.transport, + initializationOptions, + runtimeRootUri, + ); + await waitForInitialization(client.initializing, signal, server.id); + if (!client.__acodeLoggedInfo) { + // Log root URI info to console + if (normalizedRootUri) { + if (originalRootUri && originalRootUri !== normalizedRootUri) { + logLspInfo( + `[LSP:${server.id}] root ${normalizedRootUri} (from ${originalRootUri})`, + ); + } else { + logLspInfo(`[LSP:${server.id}] root`, normalizedRootUri); + } + } else if (originalRootUri) { + logLspInfo(`[LSP:${server.id}] root ignored`, originalRootUri); + } + if (initializationOptions) { + logLspInfo( + `[LSP:${server.id}] initializationOptions keys`, + Object.keys(initializationOptions), + ); + } + logLspInfo(`[LSP:${server.id}] initialized`); + addLspLog( + server.id, + "info", + runtimeRootUri + ? `Initialized workspace ${runtimeRootUri}` + : "Initialized without a workspace root", + ); + client.__acodeLoggedInfo = true; + } + } catch (error) { + addLspLog(server.id, "error", formatInitializationError(error), error); + try { + client?.disconnect(); + } catch { + /* Client may not have finished connecting */ + } + if (transportHandle) { + await transportHandle.dispose?.(); + } else { + await runtimeConnection?.dispose?.(); + } + throw error; + } + + const state = this.#createClientState({ + key, + server, + client, + transportHandle, + normalizedRootUri, + originalRootUri: scope === "document" ? null : originalRootUri, + }); + + this.#clients.set(key, state); + return state; + } + + #createClientState(params: { + key: string; + server: LspServerDefinition; + client: LSPClient; + transportHandle: TransportHandle; + normalizedRootUri: string | null; + originalRootUri: string | null; + }): ClientState { + const { + key, + server, + client, + transportHandle, + normalizedRootUri, + originalRootUri, + } = params; + const fileRefs = new Map>(); + const uriAliases = new Map(); + const effectiveRoot = normalizedRootUri ?? originalRootUri ?? null; + let disposed = false; + let idleTimer: ReturnType | null = null; + + const cancelIdleTimer = (): void => { + if (idleTimer === null) return; + clearTimeout(idleTimer); + idleTimer = null; + }; + + const attach = ( + uri: string, + view: EditorView, + aliases: string[] = [], + ): void => { + cancelIdleTimer(); + const existing = fileRefs.get(uri) ?? new Set(); + existing.add(view); + fileRefs.set(uri, existing); + uriAliases.set(uri, uri); + for (const alias of aliases) { + if (!alias || alias === uri) continue; + uriAliases.set(alias, uri); + } + const suffix = effectiveRoot ? ` (root ${effectiveRoot})` : ""; + logLspInfo(`[LSP:${server.id}] attached to ${uri}${suffix}`); + }; + + const clearClientDiagnostics = (view: EditorView): void => { + try { + view.dispatch({ effects: clearDiagnosticsEffect(client) }); + } catch { + /* View may already be destroyed or may not have diagnostics state. */ + } + }; + + const dispose = async (): Promise => { + if (disposed) return; + disposed = true; + cancelIdleTimer(); + disposePullDiagnostics(client); + this.#clients.delete(key); + for (const views of fileRefs.values()) { + for (const view of views) clearClientDiagnostics(view); + } + try { + client.disconnect(); + } catch (error) { + console.warn(`Error disconnecting LSP client ${server.id}`, error); + } + try { + await transportHandle.dispose?.(); + } catch (error) { + console.warn(`Error disposing LSP transport ${server.id}`, error); + } + }; + + const detach = (uri: string, view?: EditorView): void => { + const actualUri = uriAliases.get(uri) ?? uri; + const existing = fileRefs.get(actualUri); + if (!existing) return; + if (view) { + existing.delete(view); + clearClientDiagnostics(view); + } + if (!view || !existing.size) { + fileRefs.delete(actualUri); + for (const [alias, target] of uriAliases.entries()) { + if (target === actualUri) { + uriAliases.delete(alias); + } + } + try { + // Only pass uri to closeFile - view is not needed for closing + // and passing it may cause issues if the view is already disposed + (client.workspace as AcodeWorkspace)?.closeFile?.(actualUri); + } catch (error) { + console.warn(`Failed to close LSP file ${actualUri}`, error); + } + } + + if (fileRefs.size || idleTimer !== null) return; + + const configuredGracePeriod = this.options.clientIdleGracePeriodMs; + const gracePeriod = + typeof configuredGracePeriod === "number" && + Number.isFinite(configuredGracePeriod) + ? Math.max(0, configuredGracePeriod) + : DEFAULT_CLIENT_IDLE_GRACE_PERIOD_MS; + idleTimer = setTimeout(() => { + idleTimer = null; + if (disposed || fileRefs.size) return; + this.options.onClientIdle?.({ + server, + client, + rootUri: effectiveRoot, + dispose, + }); + }, gracePeriod); + }; + + return { + server, + client, + transport: transportHandle, + rootUri: effectiveRoot, + attach, + detach, + dispose, + }; + } + + async #resolveRootUri( + server: LspServerDefinition, + context: RootUriContext, + ): Promise { + if (typeof server.rootUri === "function") { + try { + const value = await server.rootUri(context?.uri ?? "", context); + if (value) return safeString(value); + } catch (error) { + console.warn(`Server root resolver failed for ${server.id}`, error); + } + } + + if (context?.rootUri) return safeString(context.rootUri); + + if (typeof this.options.resolveRoot === "function") { + try { + const value = await this.options.resolveRoot(context); + if (value) return safeString(value); + } catch (error) { + console.warn("Global LSP root resolver failed", error); + } + } + + return null; + } + + async #resolveRuntimeTarget( + server: LspServerDefinition, + context: RootUriContext, + ): Promise { + const originalDocumentUri = context.uri; + if (!originalDocumentUri) return null; + + const originalRootUri = await this.#resolveRootUri(server, context); + const { normalizedRootUri } = normalizeRootUriForServer( + server, + originalRootUri, + ); + const normalizedDocumentUri = await this.#resolveDocumentUri( + server, + context, + ); + const providerContext = { + ...context, + uri: originalDocumentUri, + documentUri: normalizedDocumentUri, + originalDocumentUri, + rootUri: originalRootUri, + originalRootUri: originalRootUri ?? undefined, + serverId: server.id, + allowNonTerminalWorkspace: + this.options.allowNonTerminalWorkspace === true, + }; + const runtimeProvider = await selectRuntimeProvider( + server, + providerContext, + ); + if (!runtimeProvider) { + console.warn( + `No LSP runtime provider selected for ${server.id}: uri=${originalDocumentUri}, root=${originalRootUri ?? "none"}, normalizedUri=${normalizedDocumentUri ?? "none"}`, + ); + return null; + } + + let documentUri = normalizedDocumentUri; + let rootUri = normalizedRootUri; + let scope: LspClientScope = "workspace"; + + if (runtimeProvider.resolveUris) { + try { + const resolution = await runtimeProvider.resolveUris(server, { + ...providerContext, + originalRootUri, + normalizedDocumentUri, + normalizedRootUri, + }); + if (resolution) { + if (Object.prototype.hasOwnProperty.call(resolution, "documentUri")) { + documentUri = resolution.documentUri || null; + } + if (Object.prototype.hasOwnProperty.call(resolution, "rootUri")) { + rootUri = resolution.rootUri || null; + } + if (resolution.scope) scope = resolution.scope; + } + } catch (error) { + console.warn( + `LSP runtime provider ${runtimeProvider.id} failed to resolve URIs for ${server.id}`, + error, + ); + return null; + } + } + + if (!documentUri) { + console.warn( + `LSP runtime provider ${runtimeProvider.id} produced no document URI for ${server.id}: uri=${originalDocumentUri}, normalizedUri=${normalizedDocumentUri ?? "none"}`, + ); + return null; + } + return { + originalDocumentUri, + documentUri, + normalizedRootUri: rootUri, + originalRootUri, + runtimeProvider, + scope, + }; + } + + async #resolveDocumentUri( + server: LspServerDefinition, + context: RootUriContext, + ): Promise { + const originalUri = context?.uri; + if (!originalUri) return null; + + const normalizedUri = normalizeDocumentUri(originalUri); + + if (typeof server.documentUri === "function") { + try { + const value = await server.documentUri(originalUri, { + ...context, + normalizedUri, + } as DocumentUriContext); + if (value) return safeString(value); + } catch (error) { + console.warn( + `Server document URI resolver failed for ${server.id}`, + error, + ); + } + } + + return normalizedUri; + } } function createTransportFromRuntimeConnection( - server: LspServerDefinition, - context: TransportContext, - connection: LspRuntimeConnection, + server: LspServerDefinition, + context: TransportContext, + connection: LspRuntimeConnection, ): TransportHandle { - if (connection.kind === "transport") { - if (!connection.dispose) return connection.transport; - return { - ...connection.transport, - dispose: async () => { - await connection.transport.dispose?.(); - await connection.dispose?.(); - }, - }; - } - - const transportServer: LspServerDefinition = { - ...server, - transport: { - ...server.transport, - kind: "websocket", - url: connection.url, - protocols: connection.protocols, - }, - }; - const handle = createTransport(transportServer, context); - if (!connection.dispose) return handle; - return { - ...handle, - dispose: async () => { - await handle.dispose?.(); - await connection.dispose?.(); - }, - }; + if (connection.kind === "transport") { + if (!connection.dispose) return connection.transport; + return { + ...connection.transport, + dispose: async () => { + await connection.transport.dispose?.(); + await connection.dispose?.(); + }, + }; + } + + const transportServer: LspServerDefinition = { + ...server, + transport: { + ...server.transport, + kind: "websocket", + url: connection.url, + protocols: connection.protocols, + }, + }; + const handle = createTransport(transportServer, context); + if (!connection.dispose) return handle; + return { + ...handle, + dispose: async () => { + await handle.dispose?.(); + await connection.dispose?.(); + }, + }; } interface Change { - from: number; - to: number; - insert: string; + from: number; + to: number; + insert: string; } function applyTextEdits( - plugin: LSPPlugin, - view: EditorView, - edits: TextEdit[], + plugin: LSPPlugin, + view: EditorView, + edits: TextEdit[], ): boolean { - const changes: Change[] = []; - for (const edit of edits) { - if (!edit?.range) continue; - let fromBase: number; - let toBase: number; - fromBase = safeLspPositionToOffset(plugin.syncedDoc, edit.range.start); - toBase = safeLspPositionToOffset(plugin.syncedDoc, edit.range.end); - const fromResult = plugin.unsyncedChanges.mapPos( - fromBase, - 1, - MapMode.TrackDel, - ); - const toResult = plugin.unsyncedChanges.mapPos( - toBase, - -1, - MapMode.TrackDel, - ); - if (fromResult == null || toResult == null) continue; - const insert = - typeof edit.newText === "string" - ? edit.newText.replace(/\r\n/g, "\n") - : ""; - changes.push({ from: fromResult, to: toResult, insert }); - } - if (!changes.length) return false; - changes.sort((a, b) => a.from - b.from || a.to - b.to); - view.dispatch({ changes }); - return true; + const changes: Change[] = []; + for (const edit of edits) { + if (!edit?.range) continue; + let fromBase: number; + let toBase: number; + fromBase = safeLspPositionToOffset(plugin.syncedDoc, edit.range.start); + toBase = safeLspPositionToOffset(plugin.syncedDoc, edit.range.end); + const fromResult = plugin.unsyncedChanges.mapPos( + fromBase, + 1, + MapMode.TrackDel, + ); + const toResult = plugin.unsyncedChanges.mapPos( + toBase, + -1, + MapMode.TrackDel, + ); + if (fromResult == null || toResult == null) continue; + const insert = + typeof edit.newText === "string" + ? edit.newText.replace(/\r\n/g, "\n") + : ""; + changes.push({ from: fromResult, to: toResult, insert }); + } + if (!changes.length) return false; + changes.sort((a, b) => a.from - b.from || a.to - b.to); + view.dispatch({ changes }); + return true; } function buildFormattingOptions( - view: EditorView, - overrides: FormattingOptions = {}, + view: EditorView, + overrides: FormattingOptions = {}, ): FormattingOptions { - const state = view?.state; - if (!state) return { ...overrides }; - - const unitValue = state.facet(indentUnit); - const unit = - typeof unitValue === "string" && unitValue.length - ? unitValue - : String(unitValue ?? "\t"); - let tabSize = getIndentUnit(state); - if ( - typeof tabSize !== "number" || - !Number.isFinite(tabSize) || - tabSize <= 0 - ) { - tabSize = resolveIndentWidth(unit); - } - const insertSpaces = !unit.includes("\t"); - - return { - tabSize, - insertSpaces, - ...overrides, - }; + const state = view?.state; + if (!state) return { ...overrides }; + + const unitValue = state.facet(indentUnit); + const unit = + typeof unitValue === "string" && unitValue.length + ? unitValue + : String(unitValue ?? "\t"); + let tabSize = getIndentUnit(state); + if ( + typeof tabSize !== "number" || + !Number.isFinite(tabSize) || + tabSize <= 0 + ) { + tabSize = resolveIndentWidth(unit); + } + const insertSpaces = !unit.includes("\t"); + + return { + tabSize, + insertSpaces, + ...overrides, + }; } function resolveIndentWidth(unit: string): number { - if (typeof unit !== "string" || !unit.length) return 4; - let width = 0; - for (const ch of unit) { - if (ch === "\t") return 4; - width += 1; - } - return width || 4; + if (typeof unit !== "string" || !unit.length) return 4; + let width = 0; + for (const ch of unit) { + if (ch === "\t") return 4; + width += 1; + } + return width || 4; } const defaultManager = new LspClientManager(); @@ -1514,133 +1512,133 @@ const defaultManager = new LspClientManager(); export default defaultManager; function normalizeRootUriForServer( - _server: LspServerDefinition, - rootUri: string | null, + _server: LspServerDefinition, + rootUri: string | null, ): NormalizedRootUri { - if (!rootUri || typeof rootUri !== "string") { - return { normalizedRootUri: null, originalRootUri: null }; - } - const schemeMatch = /^([a-zA-Z][\w+\-.]*?):/.exec(rootUri); - const scheme = schemeMatch ? schemeMatch[1].toLowerCase() : null; - - // Already a file:// URI - use as-is - if (scheme === "file") { - return { normalizedRootUri: rootUri, originalRootUri: rootUri }; - } - - // Try to convert content:// URIs to file:// URIs - if (scheme === "content") { - const fileUri = contentUriToFileUri(rootUri); - if (fileUri) { - return { normalizedRootUri: fileUri, originalRootUri: rootUri }; - } - // Can't convert to file:// - server won't work properly - return { normalizedRootUri: null, originalRootUri: rootUri }; - } - - // Unknown scheme - try to use as-is - return { normalizedRootUri: rootUri, originalRootUri: rootUri }; + if (!rootUri || typeof rootUri !== "string") { + return { normalizedRootUri: null, originalRootUri: null }; + } + const schemeMatch = /^([a-zA-Z][\w+\-.]*?):/.exec(rootUri); + const scheme = schemeMatch ? schemeMatch[1].toLowerCase() : null; + + // Already a file:// URI - use as-is + if (scheme === "file") { + return { normalizedRootUri: rootUri, originalRootUri: rootUri }; + } + + // Try to convert content:// URIs to file:// URIs + if (scheme === "content") { + const fileUri = contentUriToFileUri(rootUri); + if (fileUri) { + return { normalizedRootUri: fileUri, originalRootUri: rootUri }; + } + // Can't convert to file:// - server won't work properly + return { normalizedRootUri: null, originalRootUri: rootUri }; + } + + // Unknown scheme - try to use as-is + return { normalizedRootUri: rootUri, originalRootUri: rootUri }; } function normalizeDocumentUri(uri: string | null | undefined): string | null { - if (!uri || typeof uri !== "string") return null; - - const schemeMatch = /^([a-zA-Z][\w+\-.]*?):/.exec(uri); - const scheme = schemeMatch ? schemeMatch[1].toLowerCase() : null; - - // Already a file:// URI or untitled use as-is - if (scheme === "file" || scheme === "untitled") { - return uri; - } - - // Convert content:// URIs to file:// URIs - if (scheme === "content") { - const fileUri = contentUriToFileUri(uri); - if (fileUri) { - return fileUri; - } - return null; - } - - // Unknown scheme - return uri; + if (!uri || typeof uri !== "string") return null; + + const schemeMatch = /^([a-zA-Z][\w+\-.]*?):/.exec(uri); + const scheme = schemeMatch ? schemeMatch[1].toLowerCase() : null; + + // Already a file:// URI or untitled use as-is + if (scheme === "file" || scheme === "untitled") { + return uri; + } + + // Convert content:// URIs to file:// URIs + if (scheme === "content") { + const fileUri = contentUriToFileUri(uri); + if (fileUri) { + return fileUri; + } + return null; + } + + // Unknown scheme + return uri; } function contentUriToFileUri(uri: string): string | null { - try { - const parsed = Uri.parse(uri) as ParsedUri | null; - if (!parsed || typeof parsed !== "object") return null; - const { docId, rootUri, isFileUri } = parsed; - if (!docId) return null; - - if (isFileUri && rootUri) { - return rootUri; - } - - const providerMatch = - /^content:\/\/com\.((?![:<>"/\\|?*]).*?)\.documents\//.exec( - rootUri ?? "", - ); - const providerId = providerMatch ? providerMatch[1] : null; - - let normalized = docId.trim(); - if (!normalized) return null; - - switch (providerId) { - case "foxdebug.acode": - case "foxdebug.acodefree": - normalized = normalized.replace(/:+$/, ""); - if (!normalized) return null; - if (normalized.startsWith("raw:/")) { - normalized = normalized.slice(4); - } else if (normalized.startsWith("raw:")) { - normalized = normalized.slice(4); - } - if (!normalized.startsWith("/")) return null; - return buildFileUri(normalized); - case "android.externalstorage": - normalized = normalized.replace(/:+$/, ""); - if (!normalized) return null; - - if (normalized.startsWith("/")) { - return buildFileUri(normalized); - } - - if (normalized.startsWith("raw:/")) { - return buildFileUri(normalized.slice(4)); - } - - if (normalized.startsWith("raw:")) { - return buildFileUri(normalized.slice(4)); - } - - const separator = normalized.indexOf(":"); - if (separator === -1) return null; - - const root = normalized.slice(0, separator); - const remainder = normalized.slice(separator + 1); - if (!remainder) return null; - - switch (root) { - case "primary": - return buildFileUri(`/storage/emulated/0/${remainder}`); - default: - if (/^[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}$/.test(root)) { - return buildFileUri(`/storage/${root}/${remainder}`); - } - } - return null; - default: - return null; - } - } catch (_) { - return null; - } + try { + const parsed = Uri.parse(uri) as ParsedUri | null; + if (!parsed || typeof parsed !== "object") return null; + const { docId, rootUri, isFileUri } = parsed; + if (!docId) return null; + + if (isFileUri && rootUri) { + return rootUri; + } + + const providerMatch = + /^content:\/\/com\.((?![:<>"/\\|?*]).*?)\.documents\//.exec( + rootUri ?? "", + ); + const providerId = providerMatch ? providerMatch[1] : null; + + let normalized = docId.trim(); + if (!normalized) return null; + + switch (providerId) { + case "foxdebug.acode": + case "foxdebug.acodefree": + normalized = normalized.replace(/:+$/, ""); + if (!normalized) return null; + if (normalized.startsWith("raw:/")) { + normalized = normalized.slice(4); + } else if (normalized.startsWith("raw:")) { + normalized = normalized.slice(4); + } + if (!normalized.startsWith("/")) return null; + return buildFileUri(normalized); + case "android.externalstorage": + normalized = normalized.replace(/:+$/, ""); + if (!normalized) return null; + + if (normalized.startsWith("/")) { + return buildFileUri(normalized); + } + + if (normalized.startsWith("raw:/")) { + return buildFileUri(normalized.slice(4)); + } + + if (normalized.startsWith("raw:")) { + return buildFileUri(normalized.slice(4)); + } + + const separator = normalized.indexOf(":"); + if (separator === -1) return null; + + const root = normalized.slice(0, separator); + const remainder = normalized.slice(separator + 1); + if (!remainder) return null; + + switch (root) { + case "primary": + return buildFileUri(`/storage/emulated/0/${remainder}`); + default: + if (/^[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}$/.test(root)) { + return buildFileUri(`/storage/${root}/${remainder}`); + } + } + return null; + default: + return null; + } + } catch (_) { + return null; + } } function buildFileUri(pathname: string): string | null { - if (!pathname) return null; - const normalized = pathname.startsWith("/") ? pathname : `/${pathname}`; - const encoded = encodeURI(normalized).replace(/#/g, "%23"); - return `file://${encoded}`; + if (!pathname) return null; + const normalized = pathname.startsWith("/") ? pathname : `/${pathname}`; + const encoded = encodeURI(normalized).replace(/#/g, "%23"); + return `file://${encoded}`; } diff --git a/src/cm/lsp/codeActions.ts b/src/cm/lsp/codeActions.ts index 7e8996e..aa08478 100644 --- a/src/cm/lsp/codeActions.ts +++ b/src/cm/lsp/codeActions.ts @@ -12,9 +12,9 @@ import type { Range as LspRange, WorkspaceEdit, } from "vscode-languageserver-types"; -import type { Range } from "./types"; import { addLspLogFor } from "./logs"; import { safeLspPositionToOffset } from "./positionUtils"; +import type { Range } from "./types"; import type AcodeWorkspace from "./workspace"; type CodeActionResponse = (CodeAction | Command)[] | null; @@ -135,7 +135,12 @@ async function executeCommand( // -32601 = Method not implemented (expected for some LSP servers) const lspError = error as { code?: number }; if (lspError?.code !== -32601) { - addLspLogFor(plugin, "warn", "Code action command execution failed", error); + addLspLogFor( + plugin, + "warn", + "Code action command execution failed", + error, + ); console.warn("[LSP:CodeAction] Command execution failed:", error); } return false; @@ -190,10 +195,7 @@ async function applyChangesToFile( displayedView.dispatch({ changes: changes.map((c) => ({ - from: safeLspPositionToOffset( - displayedView.state.doc, - c.range.start, - ), + from: safeLspPositionToOffset(displayedView.state.doc, c.range.start), to: safeLspPositionToOffset(displayedView.state.doc, c.range.end), insert: c.newText, })), @@ -347,13 +349,13 @@ export async function fetchCodeActions( // Sort: preferred first, then quickfixes, then alphabetically. Stable // sorting preserves server priority for otherwise identical actions. items.sort((a, b) => { - if (a.isPreferred && !b.isPreferred) return -1; - if (!a.isPreferred && b.isPreferred) return 1; - if (a.kind?.startsWith("quickfix") && !b.kind?.startsWith("quickfix")) - return -1; - if (!a.kind?.startsWith("quickfix") && b.kind?.startsWith("quickfix")) - return 1; - return a.title.localeCompare(b.title); + if (a.isPreferred && !b.isPreferred) return -1; + if (!a.isPreferred && b.isPreferred) return 1; + if (a.kind?.startsWith("quickfix") && !b.kind?.startsWith("quickfix")) + return -1; + if (!a.kind?.startsWith("quickfix") && b.kind?.startsWith("quickfix")) + return 1; + return a.title.localeCompare(b.title); }); return items; } diff --git a/src/cm/lsp/definition.ts b/src/cm/lsp/definition.ts index 53d3726..ad85b08 100644 --- a/src/cm/lsp/definition.ts +++ b/src/cm/lsp/definition.ts @@ -4,7 +4,7 @@ import { showReferencesPanel } from "components/referencesPanel"; import { navigateToReference } from "components/referencesPanel/utils"; import toast from "components/toast"; import type { ServerCapabilities } from "vscode-languageserver-protocol"; -import { normalizeLocations, type LspLocationResult } from "./locationUtils"; +import { type LspLocationResult, normalizeLocations } from "./locationUtils"; import { addLspLogFor } from "./logs"; import { fetchLineText, getWordAtCursor } from "./references"; diff --git a/src/cm/lsp/diagnostics.ts b/src/cm/lsp/diagnostics.ts index 65679d6..7877d40 100644 --- a/src/cm/lsp/diagnostics.ts +++ b/src/cm/lsp/diagnostics.ts @@ -1,6 +1,5 @@ import { Diagnostic, linter, lintGutter } from "@codemirror/lint"; import type { LSPClient } from "@codemirror/lsp-client"; -import { getAllLspPlugins, getLspPlugin } from "./clientLoader"; import type { Extension } from "@codemirror/state"; import { EditorState, @@ -9,6 +8,7 @@ import { StateField, } from "@codemirror/state"; import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; +import { getAllLspPlugins, getLspPlugin } from "./clientLoader"; import { addLspLogFor } from "./logs"; import { safeLspPositionToOffset } from "./positionUtils"; import type { @@ -133,16 +133,8 @@ const lspPublishedDiagnostics = StateField.define< for (const [client, diagnostics] of value) { const mapped: LspDiagnostic[] = []; for (const diagnostic of diagnostics) { - const from = tr.changes.mapPos( - diagnostic.from, - 1, - MapMode.TrackDel, - ); - const to = tr.changes.mapPos( - diagnostic.to, - -1, - MapMode.TrackDel, - ); + const from = tr.changes.mapPos(diagnostic.from, 1, MapMode.TrackDel); + const to = tr.changes.mapPos(diagnostic.to, -1, MapMode.TrackDel); if (from != null && to != null) { mapped.push({ ...diagnostic, from, to }); } @@ -346,8 +338,7 @@ async function pullDiagnostics( applyDiagnostics(client, uri, version, report.items); } catch (error) { if (state.generations.get(uri) === generation) { - const message = - error instanceof Error ? error.message : String(error); + const message = error instanceof Error ? error.message : String(error); const failures = (state.failures.get(uri) ?? 0) + 1; state.failures.set(uri, failures); if (/timed out/i.test(message) && failures <= 2) { @@ -390,10 +381,13 @@ export function schedulePullDiagnostics( state.generations.set(uri, generation); state.timers.set( uri, - setTimeout(() => { - state.timers.delete(uri); - void pullDiagnostics(client, uri, generation); - }, Math.max(0, delay)), + setTimeout( + () => { + state.timers.delete(uri); + void pullDiagnostics(client, uri, generation); + }, + Math.max(0, delay), + ), ); } diff --git a/src/cm/lsp/documentColors.ts b/src/cm/lsp/documentColors.ts index f16ecc5..a21636c 100644 --- a/src/cm/lsp/documentColors.ts +++ b/src/cm/lsp/documentColors.ts @@ -1,12 +1,4 @@ -import type { - LSPClient, - LSPClientExtension, -} from "@codemirror/lsp-client"; -import { - getAllLspPlugins, - getLspPlugin, - getLspPluginForFeature, -} from "./clientLoader"; +import type { LSPClient, LSPClientExtension } from "@codemirror/lsp-client"; import type { Extension, Range } from "@codemirror/state"; import { MapMode, StateEffect, StateField } from "@codemirror/state"; import { @@ -25,6 +17,7 @@ import type { TextEdit, } from "vscode-languageserver-types"; import { + type ColorChipPayload, clamp01, colorChipDecoration, colorChipTheme, @@ -37,10 +30,14 @@ import { lspColorToCss, lspColorToHex, openColorPicker, - type ColorChipPayload, } from "../colorChip"; -import type { LSPPluginAPI } from "./types"; +import { + getAllLspPlugins, + getLspPlugin, + getLspPluginForFeature, +} from "./clientLoader"; import { safeLspPositionToOffset } from "./positionUtils"; +import type { LSPPluginAPI } from "./types"; export interface DocumentColorsConfig { enabled?: boolean; @@ -299,8 +296,10 @@ function createPlugin(config: DocumentColorsConfig) { return; } - const bindings = getAllLspPlugins(this.view, "documentColor") as - readonly LSPPluginAPI[]; + const bindings = getAllLspPlugins( + this.view, + "documentColor", + ) as readonly LSPPluginAPI[]; const connected = bindings.filter((lsp) => lsp.client.connected); if (!connected.length) { if (this.hasProvider || this.providerKnown) { @@ -349,11 +348,7 @@ function createPlugin(config: DocumentColorsConfig) { >("textDocument/documentColor", { textDocument: { uri: lsp.uri }, }); - return this.process( - lsp, - result ?? [], - this.view.state.doc.length, - ); + return this.process(lsp, result ?? [], this.view.state.doc.length); }), ); if (id !== this.reqId) return; @@ -443,9 +438,11 @@ async function handleColorPick( view: EditorView, payload: ColorChipPayload, ): Promise { - const lsp = (payload.lspClient - ? getLspPlugin(view, payload.lspClient) - : getLspPluginForFeature(view, "documentColor")) as LSPPluginAPI | null; + const lsp = ( + payload.lspClient + ? getLspPlugin(view, payload.lspClient) + : getLspPluginForFeature(view, "documentColor") + ) as LSPPluginAPI | null; if (!lsp?.client.connected) return; const doc = view.state.doc; @@ -459,9 +456,11 @@ async function handleColorPick( const picked = await openColorPicker(seed); if (!picked) return; - const lsp2 = (payload.lspClient - ? getLspPlugin(view, payload.lspClient) - : getLspPluginForFeature(view, "documentColor")) as LSPPluginAPI | null; + const lsp2 = ( + payload.lspClient + ? 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/index.ts b/src/cm/lsp/index.ts index e165f91..18c2fb5 100644 --- a/src/cm/lsp/index.ts +++ b/src/cm/lsp/index.ts @@ -1,5 +1,9 @@ import "./runtimes/registerBuiltins"; +export type { + LspWorkerHostHandler, + LspWorkerTransportOptions, +} from "./api"; export { bundles, default as lspApi, @@ -14,11 +18,6 @@ export { upsert, workers, } from "./api"; -export type { - LspWorkerHostHandler, - LspWorkerTransportOptions, -} from "./api"; -export { createWorkerTransport } from "./workerTransport"; export { default as clientManager, LspClientManager } from "./clientManager"; export type { CodeActionItem } from "./codeActions"; export { @@ -31,6 +30,16 @@ export { showCodeActionsMenu, supportsCodeActions, } from "./codeActions"; +export { + goToDeclaration, + goToDefinition, + goToImplementation, + goToTypeDefinition, + jumpToDeclaration, + jumpToDefinition, + jumpToImplementation, + jumpToTypeDefinition, +} from "./definition"; export { clearDiagnosticsEffect, getLspDiagnostics, @@ -39,6 +48,21 @@ export { lspDiagnosticsExtension, lspDiagnosticsUiExtension, } from "./diagnostics"; +export type { + DocumentColorsConfig, + LspDocumentColor, +} from "./documentColors"; +export { + cssToLspColor, + didReplaceLspDocumentColors, + documentColorsClientExtension, + documentColorsEditorExtension, + documentColorsExtension, + hasColorProvider, + lspColorToCss, + lspColorToHex, + lspDocumentColorsField, +} from "./documentColors"; export type { DocumentSymbolsResult, FlatSymbol, @@ -55,21 +79,6 @@ export { supportsDocumentSymbols, } from "./documentSymbols"; export { registerLspFormatter } from "./formatter"; -export type { - DocumentColorsConfig, - LspDocumentColor, -} from "./documentColors"; -export { - cssToLspColor, - didReplaceLspDocumentColors, - documentColorsClientExtension, - documentColorsEditorExtension, - documentColorsExtension, - hasColorProvider, - lspColorToCss, - lspColorToHex, - lspDocumentColorsField, -} from "./documentColors"; export type { InlayHintsConfig } from "./inlayHints"; export { inlayHintsClientExtension, @@ -80,35 +89,28 @@ export { addLspLog, clearLspLogs, getLspLogs, - onLspLog, type LspLogEntry, type LspLogLevel, + onLspLog, } from "./logs"; export { closeReferencesPanel, findAllReferences, findAllReferencesInTab, } from "./references"; -export { - goToDeclaration, - goToDefinition, - goToImplementation, - goToTypeDefinition, - jumpToDeclaration, - jumpToDefinition, - jumpToImplementation, - jumpToTypeDefinition, -} from "./definition"; export { acodeRenameExtension, acodeRenameKeymap, renameSymbol, } from "./rename"; export { - ensureServerRunning, - resetManagedServers, - stopManagedServer, -} from "./serverLauncher"; + checkRuntimeServerInstallation, + getRuntimeInstallCommand, + getRuntimeLabelForServer, + getRuntimeUninstallCommand, + installRuntimeServer, + uninstallRuntimeServer, +} from "./runtimeActions"; export { BUILTIN_ALPINE_RUNTIME_ID, EXTERNAL_WEBSOCKET_RUNTIME_ID, @@ -121,14 +123,6 @@ export { unregisterRuntimeProvider, WEB_WORKER_RUNTIME_ID, } from "./runtimeProviders"; -export { - checkRuntimeServerInstallation, - getRuntimeInstallCommand, - getRuntimeLabelForServer, - getRuntimeUninstallCommand, - installRuntimeServer, - uninstallRuntimeServer, -} from "./runtimeActions"; export { AUTO_RUNTIME_ID, getDefaultRuntimeSetting, @@ -136,6 +130,11 @@ export { setDefaultRuntime, setServerRuntime, } from "./runtimeSettings"; +export { + ensureServerRunning, + resetManagedServers, + stopManagedServer, +} from "./serverLauncher"; export { default as serverRegistry } from "./serverRegistry"; export { nextSignature, @@ -143,7 +142,6 @@ export { showSignatureHelp, } from "./tooltipExtensions"; export { createTransport } from "./transport"; - export type { BuiltinExtensionsConfig, ClientManagerOptions, @@ -156,8 +154,8 @@ export type { LSPDiagnostic, LSPFormattingOptions, LSPPluginAPI, - LspDiagnostic, LspClientScope, + LspDiagnostic, LspRuntimeConnection, LspRuntimeContext, LspRuntimeProvider, @@ -169,7 +167,8 @@ export type { TextEdit, TransportDescriptor, TransportHandle, - WorkspaceOptions, WorkspaceKind, + WorkspaceOptions, } from "./types"; +export { createWorkerTransport } from "./workerTransport"; export { default as AcodeWorkspace } from "./workspace"; diff --git a/src/cm/lsp/inlayHints.ts b/src/cm/lsp/inlayHints.ts index 112ec3e..133381b 100644 --- a/src/cm/lsp/inlayHints.ts +++ b/src/cm/lsp/inlayHints.ts @@ -21,8 +21,8 @@ import type { InlayHintLabelPart, Position, } from "vscode-languageserver-types"; -import type { LSPPluginAPI } from "./types"; import { safeLspPositionToOffset } from "./positionUtils"; +import type { LSPPluginAPI } from "./types"; // ============================================================================ // Types @@ -348,11 +348,7 @@ export function inlayHintsEditorExtension( ): Extension { if (config.enabled === false) return []; if (!Object.keys(config).length) { - defaultInlayHintsEditorExtension ??= [ - hintsField, - createPlugin({}), - styles, - ]; + defaultInlayHintsEditorExtension ??= [hintsField, createPlugin({}), styles]; return defaultInlayHintsEditorExtension; } return [hintsField, createPlugin(config), styles]; diff --git a/src/cm/lsp/locationUtils.ts b/src/cm/lsp/locationUtils.ts index 672c6a2..78f7ae9 100644 --- a/src/cm/lsp/locationUtils.ts +++ b/src/cm/lsp/locationUtils.ts @@ -1,13 +1,6 @@ -import type { - Location, - LocationLink, -} from "vscode-languageserver-protocol"; +import type { Location, LocationLink } from "vscode-languageserver-protocol"; -export type LspLocationResult = - | Location - | Location[] - | LocationLink[] - | null; +export type LspLocationResult = Location | Location[] | LocationLink[] | null; /** Normalize definition-style responses to the Location shape used by Acode. */ export function normalizeLocations(result: LspLocationResult): Location[] { diff --git a/src/cm/lsp/logs.ts b/src/cm/lsp/logs.ts index dca83a9..b3083cb 100644 --- a/src/cm/lsp/logs.ts +++ b/src/cm/lsp/logs.ts @@ -1,4 +1,10 @@ -export type LspLogLevel = "debug" | "info" | "log" | "warn" | "error" | "stderr"; +export type LspLogLevel = + | "debug" + | "info" + | "log" + | "warn" + | "error" + | "stderr"; export interface LspLogEntry { timestamp: Date; @@ -79,10 +85,7 @@ export function getLspLogServerId(source: unknown): string | null { Object.prototype.hasOwnProperty.call(source, "client") ? (source as { client?: unknown }).client : source; - const metadata = client as - | { __acodeServerId?: unknown } - | null - | undefined; + const metadata = client as { __acodeServerId?: unknown } | null | undefined; const serverId = metadata?.__acodeServerId; return typeof serverId === "string" && serverId.trim() ? serverId.trim() diff --git a/src/cm/lsp/references.ts b/src/cm/lsp/references.ts index cb02fe2..498bdce 100644 --- a/src/cm/lsp/references.ts +++ b/src/cm/lsp/references.ts @@ -1,11 +1,11 @@ import fsOperation from "fileSystem"; import { LSPPlugin } from "@codemirror/lsp-client"; import type { EditorView } from "@codemirror/view"; +import { getDocText } from "cm/editorUtils"; import { openReferencesTab, showReferencesPanel, } from "components/referencesPanel"; -import { getDocText } from "cm/editorUtils"; import settings from "lib/settings"; interface Position { @@ -33,7 +33,10 @@ interface ReferenceParams { context: { includeDeclaration: boolean }; } -export async function fetchLineText(uri: string, line: number): Promise { +export async function fetchLineText( + uri: string, + line: number, +): Promise { try { interface EditorManagerLike { getFile?: (uri: string, type: string) => EditorFileLike | null; diff --git a/src/cm/lsp/rename.ts b/src/cm/lsp/rename.ts index 5e4c93b..63c30ac 100644 --- a/src/cm/lsp/rename.ts +++ b/src/cm/lsp/rename.ts @@ -104,10 +104,7 @@ async function performRename(view: EditorView): Promise { view.state.doc, prepareResult.start, ); - const to = safeLspPositionToOffset( - view.state.doc, - prepareResult.end, - ); + const to = safeLspPositionToOffset(view.state.doc, prepareResult.end); initialValue = view.state.sliceDoc(from, to); } else if ("range" in prepareResult && prepareResult.range) { const from = safeLspPositionToOffset( @@ -194,7 +191,11 @@ async function applyChangesToFile( const displayedView = await workspace.displayFile(uri); if (!displayedView?.state?.doc) { - addLspLogFor(workspace.client, "warn", `Rename could not open file: ${uri}`); + addLspLogFor( + workspace.client, + "warn", + `Rename could not open file: ${uri}`, + ); console.warn(`[LSP:Rename] Could not open file: ${uri}`); return false; } @@ -272,7 +273,11 @@ async function doRename( console.info( `[LSP:Rename] Renamed to "${newName}" in ${filesChanged} file(s)`, ); - addLspLogFor(plugin, "info", `Renamed to "${newName}" in ${filesChanged} file(s)`); + addLspLogFor( + plugin, + "info", + `Renamed to "${newName}" in ${filesChanged} file(s)`, + ); } export const renameSymbol: Command = (view) => { diff --git a/src/cm/lsp/runtimeActions.ts b/src/cm/lsp/runtimeActions.ts index 2fe36af..9ff4a57 100644 --- a/src/cm/lsp/runtimeActions.ts +++ b/src/cm/lsp/runtimeActions.ts @@ -34,7 +34,11 @@ export async function checkRuntimeServerInstallation( server: LspServerDefinition, context?: LspRuntimeContext, ): Promise { - const settingsContext = getSettingsContext(server, context, "checkInstallation"); + const settingsContext = getSettingsContext( + server, + context, + "checkInstallation", + ); const provider = await getProvider(server, context, "checkInstallation"); if (!provider?.checkInstallation) { return { @@ -82,13 +86,7 @@ export async function getRuntimeInstallCommand( ): Promise { const settingsContext = getSettingsContext(server, context, "command"); const provider = await getProvider(server, context, "command"); - return ( - provider?.getInstallCommand?.( - server, - settingsContext, - mode, - ) ?? null - ); + return provider?.getInstallCommand?.(server, settingsContext, mode) ?? null; } export async function getRuntimeUninstallCommand( @@ -97,12 +95,7 @@ export async function getRuntimeUninstallCommand( ): Promise { const settingsContext = getSettingsContext(server, context, "command"); const provider = await getProvider(server, context, "command"); - return ( - provider?.getUninstallCommand?.( - server, - settingsContext, - ) ?? null - ); + return provider?.getUninstallCommand?.(server, settingsContext) ?? null; } export async function getRuntimeLabelForServer( diff --git a/src/cm/lsp/runtimeProviders.ts b/src/cm/lsp/runtimeProviders.ts index 206ad7d..c8c32b2 100644 --- a/src/cm/lsp/runtimeProviders.ts +++ b/src/cm/lsp/runtimeProviders.ts @@ -1,10 +1,10 @@ +import { getConfiguredRuntimeId } from "./runtimeSettings"; import type { LspRuntimeContext, LspRuntimeProvider, LspServerDefinition, WorkspaceKind, } from "./types"; -import { getConfiguredRuntimeId } from "./runtimeSettings"; export const BUILTIN_ALPINE_RUNTIME_ID = "builtin-alpine"; export const EXTERNAL_WEBSOCKET_RUNTIME_ID = "external-websocket"; @@ -22,9 +22,7 @@ function toKey(id: string | null | undefined): string { .toLowerCase(); } -function normalizeProvider( - provider: LspRuntimeProvider, -): LspRuntimeProvider { +function normalizeProvider(provider: LspRuntimeProvider): LspRuntimeProvider { const id = toKey(provider?.id); if (!id) throw new Error("LSP runtime provider requires a non-empty id"); if (!provider.label || typeof provider.label !== "string") { diff --git a/src/cm/lsp/runtimeSettings.ts b/src/cm/lsp/runtimeSettings.ts index 2c68408..c67d691 100644 --- a/src/cm/lsp/runtimeSettings.ts +++ b/src/cm/lsp/runtimeSettings.ts @@ -124,7 +124,9 @@ export async function setServerRuntime( } export function getDefaultRuntimeSetting(): string { - return toRuntimeId(appSettings.value?.lsp?.runtime?.default) || AUTO_RUNTIME_ID; + return ( + toRuntimeId(appSettings.value?.lsp?.runtime?.default) || AUTO_RUNTIME_ID + ); } export function getServerRuntimeSetting(serverId: string): string { @@ -135,8 +137,5 @@ export function getServerRuntimeSetting(serverId: string): string { string, unknown >; - return ( - toRuntimeId(servers[normalizedServerId]) || - AUTO_RUNTIME_ID - ); + return toRuntimeId(servers[normalizedServerId]) || AUTO_RUNTIME_ID; } diff --git a/src/cm/lsp/runtimes/axsBridge.ts b/src/cm/lsp/runtimes/axsBridge.ts index 8e1176c..3285c50 100644 --- a/src/cm/lsp/runtimes/axsBridge.ts +++ b/src/cm/lsp/runtimes/axsBridge.ts @@ -81,9 +81,9 @@ export async function checkAxsBridgeStatus( return "unsupported"; } - const data = (await response.json().catch(() => null)) as - | AxsBridgeStatusResponse - | null; + const data = (await response + .json() + .catch(() => null)) as AxsBridgeStatusResponse | null; return isAxsBridgeStatusResponse(data) ? "alive" : "dead"; } catch { return "dead"; diff --git a/src/cm/lsp/runtimes/builtinAlpine.ts b/src/cm/lsp/runtimes/builtinAlpine.ts index 3da1e1c..2f107ca 100644 --- a/src/cm/lsp/runtimes/builtinAlpine.ts +++ b/src/cm/lsp/runtimes/builtinAlpine.ts @@ -1,6 +1,6 @@ import toast from "components/toast"; import confirm from "dialogs/confirm"; -import { createTransport } from "../transport"; +import { isBuiltinAlpineAccessible } from "../runtimeProviders"; import { checkServerInstallation, ensureServerRunning, @@ -9,12 +9,12 @@ import { installServer, uninstallServer, } from "../serverLauncher"; -import { isBuiltinAlpineAccessible } from "../runtimeProviders"; +import { createTransport } from "../transport"; import type { LspRuntimeContext, LspRuntimeProvider, - LspServerDefinition, LspRuntimeUriResolutionContext, + LspServerDefinition, } from "../types"; export const BUILTIN_ALPINE_RUNTIME_ID = "builtin-alpine"; @@ -52,10 +52,7 @@ export const builtinAlpineRuntimeProvider: LspRuntimeProvider = { label: "Built-in Alpine", priority: -100, - canHandle( - server: LspServerDefinition, - context: LspRuntimeContext, - ): boolean { + canHandle(server: LspServerDefinition, context: LspRuntimeContext): boolean { if (context.runtimeAction && server.launcher) { return true; } diff --git a/src/cm/lsp/runtimes/externalWebSocket.ts b/src/cm/lsp/runtimes/externalWebSocket.ts index 0d0d7e6..b8e0af4 100644 --- a/src/cm/lsp/runtimes/externalWebSocket.ts +++ b/src/cm/lsp/runtimes/externalWebSocket.ts @@ -1,8 +1,5 @@ -import type { - InstallCheckResult, - LspRuntimeProvider, -} from "../types"; import { EXTERNAL_WEBSOCKET_RUNTIME_ID } from "../runtimeProviders"; +import type { InstallCheckResult, LspRuntimeProvider } from "../types"; const externallyManagedStatus: InstallCheckResult = { status: "unknown", diff --git a/src/cm/lsp/runtimes/registerBuiltins.ts b/src/cm/lsp/runtimes/registerBuiltins.ts index ea8e694..bdbfab3 100644 --- a/src/cm/lsp/runtimes/registerBuiltins.ts +++ b/src/cm/lsp/runtimes/registerBuiltins.ts @@ -7,4 +7,5 @@ if (platform.localExecution) registerRuntimeProvider(builtinAlpineRuntimeProvider, { replace: true }); registerRuntimeProvider(externalWebSocketRuntimeProvider, { replace: true }); registerRuntimeProvider(webWorkerRuntimeProvider, { replace: true }); + import platform from "lib/platform"; diff --git a/src/cm/lsp/serverLauncher.ts b/src/cm/lsp/serverLauncher.ts index bc83251..da1e313 100644 --- a/src/cm/lsp/serverLauncher.ts +++ b/src/cm/lsp/serverLauncher.ts @@ -2,31 +2,31 @@ import lspStatusBar from "components/lspStatusBar"; import toast from "components/toast"; import confirm from "dialogs/confirm"; import loader from "dialogs/loader"; +import notificationManager from "lib/notificationManager"; import { buildShellArchCase } from "./installerUtils"; import { - formatCommand, - quoteArg, - runForegroundCommand, - runQuickCommand, + formatCommand, + quoteArg, + runForegroundCommand, + runQuickCommand, } from "./installRuntime"; +import { addLspLog } from "./logs"; import { - buildAxsBridgeCommand, - checkAxsBridgeStatus, - checkServerAliveViaWebSocket, + buildAxsBridgeCommand, + checkAxsBridgeStatus, + checkServerAliveViaWebSocket, } from "./runtimes/axsBridge"; import { getServerBundle } from "./serverCatalog"; -import notificationManager from "lib/notificationManager"; -import { addLspLog } from "./logs"; import type { - InstallCheckResult, - InstallStatus, - LauncherConfig, - LspServerDefinition, - LspServerStats, - LspServerStatsFormatted, - ManagedServerEntry, - PortInfo, - WaitOptions, + InstallCheckResult, + InstallStatus, + LauncherConfig, + LspServerDefinition, + LspServerStats, + LspServerStatsFormatted, + ManagedServerEntry, + PortInfo, + WaitOptions, } from "./types"; const managedServers = new Map(); @@ -45,40 +45,40 @@ const DONT_ASK_TERMINAL_REQUIRED_FOR_LSP = "dontAskTerminalRequiredForLsp"; // canonicalized (see init-sandbox.sh). Drop only that known, harmless message // so genuine proot warnings are still surfaced in the LSP log. const PROOT_FD_BINDING_WARNING = - /can'?t sanitize binding "\/proc\/self\/fd(?:\/[012])?"/i; + /can'?t sanitize binding "\/proc\/self\/fd(?:\/[012])?"/i; let alreadyInformed = false; function getTerminalRequiredMessage(): string { - return ( - strings?.terminal_required_message_for_lsp ?? - "Terminal not installed. Please install Terminal first to use LSP servers." - ); + return ( + strings?.terminal_required_message_for_lsp ?? + "Terminal not installed. Please install Terminal first to use LSP servers." + ); } interface LspError extends Error { - code?: string; + code?: string; } function getExecutor(): Executor { - const executor = (globalThis as unknown as { Executor?: Executor }).Executor; - if (!executor) { - throw new Error("Executor plugin is not available"); - } - return executor; + const executor = (globalThis as unknown as { Executor?: Executor }).Executor; + if (!executor) { + throw new Error("Executor plugin is not available"); + } + return executor; } /** * Get the background executor */ function getBackgroundExecutor(): Executor { - const executor = getExecutor(); - return executor.BackgroundExecutor ?? executor; + const executor = getExecutor(); + return executor.BackgroundExecutor ?? executor; } function joinCommand(command: string, args: string[] = []): string { - if (!Array.isArray(args) || !args.length) return quoteArg(command); - return [quoteArg(command), ...args.map((arg) => quoteArg(arg))].join(" "); + if (!Array.isArray(args) || !args.length) return quoteArg(command); + return [quoteArg(command), ...args.map((arg) => quoteArg(arg))].join(" "); } export { formatCommand } from "./installRuntime"; @@ -96,34 +96,34 @@ let cachedFilesDir: string | null = null; * alpine/home, and some installs keep it as a symlink for shell compatibility. */ async function getTerminalDataDirs(): Promise { - if (cachedFilesDir) { - return [`${cachedFilesDir}/public`, `${cachedFilesDir}/alpine/home`]; - } - - const system = ( - globalThis as unknown as { - system?: { - getFilesDir: ( - success: (filesDir: string) => void, - error: (error: string) => void, - ) => void; - }; - } - ).system; - - if (!system?.getFilesDir) { - throw new Error("System plugin is not available"); - } - - return new Promise((resolve, reject) => { - system.getFilesDir( - (filesDir: string) => { - cachedFilesDir = filesDir; - resolve([`${filesDir}/public`, `${filesDir}/alpine/home`]); - }, - (error: string) => reject(new Error(error)), - ); - }); + if (cachedFilesDir) { + return [`${cachedFilesDir}/public`, `${cachedFilesDir}/alpine/home`]; + } + + const system = ( + globalThis as unknown as { + system?: { + getFilesDir: ( + success: (filesDir: string) => void, + error: (error: string) => void, + ) => void; + }; + } + ).system; + + if (!system?.getFilesDir) { + throw new Error("System plugin is not available"); + } + + return new Promise((resolve, reject) => { + system.getFilesDir( + (filesDir: string) => { + cachedFilesDir = filesDir; + resolve([`${filesDir}/public`, `${filesDir}/alpine/home`]); + }, + (error: string) => reject(new Error(error)), + ); + }); } /** @@ -131,15 +131,15 @@ async function getTerminalDataDirs(): Promise { * Port file format: ~/.axs/lsp_ports/{serverName}_{session} */ async function getPortFilePaths( - serverName: string, - session: string, + serverName: string, + session: string, ): Promise { - const dataDirs = await getTerminalDataDirs(); - // Use just the binary name (not full path), mirroring axs behavior - const baseName = serverName.split("/").pop() || serverName; - return dataDirs.map( - (dataDir) => `file://${dataDir}/.axs/lsp_ports/${baseName}_${session}`, - ); + const dataDirs = await getTerminalDataDirs(); + // Use just the binary name (not full path), mirroring axs behavior + const baseName = serverName.split("/").pop() || serverName; + return dataDirs.map( + (dataDir) => `file://${dataDir}/.axs/lsp_ports/${baseName}_${session}`, + ); } /** @@ -147,30 +147,30 @@ async function getPortFilePaths( * Returns null if the file doesn't exist or contains invalid data. */ async function readPortFromFile(filePath: string): Promise { - try { - // Dynamic import to get fsOperation - const { default: fsOperation } = await import("fileSystem"); - const fs = fsOperation(filePath); - - // Check if file exists first - const exists = await fs.exists(); - if (!exists) { - return null; - } - - // Read the file content as text - const content = (await fs.readFile("utf-8")) as string; - const port = Number.parseInt(content.trim(), 10); - - if (!Number.isFinite(port) || port <= 0 || port > 65535) { - return null; - } - - return port; - } catch { - // File doesn't exist or couldn't be read - return null; - } + try { + // Dynamic import to get fsOperation + const { default: fsOperation } = await import("fileSystem"); + const fs = fsOperation(filePath); + + // Check if file exists first + const exists = await fs.exists(); + if (!exists) { + return null; + } + + // Read the file content as text + const content = (await fs.readFile("utf-8")) as string; + const port = Number.parseInt(content.trim(), 10); + + if (!Number.isFinite(port) || port <= 0 || port > 65535) { + return null; + } + + return port; + } catch { + // File doesn't exist or couldn't be read + return null; + } } /** @@ -179,23 +179,23 @@ async function readPortFromFile(filePath: string): Promise { * @param session - Session ID for port file naming */ export async function getLspPort( - serverName: string, - session: string, + serverName: string, + session: string, ): Promise { - try { - const filePaths = await getPortFilePaths(serverName, session); - - for (const filePath of filePaths) { - const port = await readPortFromFile(filePath); - if (port !== null) { - return { port, filePath, session }; - } - } - - return null; - } catch { - return null; - } + try { + const filePaths = await getPortFilePaths(serverName, session); + + for (const filePath of filePaths) { + const port = await readPortFromFile(filePath); + if (port !== null) { + return { port, filePath, session }; + } + } + + return null; + } catch { + return null; + } } /** @@ -204,21 +204,21 @@ export async function getLspPort( * So once the signal is received, the port file should be available. */ async function waitForServerReady( - serverId: string, - timeout = 10000, + serverId: string, + timeout = 10000, ): Promise { - const deadline = Date.now() + timeout; - const pollInterval = 50; - - while (Date.now() < deadline) { - if (serverReadySignals.has(serverId)) { - serverReadySignals.delete(serverId); - return true; - } - await sleep(pollInterval); - } - - return false; + const deadline = Date.now() + timeout; + const pollInterval = 50; + + while (Date.now() < deadline) { + if (serverReadySignals.has(serverId)) { + serverReadySignals.delete(serverId); + return true; + } + await sleep(pollInterval); + } + + return false; } /** @@ -226,36 +226,36 @@ async function waitForServerReady( * This is the most efficient approach: wait for ready signal, then read port. */ async function waitForPort( - serverId: string, - serverName: string, - session: string, - timeout = 10000, + serverId: string, + serverName: string, + session: string, + timeout = 10000, ): Promise { - // First, wait for the server to signal it's ready - const ready = await waitForServerReady(serverId, timeout); - - if (!ready) { - console.warn( - `[LSP:${serverId}] Server did not signal ready within timeout`, - ); - } - - // The port file should be available now (axs writes it before printing "listening on") - // Read it directly - const portInfo = await getLspPort(serverName, session); - - if (!portInfo && ready) { - // Server signaled ready but port file not found - retry a few times - for (let i = 0; i < 5; i++) { - await sleep(100); - const retryPortInfo = await getLspPort(serverName, session); - if (retryPortInfo) { - return retryPortInfo; - } - } - } - - return portInfo; + // First, wait for the server to signal it's ready + const ready = await waitForServerReady(serverId, timeout); + + if (!ready) { + console.warn( + `[LSP:${serverId}] Server did not signal ready within timeout`, + ); + } + + // The port file should be available now (axs writes it before printing "listening on") + // Read it directly + const portInfo = await getLspPort(serverName, session); + + if (!portInfo && ready) { + // Server signaled ready but port file not found - retry a few times + for (let i = 0; i < 5; i++) { + await sleep(100); + const retryPortInfo = await getLspPort(serverName, session); + if (retryPortInfo) { + return retryPortInfo; + } + } + } + + return portInfo; } /** @@ -263,700 +263,704 @@ async function waitForPort( * Returns the port number if the server is alive, null otherwise. */ export async function canReuseExistingServer( - server: LspServerDefinition, - session: string, + server: LspServerDefinition, + session: string, ): Promise { - const bridge = server.launcher?.bridge; - const serverName = - resolveServerExecutable(server) || - bridge?.command || - server.launcher?.command || - server.id; - - const portInfo = await getLspPort(serverName, session); - if (!portInfo) { - return null; - } - - const url = `ws://127.0.0.1:${portInfo.port}/`; - const status = await checkAxsBridgeStatus(url, 1000); - const alive = - status === "alive" || - (status === "unsupported" && - (await checkServerAliveViaWebSocket(url, 1000))); - - if (alive) { - addLspLog(server.id, "info", `Reusing existing server on port ${portInfo.port}`); - console.info( - `[LSP:${server.id}] Reusing existing server on port ${portInfo.port}`, - ); - return portInfo.port; - } - - console.info( - `[LSP:${server.id}] Found stale port file, will start new server`, - ); - addLspLog(server.id, "warn", "Found stale port file, starting a new server"); - return null; + const bridge = server.launcher?.bridge; + const serverName = + resolveServerExecutable(server) || + bridge?.command || + server.launcher?.command || + server.id; + + const portInfo = await getLspPort(serverName, session); + if (!portInfo) { + return null; + } + + const url = `ws://127.0.0.1:${portInfo.port}/`; + const status = await checkAxsBridgeStatus(url, 1000); + const alive = + status === "alive" || + (status === "unsupported" && + (await checkServerAliveViaWebSocket(url, 1000))); + + if (alive) { + addLspLog( + server.id, + "info", + `Reusing existing server on port ${portInfo.port}`, + ); + console.info( + `[LSP:${server.id}] Reusing existing server on port ${portInfo.port}`, + ); + return portInfo.port; + } + + console.info( + `[LSP:${server.id}] Found stale port file, will start new server`, + ); + addLspLog(server.id, "warn", "Found stale port file, starting a new server"); + return null; } function resolveStartCommand( - server: LspServerDefinition, - session?: string, + server: LspServerDefinition, + session?: string, ): string | null { - const launcher = server.launcher; - if (!launcher) return null; - const executable = resolveServerExecutable(server); - - if (launcher.startCommand) { - return formatCommand(launcher.startCommand); - } - if (launcher.command) { - return joinCommand(executable || launcher.command, launcher.args); - } - if (launcher.bridge) { - return buildAxsBridgeCommand(launcher.bridge, executable, session); - } - return null; + const launcher = server.launcher; + if (!launcher) return null; + const executable = resolveServerExecutable(server); + + if (launcher.startCommand) { + return formatCommand(launcher.startCommand); + } + if (launcher.command) { + return joinCommand(executable || launcher.command, launcher.args); + } + if (launcher.bridge) { + return buildAxsBridgeCommand(launcher.bridge, executable, session); + } + return null; } export function getStartCommand(server: LspServerDefinition): string | null { - return resolveStartCommand(server); + return resolveStartCommand(server); } function getInstallCacheKey(server: LspServerDefinition): string | null { - const checkCommand = - server.launcher?.checkCommand || buildDerivedCheckCommand(server); - if (!checkCommand) return null; - return `${server.id}:${checkCommand}`; + const checkCommand = + server.launcher?.checkCommand || buildDerivedCheckCommand(server); + if (!checkCommand) return null; + return `${server.id}:${checkCommand}`; } function normalizeInstallSpec(server: LspServerDefinition) { - const install = server.launcher?.install; - if (!install) return null; - - const packages = Array.isArray(install.packages) - ? install.packages - .map((entry) => String(entry || "").trim()) - .filter(Boolean) - : []; - const kind = - install.kind || - (install.binaryPath ? "manual" : null) || - (install.source === "apk" ? "apk" : null) || - (install.source === "npm" ? "npm" : null) || - (install.source === "pip" ? "pip" : null) || - (install.source === "cargo" ? "cargo" : null) || - (install.command ? "shell" : null) || - "shell"; - - return { - ...install, - kind, - packages, - command: - typeof install.command === "string" && install.command.trim() - ? install.command.trim() - : undefined, - updateCommand: - typeof install.updateCommand === "string" && install.updateCommand.trim() - ? install.updateCommand.trim() - : undefined, - source: - install.source || - (kind === "shell" ? "custom" : kind === "manual" ? "manual" : kind), - executable: - typeof install.executable === "string" && install.executable.trim() - ? install.executable.trim() - : undefined, - binaryPath: - typeof install.binaryPath === "string" && install.binaryPath.trim() - ? install.binaryPath.trim() - : undefined, - repo: - typeof install.repo === "string" && install.repo.trim() - ? install.repo.trim() - : undefined, - assetNames: - install.assetNames && typeof install.assetNames === "object" - ? Object.fromEntries( - Object.entries(install.assetNames) - .map(([key, value]) => [String(key), String(value || "").trim()]) - .filter(([, value]) => Boolean(value)), - ) - : {}, - archiveType: install.archiveType === "binary" ? "binary" : "zip", - extractFile: - typeof install.extractFile === "string" && install.extractFile.trim() - ? install.extractFile.trim() - : undefined, - npmCommand: - typeof install.npmCommand === "string" && install.npmCommand.trim() - ? install.npmCommand.trim() - : "npm", - pipCommand: - typeof install.pipCommand === "string" && install.pipCommand.trim() - ? install.pipCommand.trim() - : "pip", - pythonCommand: - typeof install.pythonCommand === "string" && install.pythonCommand.trim() - ? install.pythonCommand.trim() - : "python3", - global: install.global !== false, - breakSystemPackages: install.breakSystemPackages !== false, - }; + const install = server.launcher?.install; + if (!install) return null; + + const packages = Array.isArray(install.packages) + ? install.packages + .map((entry) => String(entry || "").trim()) + .filter(Boolean) + : []; + const kind = + install.kind || + (install.binaryPath ? "manual" : null) || + (install.source === "apk" ? "apk" : null) || + (install.source === "npm" ? "npm" : null) || + (install.source === "pip" ? "pip" : null) || + (install.source === "cargo" ? "cargo" : null) || + (install.command ? "shell" : null) || + "shell"; + + return { + ...install, + kind, + packages, + command: + typeof install.command === "string" && install.command.trim() + ? install.command.trim() + : undefined, + updateCommand: + typeof install.updateCommand === "string" && install.updateCommand.trim() + ? install.updateCommand.trim() + : undefined, + source: + install.source || + (kind === "shell" ? "custom" : kind === "manual" ? "manual" : kind), + executable: + typeof install.executable === "string" && install.executable.trim() + ? install.executable.trim() + : undefined, + binaryPath: + typeof install.binaryPath === "string" && install.binaryPath.trim() + ? install.binaryPath.trim() + : undefined, + repo: + typeof install.repo === "string" && install.repo.trim() + ? install.repo.trim() + : undefined, + assetNames: + install.assetNames && typeof install.assetNames === "object" + ? Object.fromEntries( + Object.entries(install.assetNames) + .map(([key, value]) => [String(key), String(value || "").trim()]) + .filter(([, value]) => Boolean(value)), + ) + : {}, + archiveType: install.archiveType === "binary" ? "binary" : "zip", + extractFile: + typeof install.extractFile === "string" && install.extractFile.trim() + ? install.extractFile.trim() + : undefined, + npmCommand: + typeof install.npmCommand === "string" && install.npmCommand.trim() + ? install.npmCommand.trim() + : "npm", + pipCommand: + typeof install.pipCommand === "string" && install.pipCommand.trim() + ? install.pipCommand.trim() + : "pip", + pythonCommand: + typeof install.pythonCommand === "string" && install.pythonCommand.trim() + ? install.pythonCommand.trim() + : "python3", + global: install.global !== false, + breakSystemPackages: install.breakSystemPackages !== false, + }; } function getInstallerExecutable(server: LspServerDefinition): string | null { - const install = normalizeInstallSpec(server); - if (!install) return null; - return install.binaryPath || install.executable || null; + const install = normalizeInstallSpec(server); + if (!install) return null; + return install.binaryPath || install.executable || null; } function getProviderExecutable(server: LspServerDefinition): string | null { - const bundle = getServerBundle(server.id); - if (!bundle?.getExecutable) return null; - try { - return bundle.getExecutable(server.id, server) || null; - } catch (error) { - console.warn(`Failed to resolve bundle executable for ${server.id}`, error); - return null; - } + const bundle = getServerBundle(server.id); + if (!bundle?.getExecutable) return null; + try { + return bundle.getExecutable(server.id, server) || null; + } catch (error) { + console.warn(`Failed to resolve bundle executable for ${server.id}`, error); + return null; + } } function resolveServerExecutable(server: LspServerDefinition): string | null { - return ( - getProviderExecutable(server) || - getInstallerExecutable(server) || - server.launcher?.bridge?.command || - server.launcher?.command || - null - ); + return ( + getProviderExecutable(server) || + getInstallerExecutable(server) || + server.launcher?.bridge?.command || + server.launcher?.command || + null + ); } function getInstallLabel(server: LspServerDefinition): string { - return ( - normalizeInstallSpec(server)?.label || - server.launcher?.install?.label || - server.label || - server.id - ).trim(); + return ( + normalizeInstallSpec(server)?.label || + server.launcher?.install?.label || + server.label || + server.id + ).trim(); } function buildUninstallCommand(server: LspServerDefinition): string | null { - const spec = normalizeInstallSpec(server); - if (!spec) return null; - - if (spec.uninstallCommand) { - return spec.uninstallCommand; - } - if (server.launcher?.uninstallCommand) { - return server.launcher.uninstallCommand; - } - - switch (spec.kind) { - case "apk": - return spec.packages.length - ? `apk del ${spec.packages.map((entry) => quoteArg(entry)).join(" ")}` - : null; - case "npm": { - if (!spec.packages.length) return null; - const npmCommand = spec.npmCommand || "npm"; - const uninstallFlags = - spec.global !== false ? "uninstall -g" : "uninstall"; - return `${npmCommand} ${uninstallFlags} ${spec.packages.map((entry) => quoteArg(entry)).join(" ")}`; - } - case "pip": - return spec.packages.length - ? `${spec.pipCommand || "pip"} uninstall -y ${spec.packages.map((entry) => quoteArg(entry)).join(" ")}` - : null; - case "cargo": - return spec.packages.length - ? spec.packages - .map((entry) => `cargo uninstall ${quoteArg(entry)}`) - .join(" && ") - : null; - case "github-release": - case "manual": - return spec.binaryPath ? `rm -f ${quoteArg(spec.binaryPath)}` : null; - default: - return null; - } + const spec = normalizeInstallSpec(server); + if (!spec) return null; + + if (spec.uninstallCommand) { + return spec.uninstallCommand; + } + if (server.launcher?.uninstallCommand) { + return server.launcher.uninstallCommand; + } + + switch (spec.kind) { + case "apk": + return spec.packages.length + ? `apk del ${spec.packages.map((entry) => quoteArg(entry)).join(" ")}` + : null; + case "npm": { + if (!spec.packages.length) return null; + const npmCommand = spec.npmCommand || "npm"; + const uninstallFlags = + spec.global !== false ? "uninstall -g" : "uninstall"; + return `${npmCommand} ${uninstallFlags} ${spec.packages.map((entry) => quoteArg(entry)).join(" ")}`; + } + case "pip": + return spec.packages.length + ? `${spec.pipCommand || "pip"} uninstall -y ${spec.packages.map((entry) => quoteArg(entry)).join(" ")}` + : null; + case "cargo": + return spec.packages.length + ? spec.packages + .map((entry) => `cargo uninstall ${quoteArg(entry)}`) + .join(" && ") + : null; + case "github-release": + case "manual": + return spec.binaryPath ? `rm -f ${quoteArg(spec.binaryPath)}` : null; + default: + return null; + } } function buildInstallCommand( - server: LspServerDefinition, - mode: "install" | "update" = "install", + server: LspServerDefinition, + mode: "install" | "update" = "install", ): string | null { - const spec = normalizeInstallSpec(server); - if (!spec) return null; - - if (mode === "update" && spec.updateCommand) { - return spec.updateCommand; - } - - switch (spec.kind) { - case "apk": - return spec.packages.length - ? `apk add --no-cache ${spec.packages.map((entry) => quoteArg(entry)).join(" ")}` - : null; - case "npm": { - if (!spec.packages.length) return null; - const npmCommand = spec.npmCommand || "npm"; - const installFlags = spec.global !== false ? "install -g" : "install"; - return `apk add --no-cache nodejs npm && ${npmCommand} ${installFlags} ${spec.packages.map((entry) => quoteArg(entry)).join(" ")}`; - } - case "pip": { - if (!spec.packages.length) return null; - const pipCommand = spec.pipCommand || "pip"; - const breakPackages = - spec.breakSystemPackages !== false - ? "PIP_BREAK_SYSTEM_PACKAGES=1 " - : ""; - return `apk add --no-cache python3 py3-pip && ${breakPackages}${pipCommand} install ${spec.packages.map((entry) => quoteArg(entry)).join(" ")}`; - } - case "cargo": - return spec.packages.length - ? `apk add --no-cache rust cargo && cargo install ${spec.packages.map((entry) => quoteArg(entry)).join(" ")}` - : null; - case "github-release": { - if (!spec.repo || !spec.binaryPath) return null; - const caseLines = buildShellArchCase(spec.assetNames, quoteArg); - if (!caseLines) return null; - const archivePath = '"$TMP_DIR/$ASSET"'; - const extractedFile = quoteArg(spec.extractFile || "luau-lsp"); - const installTarget = quoteArg(spec.binaryPath); - const downloadUrl = `https://github.com/${spec.repo}/releases/latest/download/$ASSET`; - - if (spec.archiveType === "binary") { - return `apk add --no-cache curl && ARCH="$(uname -m)" && case "$ARCH" in\n${caseLines}\n\t*) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;;\nesac && TMP_DIR="$(mktemp -d)" && cleanup() { rm -rf "$TMP_DIR"; } && trap cleanup EXIT && curl -fsSL "${downloadUrl}" -o ${archivePath} && install -Dm755 ${archivePath} ${installTarget}`; - } - - return `apk add --no-cache curl unzip && ARCH="$(uname -m)" && case "$ARCH" in\n${caseLines}\n\t*) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;;\nesac && TMP_DIR="$(mktemp -d)" && cleanup() { rm -rf "$TMP_DIR"; } && trap cleanup EXIT && curl -fsSL "${downloadUrl}" -o ${archivePath} && unzip -oq ${archivePath} -d "$TMP_DIR" && install -Dm755 "$TMP_DIR"/${extractedFile} ${installTarget}`; - } - case "manual": - return null; - default: - return spec.command || null; - } + const spec = normalizeInstallSpec(server); + if (!spec) return null; + + if (mode === "update" && spec.updateCommand) { + return spec.updateCommand; + } + + switch (spec.kind) { + case "apk": + return spec.packages.length + ? `apk add --no-cache ${spec.packages.map((entry) => quoteArg(entry)).join(" ")}` + : null; + case "npm": { + if (!spec.packages.length) return null; + const npmCommand = spec.npmCommand || "npm"; + const installFlags = spec.global !== false ? "install -g" : "install"; + return `apk add --no-cache nodejs npm && ${npmCommand} ${installFlags} ${spec.packages.map((entry) => quoteArg(entry)).join(" ")}`; + } + case "pip": { + if (!spec.packages.length) return null; + const pipCommand = spec.pipCommand || "pip"; + const breakPackages = + spec.breakSystemPackages !== false + ? "PIP_BREAK_SYSTEM_PACKAGES=1 " + : ""; + return `apk add --no-cache python3 py3-pip && ${breakPackages}${pipCommand} install ${spec.packages.map((entry) => quoteArg(entry)).join(" ")}`; + } + case "cargo": + return spec.packages.length + ? `apk add --no-cache rust cargo && cargo install ${spec.packages.map((entry) => quoteArg(entry)).join(" ")}` + : null; + case "github-release": { + if (!spec.repo || !spec.binaryPath) return null; + const caseLines = buildShellArchCase(spec.assetNames, quoteArg); + if (!caseLines) return null; + const archivePath = '"$TMP_DIR/$ASSET"'; + const extractedFile = quoteArg(spec.extractFile || "luau-lsp"); + const installTarget = quoteArg(spec.binaryPath); + const downloadUrl = `https://github.com/${spec.repo}/releases/latest/download/$ASSET`; + + if (spec.archiveType === "binary") { + return `apk add --no-cache curl && ARCH="$(uname -m)" && case "$ARCH" in\n${caseLines}\n\t*) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;;\nesac && TMP_DIR="$(mktemp -d)" && cleanup() { rm -rf "$TMP_DIR"; } && trap cleanup EXIT && curl -fsSL "${downloadUrl}" -o ${archivePath} && install -Dm755 ${archivePath} ${installTarget}`; + } + + return `apk add --no-cache curl unzip && ARCH="$(uname -m)" && case "$ARCH" in\n${caseLines}\n\t*) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;;\nesac && TMP_DIR="$(mktemp -d)" && cleanup() { rm -rf "$TMP_DIR"; } && trap cleanup EXIT && curl -fsSL "${downloadUrl}" -o ${archivePath} && unzip -oq ${archivePath} -d "$TMP_DIR" && install -Dm755 "$TMP_DIR"/${extractedFile} ${installTarget}`; + } + case "manual": + return null; + default: + return spec.command || null; + } } function buildDerivedCheckCommand(server: LspServerDefinition): string | null { - const binary = resolveServerExecutable(server)?.trim() || ""; - const install = normalizeInstallSpec(server); + const binary = resolveServerExecutable(server)?.trim() || ""; + const install = normalizeInstallSpec(server); - if (install?.kind === "manual" && install.binaryPath) { - return `test -x ${quoteArg(install.binaryPath)}`; - } + if (install?.kind === "manual" && install.binaryPath) { + return `test -x ${quoteArg(install.binaryPath)}`; + } - if (binary.includes("/")) { - return `test -x ${quoteArg(binary)}`; - } + if (binary.includes("/")) { + return `test -x ${quoteArg(binary)}`; + } - if (binary) { - return `which ${quoteArg(binary)}`; - } + if (binary) { + return `which ${quoteArg(binary)}`; + } - return null; + return null; } function getUpdateCommand(server: LspServerDefinition): string | null { - const launcher = server.launcher; - if (!launcher) return null; - if ( - typeof launcher.updateCommand === "string" && - launcher.updateCommand.trim() - ) { - return launcher.updateCommand.trim(); - } - return buildInstallCommand(server, "update"); + const launcher = server.launcher; + if (!launcher) return null; + if ( + typeof launcher.updateCommand === "string" && + launcher.updateCommand.trim() + ) { + return launcher.updateCommand.trim(); + } + return buildInstallCommand(server, "update"); } async function readServerVersion( - server: LspServerDefinition, + server: LspServerDefinition, ): Promise { - const command = server.launcher?.versionCommand; - if (!command) return null; - - try { - const output = await runQuickCommand(command); - const version = String(output || "") - .split("\n") - .map((line) => line.trim()) - .find(Boolean); - return version || null; - } catch { - return null; - } + const command = server.launcher?.versionCommand; + if (!command) return null; + + try { + const output = await runQuickCommand(command); + const version = String(output || "") + .split("\n") + .map((line) => line.trim()) + .find(Boolean); + return version || null; + } catch { + return null; + } } export function getInstallCommand( - server: LspServerDefinition, - mode: "install" | "update" = "install", + server: LspServerDefinition, + mode: "install" | "update" = "install", ): string | null { - if (mode === "update") { - return getUpdateCommand(server); - } - return buildInstallCommand(server, "install"); + if (mode === "update") { + return getUpdateCommand(server); + } + return buildInstallCommand(server, "install"); } export function getInstallSource(server: LspServerDefinition): string | null { - return normalizeInstallSpec(server)?.source || null; + return normalizeInstallSpec(server)?.source || null; } export function getUninstallCommand( - server: LspServerDefinition, + server: LspServerDefinition, ): string | null { - return buildUninstallCommand(server); + return buildUninstallCommand(server); } export async function checkServerInstallation( - server: LspServerDefinition, + server: LspServerDefinition, ): Promise { - const bundle = getServerBundle(server.id); - if (bundle?.checkInstallation) { - try { - const result = await bundle.checkInstallation(server.id, server); - if (result) return result; - } catch (error) { - return { - status: "failed", - version: null, - canInstall: Boolean(getInstallCommand(server, "install")), - canUpdate: Boolean(getInstallCommand(server, "update")), - message: error instanceof Error ? error.message : String(error), - }; - } - } - - const launcher = server.launcher; - const installCommand = getInstallCommand(server, "install"); - const updateCommand = getInstallCommand(server, "update"); - const checkCommand = - launcher?.checkCommand || buildDerivedCheckCommand(server); - - if (!checkCommand) { - return { - status: "unknown", - version: await readServerVersion(server), - canInstall: Boolean(installCommand), - canUpdate: Boolean(updateCommand), - message: "No install check configured for this server.", - }; - } - - try { - await runQuickCommand(checkCommand); - return { - status: "present", - version: await readServerVersion(server), - canInstall: Boolean(installCommand), - canUpdate: Boolean(updateCommand), - }; - } catch (error) { - return { - status: installCommand ? "missing" : "failed", - version: null, - canInstall: Boolean(installCommand), - canUpdate: Boolean(updateCommand), - message: error instanceof Error ? error.message : String(error), - }; - } + const bundle = getServerBundle(server.id); + if (bundle?.checkInstallation) { + try { + const result = await bundle.checkInstallation(server.id, server); + if (result) return result; + } catch (error) { + return { + status: "failed", + version: null, + canInstall: Boolean(getInstallCommand(server, "install")), + canUpdate: Boolean(getInstallCommand(server, "update")), + message: error instanceof Error ? error.message : String(error), + }; + } + } + + const launcher = server.launcher; + const installCommand = getInstallCommand(server, "install"); + const updateCommand = getInstallCommand(server, "update"); + const checkCommand = + launcher?.checkCommand || buildDerivedCheckCommand(server); + + if (!checkCommand) { + return { + status: "unknown", + version: await readServerVersion(server), + canInstall: Boolean(installCommand), + canUpdate: Boolean(updateCommand), + message: "No install check configured for this server.", + }; + } + + try { + await runQuickCommand(checkCommand); + return { + status: "present", + version: await readServerVersion(server), + canInstall: Boolean(installCommand), + canUpdate: Boolean(updateCommand), + }; + } catch (error) { + return { + status: installCommand ? "missing" : "failed", + version: null, + canInstall: Boolean(installCommand), + canUpdate: Boolean(updateCommand), + message: error instanceof Error ? error.message : String(error), + }; + } } export function resetInstallState(serverId?: string): void { - if (!serverId) { - checkedCommands.clear(); - return; - } - - const prefix = `${serverId}:`; - for (const key of Array.from(checkedCommands.keys())) { - if (key.startsWith(prefix)) { - checkedCommands.delete(key); - } - } + if (!serverId) { + checkedCommands.clear(); + return; + } + + const prefix = `${serverId}:`; + for (const key of Array.from(checkedCommands.keys())) { + if (key.startsWith(prefix)) { + checkedCommands.delete(key); + } + } } async function ensureInstalled(server: LspServerDefinition): Promise { - const launcher = server.launcher; - const checkCommand = - launcher?.checkCommand || buildDerivedCheckCommand(server); - if (!checkCommand) return true; - - const cacheKey = getInstallCacheKey(server); - if (!cacheKey) return true; - - // Return cached result if already checked - if (checkedCommands.has(cacheKey)) { - const status = checkedCommands.get(cacheKey); - if (status === STATUS_PRESENT) { - return true; - } - if (status === STATUS_DECLINED) { - return false; - } - checkedCommands.delete(cacheKey); - } - - // If there's already a pending check for this server, wait for it - if (pendingInstallChecks.has(cacheKey)) { - const pending = pendingInstallChecks.get(cacheKey); - if (pending) return pending; - } - - // Create and track the pending promise - const checkPromise = performInstallCheck(server, launcher, cacheKey); - pendingInstallChecks.set(cacheKey, checkPromise); - - try { - return await checkPromise; - } finally { - pendingInstallChecks.delete(cacheKey); - } + const launcher = server.launcher; + const checkCommand = + launcher?.checkCommand || buildDerivedCheckCommand(server); + if (!checkCommand) return true; + + const cacheKey = getInstallCacheKey(server); + if (!cacheKey) return true; + + // Return cached result if already checked + if (checkedCommands.has(cacheKey)) { + const status = checkedCommands.get(cacheKey); + if (status === STATUS_PRESENT) { + return true; + } + if (status === STATUS_DECLINED) { + return false; + } + checkedCommands.delete(cacheKey); + } + + // If there's already a pending check for this server, wait for it + if (pendingInstallChecks.has(cacheKey)) { + const pending = pendingInstallChecks.get(cacheKey); + if (pending) return pending; + } + + // Create and track the pending promise + const checkPromise = performInstallCheck(server, launcher, cacheKey); + pendingInstallChecks.set(cacheKey, checkPromise); + + try { + return await checkPromise; + } finally { + pendingInstallChecks.delete(cacheKey); + } } interface LoaderDialog { - show: () => void; - destroy: () => void; + show: () => void; + destroy: () => void; } type InstallActionMode = "install" | "update" | "reinstall"; export async function installServer( - server: LspServerDefinition, - mode: InstallActionMode = "install", - options: { promptConfirm?: boolean } = {}, + server: LspServerDefinition, + mode: InstallActionMode = "install", + options: { promptConfirm?: boolean } = {}, ): Promise { - const bundle = getServerBundle(server.id); - if (bundle?.installServer) { - return bundle.installServer(server.id, server, mode, options); - } - - const { promptConfirm = false } = options; - const cacheKey = getInstallCacheKey(server); - const displayLabel = getInstallLabel(server); - const isUpdate = mode === "update"; - const actionLabel = isUpdate ? "Update" : "Install"; - const command = - mode === "install" - ? getInstallCommand(server, "install") - : getUpdateCommand(server); - - if (!command) { - throw new Error( - `${displayLabel} has no ${actionLabel.toLowerCase()} command.`, - ); - } - - if (promptConfirm) { - let resolveThis: (val: boolean) => void; - let rejectThis: (err: Error) => void; - notificationManager.pushNotification({ - icon: "zap", - title: displayLabel, - message: strings["lsp-install-notification"].replace( - "{server}", - displayLabel, - ), - type: "warning", - action: async (notification) => { - notificationManager.closeNotification(notification); - try { - resolveThis(await installServer(server, mode)); - } catch (error) { - rejectThis(error as Error); - } - }, - onDismiss: () => { - resolveThis(false); - if (cacheKey) { - checkedCommands.set(cacheKey, STATUS_DECLINED); - } - }, - }); - - return new Promise((resolve, reject) => { - resolveThis = resolve; - rejectThis = reject; - }); - } - - let loading = true; - try { - notificationManager.pushNotification({ - icon: "zap", - loading: () => loading, - title: displayLabel, - message: `${isUpdate ? "Updating" : "Installing"} ${displayLabel}...`, - }); - await runForegroundCommand(command); - resetInstallState(server.id); - - const result = await checkServerInstallation(server); - if (cacheKey && result.status === "present") { - checkedCommands.set(cacheKey, STATUS_PRESENT); - } - - toast( - result.status === "present" - ? `${displayLabel} ${isUpdate ? "updated" : "installed"}` - : `${displayLabel} ${actionLabel.toLowerCase()} finished`, - ); - return true; - } catch (error) { - console.error(`Failed to ${actionLabel.toLowerCase()} ${server.id}`, error); - if (cacheKey) { - checkedCommands.set(cacheKey, STATUS_FAILED); - } - toast(strings?.error ?? "Error"); - throw error; - } finally { - loading = false; - } + const bundle = getServerBundle(server.id); + if (bundle?.installServer) { + return bundle.installServer(server.id, server, mode, options); + } + + const { promptConfirm = false } = options; + const cacheKey = getInstallCacheKey(server); + const displayLabel = getInstallLabel(server); + const isUpdate = mode === "update"; + const actionLabel = isUpdate ? "Update" : "Install"; + const command = + mode === "install" + ? getInstallCommand(server, "install") + : getUpdateCommand(server); + + if (!command) { + throw new Error( + `${displayLabel} has no ${actionLabel.toLowerCase()} command.`, + ); + } + + if (promptConfirm) { + let resolveThis: (val: boolean) => void; + let rejectThis: (err: Error) => void; + notificationManager.pushNotification({ + icon: "zap", + title: displayLabel, + message: strings["lsp-install-notification"].replace( + "{server}", + displayLabel, + ), + type: "warning", + action: async (notification) => { + notificationManager.closeNotification(notification); + try { + resolveThis(await installServer(server, mode)); + } catch (error) { + rejectThis(error as Error); + } + }, + onDismiss: () => { + resolveThis(false); + if (cacheKey) { + checkedCommands.set(cacheKey, STATUS_DECLINED); + } + }, + }); + + return new Promise((resolve, reject) => { + resolveThis = resolve; + rejectThis = reject; + }); + } + + let loading = true; + try { + notificationManager.pushNotification({ + icon: "zap", + loading: () => loading, + title: displayLabel, + message: `${isUpdate ? "Updating" : "Installing"} ${displayLabel}...`, + }); + await runForegroundCommand(command); + resetInstallState(server.id); + + const result = await checkServerInstallation(server); + if (cacheKey && result.status === "present") { + checkedCommands.set(cacheKey, STATUS_PRESENT); + } + + toast( + result.status === "present" + ? `${displayLabel} ${isUpdate ? "updated" : "installed"}` + : `${displayLabel} ${actionLabel.toLowerCase()} finished`, + ); + return true; + } catch (error) { + console.error(`Failed to ${actionLabel.toLowerCase()} ${server.id}`, error); + if (cacheKey) { + checkedCommands.set(cacheKey, STATUS_FAILED); + } + toast(strings?.error ?? "Error"); + throw error; + } finally { + loading = false; + } } export async function uninstallServer( - server: LspServerDefinition, - options: { promptConfirm?: boolean } = {}, + server: LspServerDefinition, + options: { promptConfirm?: boolean } = {}, ): Promise { - const bundle = getServerBundle(server.id); - if (bundle?.uninstallServer) { - return bundle.uninstallServer(server.id, server, options); - } - - const { promptConfirm = false } = options; - const cacheKey = getInstallCacheKey(server); - const displayLabel = getInstallLabel(server); - const command = getUninstallCommand(server); - - if (!command) { - throw new Error(`${displayLabel} has no uninstall command.`); - } - - if (promptConfirm) { - notificationManager.pushNotification({ - icon: "zap", - title: displayLabel, - message: strings["lsp-uninstall-notification"].replace( - "{server}", - displayLabel, - ), - type: "warning", - action: (notification) => { - uninstallServer(server, { promptConfirm: false }); - notificationManager.closeNotification(notification); - }, - }); - return false; - } - - let loading = true; - try { - notificationManager.pushNotification({ - icon: "zap", - title: displayLabel, - message: `Uninstalling ${displayLabel}...`, - loading: () => loading, - }); - //await runForegroundCommand(command); - await runQuickCommand(command); - if (cacheKey) { - checkedCommands.delete(cacheKey); - } - resetInstallState(server.id); - stopManagedServer(server.id); - return true; - } catch (error) { - console.error(`Failed to uninstall ${server.id}`, error); - toast(strings?.error ?? "Error"); - throw error; - } finally { - loading = false; - } + const bundle = getServerBundle(server.id); + if (bundle?.uninstallServer) { + return bundle.uninstallServer(server.id, server, options); + } + + const { promptConfirm = false } = options; + const cacheKey = getInstallCacheKey(server); + const displayLabel = getInstallLabel(server); + const command = getUninstallCommand(server); + + if (!command) { + throw new Error(`${displayLabel} has no uninstall command.`); + } + + if (promptConfirm) { + notificationManager.pushNotification({ + icon: "zap", + title: displayLabel, + message: strings["lsp-uninstall-notification"].replace( + "{server}", + displayLabel, + ), + type: "warning", + action: (notification) => { + uninstallServer(server, { promptConfirm: false }); + notificationManager.closeNotification(notification); + }, + }); + return false; + } + + let loading = true; + try { + notificationManager.pushNotification({ + icon: "zap", + title: displayLabel, + message: `Uninstalling ${displayLabel}...`, + loading: () => loading, + }); + //await runForegroundCommand(command); + await runQuickCommand(command); + if (cacheKey) { + checkedCommands.delete(cacheKey); + } + resetInstallState(server.id); + stopManagedServer(server.id); + return true; + } catch (error) { + console.error(`Failed to uninstall ${server.id}`, error); + toast(strings?.error ?? "Error"); + throw error; + } finally { + loading = false; + } } async function performInstallCheck( - server: LspServerDefinition, - launcher: LauncherConfig | undefined, - cacheKey: string, + server: LspServerDefinition, + launcher: LauncherConfig | undefined, + cacheKey: string, ): Promise { - try { - const checkCommand = - launcher?.checkCommand || buildDerivedCheckCommand(server); - if (checkCommand) { - await runQuickCommand(checkCommand); - } - checkedCommands.set(cacheKey, STATUS_PRESENT); - return true; - } catch (error) { - if (!getInstallCommand(server, "install")) { - checkedCommands.set(cacheKey, STATUS_FAILED); - console.warn( - `LSP server ${server.id} is missing check command result and has no installer.`, - error, - ); - throw error; - } - - const installed = await installServer(server, "install", { - promptConfirm: true, - }); - if (!installed) { - checkedCommands.set(cacheKey, STATUS_DECLINED); - return false; - } - checkedCommands.set(cacheKey, STATUS_PRESENT); - return true; - } + try { + const checkCommand = + launcher?.checkCommand || buildDerivedCheckCommand(server); + if (checkCommand) { + await runQuickCommand(checkCommand); + } + checkedCommands.set(cacheKey, STATUS_PRESENT); + return true; + } catch (error) { + if (!getInstallCommand(server, "install")) { + checkedCommands.set(cacheKey, STATUS_FAILED); + console.warn( + `LSP server ${server.id} is missing check command result and has no installer.`, + error, + ); + throw error; + } + + const installed = await installServer(server, "install", { + promptConfirm: true, + }); + if (!installed) { + checkedCommands.set(cacheKey, STATUS_DECLINED); + return false; + } + checkedCommands.set(cacheKey, STATUS_PRESENT); + return true; + } } async function startInteractiveServer( - command: string, - serverId: string, - logOutput: LauncherConfig["logOutput"] = "all", + command: string, + serverId: string, + logOutput: LauncherConfig["logOutput"] = "all", ): Promise { - const executor = getExecutor(); - const callback: ExecutorCallback = (type, data) => { - if (type === "stderr" && PROOT_FD_BINDING_WARNING.test(data)) return; - if (type === "stdout" && /listening on/i.test(data)) { - signalServerReady(serverId); - } - - if (logOutput === "warnings-and-errors") { - const level = /\b(error|failed|failure|fatal|panic)\b/i.test(data) - ? "error" - : /\b(warn(?:ing)?|unknown method|disabled|not supported|lacks)\b/i.test( - data, - ) - ? "warn" - : null; - if (!level) return; - addLspLog(serverId, level, data); - console[level](`[LSP:${serverId}] ${data}`); - return; - } - - if (type === "stderr") { - addLspLog(serverId, "stderr", data); - console.warn(`[LSP:${serverId}] ${data}`); - } else if (type === "stdout" && data && data.trim()) { - addLspLog(serverId, "info", data); - console.info(`[LSP:${serverId}] ${data}`); - } - }; - const uuid = await executor.start(command, callback, true); - addLspLog(serverId, "info", `Started shell process ${uuid}`); - managedServers.set(serverId, { - uuid, - command, - startedAt: Date.now(), - }); - return uuid; + const executor = getExecutor(); + const callback: ExecutorCallback = (type, data) => { + if (type === "stderr" && PROOT_FD_BINDING_WARNING.test(data)) return; + if (type === "stdout" && /listening on/i.test(data)) { + signalServerReady(serverId); + } + + if (logOutput === "warnings-and-errors") { + const level = /\b(error|failed|failure|fatal|panic)\b/i.test(data) + ? "error" + : /\b(warn(?:ing)?|unknown method|disabled|not supported|lacks)\b/i.test( + data, + ) + ? "warn" + : null; + if (!level) return; + addLspLog(serverId, level, data); + console[level](`[LSP:${serverId}] ${data}`); + return; + } + + if (type === "stderr") { + addLspLog(serverId, "stderr", data); + console.warn(`[LSP:${serverId}] ${data}`); + } else if (type === "stdout" && data && data.trim()) { + addLspLog(serverId, "info", data); + console.info(`[LSP:${serverId}] ${data}`); + } + }; + const uuid = await executor.start(command, callback, true); + addLspLog(serverId, "info", `Started shell process ${uuid}`); + managedServers.set(serverId, { + uuid, + command, + startedAt: Date.now(), + }); + return uuid; } function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } /** @@ -970,7 +974,7 @@ const serverReadySignals = new Map(); * This signals that the server is ready to accept connections. */ export function signalServerReady(serverId: string): void { - serverReadySignals.set(serverId, Date.now()); + serverReadySignals.set(serverId, Date.now()); } /** @@ -979,293 +983,289 @@ export function signalServerReady(serverId: string): void { * This function polls for a ready signal (set when stdout contains "listening") */ async function waitForWebSocket( - url: string, - options: WaitOptions = {}, + url: string, + options: WaitOptions = {}, ): Promise { - const { - delay = 100, // Poll interval - probeTimeout = 5000, // Max wait time - } = options; - - // Extract server ID from URL (e.g., "ws://127.0.0.1:2090" -> check by port) - const portMatch = url.match(/:(\d+)/); - const port = portMatch ? portMatch[1] : null; - - // Find the server ID that's starting on this port - let targetServerId: string | null = null; - const entries = Array.from(managedServers.entries()); - for (const [serverId, entry] of entries) { - if ( - entry.command.includes(`--port ${port}`) || - entry.command.includes(`:${port}`) - ) { - targetServerId = serverId; - break; - } - } - - const deadline = Date.now() + probeTimeout; - - while (Date.now() < deadline) { - // Check if we got a ready signal - if (targetServerId && serverReadySignals.has(targetServerId)) { - // Server is ready, clear the signal and return - serverReadySignals.delete(targetServerId); - return; - } - - await sleep(delay); - } - - // Timeout reached, proceed anyway (transport will retry if needed) - console.debug( - `[LSP] waitForWebSocket timed out for ${url}, proceeding anyway`, - ); + const { + delay = 100, // Poll interval + probeTimeout = 5000, // Max wait time + } = options; + + // Extract server ID from URL (e.g., "ws://127.0.0.1:2090" -> check by port) + const portMatch = url.match(/:(\d+)/); + const port = portMatch ? portMatch[1] : null; + + // Find the server ID that's starting on this port + let targetServerId: string | null = null; + const entries = Array.from(managedServers.entries()); + for (const [serverId, entry] of entries) { + if ( + entry.command.includes(`--port ${port}`) || + entry.command.includes(`:${port}`) + ) { + targetServerId = serverId; + break; + } + } + + const deadline = Date.now() + probeTimeout; + + while (Date.now() < deadline) { + // Check if we got a ready signal + if (targetServerId && serverReadySignals.has(targetServerId)) { + // Server is ready, clear the signal and return + serverReadySignals.delete(targetServerId); + return; + } + + await sleep(delay); + } + + // Timeout reached, proceed anyway (transport will retry if needed) + console.debug( + `[LSP] waitForWebSocket timed out for ${url}, proceeding anyway`, + ); } export interface EnsureServerResult { - uuid: string | null; - /** Port discovered from port file (for auto-port discovery) */ - discoveredPort?: number; + uuid: string | null; + /** Port discovered from port file (for auto-port discovery) */ + discoveredPort?: number; } export async function ensureServerRunning( - server: LspServerDefinition, - session?: string, + server: LspServerDefinition, + session?: string, ): Promise { - const launcher = server.launcher; - if (!launcher) return { uuid: null }; - - // Derive session from server ID if not provided - const effectiveSession = session || server.id; - - // Check if server is already running via port file (dead client detection) - const bridge = launcher.bridge; - const serverName = - resolveServerExecutable(server) || - bridge?.command || - launcher.command || - server.id; - - try { - const existingPort = await canReuseExistingServer(server, effectiveSession); - if (existingPort !== null) { - // Server is already running and responsive, no need to start - return { uuid: null, discoveredPort: existingPort }; - } - } catch { - // Failed to check, proceed with normal startup - } - - const terminal = ( - globalThis as unknown as { - Terminal?: { isInstalled?: () => Promise | boolean }; - } - ).Terminal; - let isTerminalInstalled = false; - try { - isTerminalInstalled = Boolean(await terminal?.isInstalled?.()); - } catch {} - if (!isTerminalInstalled) { - const message = getTerminalRequiredMessage(); - - if (!localStorage.getItem(DONT_ASK_TERMINAL_REQUIRED_FOR_LSP)) { - if (!alreadyInformed) { - const response = await confirm(strings?.error, message, false, { - checkboxText: strings["don't ask again"], - returnState: true, - }); - if ( - typeof response === "object" && - response.confirmed && - response.checked - ) { - localStorage.setItem(DONT_ASK_TERMINAL_REQUIRED_FOR_LSP, "true"); - } - alreadyInformed = true; - } else { - toast(message); - } - } - - const unavailable: LspError = new Error(message); - unavailable.code = "LSP_SERVER_UNAVAILABLE"; - throw unavailable; - } - - const installed = await ensureInstalled(server); - if (!installed) { - const unavailable: LspError = new Error( - `Language server ${server.id} is not available.`, - ); - unavailable.code = "LSP_SERVER_UNAVAILABLE"; - throw unavailable; - } - - const key = server.id; - if (managedServers.has(key)) { - const existing = managedServers.get(key); - if (bridge && !bridge.port) { - if (existing?.port) { - return { uuid: existing.uuid, discoveredPort: existing.port }; - } - const portInfo = await getLspPort(serverName, effectiveSession); - if (portInfo) { - if (existing) { - existing.port = portInfo.port; - } - return { uuid: existing?.uuid ?? null, discoveredPort: portInfo.port }; - } - } - return { uuid: existing?.uuid ?? null }; - } - - const command = resolveStartCommand(server, effectiveSession); - if (!command) { - return { uuid: null }; - } - - try { - const uuid = await startInteractiveServer( - command, - key, - launcher.logOutput, - ); - - // For auto-port discovery, wait for server ready signal then read port - let discoveredPort: number | undefined; - if (bridge && !bridge.port) { - // Auto-port mode - wait for server ready signal and then read port file - const portInfo = await waitForPort( - key, - serverName, - effectiveSession, - 10000, - ); - if (portInfo) { - discoveredPort = portInfo.port; - console.info( - `[LSP:${server.id}] Auto-discovered port ${discoveredPort}`, - ); - addLspLog(server.id, "info", `Auto-discovered port ${discoveredPort}`); - // Update managed server entry with the port - const entry = managedServers.get(key); - if (entry) { - entry.port = discoveredPort; - } - } - if (!discoveredPort) { - throw new Error( - `Could not discover websocket bridge port for ${server.id}`, - ); - } - } else if ( - server.transport?.url && - (server.transport.kind === "websocket" || - server.transport.kind === "stdio") - ) { - // Fixed port mode - wait for the server to signal ready - await waitForWebSocket(server.transport.url); - } - - if (!announcedServers.has(key)) { - console.info(`[LSP:${server.id}] ${server.label} connected`); - addLspLog(server.id, "info", `${server.label} connected`); - announcedServers.add(key); - } - return { uuid, discoveredPort }; - } catch (error) { - console.error(`Failed to start language server ${server.id}`, error); - const errorMessage = error instanceof Error ? error.message : String(error); - addLspLog(server.id, "error", errorMessage || "Connection failed"); - lspStatusBar.show({ - message: errorMessage || "Connection failed", - title: `${server.label} failed`, - type: "error", - icon: "error", - duration: false, - }); - const entry = managedServers.get(key); - if (entry) { - getExecutor() - .stop(entry.uuid) - .catch((err: Error) => { - console.warn( - `Failed to stop language server shell ${server.id}`, - err, - ); - }); - managedServers.delete(key); - } - const unavailable: LspError = new Error( - `Language server ${server.id} failed to start (${errorMessage})`, - ); - unavailable.code = "LSP_SERVER_UNAVAILABLE"; - throw unavailable; - } + const launcher = server.launcher; + if (!launcher) return { uuid: null }; + + // Derive session from server ID if not provided + const effectiveSession = session || server.id; + + // Check if server is already running via port file (dead client detection) + const bridge = launcher.bridge; + const serverName = + resolveServerExecutable(server) || + bridge?.command || + launcher.command || + server.id; + + try { + const existingPort = await canReuseExistingServer(server, effectiveSession); + if (existingPort !== null) { + // Server is already running and responsive, no need to start + return { uuid: null, discoveredPort: existingPort }; + } + } catch { + // Failed to check, proceed with normal startup + } + + const terminal = ( + globalThis as unknown as { + Terminal?: { isInstalled?: () => Promise | boolean }; + } + ).Terminal; + let isTerminalInstalled = false; + try { + isTerminalInstalled = Boolean(await terminal?.isInstalled?.()); + } catch {} + if (!isTerminalInstalled) { + const message = getTerminalRequiredMessage(); + + if (!localStorage.getItem(DONT_ASK_TERMINAL_REQUIRED_FOR_LSP)) { + if (!alreadyInformed) { + const response = await confirm(strings?.error, message, false, { + checkboxText: strings["don't ask again"], + returnState: true, + }); + if ( + typeof response === "object" && + response.confirmed && + response.checked + ) { + localStorage.setItem(DONT_ASK_TERMINAL_REQUIRED_FOR_LSP, "true"); + } + alreadyInformed = true; + } else { + toast(message); + } + } + + const unavailable: LspError = new Error(message); + unavailable.code = "LSP_SERVER_UNAVAILABLE"; + throw unavailable; + } + + const installed = await ensureInstalled(server); + if (!installed) { + const unavailable: LspError = new Error( + `Language server ${server.id} is not available.`, + ); + unavailable.code = "LSP_SERVER_UNAVAILABLE"; + throw unavailable; + } + + const key = server.id; + if (managedServers.has(key)) { + const existing = managedServers.get(key); + if (bridge && !bridge.port) { + if (existing?.port) { + return { uuid: existing.uuid, discoveredPort: existing.port }; + } + const portInfo = await getLspPort(serverName, effectiveSession); + if (portInfo) { + if (existing) { + existing.port = portInfo.port; + } + return { uuid: existing?.uuid ?? null, discoveredPort: portInfo.port }; + } + } + return { uuid: existing?.uuid ?? null }; + } + + const command = resolveStartCommand(server, effectiveSession); + if (!command) { + return { uuid: null }; + } + + try { + const uuid = await startInteractiveServer(command, key, launcher.logOutput); + + // For auto-port discovery, wait for server ready signal then read port + let discoveredPort: number | undefined; + if (bridge && !bridge.port) { + // Auto-port mode - wait for server ready signal and then read port file + const portInfo = await waitForPort( + key, + serverName, + effectiveSession, + 10000, + ); + if (portInfo) { + discoveredPort = portInfo.port; + console.info( + `[LSP:${server.id}] Auto-discovered port ${discoveredPort}`, + ); + addLspLog(server.id, "info", `Auto-discovered port ${discoveredPort}`); + // Update managed server entry with the port + const entry = managedServers.get(key); + if (entry) { + entry.port = discoveredPort; + } + } + if (!discoveredPort) { + throw new Error( + `Could not discover websocket bridge port for ${server.id}`, + ); + } + } else if ( + server.transport?.url && + (server.transport.kind === "websocket" || + server.transport.kind === "stdio") + ) { + // Fixed port mode - wait for the server to signal ready + await waitForWebSocket(server.transport.url); + } + + if (!announcedServers.has(key)) { + console.info(`[LSP:${server.id}] ${server.label} connected`); + addLspLog(server.id, "info", `${server.label} connected`); + announcedServers.add(key); + } + return { uuid, discoveredPort }; + } catch (error) { + console.error(`Failed to start language server ${server.id}`, error); + const errorMessage = error instanceof Error ? error.message : String(error); + addLspLog(server.id, "error", errorMessage || "Connection failed"); + lspStatusBar.show({ + message: errorMessage || "Connection failed", + title: `${server.label} failed`, + type: "error", + icon: "error", + duration: false, + }); + const entry = managedServers.get(key); + if (entry) { + getExecutor() + .stop(entry.uuid) + .catch((err: Error) => { + console.warn( + `Failed to stop language server shell ${server.id}`, + err, + ); + }); + managedServers.delete(key); + } + const unavailable: LspError = new Error( + `Language server ${server.id} failed to start (${errorMessage})`, + ); + unavailable.code = "LSP_SERVER_UNAVAILABLE"; + throw unavailable; + } } export function stopManagedServer(serverId: string): void { - const entry = managedServers.get(serverId); - if (!entry) return; - addLspLog(serverId, "info", "Stopping managed server"); - const executor = getExecutor(); - executor.stop(entry.uuid).catch((error: Error) => { - console.warn(`Failed to stop language server ${serverId}`, error); - }); - managedServers.delete(serverId); - announcedServers.delete(serverId); - - // Stop foreground service when all servers are stopped - if (managedServers.size === 0) { - executor.stopService().catch(() => {}); - } + const entry = managedServers.get(serverId); + if (!entry) return; + addLspLog(serverId, "info", "Stopping managed server"); + const executor = getExecutor(); + executor.stop(entry.uuid).catch((error: Error) => { + console.warn(`Failed to stop language server ${serverId}`, error); + }); + managedServers.delete(serverId); + announcedServers.delete(serverId); + + // Stop foreground service when all servers are stopped + if (managedServers.size === 0) { + executor.stopService().catch(() => {}); + } } export function resetManagedServers(): void { - for (const id of Array.from(managedServers.keys())) { - stopManagedServer(id); - } - managedServers.clear(); - // Ensure foreground service is stopped - getExecutor() - .stopService() - .catch(() => {}); + for (const id of Array.from(managedServers.keys())) { + stopManagedServer(id); + } + managedServers.clear(); + // Ensure foreground service is stopped + getExecutor() + .stopService() + .catch(() => {}); } /** * Get managed server info by server ID */ export function getManagedServerInfo( - serverId: string, + serverId: string, ): ManagedServerEntry | null { - return managedServers.get(serverId) ?? null; + return managedServers.get(serverId) ?? null; } /** * Get all managed servers */ export function getAllManagedServers(): Map { - return new Map(managedServers); + return new Map(managedServers); } function formatMemory(bytes: number): string { - if (!bytes || bytes <= 0) return "—"; - const mb = bytes / (1024 * 1024); - if (mb >= 1) return `${mb.toFixed(1)} MB`; - const kb = bytes / 1024; - return `${kb.toFixed(0)} KB`; + if (!bytes || bytes <= 0) return "—"; + const mb = bytes / (1024 * 1024); + if (mb >= 1) return `${mb.toFixed(1)} MB`; + const kb = bytes / 1024; + return `${kb.toFixed(0)} KB`; } function formatUptime(seconds: number): string { - if (!seconds || seconds <= 0) return "—"; - if (seconds < 60) return `${seconds}s`; - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - if (mins < 60) return `${mins}m ${secs}s`; - const hours = Math.floor(mins / 60); - const remainingMins = mins % 60; - return `${hours}h ${remainingMins}m`; + if (!seconds || seconds <= 0) return "—"; + if (seconds < 60) return `${seconds}s`; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + if (mins < 60) return `${mins}m ${secs}s`; + const hours = Math.floor(mins / 60); + const remainingMins = mins % 60; + return `${hours}h ${remainingMins}m`; } /** @@ -1274,54 +1274,54 @@ function formatUptime(seconds: number): string { * @param timeout - Timeout in milliseconds (default: 2000) */ export async function getServerStats( - serverId: string, - timeout = 2000, + serverId: string, + timeout = 2000, ): Promise { - const entry = managedServers.get(serverId); - if (!entry?.port) { - return null; - } - - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - - const response = await fetch(`http://127.0.0.1:${entry.port}/status`, { - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - return null; - } - - const data = (await response.json()) as LspServerStats; - - // Aggregate stats from all processes - let totalMemory = 0; - let maxUptime = 0; - let firstPid: number | null = null; - - for (const proc of data.processes || []) { - totalMemory += proc.memory_bytes || 0; - if (proc.uptime_secs > maxUptime) { - maxUptime = proc.uptime_secs; - } - if (firstPid === null && proc.pid) { - firstPid = proc.pid; - } - } - - return { - memoryBytes: totalMemory, - memoryFormatted: formatMemory(totalMemory), - uptimeSeconds: maxUptime, - uptimeFormatted: formatUptime(maxUptime), - pid: firstPid, - processCount: data.processes?.length ?? 0, - }; - } catch { - return null; - } + const entry = managedServers.get(serverId); + if (!entry?.port) { + return null; + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + const response = await fetch(`http://127.0.0.1:${entry.port}/status`, { + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as LspServerStats; + + // Aggregate stats from all processes + let totalMemory = 0; + let maxUptime = 0; + let firstPid: number | null = null; + + for (const proc of data.processes || []) { + totalMemory += proc.memory_bytes || 0; + if (proc.uptime_secs > maxUptime) { + maxUptime = proc.uptime_secs; + } + if (firstPid === null && proc.pid) { + firstPid = proc.pid; + } + } + + return { + memoryBytes: totalMemory, + memoryFormatted: formatMemory(totalMemory), + uptimeSeconds: maxUptime, + uptimeFormatted: formatUptime(maxUptime), + pid: firstPid, + processCount: data.processes?.length ?? 0, + }; + } catch { + return null; + } } diff --git a/src/cm/lsp/serverRegistry.ts b/src/cm/lsp/serverRegistry.ts index faaeaa9..f8157af 100644 --- a/src/cm/lsp/serverRegistry.ts +++ b/src/cm/lsp/serverRegistry.ts @@ -2,6 +2,11 @@ import { bindServerRegistry, ensureBuiltinBundlesRegistered, } from "./serverCatalog"; +import { + addJsTsLanguageAliases, + isTailwindCssServer, + resolveJsTsLanguageId, +} from "./servers/shared"; import type { AcodeClientConfig, BridgeConfig, @@ -15,11 +20,6 @@ import type { TransportDescriptor, WebSocketTransportOptions, } from "./types"; -import { - addJsTsLanguageAliases, - isTailwindCssServer, - resolveJsTsLanguageId, -} from "./servers/shared"; const registry = new Map(); const listeners = new Set(); @@ -197,10 +197,7 @@ function sanitizeDefinition( throw new Error(`LSP server ${id} is missing a transport descriptor`); } - if ( - !("languages" in definition) || - !languages.length - ) { + if (!("languages" in definition) || !languages.length) { throw new Error(`LSP server ${id} must declare supported languages`); } diff --git a/src/cm/lsp/tooltipExtensions.ts b/src/cm/lsp/tooltipExtensions.ts index 7332d12..7cab0e1 100644 --- a/src/cm/lsp/tooltipExtensions.ts +++ b/src/cm/lsp/tooltipExtensions.ts @@ -52,10 +52,7 @@ const SIGNATURE_TRIGGER_DELAY = 120; const SIGNATURE_RETRIGGER_DELAY = 250; const hoverLanguageLoads = new Map>(); const pluginHoverLanguages = new WeakMap(); -const pluginHoverLanguageLoads = new WeakMap< - Mode, - Promise ->(); +const pluginHoverLanguageLoads = new WeakMap>(); function clientHasCapability( client: LSPClient, @@ -250,7 +247,9 @@ function collectHoverLanguages( return result; } -async function loadHoverContentLanguages(contents: Hover["contents"]): Promise { +async function loadHoverContentLanguages( + contents: Hover["contents"], +): Promise { const languageTags = collectHoverLanguages(contents); await Promise.all( Array.from(languageTags, (language) => @@ -368,8 +367,8 @@ function lspTooltipSource( view: EditorView, pos: number, ): Promise { - const plugins = LSPPlugin.getAll(view, "hover").filter( - (plugin) => clientHasCapability(plugin.client, "hoverProvider"), + const plugins = LSPPlugin.getAll(view, "hover").filter((plugin) => + clientHasCapability(plugin.client, "hoverProvider"), ); if (!plugins.length) return Promise.resolve(null); @@ -808,9 +807,8 @@ export const showSignatureHelp: Command = (view) => { const field = view.state.field(signatureState); if (!plugin || field === undefined) return false; - const lspPlugin = LSPPlugin.getAll(view, "signatureHelp").find( - (candidate) => - clientHasCapability(candidate.client, "signatureHelpProvider"), + const lspPlugin = LSPPlugin.getAll(view, "signatureHelp").find((candidate) => + clientHasCapability(candidate.client, "signatureHelpProvider"), ); if (!lspPlugin) return false; diff --git a/src/cm/lsp/transport.ts b/src/cm/lsp/transport.ts index 1095687..46468be 100644 --- a/src/cm/lsp/transport.ts +++ b/src/cm/lsp/transport.ts @@ -45,7 +45,11 @@ function createWebSocketTransport( console.info( `[LSP:${server.id}] Using auto-discovered port ${context.dynamicPort}`, ); - addLspLog(server.id, "info", `Using auto-discovered port ${context.dynamicPort}`); + addLspLog( + server.id, + "info", + `Using auto-discovered port ${context.dynamicPort}`, + ); } // URL is only required when not using dynamic port @@ -176,9 +180,8 @@ function createWebSocketTransport( break; case "workspace/configuration": result = Array.isArray(msg.params?.items) - ? msg.params.items.map( - (item: { section?: unknown }) => - resolveWorkspaceConfiguration(item?.section), + ? msg.params.items.map((item: { section?: unknown }) => + resolveWorkspaceConfiguration(item?.section), ) : []; break; @@ -189,8 +192,7 @@ function createWebSocketTransport( { uri: rootUri, name: - rootUri.replace(/\/$/, "").split("/").pop() || - rootUri, + rootUri.replace(/\/$/, "").split("/").pop() || rootUri, }, ] : null; diff --git a/src/cm/lsp/types.ts b/src/cm/lsp/types.ts index 6f39038..6374803 100644 --- a/src/cm/lsp/types.ts +++ b/src/cm/lsp/types.ts @@ -1,3 +1,4 @@ +import type { Language } from "@codemirror/language"; import type { LSPClient, LSPClientConfig, @@ -6,7 +7,6 @@ import type { Workspace, WorkspaceFile, } from "@codemirror/lsp-client"; -import type { Language } from "@codemirror/language"; import type { ChangeSet, Extension, MapMode, Text } from "@codemirror/state"; import type { EditorView } from "@codemirror/view"; diff --git a/src/cm/lsp/workerTransport.ts b/src/cm/lsp/workerTransport.ts index 6197fd7..9d6527b 100644 --- a/src/cm/lsp/workerTransport.ts +++ b/src/cm/lsp/workerTransport.ts @@ -77,7 +77,11 @@ function extractHostRequest( return null; } - if (data.params && typeof data.params === "object" && !Array.isArray(data.params)) { + if ( + data.params && + typeof data.params === "object" && + !Array.isArray(data.params) + ) { return { id: data.id, method: data.method, @@ -87,7 +91,12 @@ function extractHostRequest( const params: Record = {}; for (const [key, value] of Object.entries(data)) { - if (key === "kind" || key === "id" || key === "method" || key === "params") { + if ( + key === "kind" || + key === "id" || + key === "method" || + key === "params" + ) { continue; } params[key] = value; @@ -178,7 +187,10 @@ export function createWorkerTransport( try { listener(data); } catch (error) { - console.error(`[LSP:${serverId}] Worker transport listener failed`, error); + console.error( + `[LSP:${serverId}] Worker transport listener failed`, + error, + ); } } } @@ -201,7 +213,9 @@ export function createWorkerTransport( } } - async function handleHostRequest(message: WorkerControlMessage): Promise { + async function handleHostRequest( + message: WorkerControlMessage, + ): Promise { const request = extractHostRequest(message); if (!request) return; @@ -217,8 +231,7 @@ export function createWorkerTransport( try { response.result = await handler(request.params); } catch (error) { - response.error = - error instanceof Error ? error.message : String(error); + response.error = error instanceof Error ? error.message : String(error); } } @@ -276,9 +289,7 @@ export function createWorkerTransport( }; worker.onerror = (event: ErrorEvent) => { - failTransport( - new Error(event.message || `The ${serverId} worker failed`), - ); + failTransport(new Error(event.message || `The ${serverId} worker failed`)); }; if (options.configure && typeof options.configure === "object") { diff --git a/src/cm/lsp/workers/css.worker.ts b/src/cm/lsp/workers/css.worker.ts index c5c3b5c..89186dc 100644 --- a/src/cm/lsp/workers/css.worker.ts +++ b/src/cm/lsp/workers/css.worker.ts @@ -81,10 +81,7 @@ startWorkerServer(({ documents }) => { validate(document) { const service = getService(document); - return service.doValidation( - document, - getStylesheet(document, service), - ); + return service.doValidation(document, getStylesheet(document, service)); }, async request(method, params) { @@ -127,11 +124,7 @@ startWorkerServer(({ documents }) => { return definition ? [definition] : null; } case "textDocument/references": - return service.findReferences( - document, - request.position, - stylesheet, - ); + return service.findReferences(document, request.position, stylesheet); case "textDocument/prepareRename": return ( service.prepareRename(document, request.position, stylesheet) ?? diff --git a/src/cm/lsp/workers/html.worker.ts b/src/cm/lsp/workers/html.worker.ts index b6e8e2d..e2046d0 100644 --- a/src/cm/lsp/workers/html.worker.ts +++ b/src/cm/lsp/workers/html.worker.ts @@ -1,6 +1,6 @@ import { - getCSSLanguageService, type LanguageService as CssLanguageService, + getCSSLanguageService, type Stylesheet, } from "vscode-css-languageservice"; import { @@ -67,8 +67,14 @@ startWorkerServer(({ documents, requestFile, rootUri }) => { const jsonService = createImportMapService(); const parsedHtml = new Map(); const embeddedDocuments = new Map(); - const parsedCss = new Map(); - const parsedJson = new Map(); + const parsedCss = new Map< + string, + { version: number; document: Stylesheet } + >(); + const parsedJson = new Map< + string, + { version: number; document: JSONDocument } + >(); const documentContext = { resolveReference }; let typescriptClient: NestedLspClient | null | undefined; @@ -121,12 +127,7 @@ startWorkerServer(({ documents, requestFile, rootUri }) => { version: document.version, regions, css: cssText - ? TextDocument.create( - document.uri, - "css", - document.version, - cssText, - ) + ? TextDocument.create(document.uri, "css", document.version, cssText) : null, importmap: importMapText ? TextDocument.create( @@ -215,10 +216,7 @@ startWorkerServer(({ documents, requestFile, rootUri }) => { if (embedded.css) { diagnostics.push( - ...cssService.doValidation( - embedded.css, - getCss(embedded.css), - ), + ...cssService.doValidation(embedded.css, getCss(embedded.css)), ); } if (embedded.importmap) { @@ -327,30 +325,22 @@ startWorkerServer(({ documents, requestFile, rootUri }) => { return cssService.format(css, request.range, request.options); } if (languageId === "importmap" && importmap) { - return jsonService.format( - importmap, - request.range, - { - tabSize: request.options.tabSize ?? 4, - insertSpaces: request.options.insertSpaces ?? true, - }, - ); + return jsonService.format(importmap, request.range, { + tabSize: request.options.tabSize ?? 4, + insertSpaces: request.options.insertSpaces ?? true, + }); } if (languageId === "javascript") { return requestTypeScript(method, params, javascript); } - return htmlService.format( + return htmlService.format(document, request.range, request.options); + case "textDocument/documentSymbol": { + const symbols: DocumentSymbol[] = htmlService.findDocumentSymbols2( document, - request.range, - request.options, + html, ); - case "textDocument/documentSymbol": { - const symbols: DocumentSymbol[] = - htmlService.findDocumentSymbols2(document, html); if (css) { - symbols.push( - ...cssService.findDocumentSymbols2(css, getCss(css)), - ); + symbols.push(...cssService.findDocumentSymbols2(css, getCss(css))); } if (importmap) { symbols.push( @@ -397,11 +387,7 @@ startWorkerServer(({ documents, requestFile, rootUri }) => { return requestTypeScript(method, params, javascript); } return languageId === "css" && css - ? cssService.findReferences( - css, - request.position, - getCss(css), - ) + ? cssService.findReferences(css, request.position, getCss(css)) : null; case "textDocument/rename": if (languageId === "css" && css) { @@ -455,10 +441,7 @@ startWorkerServer(({ documents, requestFile, rootUri }) => { } if (importmap) { links.push( - ...(await jsonService.findLinks( - importmap, - getJson(importmap), - )), + ...(await jsonService.findLinks(importmap, getJson(importmap))), ); } return links; @@ -476,9 +459,7 @@ startWorkerServer(({ documents, requestFile, rootUri }) => { ) : []; case "textDocument/documentColor": - return css - ? cssService.findDocumentColors(css, getCss(css)) - : []; + return css ? cssService.findDocumentColors(css, getCss(css)) : []; case "textDocument/colorPresentation": return languageId === "css" && css ? cssService.getColorPresentations( @@ -489,8 +470,7 @@ startWorkerServer(({ documents, requestFile, rootUri }) => { ) : []; case "textDocument/foldingRange": { - const ranges: FoldingRange[] = - htmlService.getFoldingRanges(document); + const ranges: FoldingRange[] = htmlService.getFoldingRanges(document); if (css) ranges.push(...cssService.getFoldingRanges(css)); if (importmap) { ranges.push(...jsonService.getFoldingRanges(importmap)); @@ -536,10 +516,7 @@ startWorkerServer(({ documents, requestFile, rootUri }) => { ) { return requestTypeScript(method, params, javascript); } - return htmlService.getSelectionRanges( - document, - request.positions, - ); + return htmlService.getSelectionRanges(document, request.positions); case "textDocument/linkedEditingRange": { if (languageId) return null; const ranges = htmlService.findLinkedEditingRanges( diff --git a/src/cm/lsp/workers/htmlEmbeddedSupport.ts b/src/cm/lsp/workers/htmlEmbeddedSupport.ts index 9a56ac3..b396262 100644 --- a/src/cm/lsp/workers/htmlEmbeddedSupport.ts +++ b/src/cm/lsp/workers/htmlEmbeddedSupport.ts @@ -134,12 +134,7 @@ export function getDocumentRegions( return { regions, getEmbeddedDocument: (languageId, ignoreAttributeValues) => - getEmbeddedDocument( - document, - regions, - languageId, - ignoreAttributeValues, - ), + getEmbeddedDocument(document, regions, languageId, ignoreAttributeValues), getEmbeddedLanguages: (ignoreAttributeValues) => getEmbeddedLanguages(regions, ignoreAttributeValues), getEmbeddedLanguageAtPosition: (position) => diff --git a/src/cm/lsp/workers/json.worker.ts b/src/cm/lsp/workers/json.worker.ts index 5357d6e..9c898b1 100644 --- a/src/cm/lsp/workers/json.worker.ts +++ b/src/cm/lsp/workers/json.worker.ts @@ -5,12 +5,12 @@ import { type LanguageService, } from "vscode-json-languageservice"; import type { TextDocument } from "vscode-languageserver-textdocument"; +import { schemas } from "./jsonSchemas"; import { getTextDocument, METHOD_NOT_HANDLED, startWorkerServer, } from "./protocol"; -import { schemas } from "./jsonSchemas"; interface CachedJsonDocument { version: number; @@ -105,8 +105,7 @@ startWorkerServer(({ documents, requestFile }) => { const service = getService(document); return service.doValidation(document, getParsed(document, service), { comments: document.languageId === "jsonc" ? "ignore" : "error", - trailingCommas: - document.languageId === "jsonc" ? "warning" : "error", + trailingCommas: document.languageId === "jsonc" ? "warning" : "error", }); }, @@ -156,11 +155,7 @@ startWorkerServer(({ documents, requestFile }) => { case "textDocument/foldingRange": return service.getFoldingRanges(document); case "textDocument/selectionRange": - return service.getSelectionRanges( - document, - request.positions, - json, - ); + return service.getSelectionRanges(document, request.positions, json); default: return METHOD_NOT_HANDLED; } diff --git a/src/cm/lsp/workers/nestedLspClient.ts b/src/cm/lsp/workers/nestedLspClient.ts index a4dc90c..43e16a2 100644 --- a/src/cm/lsp/workers/nestedLspClient.ts +++ b/src/cm/lsp/workers/nestedLspClient.ts @@ -35,9 +35,7 @@ export class NestedLspClient { }); this.ready = new Promise((resolve, reject) => { const timeout = setTimeout(() => { - const error = new Error( - `Timed out starting ${config.serverId} worker`, - ); + const error = new Error(`Timed out starting ${config.serverId} worker`); this.fail(error); reject(error); }, 10000); @@ -182,7 +180,9 @@ export class NestedLspClient { if (!request) return; this.pending.delete(message.id); if (message.error) { - request.reject(new Error(message.error.message || "Nested request failed")); + request.reject( + new Error(message.error.message || "Nested request failed"), + ); } else { request.resolve(message.result); } diff --git a/src/cm/lsp/workers/protocol.ts b/src/cm/lsp/workers/protocol.ts index bebab57..c49c244 100644 --- a/src/cm/lsp/workers/protocol.ts +++ b/src/cm/lsp/workers/protocol.ts @@ -511,9 +511,8 @@ export function getTextDocument( documentsMap: Map, params: unknown, ): TextDocument | null { - const uri = ( - params as { textDocument?: { uri?: unknown } } | null - )?.textDocument?.uri; + const uri = (params as { textDocument?: { uri?: unknown } } | null) + ?.textDocument?.uri; return typeof uri === "string" ? (documentsMap.get(uri) ?? null) : null; } diff --git a/src/cm/lsp/workers/typescriptLibs.ts b/src/cm/lsp/workers/typescriptLibs.ts index 616e5db..29d5023 100644 --- a/src/cm/lsp/workers/typescriptLibs.ts +++ b/src/cm/lsp/workers/typescriptLibs.ts @@ -22,8 +22,7 @@ const context = require.context( const libraries: Record = {}; for (const key of context.keys()) { const value = context(key); - libraries[key.slice(2)] = - typeof value === "string" ? value : value.default; + libraries[key.slice(2)] = typeof value === "string" ? value : value.default; } export default libraries; diff --git a/src/cm/lsp/workspace.ts b/src/cm/lsp/workspace.ts index b3d02e3..890df71 100644 --- a/src/cm/lsp/workspace.ts +++ b/src/cm/lsp/workspace.ts @@ -3,10 +3,7 @@ import { LSPPlugin, Workspace } from "@codemirror/lsp-client"; import type { Text, TransactionSpec } from "@codemirror/state"; import type { EditorView } from "@codemirror/view"; import { getModeForPath } from "cm/modelist"; -import { - forgetPullDiagnostics, - schedulePullDiagnostics, -} from "./diagnostics"; +import { forgetPullDiagnostics, schedulePullDiagnostics } from "./diagnostics"; import { addLspLogFor, type LspLogLevel } from "./logs"; import type { WorkspaceFileUpdate, WorkspaceOptions } from "./types"; diff --git a/src/cm/modes/astro/index.ts b/src/cm/modes/astro/index.ts index 691db69..1bfdec8 100644 --- a/src/cm/modes/astro/index.ts +++ b/src/cm/modes/astro/index.ts @@ -5,17 +5,19 @@ import { typescriptLanguage, } from "@codemirror/lang-javascript"; import { - LRLanguage, - LanguageSupport, foldInside, foldNodeProp, indentNodeProp, + LanguageSupport, + LRLanguage, } from "@codemirror/language"; import { parseMixed } from "@lezer/common"; import { styleTags, tags as t } from "@lezer/highlight"; import { parser } from "./parser/parser"; -export function astro(config: { autoCloseTags?: boolean } = {}): LanguageSupport { +export function astro( + config: { autoCloseTags?: boolean } = {}, +): LanguageSupport { const htmlSupport = html({ autoCloseTags: config.autoCloseTags, matchClosingTags: false, @@ -68,5 +70,8 @@ export function astro(config: { autoCloseTags?: boolean } = {}): LanguageSupport }, }); - return new LanguageSupport(astroLanguage, [htmlSupport.support, jsSupport.support]); + return new LanguageSupport(astroLanguage, [ + htmlSupport.support, + jsSupport.support, + ]); } diff --git a/src/cm/modes/astro/parser/parser.terms.ts b/src/cm/modes/astro/parser/parser.terms.ts index 3e171fc..920968d 100644 --- a/src/cm/modes/astro/parser/parser.terms.ts +++ b/src/cm/modes/astro/parser/parser.terms.ts @@ -1,12 +1,11 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. -export const - FrontmatterOpen = 1, - FrontmatterContent = 2, - FrontmatterClose = 3, - ExpressionOpen = 4, - ExpressionContent = 5, - ExpressionClose = 6, - HtmlContent = 7, - Document = 8, - Frontmatter = 9, - AstroExpression = 10 +export const FrontmatterOpen = 1, + FrontmatterContent = 2, + FrontmatterClose = 3, + ExpressionOpen = 4, + ExpressionContent = 5, + ExpressionClose = 6, + HtmlContent = 7, + Document = 8, + Frontmatter = 9, + AstroExpression = 10; diff --git a/src/cm/modes/astro/parser/parser.ts b/src/cm/modes/astro/parser/parser.ts index fc43616..5878630 100644 --- a/src/cm/modes/astro/parser/parser.ts +++ b/src/cm/modes/astro/parser/parser.ts @@ -1,17 +1,19 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. -import {LRParser} from "@lezer/lr" -import {astroTokens} from "./tokens" +import { LRParser } from "@lezer/lr"; +import { astroTokens } from "./tokens"; export const parser = LRParser.deserialize({ - version: 14, - states: "!pQQOPOOO]OPO'#CeOeOPO'#CfOOOP'#Cg'#CgQTOPOOQTOPOOOOOP,59P,59POmOPO,59POOOP,59Q,59QOrOPO,59QOOOP-E6e-E6eOOOP1G.k1G.kOOOP1G.l1G.l", - stateData: "w~OPPOSQOVRO~OQVORUO~OTXOUWO~ORZO~OU[O~O", - goto: "l[PPPPPPPPP]`eRTOVROSTSSOTRYS", - nodeNames: "⚠ FrontmatterOpen FrontmatterContent FrontmatterClose ExpressionOpen ExpressionContent ExpressionClose HtmlContent Document Frontmatter AstroExpression", - maxTerm: 12, - skippedNodes: [0], - repeatNodeCount: 1, - tokenData: "RORO", - tokenizers: [astroTokens], - topRules: {"Document":[0,8]}, - tokenPrec: 0 -}) + version: 14, + states: + "!pQQOPOOO]OPO'#CeOeOPO'#CfOOOP'#Cg'#CgQTOPOOQTOPOOOOOP,59P,59POmOPO,59POOOP,59Q,59QOrOPO,59QOOOP-E6e-E6eOOOP1G.k1G.kOOOP1G.l1G.l", + stateData: "w~OPPOSQOVRO~OQVORUO~OTXOUWO~ORZO~OU[O~O", + goto: "l[PPPPPPPPP]`eRTOVROSTSSOTRYS", + nodeNames: + "⚠ FrontmatterOpen FrontmatterContent FrontmatterClose ExpressionOpen ExpressionContent ExpressionClose HtmlContent Document Frontmatter AstroExpression", + maxTerm: 12, + skippedNodes: [0], + repeatNodeCount: 1, + tokenData: "RORO", + tokenizers: [astroTokens], + topRules: { Document: [0, 8] }, + tokenPrec: 0, +}); diff --git a/src/cm/modes/astro/parser/tokens.ts b/src/cm/modes/astro/parser/tokens.ts index 17e751e..ea7ed6d 100644 --- a/src/cm/modes/astro/parser/tokens.ts +++ b/src/cm/modes/astro/parser/tokens.ts @@ -67,10 +67,7 @@ function atLineStart(input: Input): boolean { return input.peek(-1) === newline; } -function lineHasOnlyFrontmatterFence( - input: Input, - offset = 0, -): boolean { +function lineHasOnlyFrontmatterFence(input: Input, offset = 0): boolean { if ( input.peek(offset) !== dash || input.peek(offset + 1) !== dash || @@ -112,10 +109,7 @@ function startsWithTagName( return isNameBoundary(input.peek(offset + 1 + name.length)); } -function tagEnd( - input: Input, - offset: number, -): TagEnd { +function tagEnd(input: Input, offset: number): TagEnd { let quote = 0; let lastNonWhitespace = 0; @@ -145,11 +139,7 @@ function tagEndOffset(input: Input, offset: number): number { return tagEnd(input, offset).end; } -function closeTagEndOffset( - input: Input, - offset: number, - name: string, -): number { +function closeTagEndOffset(input: Input, offset: number, name: string): number { const nameLength = name.length; for (let i = offset; ; i++) { @@ -171,10 +161,7 @@ function closeTagEndOffset( } } -function rawTextElementEndOffset( - input: Input, - offset: number, -): number | null { +function rawTextElementEndOffset(input: Input, offset: number): number | null { if (startsWithTagName(input, offset, "script")) { const openTag = tagEnd(input, offset); if (openTag.selfClosing) return openTag.end; @@ -213,10 +200,7 @@ function htmlCommentEndOffset(input: Input, offset: number): number | null { } } -function expressionEndOffset( - input: Input, - offset: number, -): number { +function expressionEndOffset(input: Input, offset: number): number { let depth = 0; let quote = 0; let escaped = false; @@ -311,9 +295,7 @@ function startsInsideTagFragment(input: Input): boolean { } } -function htmlContentEndOffset( - input: Input, -): number { +function htmlContentEndOffset(input: Input): number { let inTag = startsInsideTagFragment(input); let quote = 0; @@ -368,9 +350,7 @@ function htmlContentEndOffset( } } -function frontmatterContentEndOffset( - input: Input, -): number { +function frontmatterContentEndOffset(input: Input): number { for (let offset = 0; ; offset++) { const next = input.peek(offset); if (next === eof) return offset; diff --git a/src/cm/modes/javascript/index.ts b/src/cm/modes/javascript/index.ts new file mode 100644 index 0000000..2098d61 --- /dev/null +++ b/src/cm/modes/javascript/index.ts @@ -0,0 +1,32 @@ +import { javascript } from "@codemirror/lang-javascript"; +import { LanguageSupport, LRLanguage } from "@codemirror/language"; +import { styleTags, tags as t } from "@lezer/highlight"; +import { memberProperty } from "../../highlightTags"; +import { jsdocWrap } from "../jsdoc"; + +const highlightTags = styleTags({ + // Lezer tags class and interface method names like property definitions; + // tag them as functions so themes can colour methods like other editors. + "MethodDeclaration/PropertyDefinition MethodDeclaration/PrivatePropertyDefinition MethodType/PropertyDefinition": + t.function(t.definition(t.propertyName)), + // Method calls keep their more specific `function(propertyName)` rule. + PropertyName: memberProperty, + PropertyDefinition: t.definition(memberProperty), +}); + +/** + * JavaScript/TypeScript support with JSDoc highlighting inside `/** *\/` + * comments, method names tagged as functions and property names tagged as + * {@link memberProperty}. + */ +export function javascriptSupport( + config: { jsx?: boolean; typescript?: boolean } = {}, +): LanguageSupport { + const support = javascript(config); + const language = support.language; + if (!(language instanceof LRLanguage)) return support; + return new LanguageSupport( + language.configure({ props: [highlightTags], wrap: jsdocWrap }), + support.support, + ); +} diff --git a/src/cm/modes/jsdoc/index.ts b/src/cm/modes/jsdoc/index.ts new file mode 100644 index 0000000..f6935bd --- /dev/null +++ b/src/cm/modes/jsdoc/index.ts @@ -0,0 +1,345 @@ +import { foldNodeProp, indentNodeProp } from "@codemirror/language"; +import { + type Input, + NodeSet, + NodeType, + Parser, + type ParseWrapper, + type PartialParse, + parseMixed, + Tree, + type TreeFragment, +} from "@lezer/common"; +import { styleTags, tags as t } from "@lezer/highlight"; + +/** + * A tiny JSDoc tokenizer mounted inside JS/TS `/** ... *\/` comments so + * themes can style block tags, types, parameter names and inline links + * separately from the comment text. Braces and other punctuation are left + * as comment text, like VS Code does. + * + * The mounted tree replaces the host `BlockComment` node, so its root keeps + * that name (comment checks in completion and rainbow brackets still match) + * and the fold/indent behaviour JS gives block comments. It is not a top + * node, so language data (comment tokens, completions) still resolves to the + * surrounding JS/TS. + */ + +enum Type { + BlockComment, + JSDocTag, + JSDocType, + JSDocParam, + JSDocLink, +} + +const nodeSet = new NodeSet( + ["BlockComment", "JSDocTag", "JSDocType", "JSDocParam", "JSDocLink"].map( + (name, id) => NodeType.define({ id, name }), + ), +).extend( + styleTags({ + BlockComment: t.docComment, + JSDocTag: t.keyword, + JSDocType: t.typeName, + JSDocParam: t.variableName, + JSDocLink: t.link, + }), + foldNodeProp.add({ + BlockComment: (tree) => ({ from: tree.from + 2, to: tree.to - 2 }), + }), + indentNodeProp.add({ BlockComment: () => null }), +); + +// Tags followed by a parameter/property name, e.g. `@param {T} name`. +const PARAM_TAGS = new Set(["param", "arg", "argument", "property", "prop"]); +// Tags followed by a type name, e.g. `@typedef {Object} Name`. +const TYPE_NAME_TAGS = new Set(["typedef", "callback", "template"]); + +const IDENTIFIER = /[A-Za-z_$][\w$]*(?:(?:\[\])?\.[\w$]+)*/y; +const TAG_NAME = /@[A-Za-z][\w-]*/y; +const LINK_TARGET = /[^\s|}]+/y; + +function matchAt(re: RegExp, text: string, pos: number): string | null { + re.lastIndex = pos; + const match = re.exec(text); + return match ? match[0] : null; +} + +function isSpace(ch: string | undefined): boolean { + return ch === " " || ch === "\t" || ch === "\r" || ch === "\n"; +} + +/** + * Index just past the string literal opening at `pos`, honouring backslash + * escapes, or -1 when it isn't closed on the same line before `limit`. + */ +function skipQuoted(text: string, pos: number, limit: number): number { + const quote = text[pos]; + for (let i = pos + 1; i < limit; i++) { + const ch = text[i]; + if (ch === "\\") i++; + else if (ch === quote) return i + 1; + else if (ch === "\n") return -1; + } + return -1; +} + +function tokenize(text: string): number[] { + // Flat [type, from, to, size] groups, as expected by Tree.build. + const buffer: number[] = []; + const push = (type: Type, from: number, to: number) => { + if (to > from) buffer.push(type, from, to, 4); + }; + + const end = text.endsWith("*/") ? text.length - 2 : text.length; + let pos = 3; // skip "/**" + let lineStart = false; + let typeAllowed = false; + let pending: "param" | "typeName" | null = null; + // First `}`, newline or end at or after the last `{@` scan. Every `{@` + // before it stops at the same place, so it is reused to keep scanning + // linear when there are many unclosed inline tags. + let inlineStop = -1; + + while (pos < end) { + const ch = text[pos]; + + if (ch === "\n") { + lineStart = true; + typeAllowed = false; + pending = null; + pos++; + continue; + } + if (isSpace(ch)) { + pos++; + continue; + } + if (lineStart && ch === "*") { + pos++; + continue; + } + lineStart = false; + + const prev = text[pos - 1]; + if (ch === "@" && (isSpace(prev) || prev === "*")) { + const tag = matchAt(TAG_NAME, text, pos); + if (tag) { + const name = tag.slice(1); + push(Type.JSDocTag, pos, pos + tag.length); + pos += tag.length; + typeAllowed = true; + pending = PARAM_TAGS.has(name) + ? "param" + : TYPE_NAME_TAGS.has(name) + ? "typeName" + : null; + continue; + } + } + + if (ch === "{" && text[pos + 1] === "@") { + if (inlineStop <= pos) { + inlineStop = pos + 1; + while ( + inlineStop < end && + text[inlineStop] !== "}" && + text[inlineStop] !== "\n" + ) { + inlineStop++; + } + } + pos = + text[inlineStop] === "}" && inlineStop < end + ? inlineTag(text, pos, inlineStop, push) + : pos + 1; + typeAllowed = false; + pending = null; + continue; + } + + if (ch === "{" && typeAllowed) { + pos = typeExpression(text, pos, end, push); + typeAllowed = false; + continue; + } + typeAllowed = false; + + if (pending === "param" && ch === "[") { + pos = optionalParam(text, pos, end, push); + pending = null; + continue; + } + + if (pending) { + const name = matchAt(IDENTIFIER, text, pos); + if (name) { + push( + pending === "param" ? Type.JSDocParam : Type.JSDocType, + pos, + pos + name.length, + ); + pos += name.length; + pending = null; + continue; + } + } + pending = null; + + // Plain description text: covered by the JSDoc root node. + pos++; + while (pos < end && !isSpace(text[pos]) && text[pos] !== "{") pos++; + } + + return buffer; +} + +type Push = (type: Type, from: number, to: number) => void; + +/** `{@link Target|label}` and friends; `close` is the matching `}`. */ +function inlineTag(text: string, pos: number, close: number, push: Push) { + let i = pos + 1; + const tag = matchAt(TAG_NAME, text, i); + if (tag) { + push(Type.JSDocTag, i, i + tag.length); + i += tag.length; + while (i < close && isSpace(text[i])) i++; + const target = matchAt(LINK_TARGET, text, i); + if (target && i + target.length <= close) { + push(Type.JSDocLink, i, i + target.length); + } + } + return close + 1; +} + +// Longest type expression scanned before giving up on a missing `}`. +const MAX_TYPE_LENGTH = 500; + +/** + * `{Array | null}`, possibly spanning lines. Tokens are only kept + * when the closing brace is found, so a stray `{` doesn't colour the rest + * of the comment as a type. + */ +function typeExpression(text: string, pos: number, end: number, push: Push) { + const tokens: [Type, number, number][] = []; + const limit = Math.min(end, pos + MAX_TYPE_LENGTH); + let depth = 1; + let i = pos + 1; + + while (i < limit) { + const ch = text[i]; + if (isSpace(ch) || ch === "*") { + i++; + continue; + } + // Types never contain `@`; it starts the next tag, so `{` was unclosed. + if (ch === "@") break; + if (ch === "{") depth++; + if (ch === "}" && --depth === 0) { + for (const [type, from, to] of tokens) push(type, from, to); + return i + 1; + } + const name = matchAt(IDENTIFIER, text, i); + if (name) { + tokens.push([Type.JSDocType, i, i + name.length]); + i += name.length; + continue; + } + if (ch === '"' || ch === "'") { + const close = skipQuoted(text, i, limit); + if (close === -1) break; + tokens.push([Type.JSDocType, i, close]); + i = close; + continue; + } + i++; + } + return pos + 1; +} + +/** `[name=default]` after `@param`. */ +function optionalParam(text: string, pos: number, end: number, push: Push) { + let i = pos + 1; + const name = matchAt(IDENTIFIER, text, i); + if (name) { + push(Type.JSDocParam, i, i + name.length); + i += name.length; + } + + // Find the closing `]`, ignoring brackets inside quoted defaults. + let depth = 1; + while (i < end && text[i] !== "\n") { + const ch = text[i]; + if (ch === '"' || ch === "'") { + const close = skipQuoted(text, i, end); + if (close === -1) return i; + i = close; + continue; + } + if (ch === "[") depth++; + if (ch === "]" && --depth === 0) return i + 1; + i++; + } + return i; +} + +// Comments are short, so the whole range is tokenized in one step. +class JSDocParse implements PartialParse { + parsedPos: number; + stoppedAt: number | null = null; + + constructor( + private input: Input, + private from: number, + private to: number, + ) { + this.parsedPos = from; + } + + advance(): Tree { + const { from, to } = this; + const buffer = tokenize(this.input.read(from, to)).map((value, index) => + index % 4 === 1 || index % 4 === 2 ? value + from : value, + ); + this.parsedPos = to; + return Tree.build({ + buffer, + nodeSet, + topID: Type.BlockComment, + start: from, + length: to - from, + }); + } + + stopAt(pos: number) { + this.stoppedAt = pos; + } +} + +class JSDocParser extends Parser { + createParse( + input: Input, + _fragments: readonly TreeFragment[], + ranges: readonly { from: number; to: number }[], + ): PartialParse { + return new JSDocParse(input, ranges[0].from, ranges[ranges.length - 1].to); + } +} + +export const jsdocParser = new JSDocParser(); + +const MAX_COMMENT_LENGTH = 100_000; + +/** Mounts the JSDoc parser on `/** ... *\/` block comments. */ +export const jsdocWrap: ParseWrapper = parseMixed((node, input) => { + if (node.name !== "BlockComment") return null; + // Every edit re-tokenizes the whole comment; leave huge ones plain. + if (node.to - node.from > MAX_COMMENT_LENGTH) return null; + // Like TypeScript, only `/**` not followed by `*` or `/` is JSDoc. + const head = input.read(node.from, Math.min(node.from + 4, node.to)); + if (!head.startsWith("/**") || head[3] === "*" || head[3] === "/") { + return null; + } + return { parser: jsdocParser }; +}); diff --git a/src/cm/punctuationWrapping.ts b/src/cm/punctuationWrapping.ts index 917c3c1..dc5ba6f 100644 --- a/src/cm/punctuationWrapping.ts +++ b/src/cm/punctuationWrapping.ts @@ -10,7 +10,8 @@ import { // VS Code's default wordWrapBreak{After,Before}Characters: // https://github.com/microsoft/vscode/blob/main/src/vs/editor/common/config/editorOptions.ts -const after = " \t})]?|/&.,;¢°′″‰℃、。。、¢,.:;?!%・・ゝゞヽヾーァィゥェォッャュョヮヵヶぁぃぅぇぉっゃゅょゎゕゖㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿ々〻ァィゥェォャュョッー”〉》」』】〕)]}」"; +const after = + " \t})]?|/&.,;¢°′″‰℃、。。、¢,.:;?!%・・ゝゞヽヾーァィゥェォッャュョヮヵヶぁぃぅぇぉっゃゅょゎゕゖㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿ々〻ァィゥェォャュョッー”〉》」』】〕)]}」"; const before = "([{‘“〈《「『【〔([{「£¥$£¥++"; const escapeClass = (text: string) => text.replace(/[\\\]\[\-^]/g, "\\$&"); diff --git a/src/cm/quickToolsModifierInput.ts b/src/cm/quickToolsModifierInput.ts index d71969a..27c56c3 100644 --- a/src/cm/quickToolsModifierInput.ts +++ b/src/cm/quickToolsModifierInput.ts @@ -1,7 +1,7 @@ import type { Extension } from "@codemirror/state"; import { - EditorView, type EditorView as CodeMirrorEditorView, + EditorView, } from "@codemirror/view"; import { blurEditorIfReadOnly, focusEditorIfEditable } from "cm/editorReadOnly"; diff --git a/src/cm/quickToolsNavigation.ts b/src/cm/quickToolsNavigation.ts index 8bec215..968d2a6 100644 --- a/src/cm/quickToolsNavigation.ts +++ b/src/cm/quickToolsNavigation.ts @@ -31,8 +31,8 @@ import { selectPageUp, } from "@codemirror/commands"; import { - type Command, type EditorView as CodeMirrorEditorView, + type Command, runScopeHandlers, } from "@codemirror/view"; import { focusEditorIfEditable } from "cm/editorReadOnly"; diff --git a/src/cm/searchMatchHighlighter.ts b/src/cm/searchMatchHighlighter.ts index 70449c5..651b727 100644 --- a/src/cm/searchMatchHighlighter.ts +++ b/src/cm/searchMatchHighlighter.ts @@ -1,9 +1,9 @@ import { getSearchQuery, - searchPanelOpen, type SearchQuery, + searchPanelOpen, } from "@codemirror/search"; -import { RangeSetBuilder, type EditorState } from "@codemirror/state"; +import { type EditorState, RangeSetBuilder } from "@codemirror/state"; import type { DecorationSet, ViewUpdate } from "@codemirror/view"; import { Decoration, EditorView, ViewPlugin } from "@codemirror/view"; diff --git a/src/cm/selectionMenuUtils.ts b/src/cm/selectionMenuUtils.ts index 65abc0c..1cbc55d 100644 --- a/src/cm/selectionMenuUtils.ts +++ b/src/cm/selectionMenuUtils.ts @@ -32,10 +32,7 @@ export function bindSelectionMenuButton( if (!pointerStart) return false; const xDistance = event.clientX - pointerStart.x; const yDistance = event.clientY - pointerStart.y; - return ( - xDistance ** 2 + yDistance ** 2 > - POINTER_MOVE_TOLERANCE ** 2 - ); + return xDistance ** 2 + yDistance ** 2 > POINTER_MOVE_TOLERANCE ** 2; }; button.addEventListener("pointerdown", (event) => { diff --git a/src/cm/supportedModes.ts b/src/cm/supportedModes.ts index 959386b..f16746a 100644 --- a/src/cm/supportedModes.ts +++ b/src/cm/supportedModes.ts @@ -67,9 +67,16 @@ function createLanguageLoader(name: string, lang: LanguageDescription) { switch (normalizedName) { case "javascript": + case "jsx": + case "typescript": + case "tsx": return async () => { - const { javascript } = await import("@codemirror/lang-javascript"); - return javascript({ jsx: true }); + const { javascriptSupport } = await import("./modes/javascript"); + return javascriptSupport({ + jsx: normalizedName !== "typescript", + typescript: + normalizedName === "typescript" || normalizedName === "tsx", + }); }; case "markdown": @@ -138,7 +145,33 @@ function createLanguageLoader(name: string, lang: LanguageDescription) { } // 1) Always register a plain text fallback -addMode("Text", "txt|text|log|plain|lock|pub|gitkeep|keep|mk|^makefile", "Plain Text", () => []); +addMode( + "Text", + "txt|text|log|plain|lock|pub|gitkeep|keep|mk|^makefile", + "Plain Text", + () => [], +); + +// Languages that @codemirror/language-data only covers with legacy stream +// tokenizers and that the official Additional Language Modes plugin +// (acode.additional.langmodes) provides as Lezer grammars. They are left out +// so the plugin registers them under their canonical names; without it, these +// files fall back to plain text and Acode recommends the plugin. +const PLUGIN_PROVIDED_LANGUAGES = new Set([ + "clojure", + "clojurescript", + "edn", + "gas", + "kotlin", + "latex", + "stex", + "lua", + "swift", +]); + +// Extensions other modes claim in @codemirror/language-data but that the +// plugin has dedicated modes for: HTML lists Handlebars templates. +const PLUGIN_PROVIDED_EXTENSIONS = new Set(["hbs", "handlebars"]); // 2) Register all languages provided by @codemirror/language-data // We convert extensions like [".js", ".mjs"] into a modelist pattern: "js|mjs" @@ -147,6 +180,7 @@ for (const lang of languages as readonly LanguageDescription[]) { try { const name = String(lang?.name || "").trim(); if (!name) continue; + if (PLUGIN_PROVIDED_LANGUAGES.has(normalizeModeKey(name))) continue; const aliases = collectAliases(name, lang.alias); const modeId = getModeId(name, aliases); @@ -217,7 +251,13 @@ for (const lang of languages as readonly LanguageDescription[]) { ); } else if (normalizedName === "json" || modeId === "json") { langExtensions.push("prettierrc", "hintrc", "eslintrc", "babelrc"); - filenames.push(".prettierrc", ".hintrc", ".eslintrc", ".babelrc", "bun.lock"); + filenames.push( + ".prettierrc", + ".hintrc", + ".eslintrc", + ".babelrc", + "bun.lock", + ); } else if (normalizedName === "yaml" || modeId === "yaml") { filenames.push("yarn.lock"); } else if (normalizedName === "toml" || modeId === "toml") { @@ -230,7 +270,9 @@ for (const lang of languages as readonly LanguageDescription[]) { for (const e of langExtensions) { if (typeof e !== "string") continue; const cleaned = e.replace(/^\./, "").trim(); - if (cleaned) parts.push(cleaned); + if (cleaned && !PLUGIN_PROVIDED_EXTENSIONS.has(cleaned.toLowerCase())) { + parts.push(cleaned); + } } const filenameMatchers: RegExp[] = []; diff --git a/src/cm/tagAutoRename.ts b/src/cm/tagAutoRename.ts index f7db333..1f3f932 100644 --- a/src/cm/tagAutoRename.ts +++ b/src/cm/tagAutoRename.ts @@ -1,7 +1,6 @@ import { syntaxTree } from "@codemirror/language"; -import type { Extension, Text } from "@codemirror/state"; +import type { ChangeSpec, Extension, Text } from "@codemirror/state"; import { Annotation, EditorState, Transaction } from "@codemirror/state"; -import type { ChangeSpec } from "@codemirror/state"; import type { SyntaxNode } from "@lezer/common"; const skipTagAutoRename = Annotation.define(); @@ -17,7 +16,11 @@ interface ChangedRange { toA: number; } -function getTagNameNode(state: EditorState, from: number, to: number): SyntaxNode | null { +function getTagNameNode( + state: EditorState, + from: number, + to: number, +): SyntaxNode | null { const tree = syntaxTree(state); const positions = from === to ? [from, from - 1] : [from, to, to - 1]; @@ -127,15 +130,14 @@ function createPairedRename(transaction: Transaction): ChangeSpec | null { change.fromA, change.toA, ); - if (!target || !touchesRange(change, target.tagName.from, target.tagName.to)) { + if ( + !target || + !touchesRange(change, target.tagName.from, target.tagName.to) + ) { return null; } if ( - touchesRange( - change, - target.pairedTagName.from, - target.pairedTagName.to, - ) + touchesRange(change, target.pairedTagName.from, target.pairedTagName.to) ) { return null; } diff --git a/src/cm/themes/aura.js b/src/cm/themes/aura.js index fdbe824..b3e94bc 100644 --- a/src/cm/themes/aura.js +++ b/src/cm/themes/aura.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; // Aura theme configuration and extensions export const config = { @@ -21,6 +22,7 @@ export const config = { variable: "#edecee", parameter: "#edecee", function: "#ffca85", + memberProperty: "#edecee", string: "#61ffca", constant: "#61ffca", type: "#82e2ff", @@ -109,6 +111,10 @@ export const auraTheme = EditorView.theme( ); export const auraHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], @@ -119,7 +125,16 @@ export const auraHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/ayuDark.js b/src/cm/themes/ayuDark.js index b7629ea..cd62ab5 100644 --- a/src/cm/themes/ayuDark.js +++ b/src/cm/themes/ayuDark.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; export const config = { name: "ayuDark", @@ -19,6 +20,7 @@ export const config = { variable: "#b3b1ad", parameter: "#f07178", function: "#ffee99", + memberProperty: "#b3b1ad", string: "#c2d94c", constant: "#e6b450", type: "#ff8f40", @@ -91,6 +93,10 @@ export const ayuDarkTheme = EditorView.theme( ); export const ayuDarkHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], @@ -101,7 +107,16 @@ export const ayuDarkHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/catppuccin.js b/src/cm/themes/catppuccin.js index 6b1e9a5..7f9d0fb 100644 --- a/src/cm/themes/catppuccin.js +++ b/src/cm/themes/catppuccin.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; const palettes = { latte: { @@ -122,6 +123,7 @@ function createConfig(name, caption, palette) { variable: palette.text, parameter: palette.text, function: palette.blue, + memberProperty: palette.text, string: palette.green, constant: palette.peach, type: palette.yellow, @@ -217,6 +219,10 @@ function createCatppuccinTheme(config) { ); const highlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.definition(t.name), t.deleted, t.character, t.macroName], @@ -225,6 +231,8 @@ function createCatppuccinTheme(config) { { tag: [ t.function(t.variableName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), t.function(t.propertyName), t.propertyName, t.labelName, diff --git a/src/cm/themes/cobalt.js b/src/cm/themes/cobalt.js index 6d23c56..84ade32 100644 --- a/src/cm/themes/cobalt.js +++ b/src/cm/themes/cobalt.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; export const config = { name: "cobalt", @@ -20,6 +21,7 @@ export const config = { variable: "#ffffff", parameter: "#ffffff", function: "#ffc600", + memberProperty: "#ffffff", string: "#3ad900", constant: "#ff628c", type: "#80ffbb", @@ -85,6 +87,10 @@ export const cobaltTheme = EditorView.theme( ); export const cobaltHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.modifier, t.definitionKeyword], color: config.storage }, { @@ -92,7 +98,14 @@ export const cobaltHighlightStyle = HighlightStyle.define([ color: config.variable, }, { - tag: [t.propertyName, t.function(t.variableName), t.labelName], + tag: [ + t.propertyName, + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], color: config.function, }, { diff --git a/src/cm/themes/dracula.js b/src/cm/themes/dracula.js index 8a02c06..f6e3cc6 100644 --- a/src/cm/themes/dracula.js +++ b/src/cm/themes/dracula.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView, lineNumbers } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; export const config = { name: "dracula", @@ -20,6 +21,7 @@ export const config = { variable: "#F8F8F2", parameter: "#F8F8F2", function: "#50FA7B", + memberProperty: "#F8F8F2", string: "#F1FA8C", constant: "#BD93F9", type: "#8BE9FD", @@ -106,6 +108,10 @@ export const draculaTheme = EditorView.theme( ); export const draculaHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], @@ -116,7 +122,16 @@ export const draculaHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/githubDark.js b/src/cm/themes/githubDark.js index e1dcf76..8ad2c8a 100644 --- a/src/cm/themes/githubDark.js +++ b/src/cm/themes/githubDark.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; export const config = { name: "githubDark", @@ -20,6 +21,7 @@ export const config = { variable: "#e6edf3", parameter: "#e6edf3", function: "#d2a8ff", + memberProperty: "#e6edf3", string: "#a5d6ff", constant: "#79c0ff", type: "#79c0ff", @@ -109,6 +111,10 @@ export const githubDarkTheme = EditorView.theme( ); export const githubDarkHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], @@ -119,7 +125,16 @@ export const githubDarkHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/githubLight.js b/src/cm/themes/githubLight.js index e05b03b..8ea5e35 100644 --- a/src/cm/themes/githubLight.js +++ b/src/cm/themes/githubLight.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView, lineNumbers } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; export const config = { name: "githubLight", @@ -20,6 +21,7 @@ export const config = { variable: "#1f2328", parameter: "#1f2328", function: "#8250df", + memberProperty: "#1f2328", string: "#0a3069", constant: "#0550ae", type: "#0550ae", @@ -109,6 +111,10 @@ export const githubLightTheme = EditorView.theme( ); export const githubLightHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], @@ -119,7 +125,16 @@ export const githubLightHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/gruvboxDark.js b/src/cm/themes/gruvboxDark.js index e86960d..06ae5a1 100644 --- a/src/cm/themes/gruvboxDark.js +++ b/src/cm/themes/gruvboxDark.js @@ -102,7 +102,16 @@ export const gruvboxDarkHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/index.js b/src/cm/themes/index.js index 1043b60..52c055c 100644 --- a/src/cm/themes/index.js +++ b/src/cm/themes/index.js @@ -1,5 +1,11 @@ +import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorState } from "@codemirror/state"; -import { oneDark } from "@codemirror/theme-one-dark"; +import { + oneDark, + oneDarkHighlightStyle, + oneDarkTheme, +} from "@codemirror/theme-one-dark"; +import { tags as t } from "@lezer/highlight"; import aura, { config as auraConfig } from "./aura"; import ayuDark, { config as ayuDarkConfig } from "./ayuDark"; import { @@ -47,6 +53,25 @@ const oneDarkConfig = { invalid: "#ff6b6b", }; +// One Dark with method calls, method definitions and function declaration +// names in the function colour, matching the other built-in themes. +const oneDarkExtensions = [ + oneDarkTheme, + syntaxHighlighting( + HighlightStyle.define([ + ...oneDarkHighlightStyle.specs, + { + tag: [ + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + ], + color: oneDarkConfig.function, + }, + ]), + ), +]; + const themes = new Map(); const warnedInvalidThemes = new Set(); @@ -177,7 +202,13 @@ export function removeTheme(id) { themes.delete(String(id).toLowerCase()); } -addBuiltInTheme("one_dark", "One Dark", true, () => [oneDark], oneDarkConfig); +addBuiltInTheme( + "one_dark", + "One Dark", + true, + () => oneDarkExtensions, + oneDarkConfig, +); addBuiltInTheme( auraConfig.name, "Aura", diff --git a/src/cm/themes/materialPalenight.js b/src/cm/themes/materialPalenight.js index 2063e88..8247e4f 100644 --- a/src/cm/themes/materialPalenight.js +++ b/src/cm/themes/materialPalenight.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; export const config = { name: "materialPalenight", @@ -19,6 +20,7 @@ export const config = { variable: "#f07178", parameter: "#eeffff", function: "#82aaff", + memberProperty: "#a6accd", string: "#c3e88d", constant: "#f78c6c", type: "#decb6b", @@ -91,6 +93,10 @@ export const materialPalenightTheme = EditorView.theme( ); export const materialPalenightHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], @@ -101,7 +107,16 @@ export const materialPalenightHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/monokai.js b/src/cm/themes/monokai.js index e43e296..82365a6 100644 --- a/src/cm/themes/monokai.js +++ b/src/cm/themes/monokai.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; export const config = { name: "monokai", @@ -20,6 +21,7 @@ export const config = { variable: "#FD971F", parameter: "#FD971F", function: "#66D9EF", + memberProperty: "#f8f8f2", string: "#E6DB74", constant: "#AE81FF", type: "#66D9EF", @@ -113,6 +115,10 @@ export const monokaiTheme = EditorView.theme( ); export const monokaiHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], @@ -123,7 +129,16 @@ export const monokaiHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/noctisLilac.js b/src/cm/themes/noctisLilac.js index c324792..c681f69 100644 --- a/src/cm/themes/noctisLilac.js +++ b/src/cm/themes/noctisLilac.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; export const config = { name: "noctisLilac", @@ -20,6 +21,7 @@ export const config = { variable: "#0c006b", parameter: "#0c006b", function: "#0095a8", + memberProperty: "#fa8900", string: "#00b368", constant: "#5842ff", type: "#b3694d", @@ -108,6 +110,10 @@ export const noctisLilacTheme = EditorView.theme( ); export const noctisLilacHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.comment, color: config.comment }, { tag: t.keyword, color: config.keyword, fontWeight: "bold" }, { tag: [t.definitionKeyword, t.modifier], color: config.keyword }, @@ -117,7 +123,13 @@ export const noctisLilacHighlightStyle = HighlightStyle.define([ }, { tag: [t.number, t.bool, t.null, t.special(t.brace)], color: config.number }, { - tag: [t.definition(t.propertyName), t.function(t.variableName)], + tag: [ + t.definition(t.propertyName), + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + ], color: config.function, }, { tag: t.typeName, color: config.type }, diff --git a/src/cm/themes/nord.js b/src/cm/themes/nord.js index 60e85cb..97cf7c7 100644 --- a/src/cm/themes/nord.js +++ b/src/cm/themes/nord.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; export const config = { name: "nord", @@ -19,6 +20,7 @@ export const config = { variable: "#d8dee9", parameter: "#d8dee9", function: "#8fbcbb", + memberProperty: "#d8dee9", string: "#a3be8c", constant: "#b48ead", type: "#88c0d0", @@ -91,6 +93,10 @@ export const nordTheme = EditorView.theme( ); export const nordHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], @@ -101,7 +107,16 @@ export const nordHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/solarizedDark.js b/src/cm/themes/solarizedDark.js index 088a200..168c8b1 100644 --- a/src/cm/themes/solarizedDark.js +++ b/src/cm/themes/solarizedDark.js @@ -118,7 +118,16 @@ export const solarizedDarkHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/solarizedLight.js b/src/cm/themes/solarizedLight.js index 01dd678..742918a 100644 --- a/src/cm/themes/solarizedLight.js +++ b/src/cm/themes/solarizedLight.js @@ -118,7 +118,16 @@ export const solarizedLightHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/tokyoNight.js b/src/cm/themes/tokyoNight.js index 3ec2548..cee0c90 100644 --- a/src/cm/themes/tokyoNight.js +++ b/src/cm/themes/tokyoNight.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView, lineNumbers } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; export const config = { name: "tokyoNight", @@ -21,6 +22,7 @@ export const config = { variable: "#c0caf5", parameter: "#cfc9c2", function: "#7aa2f7", + memberProperty: "#7dcfff", string: "#9ece6a", constant: "#ff9e64", type: "#2ac3de", @@ -109,6 +111,10 @@ export const tokyoNightTheme = EditorView.theme( ); export const tokyoNightHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], @@ -119,7 +125,16 @@ export const tokyoNightHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/tokyoNightDay.js b/src/cm/themes/tokyoNightDay.js index 75a9fcf..dd50d9c 100644 --- a/src/cm/themes/tokyoNightDay.js +++ b/src/cm/themes/tokyoNightDay.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView, lineNumbers } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; export const config = { name: "tokyoNightDay", @@ -20,6 +21,7 @@ export const config = { variable: "#3760bf", parameter: "#3760bf", function: "#2e7de9", + memberProperty: "#007197", string: "#587539", constant: "#9854f1", type: "#07879d", @@ -106,6 +108,10 @@ export const tokyoNightDayTheme = EditorView.theme( ); export const tokyoNightDayHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], @@ -116,7 +122,16 @@ export const tokyoNightDayHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/tomorrowNight.js b/src/cm/themes/tomorrowNight.js index 0ff0d39..e4102b8 100644 --- a/src/cm/themes/tomorrowNight.js +++ b/src/cm/themes/tomorrowNight.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; // Palette adapted from Tomorrow Night (Chris Kempson) export const config = { @@ -22,6 +23,7 @@ export const config = { variable: "#CC6666", parameter: "#DE935F", function: "#81A2BE", + memberProperty: "#C5C8C6", string: "#B5BD68", constant: "#DE935F", type: "#F0C674", @@ -114,6 +116,10 @@ export const tomorrowNightTheme = EditorView.theme( ); export const tomorrowNightHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], @@ -124,7 +130,16 @@ export const tomorrowNightHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/tomorrowNightBright.js b/src/cm/themes/tomorrowNightBright.js index 26bfacf..68055c5 100644 --- a/src/cm/themes/tomorrowNightBright.js +++ b/src/cm/themes/tomorrowNightBright.js @@ -1,6 +1,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { EditorView } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; +import { memberProperty } from "../highlightTags"; // Palette adapted from Tomorrow Night Bright (Chris Kempson) export const config = { @@ -22,6 +23,7 @@ export const config = { variable: "#D54E53", parameter: "#E78C45", function: "#7AA6DA", + memberProperty: "#DEDEDE", string: "#B9CA4A", constant: "#E78C45", type: "#E7C547", @@ -114,6 +116,10 @@ export const tomorrowNightBrightTheme = EditorView.theme( ); export const tomorrowNightBrightHighlightStyle = HighlightStyle.define([ + { + tag: [memberProperty, t.definition(memberProperty)], + color: config.memberProperty, + }, { tag: t.keyword, color: config.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], @@ -124,7 +130,16 @@ export const tomorrowNightBrightHighlightStyle = HighlightStyle.define([ tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: config.string, }, - { tag: [t.function(t.variableName), t.labelName], color: config.function }, + { + tag: [ + t.function(t.variableName), + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.labelName, + ], + color: config.function, + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: config.constant, diff --git a/src/cm/themes/vscodeDark.js b/src/cm/themes/vscodeDark.js index 8790b32..a6107d9 100644 --- a/src/cm/themes/vscodeDark.js +++ b/src/cm/themes/vscodeDark.js @@ -161,7 +161,12 @@ export const vscodeDarkHighlightStyle = HighlightStyle.define([ { tag: [t.typeName, t.className], color: config.type }, { tag: [t.tagName, t.standard(t.tagName)], color: config.keyword }, { - tag: [t.function(t.variableName), t.function(t.propertyName)], + tag: [ + t.function(t.variableName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + t.function(t.propertyName), + ], color: config.function, }, { diff --git a/src/index.d.ts b/src/index.d.ts index 75aa19d..97a8a0a 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -9,186 +9,186 @@ declare const KEYBINDING_FILE: string; declare const ANDROID_SDK_INT: number; declare const DOES_SUPPORT_THEME: boolean; declare const acode: { - webview: AcodeWebViewAPI; - require(module: "fullscreen"): AcodeFullscreenAPI; - require(module: "orientation"): AcodeOrientationAPI; - require(module: string): unknown; - [key: string]: unknown; + webview: AcodeWebViewAPI; + require(module: "fullscreen"): AcodeFullscreenAPI; + require(module: "orientation"): AcodeOrientationAPI; + require(module: string): unknown; + [key: string]: unknown; }; interface Window { - ASSETS_DIRECTORY: string; - DATA_STORAGE: string; - CACHE_STORAGE: string; - PLUGIN_DIR: string; - KEYBINDING_FILE: string; - ANDROID_SDK_INT: number; - DOES_SUPPORT_THEME: boolean; - acode: object; + ASSETS_DIRECTORY: string; + DATA_STORAGE: string; + CACHE_STORAGE: string; + PLUGIN_DIR: string; + KEYBINDING_FILE: string; + ANDROID_SDK_INT: number; + DOES_SUPPORT_THEME: boolean; + acode: object; } interface String { - /** - * Capitalize the first letter of a string - */ - capitalize(): string; - /** - * Generate a hash from a string - */ - hashCode(): string; + /** + * Capitalize the first letter of a string + */ + capitalize(): string; + /** + * Generate a hash from a string + */ + hashCode(): string; } type ExecutorCallback = ( - type: "stdout" | "stderr" | "exit", - data: string, + type: "stdout" | "stderr" | "exit", + data: string, ) => void; interface Executor { - execute: (command: string, alpine: boolean) => Promise; - start: ( - command: string, - callback: ExecutorCallback, - alpine: boolean, - ) => Promise; - write: (uuid: string, input: string) => Promise; - stop: (uuid: string) => Promise; - isRunning: (uuid: string) => Promise; - listProcesses: () => Promise; - /** Move the executor service to the foreground (shows notification) */ - moveToForeground: () => Promise; - /** Move the executor service to the background (hides notification) */ - moveToBackground: () => Promise; - /** Stop the executor service completely */ - stopService: () => Promise; - /** - * Background executor - */ - BackgroundExecutor: Executor; + execute: (command: string, alpine: boolean) => Promise; + start: ( + command: string, + callback: ExecutorCallback, + alpine: boolean, + ) => Promise; + write: (uuid: string, input: string) => Promise; + stop: (uuid: string) => Promise; + isRunning: (uuid: string) => Promise; + listProcesses: () => Promise; + /** Move the executor service to the foreground (shows notification) */ + moveToForeground: () => Promise; + /** Move the executor service to the background (hides notification) */ + moveToBackground: () => Promise; + /** Stop the executor service completely */ + stopService: () => Promise; + /** + * Background executor + */ + BackgroundExecutor: Executor; } interface ExecutorProcess { - id: string; - pid: number; - command: string; - alpine: boolean; - startedAt: number; - background: boolean; + id: string; + pid: number; + command: string; + alpine: boolean; + startedAt: number; + background: boolean; } declare const Executor: Executor | undefined; interface Window { - Executor?: Executor; - editorManager?: EditorManager; + Executor?: Executor; + editorManager?: EditorManager; } interface EditorManager { - editor?: import("@codemirror/view").EditorView; - isCodeMirror?: boolean; - activeFile?: AcodeFile; - getLspMetadata?: (file: AcodeFile) => LspFileMetadata | null; + editor?: import("@codemirror/view").EditorView; + isCodeMirror?: boolean; + activeFile?: AcodeFile; + getLspMetadata?: (file: AcodeFile) => LspFileMetadata | null; } interface LspFileMetadata { - uri: string; - languageId?: string; - languageName?: string; - view?: import("@codemirror/view").EditorView; - file?: AcodeFile; - rootUri?: string; + uri: string; + languageId?: string; + languageName?: string; + view?: import("@codemirror/view").EditorView; + file?: AcodeFile; + rootUri?: string; } /** * Acode file object */ interface AcodeFile { - uri?: string; - name?: string; - session?: unknown; - cacheFile?: string; - [key: string]: unknown; + uri?: string; + name?: string; + session?: unknown; + cacheFile?: string; + [key: string]: unknown; } // Extend globalThis with Executor declare global { - var Executor: Executor | undefined; + var Executor: Executor | undefined; } interface WebViewOptions { - /** Title applied to the hosting activity in fullscreen mode. */ - title?: string; - /** - * "fullscreen" displays the WebView in its own activity, "hidden" runs it - * headless (never displayed). Defaults to "hidden". - */ - mode?: "fullscreen" | "hidden"; - /** - * Allow in-WebView navigation. Defaults to true. Only http(s) targets - * ever load; other schemes are always blocked for isolation. - */ - allowNavigation?: boolean; - /** - * Ask the user with a confirmation dialog before downloading files via - * the system DownloadManager. Defaults to false. - */ - allowDownloads?: boolean; - /** - * Show immediately after creation. Defaults to true. Only meaningful for - * fullscreen mode: when false, the activity launch is deferred until - * show() is called. - */ - visible?: boolean; + /** Title applied to the hosting activity in fullscreen mode. */ + title?: string; + /** + * "fullscreen" displays the WebView in its own activity, "hidden" runs it + * headless (never displayed). Defaults to "hidden". + */ + mode?: "fullscreen" | "hidden"; + /** + * Allow in-WebView navigation. Defaults to true. Only http(s) targets + * ever load; other schemes are always blocked for isolation. + */ + allowNavigation?: boolean; + /** + * Ask the user with a confirmation dialog before downloading files via + * the system DownloadManager. Defaults to false. + */ + allowDownloads?: boolean; + /** + * Show immediately after creation. Defaults to true. Only meaningful for + * fullscreen mode: when false, the activity launch is deferred until + * show() is called. + */ + visible?: boolean; } interface AcodeWebView { - readonly id: string; - readonly options: WebViewOptions; - /** Load an http(s) URL. Other schemes are rejected. */ - loadURL(url: string): Promise; - loadHTML(html: string): Promise; - evaluate(js: string): Promise; - onMessage(callback: (message: unknown) => void): void; - offMessage(callback: (message: unknown) => void): void; - /** - * Subscribe to lifecycle events: "pageFinished", "titleChanged" and - * "closed" (fullscreen closed by the user or the system). After "closed" - * the instance is destroyed and cannot be reused. - */ - on(event: string, callback: (event: string, data?: unknown) => void): void; - off(event: string, callback: (event: string, data?: unknown) => void): void; - postMessage(message: unknown): Promise; - /** - * Show the WebView. Only fullscreen instances can be shown; rejects for - * "hidden" mode. - */ - show(): Promise; - /** - * Hide the WebView. Fullscreen instances are moved to the background; - * show() brings the same WebView back with its page state intact. - * No-op for "hidden" mode. - */ - hide(): Promise; - reload(): Promise; - destroy(): Promise; + readonly id: string; + readonly options: WebViewOptions; + /** Load an http(s) URL. Other schemes are rejected. */ + loadURL(url: string): Promise; + loadHTML(html: string): Promise; + evaluate(js: string): Promise; + onMessage(callback: (message: unknown) => void): void; + offMessage(callback: (message: unknown) => void): void; + /** + * Subscribe to lifecycle events: "pageFinished", "titleChanged" and + * "closed" (fullscreen closed by the user or the system). After "closed" + * the instance is destroyed and cannot be reused. + */ + on(event: string, callback: (event: string, data?: unknown) => void): void; + off(event: string, callback: (event: string, data?: unknown) => void): void; + postMessage(message: unknown): Promise; + /** + * Show the WebView. Only fullscreen instances can be shown; rejects for + * "hidden" mode. + */ + show(): Promise; + /** + * Hide the WebView. Fullscreen instances are moved to the background; + * show() brings the same WebView back with its page state intact. + * No-op for "hidden" mode. + */ + hide(): Promise; + reload(): Promise; + destroy(): Promise; } interface AcodeWebViewAPI { - create(options?: WebViewOptions): Promise; + create(options?: WebViewOptions): Promise; } interface AcodeFullscreenAPI { - /** - * Claim Back for the current browser fullscreen owner, or release with null. - * The callback must explicitly exit fullscreen when desired. - * Resolves when the native request is accepted. - */ - setBackHandler(callback: (() => void | Promise) | null): Promise; + /** + * Claim Back for the current browser fullscreen owner, or release with null. + * The callback must explicitly exit fullscreen when desired. + * Resolves when the native request is accepted. + */ + setBackHandler(callback: (() => void | Promise) | null): Promise; } /** Fullscreen-scoped orientation requests resolve on native acceptance. */ interface AcodeOrientationAPI { - /** Requires a foreground browser fullscreen session. */ - lock(mode: "landscape" | "portrait"): Promise; - /** Restores the previous orientation policy, if overridden. */ - unlock(): Promise; + /** Requires a foreground browser fullscreen session. */ + lock(mode: "landscape" | "portrait"): Promise; + /** Restores the previous orientation policy, if overridden. */ + unlock(): Promise; } diff --git a/src/lang/index.d.ts b/src/lang/index.d.ts index 4fcf175..0a579b1 100644 --- a/src/lang/index.d.ts +++ b/src/lang/index.d.ts @@ -2,897 +2,897 @@ // Do not edit manually declare type LangStrings = { - "lang": string; - "about": string; - "active files": string; - "alert": string; - "app theme": string; - "autocorrect": string; - "autosave": string; - "cancel": string; - "change language": string; - "choose color": string; - "clear": string; - "close app": string; - "commit message": string; - "console": string; - "conflict error": string; - "copy": string; - "create folder error": string; - "cut": string; - "delete": string; - "dependencies": string; - "delay": string; - "editor settings": string; - "editor theme": string; - "enter file name": string; - "enter folder name": string; - "empty folder message": string; - "enter line number": string; - "error": string; - "failed": string; - "file already exists": string; - "file already exists force": string; - "file already exists force named": string; - "file changed": string; - "file deleted": string; - "file is not supported": string; - "file not supported": string; - "file too large": string; - "file renamed": string; - "file saved": string; - "folder added": string; - "folder already added": string; - "font size": string; - "goto": string; - "icons definition": string; - "info": string; - "invalid value": string; - "language changed": string; - "linting": string; - "logout": string; - "loading": string; - "my profile": string; - "new file": string; - "new folder": string; - "no": string; - "no editor message": string; - "not set": string; - "unsaved files close app": string; - "notice": string; - "open file": string; - "open files and folders": string; - "open folder": string; - "open recent": string; - "ok": string; - "overwrite": string; - "paste": string; - "preview mode": string; - "read only file": string; - "reload": string; - "rename": string; - "new name": string; - "enter new name": string; - "code actions": string; - "replace": string; - "required": string; - "run your web app": string; - "save": string; - "saving": string; - "save as": string; - "save file to run": string; - "search": string; - "see logs and errors": string; - "select folder": string; - "settings": string; - "settings saved": string; - "show line numbers": string; - "show hidden files": string; - "show share button": string; - "show spaces": string; - "soft tab": string; - "sort by name": string; - "success": string; - "tab size": string; - "text wrap": string; - "wrapped line indent": string; - "wrap-indent-same": string; - "wrap-indent-indent": string; - "wrap-indent-deep": string; - "settings-info-editor-wrapping-indent": string; - "theme": string; - "title-listfiles": string; - "ui zoom": string; - "unable to delete file": string; - "document plugin required": string; - "shared files unavailable": string; - "invalid shared file": string; - "unable to open file": string; - "unable to open folder": string; - "unable to save file": string; - "unable to rename": string; - "unsaved file": string; - "warning": string; - "use emmet": string; - "use quick tools": string; - "yes": string; - "encoding": string; - "syntax highlighting": string; - "read only": string; - "select all": string; - "select branch": string; - "create new branch": string; - "use branch": string; - "new branch": string; - "branch": string; - "key bindings": string; - "edit": string; - "reset": string; - "color": string; - "select word": string; - "quick tools": string; - "select": string; - "editor font": string; - "new project": string; - "format": string; - "project name": string; - "unsupported device": string; - "vibrate on tap": string; - "copy command is not supported by ftp.": string; - "support title": string; - "fullscreen": string; - "animation": string; - "backup": string; - "restore": string; - "backup successful": string; - "invalid backup file": string; - "add path": string; - "live autocompletion": string; - "local word completion": string; - "language package completion": string; - "recommend extensions": string; - "auto close tags": string; - "auto rename tags": string; - "file properties": string; - "path": string; - "type": string; - "word count": string; - "line count": string; - "last modified": string; - "size": string; - "share": string; - "show print margin": string; - "login": string; - "scrollbar size": string; - "scrollbar height": string; - "cursor controller size": string; - "full": string; - "scroll past end": string; - "settings-info-scroll-past-end": string; - "none": string; - "small": string; - "large": string; - "confirm on exit": string; - "show console": string; - "image": string; - "insert file": string; - "insert color": string; - "powersave mode warning": string; - "exit": string; - "custom": string; - "reset warning": string; - "theme type": string; - "light": string; - "dark": string; - "file browser": string; - "icon pack": string; - "settings-info-icon-pack": string; - "unavailable": string; - "operation not permitted": string; - "no such file or directory": string; - "input/output error": string; - "permission denied": string; - "bad address": string; - "file exists": string; - "not a directory": string; - "is a directory": string; - "invalid argument": string; - "too many open files in system": string; - "too many open files": string; - "text file busy": string; - "no space left on device": string; - "read-only file system": string; - "file name too long": string; - "too many users": string; - "connection timed out": string; - "connection refused": string; - "owner died": string; - "an error occurred": string; - "add ftp": string; - "add sftp": string; - "save file": string; - "save file as": string; - "files": string; - "help": string; - "file has been deleted": string; - "feature not available": string; - "feature available in paid version": string; - "deleted file": string; - "line height": string; - "preview info": string; - "manage all files": string; - "close file": string; - "reset connections": string; - "check file changes": string; - "open in browser": string; - "desktop mode": string; - "toggle console": string; - "new line mode": string; - "add a storage": string; - "rate acode": string; - "support": string; - "downloading file": string; - "downloading...": string; - "folder name": string; - "keyboard mode": string; - "normal": string; - "app settings": string; - "disable in-app-browser caching": string; - "copied to clipboard": string; - "clipboard not available": string; - "remember opened files": string; - "remember opened folders": string; - "no suggestions": string; - "no suggestions aggressive": string; - "install": string; - "installing": string; - "plugins": string; - "recently used": string; - "update": string; - "uninstall": string; - "download acode pro": string; - "loading plugins": string; - "faqs": string; - "feedback": string; - "header": string; - "sidebar": string; - "inapp": string; - "browser": string; - "diagonal scrolling": string; - "reverse scrolling": string; - "formatter": string; - "format on save": string; - "remove ads": string; - "fast": string; - "slow": string; - "scroll settings": string; - "scroll speed": string; - "loading...": string; - "no plugins found": string; - "name": string; - "username": string; - "optional": string; - "hostname": string; - "password": string; - "security type": string; - "connection mode": string; - "port": string; - "key file": string; - "select key file": string; - "passphrase": string; - "connecting...": string; - "type filename": string; - "unable to load files": string; - "preview port": string; - "find file": string; - "system": string; - "please select a formatter": string; - "case sensitive": string; - "regular expression": string; - "whole word": string; - "edit with": string; - "open with": string; - "no app found to handle this file": string; - "restore default settings": string; - "server port": string; - "preview settings": string; - "preview settings note": string; - "backup/restore note": string; - "host": string; - "retry ftp/sftp when fail": string; - "more": string; - "thank you :)": string; - "purchase pending": string; - "cancelled": string; - "local": string; - "remote": string; - "show console toggler": string; - "binary file": string; - "relative line numbers": string; - "elastic tabstops": string; - "line based rtl switching": string; - "hard wrap": string; - "spellcheck": string; - "wrap method": string; - "use textarea for ime": string; - "invalid plugin": string; - "type command": string; - "plugin": string; - "quicktools trigger mode": string; - "print margin": string; - "touch move threshold": string; - "info-retryremotefsafterfail": string; - "info-fullscreen": string; - "info-checkfiles": string; - "info-console": string; - "info-keyboardmode": string; - "info-rememberfiles": string; - "info-rememberfolders": string; - "info-floatingbutton": string; - "info-openfilelistpos": string; - "info-touchmovethreshold": string; - "info-scroll-settings": string; - "info-animation": string; - "info-quicktoolstriggermode": string; - "info-checkForAppUpdates": string; - "info-quickTools": string; - "info-showHiddenFiles": string; - "info-listFiles": string; - "info-all_file_access": string; - "info-fontSize": string; - "info-fontFamily": string; - "info-theme": string; - "info-cursorStyle": string; - "info-cursorInactiveStyle": string; - "info-fontWeight": string; - "info-cursorBlink": string; - "info-scrollback": string; - "info-terminal-show-scrollbar": string; - "info-tabStopWidth": string; - "info-letterSpacing": string; - "info-imageSupport": string; - "info-fontLigatures": string; - "info-confirmTabClose": string; - "info-backup": string; - "info-restore": string; - "info-uninstall": string; - "owned": string; - "api_error": string; - "installed": string; - "all": string; - "medium": string; - "refund": string; - "product not available": string; - "no-product-info": string; - "close": string; - "explore": string; - "key bindings updated": string; - "search in files": string; - "exclude files": string; - "include files": string; - "search result": string; - "invalid regex": string; - "bottom": string; - "save all": string; - "close all": string; - "unsaved files warning": string; - "save all warning": string; - "save all changes warning": string; - "close all warning": string; - "refresh": string; - "shortcut buttons": string; - "no result": string; - "searching...": string; - "quicktools:ctrl-key": string; - "quicktools:tab-key": string; - "quicktools:shift-key": string; - "quicktools:undo": string; - "quicktools:redo": string; - "quicktools:search": string; - "quicktools:save": string; - "quicktools:esc-key": string; - "quicktools:curlybracket": string; - "quicktools:squarebracket": string; - "quicktools:parentheses": string; - "quicktools:anglebracket": string; - "quicktools:left-arrow-key": string; - "quicktools:right-arrow-key": string; - "quicktools:up-arrow-key": string; - "quicktools:down-arrow-key": string; - "quicktools:moveline-up": string; - "quicktools:moveline-down": string; - "quicktools:copyline-up": string; - "quicktools:copyline-down": string; - "quicktools:semicolon": string; - "quicktools:quotation": string; - "quicktools:and": string; - "quicktools:bar": string; - "quicktools:equal": string; - "quicktools:slash": string; - "quicktools:exclamation": string; - "quicktools:alt-key": string; - "quicktools:meta-key": string; - "quicktools:search-prev": string; - "quicktools:search-next": string; - "quicktools:search-settings": string; - "quicktools:search-replace": string; - "quicktools:search-replace-all": string; - "info-quicktoolssettings": string; - "info-excludefolders": string; - "missed files": string; - "remove": string; - "quicktools:command-palette": string; - "default file encoding": string; - "remove entry": string; - "delete entry": string; - "change encoding": string; - "reopen file": string; - "plugin min version": string; - "plugin min version required": string; - "color preview": string; - "confirm": string; - "problems": string; - "show side buttons": string; - "bug_report": string; - "verified publisher": string; - "most_downloaded": string; - "newly_added": string; - "top_rated": string; - "rename not supported": string; - "compress": string; - "copy uri": string; - "copying items": string; - "cannot paste folder into itself": string; - "folder already exists": string; - "delete entries": string; - "deleting items": string; - "import project zip": string; - "changelog": string; - "notifications": string; - "no_unread_notifications": string; - "should_use_current_file_for_preview": string; - "fade fold widgets": string; - "quicktools:home-key": string; - "quicktools:end-key": string; - "quicktools:pageup-key": string; - "quicktools:pagedown-key": string; - "quicktools:delete-key": string; - "quicktools:tilde": string; - "quicktools:backtick": string; - "quicktools:hash": string; - "quicktools:dollar": string; - "quicktools:modulo": string; - "quicktools:caret": string; - "plugin_enabled": string; - "plugin_disabled": string; - "enable_plugin": string; - "disable_plugin": string; - "open_source": string; - "terminal settings": string; - "font ligatures": string; - "letter spacing": string; - "terminal:tab stop width": string; - "terminal:scrollback": string; - "terminal:show scrollbar": string; - "terminal:cursor blink": string; - "terminal:font weight": string; - "terminal:cursor inactive style": string; - "terminal:cursor style": string; - "terminal:font family": string; - "terminal:convert eol": string; - "terminal:confirm tab close": string; - "terminal:image support": string; - "terminal": string; - "running processes": string; - "terminal not installed prompt": string; - "terminal first launch prompt": string; - "don't ask again": string; - "allFileAccess": string; - "fonts": string; - "sponsor": string; - "downloads": string; - "reviews": string; - "overview": string; - "contributors": string; - "quicktools:hyphen": string; - "check for app updates": string; - "prompt update check consent message": string; - "keywords": string; - "author": string; - "filtered by": string; - "clean install state": string; - "backup created": string; - "restore completed": string; - "restore will include": string; - "restore warning": string; - "reload to apply": string; - "reload app": string; - "preparing backup": string; - "collecting settings": string; - "collecting key bindings": string; - "collecting plugins": string; - "creating backup": string; - "validating backup": string; - "restoring key bindings": string; - "restoring plugins": string; - "restoring settings": string; - "legacy backup warning": string; - "checksum mismatch": string; - "plugin not found": string; - "paid plugin skipped": string; - "source not found": string; - "restored": string; - "skipped": string; - "backup not valid object": string; - "backup no data": string; - "backup legacy warning": string; - "backup missing metadata": string; - "backup checksum mismatch": string; - "backup checksum verify failed": string; - "backup invalid settings": string; - "backup invalid keybindings": string; - "backup invalid plugins": string; - "issues found": string; - "error details": string; - "active tools": string; - "recent": string; - "command palette": string; - "change theme": string; - "documentation": string; - "open in terminal": string; - "developer mode": string; - "info-developermode": string; - "developer mode enabled": string; - "developer mode disabled": string; - "copy relative path": string; - "available tools": string; - "shortcut request sent": string; - "add to home screen": string; - "pin shortcuts not supported": string; - "save file before home shortcut": string; - "terminal_required_message_for_lsp": string; - "shift click selection": string; - "earn ad-free time": string; - "indent guides": string; - "language servers": string; - "lint gutter": string; - "rainbow brackets": string; - "lsp-add-custom-server": string; - "lsp-binary-args": string; - "lsp-binary-command": string; - "lsp-binary-path-optional": string; - "lsp-check-command-optional": string; - "lsp-checking-installation-status": string; - "lsp-configured": string; - "lsp-custom-server-added": string; - "lsp-default": string; - "lsp-details-line": string; - "lsp-edit-initialization-options": string; - "lsp-empty": string; - "lsp-enabled": string; - "lsp-error-add-server-failed": string; - "lsp-error-args-must-be-array": string; - "lsp-error-binary-command-required": string; - "lsp-error-language-id-required": string; - "lsp-error-package-required": string; - "lsp-error-server-id-required": string; - "lsp-feature-completion": string; - "lsp-feature-completion-info": string; - "lsp-feature-diagnostics": string; - "lsp-feature-diagnostics-info": string; - "lsp-feature-formatting": string; - "lsp-feature-formatting-info": string; - "lsp-feature-hover": string; - "lsp-feature-hover-info": string; - "lsp-feature-inlay-hints": string; - "lsp-feature-inlay-hints-info": string; - "lsp-feature-signature": string; - "lsp-feature-signature-info": string; - "lsp-feature-state-toast": string; - "lsp-initialization-options": string; - "lsp-initialization-options-json": string; - "lsp-initialization-options-updated": string; - "lsp-install-command": string; - "lsp-install-command-unavailable": string; - "lsp-install-info-check-failed": string; - "lsp-install-info-missing": string; - "lsp-install-info-ready": string; - "lsp-install-info-unknown": string; - "lsp-install-info-version-available": string; - "lsp-install-notification": string; - "lsp-install-method-apk": string; - "lsp-install-method-cargo": string; - "lsp-install-method-manual": string; - "lsp-install-method-npm": string; - "lsp-install-method-pip": string; - "lsp-install-method-shell": string; - "lsp-install-method-title": string; - "lsp-install-repair": string; - "lsp-installation-status": string; - "lsp-installed": string; - "lsp-invalid-timeout": string; - "lsp-language-ids": string; - "lsp-packages-prompt": string; - "lsp-remove-custom-server": string; - "lsp-remove-custom-server-confirm": string; - "lsp-remove-installed-files": string; - "lsp-custom-server-removed": string; - "lsp-server-disabled-toast": string; - "lsp-server-enabled-toast": string; - "lsp-server-id": string; - "lsp-server-label": string; - "lsp-server-not-found": string; - "lsp-server-uninstalled": string; - "lsp-startup-timeout": string; - "lsp-startup-timeout-ms": string; - "lsp-startup-timeout-set": string; - "lsp-state-disabled": string; - "lsp-state-enabled": string; - "lsp-status": string; - "lsp-status-check-failed": string; - "lsp-status-installed": string; - "lsp-status-installed-version": string; - "lsp-status-line": string; - "lsp-status-not-installed": string; - "lsp-status-unknown": string; - "lsp-timeout-ms": string; - "lsp-transport-method-stdio": string; - "lsp-transport-method-websocket": string; - "lsp-uninstall-command-unavailable": string; - "lsp-uninstall-notification": string; - "lsp-uninstall-server": string; - "lsp-update-command-optional": string; - "lsp-update-command-unavailable": string; - "lsp-update-server": string; - "lsp-version-line": string; - "lsp-websocket-url": string; - "lsp-websocket-server-managed-externally": string; - "lsp-error-websocket-url-invalid": string; - "lsp-error-websocket-url-required": string; - "lsp-view-initialization-options": string; - "settings-category-about-acode": string; - "settings-category-discover-apps": string; - "settings-category-advanced": string; - "settings-category-assistance": string; - "settings-category-core": string; - "settings-category-cursor": string; - "settings-category-cursor-selection": string; - "settings-category-custom-servers": string; - "settings-category-customization": string; - "settings-category-display": string; - "settings-category-editing": string; - "settings-category-features": string; - "settings-category-files-sessions": string; - "settings-category-fonts": string; - "settings-category-general": string; - "settings-category-guides-indicators": string; - "settings-category-installation": string; - "settings-category-interface": string; - "settings-category-maintenance": string; - "settings-category-permissions": string; - "settings-category-preview": string; - "settings-category-scrolling": string; - "settings-category-server": string; - "settings-category-servers": string; - "settings-category-session": string; - "settings-category-support-acode": string; - "settings-category-text-layout": string; - "settings-category-tools": string; - "settings-info-app-animation": string; - "settings-info-app-check-files": string; - "settings-info-app-clean-install-state": string; - "settings-info-app-confirm-on-exit": string; - "settings-info-app-console": string; - "settings-info-app-default-file-encoding": string; - "settings-info-app-exclude-folders": string; - "apply exclusions when copying": string; - "settings-info-app-use-file-operation-exclusions": string; - "settings-info-app-floating-button": string; - "settings-info-app-font-manager": string; - "settings-info-app-fullscreen": string; - "settings-info-app-keybindings": string; - "settings-info-app-keyboard-mode": string; - "settings-info-app-language": string; - "settings-info-app-open-file-list-position": string; - "settings-info-app-quick-tools-settings": string; - "settings-info-app-quick-tools-trigger-mode": string; - "settings-info-app-remember-files": string; - "settings-info-app-remember-folders": string; - "settings-info-app-retry-remote-fs": string; - "settings-info-app-side-buttons": string; - "settings-info-app-sponsor-sidebar": string; - "settings-info-app-touch-move-threshold": string; - "settings-info-app-ui-zoom": string; - "settings-info-app-vibrate-on-tap": string; - "settings-info-editor-autosave": string; - "settings-info-editor-color-preview": string; - "settings-info-editor-fade-fold-widgets": string; - "settings-info-editor-font-family": string; - "settings-info-editor-font-size": string; - "settings-info-editor-format-on-save": string; - "settings-info-editor-hard-wrap": string; - "settings-info-editor-indent-guides": string; - "settings-info-editor-line-height": string; - "settings-info-editor-line-numbers": string; - "settings-info-editor-lint-gutter": string; - "settings-info-editor-live-autocomplete": string; - "settings-info-editor-local-word-completion": string; - "settings-info-editor-language-completion": string; - "settings-info-editor-recommend-extensions": string; - "extension recommendation title": string; - "extension recommendation message": string; - "extension request title": string; - "extension request message": string; - "search plugins": string; - "request plugin": string; - "settings-info-editor-auto-close-tags": string; - "settings-info-editor-auto-rename-tags": string; - "settings-info-editor-rainbow-brackets": string; - "settings-info-editor-relative-line-numbers": string; - "settings-info-editor-rtl-text": string; - "settings-info-editor-scroll-settings": string; - "settings-info-editor-shift-click-selection": string; - "settings-info-editor-show-share-button": string; - "settings-info-editor-show-spaces": string; - "settings-info-editor-soft-tab": string; - "settings-info-editor-tab-size": string; - "settings-info-editor-teardrop-size": string; - "settings-info-editor-text-wrap": string; - "settings-info-lsp-add-custom-server": string; - "settings-info-lsp-allow-non-terminal-workspace": string; - "settings-info-lsp-edit-init-options": string; - "settings-info-lsp-install-server": string; - "settings-info-lsp-remove-custom-server": string; - "settings-info-lsp-server-enabled": string; - "settings-info-lsp-startup-timeout": string; - "settings-info-lsp-uninstall-server": string; - "settings-info-lsp-update-server": string; - "settings-info-lsp-view-init-options": string; - "settings-info-main-ad-rewards": string; - "settings-info-main-app-settings": string; - "settings-info-main-backup-restore": string; - "settings-info-main-changelog": string; - "settings-info-main-edit-settings": string; - "settings-info-main-editor-settings": string; - "settings-info-main-formatter": string; - "settings-info-main-lsp-settings": string; - "settings-info-main-plugins": string; - "settings-info-main-preview-settings": string; - "settings-info-main-rateapp": string; - "settings-info-main-remove-ads": string; - "settings-info-main-reset": string; - "settings-info-main-sponsors": string; - "settings-info-main-terminal-settings": string; - "settings-info-main-theme": string; - "settings-info-preview-disable-cache": string; - "settings-info-preview-host": string; - "settings-info-preview-mode": string; - "settings-info-preview-preview-port": string; - "settings-info-preview-server-port": string; - "settings-info-preview-show-console-toggler": string; - "settings-info-preview-use-current-file": string; - "settings-info-terminal-convert-eol": string; - "settings-note-formatter-settings": string; - "settings-note-lsp-settings": string; - "lsp-allow-non-terminal-workspace": string; - "search result label singular": string; - "search result label plural": string; - "pin tab": string; - "unpin tab": string; - "pinned tab": string; - "unpin tab before closing": string; - "app font": string; - "app icon": string; - "app icon changed": string; - "app icon change warning": string; - "settings-info-app-font-family": string; - "settings-info-app-icon": string; - "unsaved selected tabs warning": string; - "save selected tabs": string; - "close selected tabs": string; - "save selected tabs warning": string; - "close selected tabs warning": string; - "close tabs to right": string; - "close tabs to left": string; - "close other tabs": string; - "plugin-not-supported": string; - "plugin-not-supported-info": string; - "login-to-view": string; - "iap-plugin-purchase-warning": string; - "iap-pro-purchase-warning": string; - "iap-pro-purchase-warning-ios": string; - "confirm-login": string; - "terminal:failsafe": string; - "terminal:failsafe-info": string; - "compact": string; - "off": string; - "quick tools height": string; - "quick tools toggler": string; - "terminal:block": string; - "terminal:underline": string; - "terminal:bar": string; - "terminal:inactive block": string; - "terminal:inactive underline": string; - "terminal:inactive bar": string; - "terminal:inactive none": string; - "terminal:normal": string; - "terminal:bold": string; - "quicktools-trigger:click": string; - "quicktools-trigger:touch": string; - "settings-info-terminal-uninstall": string; - "terminal:inactive outline": string; - "restored successfully": string; - "uninstalled successfully": string; - "enter plugin source": string; - "enter keywords": string; - "enter author name": string; - "update available": string; - "update available info": string; - "plugin updates": string; - "plugin updates singular": string; - "plugin updates plural": string; - "just now": string; - "day singular": string; - "hour singular": string; - "min singular": string; - "day plural": string; - "hour plural": string; - "min plural": string; - "settings-category-builtin-servers": string; - "settings-category-plugin-servers": string; - "active processes": string; - "total memory": string; - "uptime": string; - "note": string; - "acode main process": string; - "ppid parent": string; - "unknown": string; - "just started": string; - "diff days": string; - "diff hr": string; - "diff min": string; - "diff sec": string; - "command": string; - "managed": string; - "acode service": string; - "horizontal scroll margin": string; - "settings-info-horizontal-scroll-margin": string; - "confirm app icon reward": string; - "rewarded ad unavailable": string; - "rewarded ad failed": string; - "rewarded ad incomplete": string; - "plugin-review:dialog-label": string; - "plugin-review:account-check-failed": string; - "plugin-review:sign-in": string; - "plugin-review:edit": string; - "plugin-review:add": string; - "plugin-review:placeholder": string; - "plugin-review:input-label": string; - "plugin-review:vote-group-label": string; - "plugin-review:thumbs-up": string; - "plugin-review:thumbs-down": string; - "plugin-review:delete": string; - "plugin-review:load-failed": string; - "plugin-review:retry": string; - "plugin-review:empty-title": string; - "plugin-review:empty-description": string; - "plugin-review:yours": string; - "plugin-review:community": string; - "plugin-review:no-other-reviews": string; - "plugin-review:login-incomplete": string; - "plugin-review:sign-in-failed": string; - "plugin-review:session-expired-review": string; - "plugin-review:save-failed": string; - "plugin-review:delete-confirmation": string; - "plugin-review:session-expired-continue": string; - "plugin-review:delete-failed": string; - "plugin-review:you": string; - "plugin-review:edit-action": string; - "plugin-review:recommended": string; - "plugin-review:error-max-length": string; - "plugin-review:error-downvote-comment": string; - "plugin-review:error-empty": string; - "plugin-review:unrated": string; + lang: string; + about: string; + "active files": string; + alert: string; + "app theme": string; + autocorrect: string; + autosave: string; + cancel: string; + "change language": string; + "choose color": string; + clear: string; + "close app": string; + "commit message": string; + console: string; + "conflict error": string; + copy: string; + "create folder error": string; + cut: string; + delete: string; + dependencies: string; + delay: string; + "editor settings": string; + "editor theme": string; + "enter file name": string; + "enter folder name": string; + "empty folder message": string; + "enter line number": string; + error: string; + failed: string; + "file already exists": string; + "file already exists force": string; + "file already exists force named": string; + "file changed": string; + "file deleted": string; + "file is not supported": string; + "file not supported": string; + "file too large": string; + "file renamed": string; + "file saved": string; + "folder added": string; + "folder already added": string; + "font size": string; + goto: string; + "icons definition": string; + info: string; + "invalid value": string; + "language changed": string; + linting: string; + logout: string; + loading: string; + "my profile": string; + "new file": string; + "new folder": string; + no: string; + "no editor message": string; + "not set": string; + "unsaved files close app": string; + notice: string; + "open file": string; + "open files and folders": string; + "open folder": string; + "open recent": string; + ok: string; + overwrite: string; + paste: string; + "preview mode": string; + "read only file": string; + reload: string; + rename: string; + "new name": string; + "enter new name": string; + "code actions": string; + replace: string; + required: string; + "run your web app": string; + save: string; + saving: string; + "save as": string; + "save file to run": string; + search: string; + "see logs and errors": string; + "select folder": string; + settings: string; + "settings saved": string; + "show line numbers": string; + "show hidden files": string; + "show share button": string; + "show spaces": string; + "soft tab": string; + "sort by name": string; + success: string; + "tab size": string; + "text wrap": string; + "wrapped line indent": string; + "wrap-indent-same": string; + "wrap-indent-indent": string; + "wrap-indent-deep": string; + "settings-info-editor-wrapping-indent": string; + theme: string; + "title-listfiles": string; + "ui zoom": string; + "unable to delete file": string; + "document plugin required": string; + "shared files unavailable": string; + "invalid shared file": string; + "unable to open file": string; + "unable to open folder": string; + "unable to save file": string; + "unable to rename": string; + "unsaved file": string; + warning: string; + "use emmet": string; + "use quick tools": string; + yes: string; + encoding: string; + "syntax highlighting": string; + "read only": string; + "select all": string; + "select branch": string; + "create new branch": string; + "use branch": string; + "new branch": string; + branch: string; + "key bindings": string; + edit: string; + reset: string; + color: string; + "select word": string; + "quick tools": string; + select: string; + "editor font": string; + "new project": string; + format: string; + "project name": string; + "unsupported device": string; + "vibrate on tap": string; + "copy command is not supported by ftp.": string; + "support title": string; + fullscreen: string; + animation: string; + backup: string; + restore: string; + "backup successful": string; + "invalid backup file": string; + "add path": string; + "live autocompletion": string; + "local word completion": string; + "language package completion": string; + "recommend extensions": string; + "auto close tags": string; + "auto rename tags": string; + "file properties": string; + path: string; + type: string; + "word count": string; + "line count": string; + "last modified": string; + size: string; + share: string; + "show print margin": string; + login: string; + "scrollbar size": string; + "scrollbar height": string; + "cursor controller size": string; + full: string; + "scroll past end": string; + "settings-info-scroll-past-end": string; + none: string; + small: string; + large: string; + "confirm on exit": string; + "show console": string; + image: string; + "insert file": string; + "insert color": string; + "powersave mode warning": string; + exit: string; + custom: string; + "reset warning": string; + "theme type": string; + light: string; + dark: string; + "file browser": string; + "icon pack": string; + "settings-info-icon-pack": string; + unavailable: string; + "operation not permitted": string; + "no such file or directory": string; + "input/output error": string; + "permission denied": string; + "bad address": string; + "file exists": string; + "not a directory": string; + "is a directory": string; + "invalid argument": string; + "too many open files in system": string; + "too many open files": string; + "text file busy": string; + "no space left on device": string; + "read-only file system": string; + "file name too long": string; + "too many users": string; + "connection timed out": string; + "connection refused": string; + "owner died": string; + "an error occurred": string; + "add ftp": string; + "add sftp": string; + "save file": string; + "save file as": string; + files: string; + help: string; + "file has been deleted": string; + "feature not available": string; + "feature available in paid version": string; + "deleted file": string; + "line height": string; + "preview info": string; + "manage all files": string; + "close file": string; + "reset connections": string; + "check file changes": string; + "open in browser": string; + "desktop mode": string; + "toggle console": string; + "new line mode": string; + "add a storage": string; + "rate acode": string; + support: string; + "downloading file": string; + "downloading...": string; + "folder name": string; + "keyboard mode": string; + normal: string; + "app settings": string; + "disable in-app-browser caching": string; + "copied to clipboard": string; + "clipboard not available": string; + "remember opened files": string; + "remember opened folders": string; + "no suggestions": string; + "no suggestions aggressive": string; + install: string; + installing: string; + plugins: string; + "recently used": string; + update: string; + uninstall: string; + "download acode pro": string; + "loading plugins": string; + faqs: string; + feedback: string; + header: string; + sidebar: string; + inapp: string; + browser: string; + "diagonal scrolling": string; + "reverse scrolling": string; + formatter: string; + "format on save": string; + "remove ads": string; + fast: string; + slow: string; + "scroll settings": string; + "scroll speed": string; + "loading...": string; + "no plugins found": string; + name: string; + username: string; + optional: string; + hostname: string; + password: string; + "security type": string; + "connection mode": string; + port: string; + "key file": string; + "select key file": string; + passphrase: string; + "connecting...": string; + "type filename": string; + "unable to load files": string; + "preview port": string; + "find file": string; + system: string; + "please select a formatter": string; + "case sensitive": string; + "regular expression": string; + "whole word": string; + "edit with": string; + "open with": string; + "no app found to handle this file": string; + "restore default settings": string; + "server port": string; + "preview settings": string; + "preview settings note": string; + "backup/restore note": string; + host: string; + "retry ftp/sftp when fail": string; + more: string; + "thank you :)": string; + "purchase pending": string; + cancelled: string; + local: string; + remote: string; + "show console toggler": string; + "binary file": string; + "relative line numbers": string; + "elastic tabstops": string; + "line based rtl switching": string; + "hard wrap": string; + spellcheck: string; + "wrap method": string; + "use textarea for ime": string; + "invalid plugin": string; + "type command": string; + plugin: string; + "quicktools trigger mode": string; + "print margin": string; + "touch move threshold": string; + "info-retryremotefsafterfail": string; + "info-fullscreen": string; + "info-checkfiles": string; + "info-console": string; + "info-keyboardmode": string; + "info-rememberfiles": string; + "info-rememberfolders": string; + "info-floatingbutton": string; + "info-openfilelistpos": string; + "info-touchmovethreshold": string; + "info-scroll-settings": string; + "info-animation": string; + "info-quicktoolstriggermode": string; + "info-checkForAppUpdates": string; + "info-quickTools": string; + "info-showHiddenFiles": string; + "info-listFiles": string; + "info-all_file_access": string; + "info-fontSize": string; + "info-fontFamily": string; + "info-theme": string; + "info-cursorStyle": string; + "info-cursorInactiveStyle": string; + "info-fontWeight": string; + "info-cursorBlink": string; + "info-scrollback": string; + "info-terminal-show-scrollbar": string; + "info-tabStopWidth": string; + "info-letterSpacing": string; + "info-imageSupport": string; + "info-fontLigatures": string; + "info-confirmTabClose": string; + "info-backup": string; + "info-restore": string; + "info-uninstall": string; + owned: string; + api_error: string; + installed: string; + all: string; + medium: string; + refund: string; + "product not available": string; + "no-product-info": string; + close: string; + explore: string; + "key bindings updated": string; + "search in files": string; + "exclude files": string; + "include files": string; + "search result": string; + "invalid regex": string; + bottom: string; + "save all": string; + "close all": string; + "unsaved files warning": string; + "save all warning": string; + "save all changes warning": string; + "close all warning": string; + refresh: string; + "shortcut buttons": string; + "no result": string; + "searching...": string; + "quicktools:ctrl-key": string; + "quicktools:tab-key": string; + "quicktools:shift-key": string; + "quicktools:undo": string; + "quicktools:redo": string; + "quicktools:search": string; + "quicktools:save": string; + "quicktools:esc-key": string; + "quicktools:curlybracket": string; + "quicktools:squarebracket": string; + "quicktools:parentheses": string; + "quicktools:anglebracket": string; + "quicktools:left-arrow-key": string; + "quicktools:right-arrow-key": string; + "quicktools:up-arrow-key": string; + "quicktools:down-arrow-key": string; + "quicktools:moveline-up": string; + "quicktools:moveline-down": string; + "quicktools:copyline-up": string; + "quicktools:copyline-down": string; + "quicktools:semicolon": string; + "quicktools:quotation": string; + "quicktools:and": string; + "quicktools:bar": string; + "quicktools:equal": string; + "quicktools:slash": string; + "quicktools:exclamation": string; + "quicktools:alt-key": string; + "quicktools:meta-key": string; + "quicktools:search-prev": string; + "quicktools:search-next": string; + "quicktools:search-settings": string; + "quicktools:search-replace": string; + "quicktools:search-replace-all": string; + "info-quicktoolssettings": string; + "info-excludefolders": string; + "missed files": string; + remove: string; + "quicktools:command-palette": string; + "default file encoding": string; + "remove entry": string; + "delete entry": string; + "change encoding": string; + "reopen file": string; + "plugin min version": string; + "plugin min version required": string; + "color preview": string; + confirm: string; + problems: string; + "show side buttons": string; + bug_report: string; + "verified publisher": string; + most_downloaded: string; + newly_added: string; + top_rated: string; + "rename not supported": string; + compress: string; + "copy uri": string; + "copying items": string; + "cannot paste folder into itself": string; + "folder already exists": string; + "delete entries": string; + "deleting items": string; + "import project zip": string; + changelog: string; + notifications: string; + no_unread_notifications: string; + should_use_current_file_for_preview: string; + "fade fold widgets": string; + "quicktools:home-key": string; + "quicktools:end-key": string; + "quicktools:pageup-key": string; + "quicktools:pagedown-key": string; + "quicktools:delete-key": string; + "quicktools:tilde": string; + "quicktools:backtick": string; + "quicktools:hash": string; + "quicktools:dollar": string; + "quicktools:modulo": string; + "quicktools:caret": string; + plugin_enabled: string; + plugin_disabled: string; + enable_plugin: string; + disable_plugin: string; + open_source: string; + "terminal settings": string; + "font ligatures": string; + "letter spacing": string; + "terminal:tab stop width": string; + "terminal:scrollback": string; + "terminal:show scrollbar": string; + "terminal:cursor blink": string; + "terminal:font weight": string; + "terminal:cursor inactive style": string; + "terminal:cursor style": string; + "terminal:font family": string; + "terminal:convert eol": string; + "terminal:confirm tab close": string; + "terminal:image support": string; + terminal: string; + "running processes": string; + "terminal not installed prompt": string; + "terminal first launch prompt": string; + "don't ask again": string; + allFileAccess: string; + fonts: string; + sponsor: string; + downloads: string; + reviews: string; + overview: string; + contributors: string; + "quicktools:hyphen": string; + "check for app updates": string; + "prompt update check consent message": string; + keywords: string; + author: string; + "filtered by": string; + "clean install state": string; + "backup created": string; + "restore completed": string; + "restore will include": string; + "restore warning": string; + "reload to apply": string; + "reload app": string; + "preparing backup": string; + "collecting settings": string; + "collecting key bindings": string; + "collecting plugins": string; + "creating backup": string; + "validating backup": string; + "restoring key bindings": string; + "restoring plugins": string; + "restoring settings": string; + "legacy backup warning": string; + "checksum mismatch": string; + "plugin not found": string; + "paid plugin skipped": string; + "source not found": string; + restored: string; + skipped: string; + "backup not valid object": string; + "backup no data": string; + "backup legacy warning": string; + "backup missing metadata": string; + "backup checksum mismatch": string; + "backup checksum verify failed": string; + "backup invalid settings": string; + "backup invalid keybindings": string; + "backup invalid plugins": string; + "issues found": string; + "error details": string; + "active tools": string; + recent: string; + "command palette": string; + "change theme": string; + documentation: string; + "open in terminal": string; + "developer mode": string; + "info-developermode": string; + "developer mode enabled": string; + "developer mode disabled": string; + "copy relative path": string; + "available tools": string; + "shortcut request sent": string; + "add to home screen": string; + "pin shortcuts not supported": string; + "save file before home shortcut": string; + terminal_required_message_for_lsp: string; + "shift click selection": string; + "earn ad-free time": string; + "indent guides": string; + "language servers": string; + "lint gutter": string; + "rainbow brackets": string; + "lsp-add-custom-server": string; + "lsp-binary-args": string; + "lsp-binary-command": string; + "lsp-binary-path-optional": string; + "lsp-check-command-optional": string; + "lsp-checking-installation-status": string; + "lsp-configured": string; + "lsp-custom-server-added": string; + "lsp-default": string; + "lsp-details-line": string; + "lsp-edit-initialization-options": string; + "lsp-empty": string; + "lsp-enabled": string; + "lsp-error-add-server-failed": string; + "lsp-error-args-must-be-array": string; + "lsp-error-binary-command-required": string; + "lsp-error-language-id-required": string; + "lsp-error-package-required": string; + "lsp-error-server-id-required": string; + "lsp-feature-completion": string; + "lsp-feature-completion-info": string; + "lsp-feature-diagnostics": string; + "lsp-feature-diagnostics-info": string; + "lsp-feature-formatting": string; + "lsp-feature-formatting-info": string; + "lsp-feature-hover": string; + "lsp-feature-hover-info": string; + "lsp-feature-inlay-hints": string; + "lsp-feature-inlay-hints-info": string; + "lsp-feature-signature": string; + "lsp-feature-signature-info": string; + "lsp-feature-state-toast": string; + "lsp-initialization-options": string; + "lsp-initialization-options-json": string; + "lsp-initialization-options-updated": string; + "lsp-install-command": string; + "lsp-install-command-unavailable": string; + "lsp-install-info-check-failed": string; + "lsp-install-info-missing": string; + "lsp-install-info-ready": string; + "lsp-install-info-unknown": string; + "lsp-install-info-version-available": string; + "lsp-install-notification": string; + "lsp-install-method-apk": string; + "lsp-install-method-cargo": string; + "lsp-install-method-manual": string; + "lsp-install-method-npm": string; + "lsp-install-method-pip": string; + "lsp-install-method-shell": string; + "lsp-install-method-title": string; + "lsp-install-repair": string; + "lsp-installation-status": string; + "lsp-installed": string; + "lsp-invalid-timeout": string; + "lsp-language-ids": string; + "lsp-packages-prompt": string; + "lsp-remove-custom-server": string; + "lsp-remove-custom-server-confirm": string; + "lsp-remove-installed-files": string; + "lsp-custom-server-removed": string; + "lsp-server-disabled-toast": string; + "lsp-server-enabled-toast": string; + "lsp-server-id": string; + "lsp-server-label": string; + "lsp-server-not-found": string; + "lsp-server-uninstalled": string; + "lsp-startup-timeout": string; + "lsp-startup-timeout-ms": string; + "lsp-startup-timeout-set": string; + "lsp-state-disabled": string; + "lsp-state-enabled": string; + "lsp-status": string; + "lsp-status-check-failed": string; + "lsp-status-installed": string; + "lsp-status-installed-version": string; + "lsp-status-line": string; + "lsp-status-not-installed": string; + "lsp-status-unknown": string; + "lsp-timeout-ms": string; + "lsp-transport-method-stdio": string; + "lsp-transport-method-websocket": string; + "lsp-uninstall-command-unavailable": string; + "lsp-uninstall-notification": string; + "lsp-uninstall-server": string; + "lsp-update-command-optional": string; + "lsp-update-command-unavailable": string; + "lsp-update-server": string; + "lsp-version-line": string; + "lsp-websocket-url": string; + "lsp-websocket-server-managed-externally": string; + "lsp-error-websocket-url-invalid": string; + "lsp-error-websocket-url-required": string; + "lsp-view-initialization-options": string; + "settings-category-about-acode": string; + "settings-category-discover-apps": string; + "settings-category-advanced": string; + "settings-category-assistance": string; + "settings-category-core": string; + "settings-category-cursor": string; + "settings-category-cursor-selection": string; + "settings-category-custom-servers": string; + "settings-category-customization": string; + "settings-category-display": string; + "settings-category-editing": string; + "settings-category-features": string; + "settings-category-files-sessions": string; + "settings-category-fonts": string; + "settings-category-general": string; + "settings-category-guides-indicators": string; + "settings-category-installation": string; + "settings-category-interface": string; + "settings-category-maintenance": string; + "settings-category-permissions": string; + "settings-category-preview": string; + "settings-category-scrolling": string; + "settings-category-server": string; + "settings-category-servers": string; + "settings-category-session": string; + "settings-category-support-acode": string; + "settings-category-text-layout": string; + "settings-category-tools": string; + "settings-info-app-animation": string; + "settings-info-app-check-files": string; + "settings-info-app-clean-install-state": string; + "settings-info-app-confirm-on-exit": string; + "settings-info-app-console": string; + "settings-info-app-default-file-encoding": string; + "settings-info-app-exclude-folders": string; + "apply exclusions when copying": string; + "settings-info-app-use-file-operation-exclusions": string; + "settings-info-app-floating-button": string; + "settings-info-app-font-manager": string; + "settings-info-app-fullscreen": string; + "settings-info-app-keybindings": string; + "settings-info-app-keyboard-mode": string; + "settings-info-app-language": string; + "settings-info-app-open-file-list-position": string; + "settings-info-app-quick-tools-settings": string; + "settings-info-app-quick-tools-trigger-mode": string; + "settings-info-app-remember-files": string; + "settings-info-app-remember-folders": string; + "settings-info-app-retry-remote-fs": string; + "settings-info-app-side-buttons": string; + "settings-info-app-sponsor-sidebar": string; + "settings-info-app-touch-move-threshold": string; + "settings-info-app-ui-zoom": string; + "settings-info-app-vibrate-on-tap": string; + "settings-info-editor-autosave": string; + "settings-info-editor-color-preview": string; + "settings-info-editor-fade-fold-widgets": string; + "settings-info-editor-font-family": string; + "settings-info-editor-font-size": string; + "settings-info-editor-format-on-save": string; + "settings-info-editor-hard-wrap": string; + "settings-info-editor-indent-guides": string; + "settings-info-editor-line-height": string; + "settings-info-editor-line-numbers": string; + "settings-info-editor-lint-gutter": string; + "settings-info-editor-live-autocomplete": string; + "settings-info-editor-local-word-completion": string; + "settings-info-editor-language-completion": string; + "settings-info-editor-recommend-extensions": string; + "extension recommendation title": string; + "extension recommendation message": string; + "extension request title": string; + "extension request message": string; + "search plugins": string; + "request plugin": string; + "settings-info-editor-auto-close-tags": string; + "settings-info-editor-auto-rename-tags": string; + "settings-info-editor-rainbow-brackets": string; + "settings-info-editor-relative-line-numbers": string; + "settings-info-editor-rtl-text": string; + "settings-info-editor-scroll-settings": string; + "settings-info-editor-shift-click-selection": string; + "settings-info-editor-show-share-button": string; + "settings-info-editor-show-spaces": string; + "settings-info-editor-soft-tab": string; + "settings-info-editor-tab-size": string; + "settings-info-editor-teardrop-size": string; + "settings-info-editor-text-wrap": string; + "settings-info-lsp-add-custom-server": string; + "settings-info-lsp-allow-non-terminal-workspace": string; + "settings-info-lsp-edit-init-options": string; + "settings-info-lsp-install-server": string; + "settings-info-lsp-remove-custom-server": string; + "settings-info-lsp-server-enabled": string; + "settings-info-lsp-startup-timeout": string; + "settings-info-lsp-uninstall-server": string; + "settings-info-lsp-update-server": string; + "settings-info-lsp-view-init-options": string; + "settings-info-main-ad-rewards": string; + "settings-info-main-app-settings": string; + "settings-info-main-backup-restore": string; + "settings-info-main-changelog": string; + "settings-info-main-edit-settings": string; + "settings-info-main-editor-settings": string; + "settings-info-main-formatter": string; + "settings-info-main-lsp-settings": string; + "settings-info-main-plugins": string; + "settings-info-main-preview-settings": string; + "settings-info-main-rateapp": string; + "settings-info-main-remove-ads": string; + "settings-info-main-reset": string; + "settings-info-main-sponsors": string; + "settings-info-main-terminal-settings": string; + "settings-info-main-theme": string; + "settings-info-preview-disable-cache": string; + "settings-info-preview-host": string; + "settings-info-preview-mode": string; + "settings-info-preview-preview-port": string; + "settings-info-preview-server-port": string; + "settings-info-preview-show-console-toggler": string; + "settings-info-preview-use-current-file": string; + "settings-info-terminal-convert-eol": string; + "settings-note-formatter-settings": string; + "settings-note-lsp-settings": string; + "lsp-allow-non-terminal-workspace": string; + "search result label singular": string; + "search result label plural": string; + "pin tab": string; + "unpin tab": string; + "pinned tab": string; + "unpin tab before closing": string; + "app font": string; + "app icon": string; + "app icon changed": string; + "app icon change warning": string; + "settings-info-app-font-family": string; + "settings-info-app-icon": string; + "unsaved selected tabs warning": string; + "save selected tabs": string; + "close selected tabs": string; + "save selected tabs warning": string; + "close selected tabs warning": string; + "close tabs to right": string; + "close tabs to left": string; + "close other tabs": string; + "plugin-not-supported": string; + "plugin-not-supported-info": string; + "login-to-view": string; + "iap-plugin-purchase-warning": string; + "iap-pro-purchase-warning": string; + "iap-pro-purchase-warning-ios": string; + "confirm-login": string; + "terminal:failsafe": string; + "terminal:failsafe-info": string; + compact: string; + off: string; + "quick tools height": string; + "quick tools toggler": string; + "terminal:block": string; + "terminal:underline": string; + "terminal:bar": string; + "terminal:inactive block": string; + "terminal:inactive underline": string; + "terminal:inactive bar": string; + "terminal:inactive none": string; + "terminal:normal": string; + "terminal:bold": string; + "quicktools-trigger:click": string; + "quicktools-trigger:touch": string; + "settings-info-terminal-uninstall": string; + "terminal:inactive outline": string; + "restored successfully": string; + "uninstalled successfully": string; + "enter plugin source": string; + "enter keywords": string; + "enter author name": string; + "update available": string; + "update available info": string; + "plugin updates": string; + "plugin updates singular": string; + "plugin updates plural": string; + "just now": string; + "day singular": string; + "hour singular": string; + "min singular": string; + "day plural": string; + "hour plural": string; + "min plural": string; + "settings-category-builtin-servers": string; + "settings-category-plugin-servers": string; + "active processes": string; + "total memory": string; + uptime: string; + note: string; + "acode main process": string; + "ppid parent": string; + unknown: string; + "just started": string; + "diff days": string; + "diff hr": string; + "diff min": string; + "diff sec": string; + command: string; + managed: string; + "acode service": string; + "horizontal scroll margin": string; + "settings-info-horizontal-scroll-margin": string; + "confirm app icon reward": string; + "rewarded ad unavailable": string; + "rewarded ad failed": string; + "rewarded ad incomplete": string; + "plugin-review:dialog-label": string; + "plugin-review:account-check-failed": string; + "plugin-review:sign-in": string; + "plugin-review:edit": string; + "plugin-review:add": string; + "plugin-review:placeholder": string; + "plugin-review:input-label": string; + "plugin-review:vote-group-label": string; + "plugin-review:thumbs-up": string; + "plugin-review:thumbs-down": string; + "plugin-review:delete": string; + "plugin-review:load-failed": string; + "plugin-review:retry": string; + "plugin-review:empty-title": string; + "plugin-review:empty-description": string; + "plugin-review:yours": string; + "plugin-review:community": string; + "plugin-review:no-other-reviews": string; + "plugin-review:login-incomplete": string; + "plugin-review:sign-in-failed": string; + "plugin-review:session-expired-review": string; + "plugin-review:save-failed": string; + "plugin-review:delete-confirmation": string; + "plugin-review:session-expired-continue": string; + "plugin-review:delete-failed": string; + "plugin-review:you": string; + "plugin-review:edit-action": string; + "plugin-review:recommended": string; + "plugin-review:error-max-length": string; + "plugin-review:error-downvote-comment": string; + "plugin-review:error-empty": string; + "plugin-review:unrated": string; }; declare var strings: LangStrings; diff --git a/src/lib/customTab.ts b/src/lib/customTab.ts index d418bae..91ee3e0 100644 --- a/src/lib/customTab.ts +++ b/src/lib/customTab.ts @@ -4,21 +4,21 @@ import helpers from "utils/helpers"; * Open custom tab */ export default function customTab( - url: string, - options?: { - showTitle?: boolean; - toolbarColor?: string; - authTabId?: string; - reportLifecycle?: boolean; - }, + url: string, + options?: { + showTitle?: boolean; + toolbarColor?: string; + authTabId?: string; + reportLifecycle?: boolean; + }, ) { - if (!options) { - options = {}; - } + if (!options) { + options = {}; + } - options.showTitle ??= true; + options.showTitle ??= true; - return new Promise((resolve, reject) => { - Bridge.exec(resolve, reject, "CustomTabs", "open", [url, options]); - }); + return new Promise((resolve, reject) => { + Bridge.exec(resolve, reject, "CustomTabs", "open", [url, options]); + }); } diff --git a/src/pages/fileBrowser/fileBrowser.js b/src/pages/fileBrowser/fileBrowser.js index ef7b9d6..a4e1b3a 100644 --- a/src/pages/fileBrowser/fileBrowser.js +++ b/src/pages/fileBrowser/fileBrowser.js @@ -1459,40 +1459,25 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { } async function renameFile(newname) { - if (isTermuxUrl(url)) { - if (helpers.isDir(type)) { - alert(strings.warning, strings["rename not supported"]); - return; - } else { - // Special handling for Termux content files - const fs = fsOperation(url); - try { + let newUrl; + try { + if (isTermuxUrl(url)) { + if (helpers.isDir(type)) { + alert(strings.warning, strings["rename not supported"]); + return; + } else { + // Special handling for Termux content files + const fs = fsOperation(url); const content = await fs.readFile(); - const newUrl = Url.join(Url.dirname(url), newname); - await fsOperation(Url.dirname(url)).createFile(newname, content); + const dirname = Url.dirname(url); + newUrl = Url.join(dirname, newname); + await fsOperation(dirname).createFile(newname, content); await fs.delete(); - - recents.removeFile(url); - recents.addFile(newUrl); - const file = editorManager.getFile(url, "uri"); - if (file) { - file.uri = newUrl; - file.filename = newname; - } - openFolder.renameItem(url, newUrl, newname); - toast(strings.success); - reload(); - return; - } catch (err) { - window.log("error", err); - helpers.error(err); - return; } + } else { + const fs = fsOperation(url); + newUrl = await fs.renameTo(newname); } - } - const fs = fsOperation(url); - try { - const newUrl = await fs.renameTo(newname); recents.removeFile(url); recents.addFile(newUrl); const file = editorManager.getFile(url, "uri"); diff --git a/tests/admob/adConsentCoordinator.test.js b/tests/admob/adConsentCoordinator.test.js index ecd5a2f..7b1471b 100644 --- a/tests/admob/adConsentCoordinator.test.js +++ b/tests/admob/adConsentCoordinator.test.js @@ -152,7 +152,9 @@ test("hides a stale privacy-option entry and keeps ads single-started", async () }, }); const states = []; - const unsubscribe = harness.coordinator.subscribe((state) => states.push(state)); + const unsubscribe = harness.coordinator.subscribe((state) => + states.push(state), + ); await harness.coordinator.start(); harness.privacy.showOptions = async () => ({ diff --git a/tests/admob/adRewardBannerPolicy.test.js b/tests/admob/adRewardBannerPolicy.test.js index 4de8d29..329201f 100644 --- a/tests/admob/adRewardBannerPolicy.test.js +++ b/tests/admob/adRewardBannerPolicy.test.js @@ -172,10 +172,7 @@ test("resume after expiry clears rewarded suppression", async () => { test("refresh failure preserves state, suppression, and existing timer", async () => { const failure = new Error("offline"); const harness = createHarness({ - statuses: [ - { isActive: true, adFreeUntil: 61_000 }, - failure, - ], + statuses: [{ isActive: true, adFreeUntil: 61_000 }, failure], }); await harness.lifecycle.initialize(); const activeState = harness.state; @@ -226,7 +223,10 @@ test("clearing reward suppression leaves Pro suppression active", () => { adFreeUntil: 0, }); - assert.deepEqual(harness.suppressions, new Set([BANNER_SUPPRESSION_REASON.PRO])); + assert.deepEqual( + harness.suppressions, + new Set([BANNER_SUPPRESSION_REASON.PRO]), + ); }); test("uses the same expiry predicate exposed to ad eligibility checks", () => { diff --git a/tests/admob/bannerWrapper.test.js b/tests/admob/bannerWrapper.test.js index cc15bb7..4fcbb4d 100644 --- a/tests/admob/bannerWrapper.test.js +++ b/tests/admob/bannerWrapper.test.js @@ -13,7 +13,10 @@ function loadBannerModule() { const { code } = transformSync(source, { filename: sourcePath, presets: [ - ["@babel/preset-env", { modules: "commonjs", targets: { node: "current" } }], + [ + "@babel/preset-env", + { modules: "commonjs", targets: { node: "current" } }, + ], "@babel/preset-typescript", ], }); diff --git a/tests/admob/privacyChoicesController.test.js b/tests/admob/privacyChoicesController.test.js index 74ce715..c2de1c8 100644 --- a/tests/admob/privacyChoicesController.test.js +++ b/tests/admob/privacyChoicesController.test.js @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import { test } from "vitest"; import { - PRIVACY_CHOICES_KEY, bindPrivacyChoices, + PRIVACY_CHOICES_KEY, } from "../../src/lib/privacyChoicesController.mjs"; function createHarness() { diff --git a/tests/admob/rewardedAd.test.js b/tests/admob/rewardedAd.test.js index afe4e16..b963e8a 100644 --- a/tests/admob/rewardedAd.test.js +++ b/tests/admob/rewardedAd.test.js @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { RewardedAd } from "../../src/native/admob/ads/rewarded"; + const mocks = vi.hoisted(() => ({ start: vi.fn(), config: { HAS_PRO: false }, @@ -292,40 +293,38 @@ describe("rewarded wrapper disposal", () => { expect(calls).toEqual([]); await expect(ad.load()).rejects.toThrow("Ad is destroyed"); }); - it.each(["SDK startup", "native creation"])( - "cancels during %s without issuing a late load or show", - async (phase) => { - let started; - if (phase === "SDK startup") { - admob.start.mockImplementation( - () => - new Promise((resolve) => { - started = resolve; - }), - ); - } - const ad = new RewardedAd({ id: "old", adUnitId: "test" }); - const loading = ad.load(); - const rejected = expect(loading).rejects.toThrow("Ad is destroyed"); - await flush(); - const disposed = ad.destroy(); - expect(ad.destroy()).toBe(disposed); - const next = new RewardedAd({ id: "next", adUnitId: "test" }); - if (started) started(); - else calls.find(({ action }) => action === "adCreate").resolve(); - await Promise.all([disposed, rejected]); - expect(calls.filter(({ action }) => action === "adLoad")).toEqual([]); - expect( - calls - .filter(({ action }) => action === "adDestroy") - .map(({ id }) => id), - ).toEqual(["old"]); - expect(window.admobAds.old).toBeUndefined(); - expect(window.admobAds.next).toBe(next); - await expect(ad.show()).rejects.toThrow("Ad is destroyed"); - await next.destroy(); - }, - ); + it.each([ + "SDK startup", + "native creation", + ])("cancels during %s without issuing a late load or show", async (phase) => { + let started; + if (phase === "SDK startup") { + admob.start.mockImplementation( + () => + new Promise((resolve) => { + started = resolve; + }), + ); + } + const ad = new RewardedAd({ id: "old", adUnitId: "test" }); + const loading = ad.load(); + const rejected = expect(loading).rejects.toThrow("Ad is destroyed"); + await flush(); + const disposed = ad.destroy(); + expect(ad.destroy()).toBe(disposed); + const next = new RewardedAd({ id: "next", adUnitId: "test" }); + if (started) started(); + else calls.find(({ action }) => action === "adCreate").resolve(); + await Promise.all([disposed, rejected]); + expect(calls.filter(({ action }) => action === "adLoad")).toEqual([]); + expect( + calls.filter(({ action }) => action === "adDestroy").map(({ id }) => id), + ).toEqual(["old"]); + expect(window.admobAds.old).toBeUndefined(); + expect(window.admobAds.next).toBe(next); + await expect(ad.show()).rejects.toThrow("Ad is destroyed"); + await next.destroy(); + }); it("destroys a pending native load and ignores its late completion", async () => { const ad = new RewardedAd({ id: "loading", adUnitId: "test" }); const loading = ad.load(); @@ -334,13 +333,11 @@ describe("rewarded wrapper disposal", () => { calls[0].resolve(); await flush(); const load = calls.find(({ action }) => action === "adLoad"); - Bridge.exec.mockImplementation( - (resolve, reject, service, action, args) => { - calls.push({ action, id: args[0].id }); - load.reject(new Error("Ad is destroyed")); - resolve(); - }, - ); + Bridge.exec.mockImplementation((resolve, reject, service, action, args) => { + calls.push({ action, id: args[0].id }); + load.reject(new Error("Ad is destroyed")); + resolve(); + }); await ad.destroy(); load.resolve(); await rejected; diff --git a/tests/helpers/loadSourceModule.js b/tests/helpers/loadSourceModule.js index a64a8e3..7ad2aa6 100644 --- a/tests/helpers/loadSourceModule.js +++ b/tests/helpers/loadSourceModule.js @@ -7,18 +7,41 @@ const compiled = new Map(); // Compile the actual source with Acode's JSX transform; replace only its environment. export function loadSourceModule(relative, dependencies, globals = {}) { const filename = path.resolve(relative); - if (!compiled.has(filename)) compiled.set(filename, transformSync(readFileSync(filename, "utf8"), { - filename, babelrc: false, configFile: false, - presets: [["@babel/preset-env", { targets: { node: "current" }, modules: "commonjs" }]], - plugins: ["html-tag-js/jsx/syntax-parser.js", "html-tag-js/jsx/jsx-to-tag.js"], - }).code); + if (!compiled.has(filename)) + compiled.set( + filename, + transformSync(readFileSync(filename, "utf8"), { + filename, + babelrc: false, + configFile: false, + presets: [ + [ + "@babel/preset-env", + { targets: { node: "current" }, modules: "commonjs" }, + ], + ], + plugins: [ + "html-tag-js/jsx/syntax-parser.js", + "html-tag-js/jsx/jsx-to-tag.js", + ], + }).code, + ); const exports = {}; - vm.runInNewContext(compiled.get(filename), { - exports, Promise, console, setTimeout, clearTimeout, - require(id) { - if (!(id in dependencies)) throw Error(`Unexpected import: ${id}`); - return dependencies[id]; - }, ...globals, - }, { filename }); + vm.runInNewContext( + compiled.get(filename), + { + exports, + Promise, + console, + setTimeout, + clearTimeout, + require(id) { + if (!(id in dependencies)) throw Error(`Unexpected import: ${id}`); + return dependencies[id]; + }, + ...globals, + }, + { filename }, + ); return exports; } diff --git a/tests/unit/actionStack.test.js b/tests/unit/actionStack.test.js index b3121a8..d467632 100644 --- a/tests/unit/actionStack.test.js +++ b/tests/unit/actionStack.test.js @@ -128,22 +128,22 @@ describe("actionStack app exit", () => { expect(mocks.exitApp).toHaveBeenCalledOnce(); }); - it.each([false, true])( - "does not start an exit flow on iOS (confirmation: %s)", - async (confirmOnExit) => { - mocks.platform.appExit = false; - mocks.settings.value.confirmOnExit = confirmOnExit; - mocks.confirm.mockResolvedValue(true); - const onClose = vi.fn(); - actionStack.onCloseApp = onClose; - - await actionStack.pop(); - - expect(mocks.confirm).not.toHaveBeenCalled(); - expect(onClose).not.toHaveBeenCalled(); - expect(mocks.exitApp).not.toHaveBeenCalled(); - }, - ); + it.each([ + false, + true, + ])("does not start an exit flow on iOS (confirmation: %s)", async (confirmOnExit) => { + mocks.platform.appExit = false; + mocks.settings.value.confirmOnExit = confirmOnExit; + mocks.confirm.mockResolvedValue(true); + const onClose = vi.fn(); + actionStack.onCloseApp = onClose; + + await actionStack.pop(); + + expect(mocks.confirm).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(mocks.exitApp).not.toHaveBeenCalled(); + }); it("still dismisses stacked pages on iOS", async () => { mocks.platform.appExit = false; diff --git a/tests/unit/androidLaunch.test.js b/tests/unit/androidLaunch.test.js index 9d27330..371db66 100644 --- a/tests/unit/androidLaunch.test.js +++ b/tests/unit/androidLaunch.test.js @@ -10,28 +10,28 @@ afterEach(() => { vi.restoreAllMocks(); }); -test.each(["com.foxdebug.acode", "com.foxdebug.acodefree"])( - "launches %s using the shared activity namespace", - (targetId) => { - const spawn = vi - .spyOn(childProcess, "spawnSync") - .mockReturnValue({ status: 0 }); - delete require.cache[scriptPath]; - const { launch } = require(scriptPath); +test.each([ + "com.foxdebug.acode", + "com.foxdebug.acodefree", +])("launches %s using the shared activity namespace", (targetId) => { + const spawn = vi + .spyOn(childProcess, "spawnSync") + .mockReturnValue({ status: 0 }); + delete require.cache[scriptPath]; + const { launch } = require(scriptPath); - launch({ targetId, target: "device" }, "/tmp/app-debug.apk"); + launch({ targetId, target: "device" }, "/tmp/app-debug.apk"); - expect(spawn.mock.calls.map(([, args]) => args)).toEqual([ - ["-s", "device", "install", "-r", "/tmp/app-debug.apk"], - [ - "-s", - "device", - "shell", - "am", - "start", - "-n", - `${targetId}/com.foxdebug.acode.MainActivity`, - ], - ]); - }, -); + expect(spawn.mock.calls.map(([, args]) => args)).toEqual([ + ["-s", "device", "install", "-r", "/tmp/app-debug.apk"], + [ + "-s", + "device", + "shell", + "am", + "start", + "-n", + `${targetId}/com.foxdebug.acode.MainActivity`, + ], + ]); +}); diff --git a/tests/unit/appIconSelection.test.js b/tests/unit/appIconSelection.test.js index 058e28e..8546fb7 100644 --- a/tests/unit/appIconSelection.test.js +++ b/tests/unit/appIconSelection.test.js @@ -30,6 +30,7 @@ vi.mock("utils/helpers", () => ({ new Promise((resolve, reject) => fn(...args, resolve, reject)), }, })); + import selectAppIcon from "lib/appIconSelection"; function harness() { @@ -77,7 +78,10 @@ beforeEach(() => { }); describe("icon selection", () => { - it.each([false, true])("uses the iOS flow with Pro access %s", async (hasPro) => { + it.each([ + false, + true, + ])("uses the iOS flow with Pro access %s", async (hasPro) => { mocks.platform.isIOS = true; mocks.config.HAS_PRO = hasPro; await harness().select("pixel_party"); diff --git a/tests/unit/appIconSetting.test.js b/tests/unit/appIconSetting.test.js index 7f81f5f..e8b7922 100644 --- a/tests/unit/appIconSetting.test.js +++ b/tests/unit/appIconSetting.test.js @@ -40,6 +40,7 @@ vi.mock("utils/helpers", () => ({ new Promise((resolve, reject) => fn(...args, resolve, reject)), }, })); + import actionStack from "lib/actionStack"; import selectAppIcon from "lib/appIconSelection"; import restoreTheme from "lib/restoreTheme"; @@ -300,7 +301,10 @@ it("opens one complete grid and closes only after native success, preserving the expect(h.loader.create).not.toHaveBeenCalled(); confirmSelection(); await vi.waitFor(() => expect(applied).toBeTypeOf("function")); - expect(h.loader.create).toHaveBeenCalledExactlyOnceWith("App icon", "Loading..."); + expect(h.loader.create).toHaveBeenCalledExactlyOnceWith( + "App icon", + "Loading...", + ); expect(h.dialog.inert).toBe(true); expect(document.querySelector("#__loader button")).toBeNull(); await actionStack.pop(); @@ -568,7 +572,10 @@ it.each([ expect(mocks.settings.value.appIcon).toBe("pixel_party"); }); -it.each([false, true])("leaves the Pro flow's loader under its own control with external checkout %s", async (external) => { +it.each([ + false, + true, +])("leaves the Pro flow's loader under its own control with external checkout %s", async (external) => { mocks.external = external; const h = dialogHarness(); const title = external ? "Login" : "Remove ads"; @@ -581,7 +588,9 @@ it.each([false, true])("leaves the Pro flow's loader under its own control with expect(h.loader.create).toHaveBeenCalledExactlyOnceWith(title, "Loading..."); expect(h.loader.destroy).not.toHaveBeenCalled(); expect(h.dialog.inert).toBe(false); - expect(document.querySelector("#__loader .title").textContent.trim()).toBe(title); + expect(document.querySelector("#__loader .title").textContent.trim()).toBe( + title, + ); h.loader.destroy(); vi.runAllTimers(); await actionStack.pop(); diff --git a/tests/unit/appIcons.test.js b/tests/unit/appIcons.test.js index e790ecd..311d2fb 100644 --- a/tests/unit/appIcons.test.js +++ b/tests/unit/appIcons.test.js @@ -31,7 +31,13 @@ describe("appIcons", () => { ); const window = new Window(); const config = new window.DOMParser().parseFromString( - fs.readFileSync(new URL("../../platforms/android/app/src/main/AndroidManifest.xml", import.meta.url), "utf8"), + fs.readFileSync( + new URL( + "../../platforms/android/app/src/main/AndroidManifest.xml", + import.meta.url, + ), + "utf8", + ), "application/xml", ); expect( diff --git a/tests/unit/binaryExtensions.test.js b/tests/unit/binaryExtensions.test.js index 1999c1d..f05f336 100644 --- a/tests/unit/binaryExtensions.test.js +++ b/tests/unit/binaryExtensions.test.js @@ -1,10 +1,10 @@ -import { describe, expect, it } from "vitest"; import { isBinaryFile, isBinaryMime, isBinaryPath, isTextPath, } from "utils/binaryExtensions"; +import { describe, expect, it } from "vitest"; describe("isBinaryPath", () => { it("detects binary extensions", () => { diff --git a/tests/unit/boot.test.js b/tests/unit/boot.test.js index ace000c..c46823f 100644 --- a/tests/unit/boot.test.js +++ b/tests/unit/boot.test.js @@ -15,69 +15,66 @@ test.each([ ["offline", true, new Error("offline"), "."], ["unavailable bundle", true, false, "."], ["packaged build", false, true, "."], -])( - "%s preserves the API origin while selecting web assets", - async (name, dev, response, assetOrigin) => { - const loaded = []; - const location = { - origin: "https://localhost", - replace: vi.fn(), - reload: vi.fn(), - }; - const timeout = vi.fn(() => 1); - const clearTimeout = vi.fn(); - const fetch = vi.fn(async () => { - if (response instanceof Error) throw response; - return { ok: response }; - }); - const sockets = []; - await vm.runInNewContext(source, { - __DEV_MODE__: dev, - __DEV_HOST__: "192.168.1.2", - __DEV_PORT__: "4000", - __DEV_PROTO__: "https", - location, - window: { location, nativeReady: Promise.resolve() }, - fetch, - AbortController, - console: { error: vi.fn() }, - setTimeout: timeout, - clearTimeout, - document: { - createElement: () => ({}), - head: { - appendChild(element) { - loaded.push(element.src || element.href); - if (element.onload) queueMicrotask(element.onload); - }, +])("%s preserves the API origin while selecting web assets", async (name, dev, response, assetOrigin) => { + const loaded = []; + const location = { + origin: "https://localhost", + replace: vi.fn(), + reload: vi.fn(), + }; + const timeout = vi.fn(() => 1); + const clearTimeout = vi.fn(); + const fetch = vi.fn(async () => { + if (response instanceof Error) throw response; + return { ok: response }; + }); + const sockets = []; + await vm.runInNewContext(source, { + __DEV_MODE__: dev, + __DEV_HOST__: "192.168.1.2", + __DEV_PORT__: "4000", + __DEV_PROTO__: "https", + location, + window: { location, nativeReady: Promise.resolve() }, + fetch, + AbortController, + console: { error: vi.fn() }, + setTimeout: timeout, + clearTimeout, + document: { + createElement: () => ({}), + head: { + appendChild(element) { + loaded.push(element.src || element.href); + if (element.onload) queueMicrotask(element.onload); }, }, - WebSocket: class { - constructor(url) { - sockets.push(url); - } - }, + }, + WebSocket: class { + constructor(url) { + sockets.push(url); + } + }, + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(location.replace).not.toHaveBeenCalled(); + expect(location.origin).toBe("https://localhost"); + expect(loaded).toEqual( + ["native.js", "main.css", "main.js"].map( + (file) => `${assetOrigin}/build/${file}`, + ), + ); + expect(sockets).toHaveLength(assetOrigin === devOrigin ? 1 : 0); + if (dev) { + expect(timeout).toHaveBeenCalledWith(expect.any(Function), 3000); + expect(clearTimeout).toHaveBeenCalledWith(1); + expect(fetch).toHaveBeenCalledWith(`${devOrigin}/build/main.js`, { + method: "HEAD", + cache: "no-store", + signal: expect.any(AbortSignal), }); - await new Promise((resolve) => setImmediate(resolve)); - expect(location.replace).not.toHaveBeenCalled(); - expect(location.origin).toBe("https://localhost"); - expect(loaded).toEqual( - ["native.js", "main.css", "main.js"].map( - (file) => `${assetOrigin}/build/${file}`, - ), - ); - expect(sockets).toHaveLength(assetOrigin === devOrigin ? 1 : 0); - if (dev) { - expect(timeout).toHaveBeenCalledWith(expect.any(Function), 3000); - expect(clearTimeout).toHaveBeenCalledWith(1); - expect(fetch).toHaveBeenCalledWith(`${devOrigin}/build/main.js`, { - method: "HEAD", - cache: "no-store", - signal: expect.any(AbortSignal), - }); - } else expect(fetch).not.toHaveBeenCalled(); - }, -); + } else expect(fetch).not.toHaveBeenCalled(); +}); test("lazy assets resolve from the loaded bundle instead of a baked-in dev origin", () => { const [config] = require("../../rspack.config.js")( diff --git a/tests/unit/codeHighlight.test.js b/tests/unit/codeHighlight.test.js index 327a763..e46e303 100644 --- a/tests/unit/codeHighlight.test.js +++ b/tests/unit/codeHighlight.test.js @@ -18,11 +18,11 @@ vi.mock("lib/settings", () => ({ import "cm/supportedModes"; import settings from "lib/settings"; import { - HIGHLIGHT_CLASS, applyHighlightStyles, clearHighlightCache, getHighlightStyleSheet, getHighlightStyles, + HIGHLIGHT_CLASS, highlightCodeBlock, highlightLine, initHighlighting, diff --git a/tests/unit/color.test.js b/tests/unit/color.test.js index d7e00ac..703c6f1 100644 --- a/tests/unit/color.test.js +++ b/tests/unit/color.test.js @@ -1,8 +1,8 @@ -import { describe, expect, it } from "vitest"; import Hex from "utils/color/hex"; import Hsl from "utils/color/hsl"; import { isValidColor } from "utils/color/regex"; import Rgb from "utils/color/rgb"; +import { describe, expect, it } from "vitest"; describe("Rgb", () => { it("stringifies opaque colors as rgb()", () => { diff --git a/tests/unit/confirm.test.js b/tests/unit/confirm.test.js index bbcadbd..238b6fd 100644 --- a/tests/unit/confirm.test.js +++ b/tests/unit/confirm.test.js @@ -2,6 +2,7 @@ import tag from "html-tag-js"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; + vi.mock("lib/settings", () => ({ default: { value: { confirmOnExit: false } }, })); @@ -9,8 +10,10 @@ vi.mock("lib/restoreTheme", () => ({ default: vi.fn() })); vi.mock("components/checkbox", () => ({ default: () => document.createElement("input"), })); + import confirm from "dialogs/confirm"; import actionStack from "lib/actionStack"; + beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); @@ -78,33 +81,35 @@ it("preserves checkbox response and ignores subsequent dismissals", async () => expect(actionStack.length).toBe(0); }); -it.each(["back", "cancel", "ok", "abort"])( - "preserves the lower confirmation's Back handler after %s dismisses the top", - async (method) => { - const firstClosed = vi.fn(); - confirm("First", "First confirmation").then(firstClosed); - const controller = new AbortController(); - const second = confirm("Second", "Second confirmation", false, { - signal: controller.signal, - }); - const top = document.querySelectorAll(".confirm")[1]; - if (method === "back") await actionStack.pop(); - if (method === "cancel") top.querySelector("button").click(); - if (method === "ok") top.querySelectorAll("button")[1].click(); - if (method === "abort") controller.abort(); - await expect(second).resolves.toBe(method === "ok"); - vi.runAllTimers(); - expect(document.querySelectorAll(".confirm")).toHaveLength(1); - expect(actionStack.length).toBe(1); - expect(firstClosed).not.toHaveBeenCalled(); +it.each([ + "back", + "cancel", + "ok", + "abort", +])("preserves the lower confirmation's Back handler after %s dismisses the top", async (method) => { + const firstClosed = vi.fn(); + confirm("First", "First confirmation").then(firstClosed); + const controller = new AbortController(); + const second = confirm("Second", "Second confirmation", false, { + signal: controller.signal, + }); + const top = document.querySelectorAll(".confirm")[1]; + if (method === "back") await actionStack.pop(); + if (method === "cancel") top.querySelector("button").click(); + if (method === "ok") top.querySelectorAll("button")[1].click(); + if (method === "abort") controller.abort(); + await expect(second).resolves.toBe(method === "ok"); + vi.runAllTimers(); + expect(document.querySelectorAll(".confirm")).toHaveLength(1); + expect(actionStack.length).toBe(1); + expect(firstClosed).not.toHaveBeenCalled(); - await actionStack.pop(); - expect(firstClosed).toHaveBeenCalledExactlyOnceWith(false); - vi.runAllTimers(); - expect(app.children.length).toBe(0); - expect(actionStack.length).toBe(0); - }, -); + await actionStack.pop(); + expect(firstClosed).toHaveBeenCalledExactlyOnceWith(false); + vi.runAllTimers(); + expect(app.children.length).toBe(0); + expect(actionStack.length).toBe(0); +}); it("preserves the top confirmation when the lower one is aborted", async () => { const controller = new AbortController(); diff --git a/tests/unit/consoleExecutionContext.test.js b/tests/unit/consoleExecutionContext.test.js index 3d6a651..153fee4 100644 --- a/tests/unit/consoleExecutionContext.test.js +++ b/tests/unit/consoleExecutionContext.test.js @@ -1,9 +1,9 @@ -import { describe, expect, it } from "vitest"; import { executeConsoleCommand, executeConsoleScript, resolveConsoleExecutionContext, } from "lib/consoleRuntime"; +import { describe, expect, it } from "vitest"; describe("console execution context", () => { it("forces standalone menu consoles to use the worker", () => { diff --git a/tests/unit/consoleExecutor.test.js b/tests/unit/consoleExecutor.test.js index 7dcd45e..b1e8881 100644 --- a/tests/unit/consoleExecutor.test.js +++ b/tests/unit/consoleExecutor.test.js @@ -1,5 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; import ConsoleExecutor from "lib/consoleRuntime"; +import { beforeEach, describe, expect, it, vi } from "vitest"; class FakeWorker { static instances = []; diff --git a/tests/unit/consoleViewport.test.js b/tests/unit/consoleViewport.test.js index 218a5ba..f8e52fb 100644 --- a/tests/unit/consoleViewport.test.js +++ b/tests/unit/consoleViewport.test.js @@ -1,10 +1,10 @@ // @vitest-environment happy-dom -import { describe, expect, it } from "vitest"; import { applyConsoleViewport, getConsoleViewportRect, } from "lib/consoleRuntime"; +import { describe, expect, it } from "vitest"; describe("console visual viewport", () => { it("uses the visual viewport when the mobile keyboard shrinks the screen", () => { diff --git a/tests/unit/dev.test.js b/tests/unit/dev.test.js index 4230bb5..ea1ea9f 100644 --- a/tests/unit/dev.test.js +++ b/tests/unit/dev.test.js @@ -9,7 +9,10 @@ const { parseOptions } = require("../../dev/scripts/android.js"); const { parseOptions: parseIOSOptions } = require("../../dev/scripts/ios.js"); const packagePath = path.resolve(import.meta.dirname, "../../package.json"); -afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); }); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); test.each([ ["com.foxdebug.acode", "paid"], @@ -18,23 +21,49 @@ test.each([ const readFile = fs.readFileSync; vi.spyOn(fs, "readFileSync").mockImplementation((file, ...args) => file === packagePath - ? JSON.stringify({ name: "unrelated.npm.name", androidPackageId, appleAppId: "app.acode" }) + ? JSON.stringify({ + name: "unrelated.npm.name", + androidPackageId, + appleAppId: "app.acode", + }) : readFile(file, ...args), ); expect(getAppConfig()).toEqual({ targetId: androidPackageId, variant }); - expect(parseOptions(["android", "prod", "bundle", "fdroid", "--target=device"])).toMatchObject({ - targetId: androidPackageId, variant, mode: "p", bundle: true, channel: "fdroid", target: "device", + expect( + parseOptions(["android", "prod", "bundle", "fdroid", "--target=device"]), + ).toMatchObject({ + targetId: androidPackageId, + variant, + mode: "p", + bundle: true, + channel: "fdroid", + target: "device", }); expect(parseIOSOptions(["ios", "prod", "--target=simulator"])).toMatchObject({ - targetId: "app.acode", variant: "free", mode: "Release", target: "simulator", device: false, + targetId: "app.acode", + variant: "free", + mode: "Release", + target: "simulator", + device: false, }); vi.stubEnv("ACODE_PLATFORM", "ios"); expect(getAppConfig()).toEqual({ targetId: "app.acode", variant: "free" }); - expect(parseOptions([])).toMatchObject({ targetId: androidPackageId, variant }); + expect(parseOptions([])).toMatchObject({ + targetId: androidPackageId, + variant, + }); }); -test.each(["free", "paid", "fdroid", "apk", "bundle"])("rejects Android-only %s arguments for iOS", (argument) => { - expect(() => parseIOSOptions(["ios", argument])).toThrow(/iOS has one free edition/); +test.each([ + "free", + "paid", + "fdroid", + "apk", + "bundle", +])("rejects Android-only %s arguments for iOS", (argument) => { + expect(() => parseIOSOptions(["ios", argument])).toThrow( + /iOS has one free edition/, + ); }); test("rereads package identity after an edit and rejects unsupported android package ids", () => { @@ -52,6 +81,13 @@ test("rereads package identity after an edit and rejects unsupported android pac expect(() => parseOptions([])).toThrow(/androidPackageId/); }); -test.each(["free", "paid", "FREE", "PAID"])("rejects the removed %s argument instead of silently building a different edition", (argument) => { - expect(() => parseOptions([argument, "dev", "apk"])).toThrow(/androidPackageId/); +test.each([ + "free", + "paid", + "FREE", + "PAID", +])("rejects the removed %s argument instead of silently building a different edition", (argument) => { + expect(() => parseOptions([argument, "dev", "apk"])).toThrow( + /androidPackageId/, + ); }); diff --git a/tests/unit/devServer.test.js b/tests/unit/devServer.test.js index d135090..eb19f2c 100644 --- a/tests/unit/devServer.test.js +++ b/tests/unit/devServer.test.js @@ -9,31 +9,55 @@ const filename = path.resolve(import.meta.dirname, "../../dev/scripts/dev.js"); const source = fs.readFileSync(filename, "utf8"); const { getWebBundlePath } = require("../../dev/config.js"); -test.each(["android", "ios"])("%s dev server serves the selected platform bundle", (platform) => { +test.each([ + "android", + "ios", +])("%s dev server serves the selected platform bundle", (platform) => { const { api, readFile } = loadDevServer(platform); for (const [url, file, type] of [ ["/", "index.html", "text/html"], ["/build/main.js?reload=1", "build/main.js", "application/javascript"], ["/build/main.css", "build/main.css", "text/css"], - ["/icons/ic_acode_default.svg", "icons/ic_acode_default.svg", "image/svg+xml"], + [ + "/icons/ic_acode_default.svg", + "icons/ic_acode_default.svg", + "image/svg+xml", + ], ]) { const response = { writeHead: vi.fn(), end: vi.fn() }; api.handleRequest({ url }, response); expect(readFile).toHaveBeenLastCalledWith( - path.join(getWebBundlePath(platform), file), expect.any(Function), + path.join(getWebBundlePath(platform), file), + expect.any(Function), + ); + expect(response.writeHead).toHaveBeenLastCalledWith( + 200, + expect.objectContaining({ "Content-Type": type }), ); - expect(response.writeHead).toHaveBeenLastCalledWith(200, expect.objectContaining({ "Content-Type": type })); expect(response.end).toHaveBeenLastCalledWith("fixture"); } }); -test.each(["android", "ios"])("%s native watcher ignores generated bundles without ignoring native code", (platform) => { +test.each([ + "android", + "ios", +])("%s native watcher ignores generated bundles without ignoring native code", (platform) => { const { api, watch } = loadDevServer(platform); api.watchNative(platform, "simulator", false); const { ignored } = watch.mock.calls[0][1]; - expect(ignored(path.join(getWebBundlePath(platform), "build/main.js"))).toBe(true); - expect(ignored(path.resolve("platforms/ios/runner/WebViewController.swift"))).toBe(false); - expect(ignored(path.resolve("platforms/android/app/src/main/java/runner/MainActivity.java"))).toBe(false); + expect(ignored(path.join(getWebBundlePath(platform), "build/main.js"))).toBe( + true, + ); + expect( + ignored(path.resolve("platforms/ios/runner/WebViewController.swift")), + ).toBe(false); + expect( + ignored( + path.resolve( + "platforms/android/app/src/main/java/runner/MainActivity.java", + ), + ), + ).toBe(false); }); function loadDevServer(platform) { @@ -48,7 +72,13 @@ function loadDevServer(platform) { }; vm.runInNewContext( `${source}\ncurrentOptions = { platform: ${JSON.stringify(platform)} }; module.exports = { handleRequest, watchNative };`, - { require: scriptRequire, module, process, console, __dirname: path.dirname(filename) }, + { + require: scriptRequire, + module, + process, + console, + __dirname: path.dirname(filename), + }, { filename }, ); return { api: module.exports, readFile, watch }; diff --git a/tests/unit/devToolsCancel.test.js b/tests/unit/devToolsCancel.test.js index 4373fb6..f5ed55f 100644 --- a/tests/unit/devToolsCancel.test.js +++ b/tests/unit/devToolsCancel.test.js @@ -32,7 +32,10 @@ function setup() { __esModule: true, default: { toInternalUri: async (url) => url }, }, - "utils/Url": { __esModule: true, default: { join: (...p) => p.join("/") } }, + "utils/Url": { + __esModule: true, + default: { join: (...p) => p.join("/") }, + }, "./config": { __esModule: true, default: {} }, "./startupLogBuffer": { discardStartupLogs, diff --git a/tests/unit/downloadProgress.test.js b/tests/unit/downloadProgress.test.js index 84e1e93..0a8178c 100644 --- a/tests/unit/downloadProgress.test.js +++ b/tests/unit/downloadProgress.test.js @@ -4,8 +4,10 @@ import formatDownloadProgress from "../../src/lib/downloadProgress.js"; test("falls back to the label when the total is unknown", () => { expect(formatDownloadProgress(50, 0, "Loading")).toBe("Loading"); expect(formatDownloadProgress(50, undefined, "Loading")).toBe("Loading"); - expect(formatDownloadProgress(50, NaN, "Loading")).toBe("Loading"); - expect(formatDownloadProgress(50, Infinity, "Loading")).toBe("Loading"); + expect(formatDownloadProgress(50, Number.NaN, "Loading")).toBe("Loading"); + expect(formatDownloadProgress(50, Number.POSITIVE_INFINITY, "Loading")).toBe( + "Loading", + ); }); test("formats the percentage when the total is known", () => { diff --git a/tests/unit/editorThemes.test.js b/tests/unit/editorThemes.test.js index 30ccb06..8f8914e 100644 --- a/tests/unit/editorThemes.test.js +++ b/tests/unit/editorThemes.test.js @@ -1,10 +1,6 @@ import { EditorState } from "@codemirror/state"; +import { getThemeConfig, getThemeExtensions, getThemes } from "cm/themes"; import { describe, expect, it } from "vitest"; -import { - getThemeConfig, - getThemeExtensions, - getThemes, -} from "cm/themes"; describe("built-in editor themes", () => { it("uses the base editor foreground for plain VS Code text", () => { @@ -68,9 +64,10 @@ describe("built-in editor themes", () => { if (!activeLine) continue; expect(activeLine, `${id} active line`).toMatch(/^#[\da-f]{8}$/i); - expect(Number.parseInt(activeLine.slice(-2), 16), `${id} active line`).toBeLessThan( - 255, - ); + expect( + Number.parseInt(activeLine.slice(-2), 16), + `${id} active line`, + ).toBeLessThan(255); } }); }); diff --git a/tests/unit/fileIconAssets.test.ts b/tests/unit/fileIconAssets.test.ts index eef03d7..c32b5dd 100644 --- a/tests/unit/fileIconAssets.test.ts +++ b/tests/unit/fileIconAssets.test.ts @@ -1,6 +1,7 @@ // @vitest-environment happy-dom -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + import fileIcons from "lib/fileIcons"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const requests: FakeImage[] = []; class FakeImage { @@ -122,9 +123,7 @@ describe("icon assets", () => { const [file, folder] = document.body.querySelectorAll("span"); expect(file.className).toBe("pack-file icon"); - expect(folder.className).toBe( - "pack-folder icon user-added-storage", - ); + expect(folder.className).toBe("pack-folder icon user-added-storage"); }); it("uses distinct CSS classes for IDs that previously collided", () => { diff --git a/tests/unit/fileIconOwnership.test.ts b/tests/unit/fileIconOwnership.test.ts index 032e9d9..83b62f4 100644 --- a/tests/unit/fileIconOwnership.test.ts +++ b/tests/unit/fileIconOwnership.test.ts @@ -1,6 +1,7 @@ // @vitest-environment happy-dom -import { afterEach, expect, it, vi } from "vitest"; + import fileIcons from "lib/fileIcons"; +import { afterEach, expect, it, vi } from "vitest"; afterEach(() => fileIcons.resetForTests()); diff --git a/tests/unit/fileIconSidebar.test.ts b/tests/unit/fileIconSidebar.test.ts index c83bfe0..2a6eef2 100644 --- a/tests/unit/fileIconSidebar.test.ts +++ b/tests/unit/fileIconSidebar.test.ts @@ -1,6 +1,7 @@ // @vitest-environment happy-dom -import { afterEach, beforeEach, expect, it, vi } from "vitest"; + import fileIcons from "lib/fileIcons"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; const sidebar = vi.hoisted(() => ({ on: vi.fn() })); vi.mock("components/sidebar", () => ({ default: sidebar })); diff --git a/tests/unit/fileIconTheme.test.ts b/tests/unit/fileIconTheme.test.ts index 6f81cf0..ecd0058 100644 --- a/tests/unit/fileIconTheme.test.ts +++ b/tests/unit/fileIconTheme.test.ts @@ -1,5 +1,5 @@ -import { afterEach, describe, expect, it } from "vitest"; import fileIcons, { BUILTIN_THEME_ID } from "lib/fileIcons"; +import { afterEach, describe, expect, it } from "vitest"; afterEach(() => { fileIcons.resetForTests(); diff --git a/tests/unit/fileIntents.test.js b/tests/unit/fileIntents.test.js index 2800f19..06440a4 100644 --- a/tests/unit/fileIntents.test.js +++ b/tests/unit/fileIntents.test.js @@ -79,7 +79,10 @@ it("queues cold-start and in-startup batches until both files and plugins are re expect(f.open).not.toHaveBeenCalled(); f.ready(); await f.processPendingIntents(); - expect(f.open.mock.calls.map(([uri]) => uri)).toEqual(["content://docs/1", "content://docs/2"]); + expect(f.open.mock.calls.map(([uri]) => uri)).toEqual([ + "content://docs/1", + "content://docs/2", + ]); expect(f.open).toHaveBeenLastCalledWith("content://docs/2", { mode: "single", render: true, @@ -98,7 +101,11 @@ it("serializes warm batches and deduplicates stream/ClipData URIs without losing finish = resolve; }), ); - const first = f.send(["content://docs/1", "content://docs/1", "file:///second.pdf"]); + const first = f.send([ + "content://docs/1", + "content://docs/1", + "file:///second.pdf", + ]); const second = f.send(["content://docs/3"], "SEND"); expect(f.open).toHaveBeenCalledTimes(1); finish(); @@ -164,36 +171,43 @@ it("continues after failures and offers Plugins once per batch, escaping provide const items = f.select.mock.calls[0][1]; expect(items[0].text).toContain("<img src=x>.pdf"); expect(items[0].text).toContain("content://docs/2"); - expect(items.map((item) => item.value)).toEqual([undefined, "plugins", "close"]); + expect(items.map((item) => item.value)).toEqual([ + undefined, + "plugins", + "close", + ]); expect(f.exec).toHaveBeenCalledWith("open", "plugins"); }); -it.each(["cancel", "reject"])( - "continues queued and later intents when the error dialog ends with %s", - async (outcome) => { - const f = setup(); - f.ready(); - vi.spyOn(console, "error").mockImplementation(() => {}); - f.open.mockRejectedValueOnce({ - code: "DOCUMENT_HANDLER_UNAVAILABLE", - filename: "missing.docx", - }); - f.select.mockImplementationOnce((_title, _items, options) => { - if (outcome === "reject") return Promise.reject(Error("Dialog failed")); - options.onCancel(); - return new Promise(() => {}); - }); - await Promise.all([f.send(["content://docs/missing"]), f.send(["content://docs/queued"])]); - await f.send(["content://docs/next"]); - expect(f.open.mock.calls.map(([uri]) => uri)).toEqual([ - "content://docs/missing", - "content://docs/queued", - "content://docs/next", - ]); - expect(f.reportError).toHaveBeenCalledTimes(outcome === "reject" ? 1 : 0); - expect(f.exec).not.toHaveBeenCalled(); - }, -); +it.each([ + "cancel", + "reject", +])("continues queued and later intents when the error dialog ends with %s", async (outcome) => { + const f = setup(); + f.ready(); + vi.spyOn(console, "error").mockImplementation(() => {}); + f.open.mockRejectedValueOnce({ + code: "DOCUMENT_HANDLER_UNAVAILABLE", + filename: "missing.docx", + }); + f.select.mockImplementationOnce((_title, _items, options) => { + if (outcome === "reject") return Promise.reject(Error("Dialog failed")); + options.onCancel(); + return new Promise(() => {}); + }); + await Promise.all([ + f.send(["content://docs/missing"]), + f.send(["content://docs/queued"]), + ]); + await f.send(["content://docs/next"]); + expect(f.open.mock.calls.map(([uri]) => uri)).toEqual([ + "content://docs/missing", + "content://docs/queued", + "content://docs/next", + ]); + expect(f.reportError).toHaveBeenCalledTimes(outcome === "reject" ? 1 : 0); + 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(); diff --git a/tests/unit/fileOperationExclusions.test.js b/tests/unit/fileOperationExclusions.test.js index 65ec3bc..ccc173b 100644 --- a/tests/unit/fileOperationExclusions.test.js +++ b/tests/unit/fileOperationExclusions.test.js @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; import { isExcludedFileOperationPath } from "utils/fileOperationExclusions"; +import { describe, expect, it } from "vitest"; const patterns = [ "**/node_modules/**", @@ -35,10 +35,7 @@ describe("isExcludedFileOperationPath", () => { it("normalizes Windows separators", () => { expect( - isExcludedFileOperationPath( - "C:\\project\\package.egg-info", - patterns, - ), + isExcludedFileOperationPath("C:\\project\\package.egg-info", patterns), ).toBe(true); }); diff --git a/tests/unit/fileSaveEvents.test.js b/tests/unit/fileSaveEvents.test.js index 6ad1582..f3726be 100644 --- a/tests/unit/fileSaveEvents.test.js +++ b/tests/unit/fileSaveEvents.test.js @@ -1,51 +1,107 @@ // @vitest-environment happy-dom -import { afterEach, expect, it, vi } from "vitest"; -import { EditorState, EditorSelection } from "@codemirror/state"; + +import { EditorSelection, EditorState } from "@codemirror/state"; import tag from "html-tag-js"; +import { afterEach, expect, it, vi } from "vitest"; import { loadSourceModule } from "../helpers/loadSourceModule"; -const deferred = () => { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b; }); return { promise, resolve, reject }; }; +const deferred = () => { + let resolve, reject; + const promise = new Promise((a, b) => { + resolve = a; + reject = b; + }); + return { promise, resolve, reject }; +}; function setup() { const write = vi.fn(async () => true); - const manager = { files: [], getFile: () => null, addFile: file => manager.files.push(file), emit: vi.fn(), onupdate: vi.fn(), activeFile: null }; - const defaults = Object.fromEntries([ - "fileSystem", "components/quickTools", "components/sidebar", "components/toast", "dialogs/confirm", "handlers/editorFileTab", "handlers/quickTools", "lib/quickToolsAdapter", "handlers/tabContextMenu", "dompurify", "utils/mimeTypes", "utils/codeHighlight", "utils/Path", "utils/remoteFilePreview", "utils/Url", "./loadPlugins", "./openFolder", "./runLazily", "cm/editorReadOnly", - ].map(id => [id, {}])); - const { default: EditorFile } = loadSourceModule("src/lib/editorFile.js", { - ...defaults, - "utils/haptic": vi.fn(), - "components/sidebar": { hide: vi.fn() }, - "@codemirror/state": { EditorState, EditorSelection }, - "cm/editorUtils": { getDocText: doc => doc.toString() }, - "cm/modelist": { getModeForPath: () => "text", getMode: () => ({ name: "text", extensions: [], getExtension: () => [] }) }, - "html-tag-js": tag, - "components/tile": options => { - const element = document.createElement("li"); - element.innerHTML = ''; - element.tail = vi.fn(); - element.text = options.text; - return element; + const manager = { + files: [], + getFile: () => null, + addFile: (file) => manager.files.push(file), + emit: vi.fn(), + onupdate: vi.fn(), + activeFile: null, + }; + const defaults = Object.fromEntries( + [ + "fileSystem", + "components/quickTools", + "components/sidebar", + "components/toast", + "dialogs/confirm", + "handlers/editorFileTab", + "handlers/quickTools", + "lib/quickToolsAdapter", + "handlers/tabContextMenu", + "dompurify", + "utils/mimeTypes", + "utils/codeHighlight", + "utils/Path", + "utils/remoteFilePreview", + "utils/Url", + "./loadPlugins", + "./openFolder", + "./runLazily", + "cm/editorReadOnly", + ].map((id) => [id, {}]), + ); + const { default: EditorFile } = loadSourceModule( + "src/lib/editorFile.js", + { + ...defaults, + "utils/haptic": vi.fn(), + "components/sidebar": { hide: vi.fn() }, + "@codemirror/state": { EditorState, EditorSelection }, + "cm/editorUtils": { getDocText: (doc) => doc.toString() }, + "cm/modelist": { + getModeForPath: () => "text", + getMode: () => ({ + name: "text", + extensions: [], + getExtension: () => [], + }), + }, + "html-tag-js": tag, + "components/tile": (options) => { + const element = document.createElement("li"); + element.innerHTML = ''; + element.tail = vi.fn(); + element.text = options.text; + return element; + }, + "utils/helpers": { + normalizeMtime: () => null, + getIconForFile: () => "file", + }, + "./config": { DEFAULT_FILE_SESSION: "default" }, + "./settings": { value: {}, on: vi.fn(), off: vi.fn() }, + "./saveFile": write, }, - "utils/helpers": { normalizeMtime: () => null, getIconForFile: () => "file" }, - "./config": { DEFAULT_FILE_SESSION: "default" }, - "./settings": { value: {}, on: vi.fn(), off: vi.fn() }, - "./saveFile": write, - }, { document, window, tag, editorManager: manager }); + { document, window, tag, editorManager: manager }, + ); EditorFile.prototype.setMode = vi.fn(); // Language setup is unrelated to save routing. EditorFile.prototype.writeToCache = vi.fn(async () => {}); - const file = (type = "docs") => new EditorFile("test.txt", { - id: String(manager.files.length), type, content: document.createElement("div"), render: false, text: "original", - }); + const file = (type = "docs") => + new EditorFile("test.txt", { + id: String(manager.files.length), + type, + content: document.createElement("div"), + render: false, + text: "original", + }); return { file, write, manager }; } afterEach(() => document.body.replaceChildren()); it("enables only custom tabs with listeners or onsave, and never uses the text writer", async () => { - const f = setup(), doc = f.file(), terminal = f.file("terminal"); + const f = setup(), + doc = f.file(), + terminal = f.file("terminal"); expect(doc.canSave).toBe(false); expect(await doc.save()).toBe(false); expect(await terminal.saveAs()).toBe(false); - const handler = e => e.respondWith(Promise.resolve(true)); + const handler = (e) => e.respondWith(Promise.resolve(true)); doc.on("save", handler); expect(doc.canSave).toBe(true); expect(await doc.save()).toBe(true); @@ -58,10 +114,17 @@ it("enables only custom tabs with listeners or onsave, and never uses the text w expect(f.write).not.toHaveBeenCalled(); }); it("awaits one response, distinguishes Save As and shares repeated requests per tab", async () => { - const f = setup(), one = f.file(), two = f.file(), hold = deferred(); - const events = [], handler = vi.fn(e => { events.push(e); e.respondWith(hold.promise); }); + const f = setup(), + one = f.file(), + two = f.file(), + hold = deferred(); + const events = [], + handler = vi.fn((e) => { + events.push(e); + e.respondWith(hold.promise); + }); one.on("save", handler); - two.on("save", e => e.respondWith(Promise.resolve(false))); + two.on("save", (e) => e.respondWith(Promise.resolve(false))); const saving = one.saveAs(); expect(one.save()).toBe(saving); await Promise.resolve(); @@ -75,50 +138,71 @@ it("awaits one response, distinguishes Save As and shares repeated requests per expect(events[1].saveAs).toBe(false); }); it("rejects failures, duplicate and late responses, and releases pending requests", async () => { - const f = setup(), file = f.file(); + const f = setup(), + file = f.file(); let event; - const fail = e => { event = e; e.respondWith(Promise.reject(Error("disk full"))); }; + const fail = (e) => { + event = e; + e.respondWith(Promise.reject(Error("disk full"))); + }; file.on("save", fail); await expect(file.save()).rejects.toThrow("disk full"); expect(() => event.respondWith(Promise.resolve(true))).toThrow(/once/); file.off("save", fail); - const duplicate = e => { e.respondWith(Promise.resolve(true)); e.respondWith(Promise.resolve(true)); }; + const duplicate = (e) => { + e.respondWith(Promise.resolve(true)); + e.respondWith(Promise.resolve(true)); + }; file.on("save", duplicate); await expect(file.save()).rejects.toThrow(/once/); file.off("save", duplicate); - file.on("save", e => { event = e; }); + file.on("save", (e) => { + event = e; + }); expect(await file.save()).toBe(false); expect(() => event.respondWith(Promise.resolve(true))).toThrow(/during/); }); it("preserves text fallback and existing cancellation/listener ordering", async () => { - const f = setup(), file = f.file("editor"); + const f = setup(), + file = f.file("editor"); file.flushCacheWrite = vi.fn(async () => {}); expect(file.canSave).toBe(true); await file.saveAs(); - expect(f.write).toHaveBeenCalledWith(file, true, { automatic: false, savedDoc: file.session.doc }); - const cancel = e => e.preventDefault(), observe = vi.fn(); + expect(f.write).toHaveBeenCalledWith(file, true, { + automatic: false, + savedDoc: file.session.doc, + }); + const cancel = (e) => e.preventDefault(), + observe = vi.fn(); file.onsave = cancel; file.on("save", observe); expect(await file.save()).toBe(false); expect(observe).toHaveBeenCalledOnce(); expect(f.write).toHaveBeenCalledOnce(); - file.onsave = e => { e.preventDefault(); e.stopPropagation(); }; + file.onsave = (e) => { + e.preventDefault(); + e.stopPropagation(); + }; await file.save(); expect(observe).toHaveBeenCalledOnce(); - file.onsave = e => e.respondWith(Promise.resolve(true)); + file.onsave = (e) => e.respondWith(Promise.resolve(true)); expect(await file.save()).toBe(true); expect(f.write).toHaveBeenCalledOnce(); }); - it("drops completion notifications after closing the captured custom tab", async () => { - const f = setup(), file = f.file(), hold = deferred(); +it("drops completion notifications after closing the captured custom tab", async () => { + const f = setup(), + file = f.file(), + hold = deferred(); f.file(); - file.on("save", e => e.respondWith(hold.promise)); + file.on("save", (e) => e.respondWith(hold.promise)); const saving = file.save(); await Promise.resolve(); await file.remove(true); expect(file.canSave).toBe(false); hold.resolve(true); expect(await saving).toBe(false); - expect(f.manager.emit.mock.calls.some(([name]) => name === "save-file")).toBe(false); + expect(f.manager.emit.mock.calls.some(([name]) => name === "save-file")).toBe( + false, + ); }); diff --git a/tests/unit/fileTreeRecycling.test.ts b/tests/unit/fileTreeRecycling.test.ts index 1c9d574..74a3e48 100644 --- a/tests/unit/fileTreeRecycling.test.ts +++ b/tests/unit/fileTreeRecycling.test.ts @@ -1,7 +1,8 @@ // @vitest-environment happy-dom -import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import tag from "html-tag-js"; + import FileTree from "components/fileTree"; +import tag from "html-tag-js"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; vi.mock("utils/helpers", () => ({ default: { diff --git a/tests/unit/fullscreen.test.js b/tests/unit/fullscreen.test.js index eb067b9..0f52bc7 100644 --- a/tests/unit/fullscreen.test.js +++ b/tests/unit/fullscreen.test.js @@ -29,9 +29,15 @@ it("validates orientation requests and waits for native lock/unlock acceptance", exec.mockImplementation(() => {}); for (const mode of ["landscape", "portrait", null]) { const settled = vi.fn(); - const request = (mode ? orientation.lock(mode) : orientation.unlock()).then(settled); + const request = (mode ? orientation.lock(mode) : orientation.unlock()).then( + settled, + ); const [success, , service, action, args] = exec.mock.lastCall; - expect([service, action, args]).toEqual(["System", "set-fullscreen-orientation", [mode]]); + expect([service, action, args]).toEqual([ + "System", + "set-fullscreen-orientation", + [mode], + ]); await Promise.resolve(); expect(settled).not.toHaveBeenCalled(); success(); @@ -47,7 +53,9 @@ describe("fullscreen Back ownership", () => { for (const value of [undefined, false, "pause", {}]) { await expect(api.setBackHandler(value)).rejects.toThrow(TypeError); } - await expect(api.setBackHandler(() => {})).rejects.toThrow(/requires fullscreen/); + await expect(api.setBackHandler(() => {})).rejects.toThrow( + /requires fullscreen/, + ); expect(exec).not.toHaveBeenCalled(); await expect(api.setBackHandler(null)).resolves.toBeUndefined(); await expect(api.setBackHandler(null)).resolves.toBeUndefined(); @@ -111,21 +119,24 @@ describe("fullscreen Back ownership", () => { expect(old).toHaveBeenCalledOnce(); }); - it.each(["throw", "reject"])( - "exits the original session when a callback fails: %s", - async (kind) => { - change({}); - await api.setBackHandler(() => { - if (kind === "throw") throw new Error("pause failed"); - return Promise.reject(new Error("pause failed")); - }); - back(); - await flush(); - expect(doc.exitFullscreen).toHaveBeenCalledOnce(); - }, - ); + it.each([ + "throw", + "reject", + ])("exits the original session when a callback fails: %s", async (kind) => { + change({}); + await api.setBackHandler(() => { + if (kind === "throw") throw new Error("pause failed"); + return Promise.reject(new Error("pause failed")); + }); + back(); + await flush(); + expect(doc.exitFullscreen).toHaveBeenCalledOnce(); + }); - it.each(["replace", "reenter"])("ignores an async callback failure after %s", async (kind) => { + it.each([ + "replace", + "reenter", + ])("ignores an async callback failure after %s", async (kind) => { const owner = {}; change(owner); let reject; @@ -164,36 +175,40 @@ describe("fullscreen Back ownership", () => { expect(doc.exitFullscreen).toHaveBeenCalledOnce(); }); - it.each(["resolve", "reject"])( - "cleans up pending registration after exit and %s, before a new owner registers", - async (result) => { - change({}); - const old = vi.fn(), - queued = vi.fn(), - next = vi.fn(); - let complete; - exec.mockImplementationOnce((resolve, reject) => { - complete = result === "resolve" ? resolve : reject; - }); - const pending = api.setBackHandler(old); - const failure = expect(pending).rejects.toThrow(); - const obsolete = api.setBackHandler(queued); - const obsoleteFailure = expect(obsolete).rejects.toThrow(/session changed/); - await flush(); - change(null); - change({}); - const fresh = api.setBackHandler(next); - complete("old request"); - await failure; - await obsoleteFailure; - await fresh; - expect(exec.mock.calls.map((call) => call[4][0])).toEqual([true, false, true]); - back(); - expect(next).toHaveBeenCalledOnce(); - expect(old).not.toHaveBeenCalled(); - expect(queued).not.toHaveBeenCalled(); - }, - ); + it.each([ + "resolve", + "reject", + ])("cleans up pending registration after exit and %s, before a new owner registers", async (result) => { + change({}); + const old = vi.fn(), + queued = vi.fn(), + next = vi.fn(); + let complete; + exec.mockImplementationOnce((resolve, reject) => { + complete = result === "resolve" ? resolve : reject; + }); + const pending = api.setBackHandler(old); + const failure = expect(pending).rejects.toThrow(); + const obsolete = api.setBackHandler(queued); + const obsoleteFailure = expect(obsolete).rejects.toThrow(/session changed/); + await flush(); + change(null); + change({}); + const fresh = api.setBackHandler(next); + complete("old request"); + await failure; + await obsoleteFailure; + await fresh; + expect(exec.mock.calls.map((call) => call[4][0])).toEqual([ + true, + false, + true, + ]); + back(); + expect(next).toHaveBeenCalledOnce(); + expect(old).not.toHaveBeenCalled(); + expect(queued).not.toHaveBeenCalled(); + }); it("invalidates a queued release on exit so it cannot release the next session", async () => { change({}); @@ -210,6 +225,10 @@ describe("fullscreen Back ownership", () => { const next = api.setBackHandler(() => {}); complete(); await Promise.all([failure, release, next]); - expect(exec.mock.calls.map((call) => call[4][0])).toEqual([true, false, true]); + expect(exec.mock.calls.map((call) => call[4][0])).toEqual([ + true, + false, + true, + ]); }); }); diff --git a/tests/unit/iap.test.ts b/tests/unit/iap.test.ts index 6bca377..02ff169 100644 --- a/tests/unit/iap.test.ts +++ b/tests/unit/iap.test.ts @@ -1,5 +1,5 @@ -import { afterEach, beforeEach, expect, test, vi } from "vitest"; import iap from "native/iap"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; const exec = vi.fn(); diff --git a/tests/unit/iconTooltips.test.js b/tests/unit/iconTooltips.test.js index 17ed23e..b870c7f 100644 --- a/tests/unit/iconTooltips.test.js +++ b/tests/unit/iconTooltips.test.js @@ -1,6 +1,7 @@ // @vitest-environment happy-dom import { afterEach, expect, it, vi } from "vitest"; import { installIconTooltips } from "../../src/components/tooltip/longPress"; + let controller; afterEach(() => { controller?.dispose(); diff --git a/tests/unit/installSource.test.js b/tests/unit/installSource.test.js index e5ec612..5039eff 100644 --- a/tests/unit/installSource.test.js +++ b/tests/unit/installSource.test.js @@ -1,5 +1,5 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; import { INSTALL_SOURCE_PLAY, isPlayStoreInstall } from "utils/installSource"; +import { afterEach, describe, expect, it, vi } from "vitest"; afterEach(() => { vi.unstubAllGlobals(); @@ -12,7 +12,9 @@ describe("isPlayStoreInstall", () => { }); it("returns false for any other install source", () => { - vi.stubGlobal("window", { appInstallSource: "com.android.packageinstaller" }); + vi.stubGlobal("window", { + appInstallSource: "com.android.packageinstaller", + }); expect(isPlayStoreInstall()).toBe(false); }); diff --git a/tests/unit/internalFsWrite.test.js b/tests/unit/internalFsWrite.test.js index f7c092d..ab80ad5 100644 --- a/tests/unit/internalFsWrite.test.js +++ b/tests/unit/internalFsWrite.test.js @@ -5,43 +5,69 @@ describe("local file writes", () => { it("saves an individually granted file without requesting its parent folder", async () => { const fixture = setup(false); const bytes = new Uint8Array([0, 127, 128, 255]).buffer; - await expect(fixture.fs.writeFile(fixture.url, bytes)).resolves.toBe(fixture.url); + await expect(fixture.fs.writeFile(fixture.url, bytes)).resolves.toBe( + fixture.url, + ); expect(fixture.written).toEqual([bytes]); - expect(fixture.resolve.mock.calls.map(([url]) => url)).toEqual([fixture.url]); + expect(fixture.resolve.mock.calls.map(([url]) => url)).toEqual([ + fixture.url, + ]); }); it("retains exclusive creation and explicit replacement flags", async () => { const fixture = setup(true); - await expect(fixture.fs.writeFile(fixture.url, "new", true)).resolves.toBe(fixture.url); - await expect(fixture.fs.writeFile(fixture.url, "replace", true, false)).resolves.toBe(fixture.url); - expect(fixture.getFile.mock.calls.map(([name, flags]) => [name, flags])).toEqual([ + await expect(fixture.fs.writeFile(fixture.url, "new", true)).resolves.toBe( + fixture.url, + ); + await expect( + fixture.fs.writeFile(fixture.url, "replace", true, false), + ).resolves.toBe(fixture.url); + expect( + fixture.getFile.mock.calls.map(([name, flags]) => [name, flags]), + ).toEqual([ ["test # 日本語.txt", { create: true, exclusive: true }], ["test # 日本語.txt", { create: true, exclusive: false }], ]); expect(fixture.written).toEqual(["new", "replace"]); - fixture.getFile.mockImplementation((name, flags, success, failure) => failure({ code: 12 })); - await expect(fixture.fs.writeFile(fixture.url, "collision", true)).rejects.toMatchObject({ code: 12 }); + fixture.getFile.mockImplementation((name, flags, success, failure) => + failure({ code: 12 }), + ); + await expect( + fixture.fs.writeFile(fixture.url, "collision", true), + ).rejects.toMatchObject({ code: 12 }); expect(fixture.written).toEqual(["new", "replace"]); }); it("does not create a missing file during an ordinary save", async () => { const fixture = setup(true); - fixture.resolve.mockImplementation((url, success, failure) => failure({ code: 1 })); - await expect(fixture.fs.writeFile(fixture.url, "missing")).rejects.toMatchObject({ code: 1 }); + fixture.resolve.mockImplementation((url, success, failure) => + failure({ code: 1 }), + ); + await expect( + fixture.fs.writeFile(fixture.url, "missing"), + ).rejects.toMatchObject({ code: 1 }); expect(fixture.written).toEqual([]); }); it("retains the type-mismatch error when the path is a directory", async () => { const fixture = setup(true); - fixture.resolve.mockImplementation((url, success) => success({ isDirectory: true })); - await expect(fixture.fs.writeFile(fixture.url, "directory")).rejects.toMatchObject({ code: 11 }); + fixture.resolve.mockImplementation((url, success) => + success({ isDirectory: true }), + ); + await expect( + fixture.fs.writeFile(fixture.url, "directory"), + ).rejects.toMatchObject({ code: 11 }); expect(fixture.written).toEqual([]); }); it("reports writer creation errors instead of leaving the save pending", async () => { const fixture = setup(true); - fixture.file.createWriter.mockImplementation((success, failure) => failure({ code: 2 })); - await expect(fixture.fs.writeFile(fixture.url, "denied")).rejects.toMatchObject({ code: 2 }); + fixture.file.createWriter.mockImplementation((success, failure) => + failure({ code: 2 }), + ); + await expect( + fixture.fs.writeFile(fixture.url, "denied"), + ).rejects.toMatchObject({ code: 2 }); expect(fixture.written).toEqual([]); }); @@ -56,7 +82,9 @@ describe("local file writes", () => { }; success(writer); }); - await expect(fixture.fs.writeFile(fixture.url, "full")).rejects.toMatchObject({ code: 10 }); + await expect( + fixture.fs.writeFile(fixture.url, "full"), + ).rejects.toMatchObject({ code: 10 }); }); }); @@ -66,7 +94,12 @@ function setup(folderAllowed) { const written = []; const file = { createWriter: vi.fn((success) => { - const writer = { write(data) { written.push(data); writer.onwriteend(); } }; + const writer = { + write(data) { + written.push(data); + writer.onwriteend(); + }, + }; success(writer); }), }; @@ -76,12 +109,16 @@ function setup(folderAllowed) { else if (path === parent && folderAllowed) success({ getFile }); else failure({ code: 2 }); }); - const { default: fs } = loadSourceModule("src/fileSystem/internalFs.js", { - fileSystem: {}, - "lib/ajax": {}, - "utils/encodings": {}, - "utils/helpers": {}, - "utils/Url": { dirname: (path) => path.slice(0, path.lastIndexOf("/")) }, - }, { window: { resolveLocalFileSystemURL: resolve } }); + const { default: fs } = loadSourceModule( + "src/fileSystem/internalFs.js", + { + fileSystem: {}, + "lib/ajax": {}, + "utils/encodings": {}, + "utils/helpers": {}, + "utils/Url": { dirname: (path) => path.slice(0, path.lastIndexOf("/")) }, + }, + { window: { resolveLocalFileSystemURL: resolve } }, + ); return { fs, url, file, getFile, resolve, written }; } diff --git a/tests/unit/internalUrl.test.ts b/tests/unit/internalUrl.test.ts index 77a996b..355b7f7 100644 --- a/tests/unit/internalUrl.test.ts +++ b/tests/unit/internalUrl.test.ts @@ -39,18 +39,20 @@ 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( + 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, `${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", - ); + 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", () => { @@ -80,5 +82,7 @@ it("defers when a root matches only as a string prefix of a sibling", () => { nativeURL: "file:///storage/emulated/0/Doc/", }), ); - expect(toInternalURL(fileSystems, "file:///storage/emulated/0/Docs/a.js")).toBeNull(); + expect( + toInternalURL(fileSystems, "file:///storage/emulated/0/Docs/a.js"), + ).toBeNull(); }); diff --git a/tests/unit/iosApi.test.ts b/tests/unit/iosApi.test.ts index 3a934ed..ed3a04d 100644 --- a/tests/unit/iosApi.test.ts +++ b/tests/unit/iosApi.test.ts @@ -13,12 +13,24 @@ beforeEach(() => { readyState = 1; withCredentials = false; headers: Record = {}; - open(...args: unknown[]) { Object.assign(this, { openArgs: args }); } - send(body: unknown) { Object.assign(this, { sentBody: body }); } - abort() { Object.assign(this, { aborted: true }); } - get responseURL() { return "browser-url"; } - setRequestHeader(name: string, value: string) { this.headers[name] = value; } - getResponseHeader() { return "https://acode.app/api/final"; } + open(...args: unknown[]) { + Object.assign(this, { openArgs: args }); + } + send(body: unknown) { + Object.assign(this, { sentBody: body }); + } + abort() { + Object.assign(this, { aborted: true }); + } + get responseURL() { + return "browser-url"; + } + setRequestHeader(name: string, value: string) { + this.headers[name] = value; + } + getResponseHeader() { + return "https://acode.app/api/final"; + } } vi.stubGlobal("XMLHttpRequest", XHR); vi.stubGlobal("ProgressEvent", Event); @@ -31,43 +43,75 @@ test("normalizes multipart XHR and cancels pending body preparation", async () = const xhr = new XMLHttpRequest(); xhr.open("POST", "https://acode.app/api/upload"); xhr.send(form); - await vi.waitFor(() => expect((xhr as unknown as { sentBody: unknown }).sentBody).toBeInstanceOf(ArrayBuffer)); - const state = xhr as unknown as { sentBody: ArrayBuffer; headers: Record }; + await vi.waitFor(() => + expect((xhr as unknown as { sentBody: unknown }).sentBody).toBeInstanceOf( + ArrayBuffer, + ), + ); + const state = xhr as unknown as { + sentBody: ArrayBuffer; + headers: Record; + }; expect(new TextDecoder().decode(state.sentBody)).toContain("✓ bytes"); - expect(state.headers["Content-Type"]).toContain("multipart/form-data; boundary="); + expect(state.headers["Content-Type"]).toContain( + "multipart/form-data; boundary=", + ); const cancelled = new XMLHttpRequest(); cancelled.open("POST", "https://acode.app/api/upload"); const aborted = vi.fn(); cancelled.addEventListener("abort", aborted); cancelled.send(form); cancelled.abort(); - await new Promise(resolve => setTimeout(resolve, 10)); + await new Promise((resolve) => setTimeout(resolve, 10)); expect(aborted).toHaveBeenCalledOnce(); - expect((cancelled as unknown as { sentBody: unknown }).sentBody).toBeUndefined(); + expect( + (cancelled as unknown as { sentBody: unknown }).sentBody, + ).toBeUndefined(); }); afterEach(() => vi.unstubAllGlobals()); test("only routes Acode HTTPS API requests through the iOS transport", async () => { - for (const url of ["https://example.com/api/login", "https://acode.app.evil.test/api/login", "http://acode.app/api/login", "https://acode.app:8443/api/login", "https://acode.app/plugins", "acode://localhost/__cdvfile_cache__/file.txt"]) { + for (const url of [ + "https://example.com/api/login", + "https://acode.app.evil.test/api/login", + "http://acode.app/api/login", + "https://acode.app:8443/api/login", + "https://acode.app/plugins", + "acode://localhost/__cdvfile_cache__/file.txt", + ]) { await window.fetch(url); expect(transport.mock.calls.at(-1)?.[0]).toBe(url); } - await window.fetch("https://acode.app/api/login?test=one%20two", { credentials: "include" }); + await window.fetch("https://acode.app/api/login?test=one%20two", { + credentials: "include", + }); const [url, options] = transport.mock.calls.at(-1)!; expect(url).toBe("acode://localhost/__api__/login?test=one%20two"); expect(options.headers.get("X-Acode-Credentials")).toBe("include"); }); test("retains Request bodies, content types, cancellation and response URLs", async () => { - transport.mockImplementation(async () => new Response("ok", { headers: { "X-Acode-Response-URL": "https://acode.app/api/final" } })); + transport.mockImplementation( + async () => + new Response("ok", { + headers: { "X-Acode-Response-URL": "https://acode.app/api/final" }, + }), + ); const controller = new AbortController(); const body = new FormData(); body.append("message", "✓ # %"); - const request = new Request("https://acode.app/api/example", { method: "POST", body, signal: controller.signal, credentials: "omit" }); + const request = new Request("https://acode.app/api/example", { + method: "POST", + body, + signal: controller.signal, + credentials: "omit", + }); const response = await window.fetch(request); const options = transport.mock.calls.at(-1)![1]; expect(new TextDecoder().decode(options.body)).toContain("✓ # %"); - expect(options.headers.get("content-type")).toMatch(/^multipart\/form-data; boundary=/); + expect(options.headers.get("content-type")).toMatch( + /^multipart\/form-data; boundary=/, + ); expect(options.headers.get("X-Acode-Credentials")).toBe("omit"); controller.abort(); expect(options.signal.aborted).toBe(true); @@ -82,7 +126,11 @@ test("preserves XHR methods and credentials without replacing the browser class" xhr.open("POST", "https://acode.app/api/review", true); xhr.withCredentials = true; xhr.send("body"); - expect((xhr as unknown as { headers: Record }).headers["X-Acode-Credentials"]).toBe("include"); + expect( + (xhr as unknown as { headers: Record }).headers[ + "X-Acode-Credentials" + ], + ).toBe("include"); Object.assign(xhr, { readyState: 4 }); expect(xhr.responseURL).toBe("https://acode.app/api/final"); xhr.open("GET", "https://example.com/"); diff --git a/tests/unit/iosFileMenu.test.js b/tests/unit/iosFileMenu.test.js index 2ec483e..3b087e6 100644 --- a/tests/unit/iosFileMenu.test.js +++ b/tests/unit/iosFileMenu.test.js @@ -2,10 +2,19 @@ import { readFileSync } from "node:fs"; import mustache from "mustache"; import { expect, it } from "vitest"; -const template = readFileSync(new URL("../../src/views/file-menu.hbs", import.meta.url), "utf8"); +const template = readFileSync( + new URL("../../src/views/file-menu.hbs", import.meta.url), + "utf8", +); -it.each([false, true])("shows Android-only file actions when supported: %s", (androidIntents) => { - const menu = mustache.render(template, { file_on_disk: true, android_intents: androidIntents }); +it.each([ + false, + true, +])("shows Android-only file actions when supported: %s", (androidIntents) => { + const menu = mustache.render(template, { + file_on_disk: true, + android_intents: androidIntents, + }); for (const action of ["edit-with", "pin-file-shortcut"]) { expect(menu.includes(`action="${action}"`)).toBe(androidIntents); } diff --git a/tests/unit/iosFilePaths.test.ts b/tests/unit/iosFilePaths.test.ts index 527ec0b..5df6e42 100644 --- a/tests/unit/iosFilePaths.test.ts +++ b/tests/unit/iosFilePaths.test.ts @@ -6,37 +6,86 @@ describe("iOS saved file paths", () => { const old = "file:///old/Documents"; const next = "file:///current/Documents"; const storage = createStorage({ - files: [{ uri: `${old}/hello # 日本語.txt`, filename: "hello # 日本語.txt", isUnsaved: true }], - folders: [{ url: old, opts: { listState: { [old]: true, [`${old}/child`]: false } } }], - recentFiles: [`${old}/hello.txt`, "sftp://profile-1/file", `${old}-other/file`], + files: [ + { + uri: `${old}/hello # 日本語.txt`, + filename: "hello # 日本語.txt", + isUnsaved: true, + }, + ], + folders: [ + { + url: old, + opts: { listState: { [old]: true, [`${old}/child`]: false } }, + }, + ], + recentFiles: [ + `${old}/hello.txt`, + "sftp://profile-1/file", + `${old}-other/file`, + ], recentFolders: [{ url: `${old}/child` }], storageList: [{ uri: old }], fileBrowserState: [{ url: old, name: "Documents" }], unrelated: { text: `Source code mentioning ${old}/file` }, }); restorePaths([{ from: old, to: next }], storage); - expect(JSON.parse(storage.getItem("files")!)).toEqual([{ uri: `${next}/hello # 日本語.txt`, filename: "hello # 日本語.txt", isUnsaved: true }]); - expect(JSON.parse(storage.getItem("folders")!)[0]).toEqual({ url: next, opts: { listState: { [next]: true, [`${next}/child`]: false } } }); - expect(JSON.parse(storage.getItem("recentFiles")!)).toEqual([`${next}/hello.txt`, "sftp://profile-1/file", `${old}-other/file`]); - expect(JSON.parse(storage.getItem("storageList")!)).toEqual([{ uri: next }]); - expect(JSON.parse(storage.getItem("recentFolders")!)).toEqual([{ url: `${next}/child` }]); - expect(JSON.parse(storage.getItem("fileBrowserState")!)).toEqual([{ url: next, name: "Documents" }]); - expect(JSON.parse(storage.getItem("unrelated")!)).toEqual({ text: `Source code mentioning ${old}/file` }); + expect(JSON.parse(storage.getItem("files")!)).toEqual([ + { + uri: `${next}/hello # 日本語.txt`, + filename: "hello # 日本語.txt", + isUnsaved: true, + }, + ]); + expect(JSON.parse(storage.getItem("folders")!)[0]).toEqual({ + url: next, + opts: { listState: { [next]: true, [`${next}/child`]: false } }, + }); + expect(JSON.parse(storage.getItem("recentFiles")!)).toEqual([ + `${next}/hello.txt`, + "sftp://profile-1/file", + `${old}-other/file`, + ]); + expect(JSON.parse(storage.getItem("storageList")!)).toEqual([ + { uri: next }, + ]); + expect(JSON.parse(storage.getItem("recentFolders")!)).toEqual([ + { url: `${next}/child` }, + ]); + expect(JSON.parse(storage.getItem("fileBrowserState")!)).toEqual([ + { url: next, name: "Documents" }, + ]); + expect(JSON.parse(storage.getItem("unrelated")!)).toEqual({ + text: `Source code mentioning ${old}/file`, + }); storage.setItem = vi.fn(storage.setItem); restorePaths([{ from: old, to: next }], storage); expect(storage.setItem).not.toHaveBeenCalled(); }); it("prefers the closest root and preserves malformed entries", () => { - const storage = createStorage({ recentFiles: ["file:///old/project/a.txt"] }); + const storage = createStorage({ + recentFiles: ["file:///old/project/a.txt"], + }); storage.setItem("files", "invalid json"); - restorePaths([{ from: "file:///old", to: "file:///new" }, { from: "file:///old/project", to: "file:///provider/renamed" }], storage); - expect(JSON.parse(storage.getItem("recentFiles")!)).toEqual(["file:///provider/renamed/a.txt"]); + restorePaths( + [ + { from: "file:///old", to: "file:///new" }, + { from: "file:///old/project", to: "file:///provider/renamed" }, + ], + storage, + ); + expect(JSON.parse(storage.getItem("recentFiles")!)).toEqual([ + "file:///provider/renamed/a.txt", + ]); expect(storage.getItem("files")).toBe("invalid json"); }); it("keeps startup available when one stored value cannot be written", () => { - const storage = createStorage({ files: [{ uri: "file:///old/file.txt" }], recentFiles: ["file:///old/file.txt"] }); + const storage = createStorage({ + files: [{ uri: "file:///old/file.txt" }], + recentFiles: ["file:///old/file.txt"], + }); const write = storage.setItem; storage.setItem = (key, value) => { if (key === "files") throw new Error("Quota exceeded"); @@ -44,29 +93,59 @@ describe("iOS saved file paths", () => { }; const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); try { - expect(() => restorePaths([{ from: "file:///old", to: "file:///new" }], storage)).not.toThrow(); - expect(JSON.parse(storage.getItem("files")!)[0].uri).toBe("file:///old/file.txt"); - expect(JSON.parse(storage.getItem("recentFiles")!)).toEqual(["file:///new/file.txt"]); - } finally { warn.mockRestore(); } + expect(() => + restorePaths([{ from: "file:///old", to: "file:///new" }], storage), + ).not.toThrow(); + expect(JSON.parse(storage.getItem("files")!)[0].uri).toBe( + "file:///old/file.txt", + ); + expect(JSON.parse(storage.getItem("recentFiles")!)).toEqual([ + "file:///new/file.txt", + ]); + } finally { + warn.mockRestore(); + } }); it("preserves encoded path components and does not relocate current destinations again", () => { - const storage = createStorage({ recentFiles: ["file:///old%20container/Documents/a%20%23%20%E6%97%A5.txt", "file:///old/current/file.txt"] }); - restorePaths([ - { from: "file:///old%20container/Documents", to: "file:///current%20container/Documents" }, - { from: "file:///old", to: "file:///old/current" }, - ], storage); - expect(JSON.parse(storage.getItem("recentFiles")!)).toEqual(["file:///current%20container/Documents/a%20%23%20%E6%97%A5.txt", "file:///old/current/file.txt"]); + const storage = createStorage({ + recentFiles: [ + "file:///old%20container/Documents/a%20%23%20%E6%97%A5.txt", + "file:///old/current/file.txt", + ], + }); + restorePaths( + [ + { + from: "file:///old%20container/Documents", + to: "file:///current%20container/Documents", + }, + { from: "file:///old", to: "file:///old/current" }, + ], + storage, + ); + expect(JSON.parse(storage.getItem("recentFiles")!)).toEqual([ + "file:///current%20container/Documents/a%20%23%20%E6%97%A5.txt", + "file:///old/current/file.txt", + ]); }); }); function createStorage(values: Record): Storage { - const data = new Map(Object.entries(values).map(([key, value]) => [key, JSON.stringify(value)])); + const data = new Map( + Object.entries(values).map(([key, value]) => [key, JSON.stringify(value)]), + ); return { - get length() { return data.size; }, + get length() { + return data.size; + }, getItem: (key) => data.get(key) ?? null, - setItem: (key, value) => { data.set(key, value); }, - removeItem: (key) => { data.delete(key); }, + setItem: (key, value) => { + data.set(key, value); + }, + removeItem: (key) => { + data.delete(key); + }, clear: () => data.clear(), key: (index) => [...data.keys()][index] ?? null, }; diff --git a/tests/unit/iosFullscreen.test.ts b/tests/unit/iosFullscreen.test.ts index bb7f78a..96f6801 100644 --- a/tests/unit/iosFullscreen.test.ts +++ b/tests/unit/iosFullscreen.test.ts @@ -2,34 +2,60 @@ import { afterAll, afterEach, beforeAll, expect, test, vi } from "vitest"; import installFullscreen from "../../src/platforms/ios/fullscreen"; -const native = vi.fn<(active: boolean) => Promise>().mockResolvedValue(undefined); +const native = vi + .fn<(active: boolean) => Promise>() + .mockResolvedValue(undefined); let nativeOwner: Element | null = null; -const nativeRequest = vi.fn(function (this: Element) { nativeOwner = this; return Promise.resolve(); }); -const nativeExit = vi.fn(async () => { nativeOwner = null; }); +const nativeRequest = vi.fn(function (this: Element) { + nativeOwner = this; + return Promise.resolve(); +}); +const nativeExit = vi.fn(async () => { + nativeOwner = null; +}); const descriptors = [ - [document, "fullscreenElement"], [ShadowRoot.prototype, "fullscreenElement"], - [document, "fullscreenEnabled"], [Element.prototype, "requestFullscreen"], - [document, "exitFullscreen"], [HTMLElement.prototype, "showPopover"], + [document, "fullscreenElement"], + [ShadowRoot.prototype, "fullscreenElement"], + [document, "fullscreenEnabled"], + [Element.prototype, "requestFullscreen"], + [document, "exitFullscreen"], + [HTMLElement.prototype, "showPopover"], [HTMLElement.prototype, "hidePopover"], [ShadowRoot.prototype, "styleSheets"], [CSSStyleRule.prototype, "selectorText"], [Document.prototype, "fullscreenElement"], ] as const; -const saved = descriptors.map(([target, key]) => Object.getOwnPropertyDescriptor(target, key)); +const saved = descriptors.map(([target, key]) => + Object.getOwnPropertyDescriptor(target, key), +); beforeAll(() => { - Object.defineProperty(Document.prototype, "fullscreenElement", { configurable: true, get: () => nativeOwner }); + Object.defineProperty(Document.prototype, "fullscreenElement", { + configurable: true, + get: () => nativeOwner, + }); Element.prototype.requestFullscreen = nativeRequest; document.exitFullscreen = nativeExit; const selectors = new WeakMap(); - const selector = Object.getOwnPropertyDescriptor(CSSStyleRule.prototype, "selectorText")!.get!; + const selector = Object.getOwnPropertyDescriptor( + CSSStyleRule.prototype, + "selectorText", + )!.get!; Object.defineProperty(CSSStyleRule.prototype, "selectorText", { configurable: true, - get() { return selectors.get(this) ?? selector.call(this); }, - set(value: string) { selectors.set(this, value); }, + get() { + return selectors.get(this) ?? selector.call(this); + }, + set(value: string) { + selectors.set(this, value); + }, }); Object.defineProperty(ShadowRoot.prototype, "styleSheets", { configurable: true, - get() { return [...this.querySelectorAll("style")].map((style: HTMLStyleElement) => style.sheet).filter(Boolean); }, + get() { + return [...this.querySelectorAll("style")] + .map((style: HTMLStyleElement) => style.sheet) + .filter(Boolean); + }, }); HTMLElement.prototype.showPopover = function () {}; HTMLElement.prototype.hidePopover = function () {}; @@ -38,7 +64,9 @@ beforeAll(() => { afterEach(async () => { await document.exitFullscreen(); document.body.replaceChildren(); - document.head.querySelectorAll("[data-fullscreen-test]").forEach(style => style.remove()); + document.head + .querySelectorAll("[data-fullscreen-test]") + .forEach((style) => style.remove()); native.mockClear(); nativeRequest.mockClear(); nativeExit.mockClear(); @@ -63,7 +91,11 @@ test("keeps manual fullscreen and exit working with unreadable cross-origin styl document.body.append(target); const adopted = document.adoptedStyleSheets; const foreignSheet = new CSSStyleSheet(); - Object.defineProperty(foreignSheet, "cssRules", { get() { throw new DOMException("Cross-origin stylesheet", "SecurityError"); } }); + Object.defineProperty(foreignSheet, "cssRules", { + get() { + throw new DOMException("Cross-origin stylesheet", "SecurityError"); + }, + }); document.adoptedStyleSheets = [foreignSheet]; try { await target.requestFullscreen(); @@ -78,12 +110,15 @@ test("keeps manual fullscreen and exit working with unreadable cross-origin styl expect(native).toHaveBeenLastCalledWith(false); expect(nativeRequest).not.toHaveBeenCalled(); expect(nativeExit).not.toHaveBeenCalled(); - } finally { document.adoptedStyleSheets = adopted; } + } finally { + document.adoptedStyleSheets = adopted; + } }); test("forces inline important layout and restores styles including changes made while fullscreen", async () => { const target = document.createElement("div"); - target.style.cssText = "position:absolute!important;left:30px!important;width:80px!important;height:70px!important;margin:12px;padding:9px;border:3px solid red;transform:translateX(5px)"; + target.style.cssText = + "position:absolute!important;left:30px!important;width:80px!important;height:70px!important;margin:12px;padding:9px;border:3px solid red;transform:translateX(5px)"; document.body.append(target); await target.requestFullscreen(); expect(target.style.width).toBe("100%"); @@ -107,41 +142,63 @@ test("forces inline important layout and restores styles including changes made test("adapts fullscreen CSS in place without changing attribute values or dynamic declarations", async () => { const style = document.createElement("style"); style.dataset.fullscreenTest = ""; - style.textContent = 'div:fullscreen > button { color:red } [data-label=":fullscreen"] { color:blue } @media (min-width:0px) { div:not(:fullscreen) { opacity:0.5 } }'; + style.textContent = + 'div:fullscreen > button { color:red } [data-label=":fullscreen"] { color:blue } @media (min-width:0px) { div:not(:fullscreen) { opacity:0.5 } }'; document.head.append(style); const target = document.createElement("div"); document.body.append(target); await target.requestFullscreen(); const sheet = style.sheet!; const rule = sheet.cssRules[0] as CSSStyleRule; - expect(rule.selectorText).toBe("div:is(:fullscreen, [data-acode-fullscreen]) > button"); - expect((sheet.cssRules[1] as CSSStyleRule).selectorText).toBe('[data-label=":fullscreen"]'); + expect(rule.selectorText).toBe( + "div:is(:fullscreen, [data-acode-fullscreen]) > button", + ); + expect((sheet.cssRules[1] as CSSStyleRule).selectorText).toBe( + '[data-label=":fullscreen"]', + ); const group = sheet.cssRules[2] as CSSGroupingRule; group.insertRule("div:fullscreen { opacity:1 }", 1); - expect((group.cssRules[1] as CSSStyleRule).selectorText).toContain("[data-acode-fullscreen]"); + expect((group.cssRules[1] as CSSStyleRule).selectorText).toContain( + "[data-acode-fullscreen]", + ); const adopted = new CSSStyleSheet(); adopted.replaceSync("div:fullscreen { color:teal }"); - expect((adopted.cssRules[0] as CSSStyleRule).selectorText).toContain("[data-acode-fullscreen]"); + expect((adopted.cssRules[0] as CSSStyleRule).selectorText).toContain( + "[data-acode-fullscreen]", + ); await adopted.replace("div:fullscreen { color:cyan }"); - expect((adopted.cssRules[0] as CSSStyleRule).selectorText).toContain("[data-acode-fullscreen]"); + expect((adopted.cssRules[0] as CSSStyleRule).selectorText).toContain( + "[data-acode-fullscreen]", + ); rule.style.color = "green"; sheet.insertRule("div:fullscreen { background:purple }", 3); - expect((sheet.cssRules[3] as CSSStyleRule).selectorText).toContain("[data-acode-fullscreen]"); + expect((sheet.cssRules[3] as CSSStyleRule).selectorText).toContain( + "[data-acode-fullscreen]", + ); style.textContent = "div:fullscreen { color:orange }"; - await vi.waitFor(() => expect((style.sheet!.cssRules[0] as CSSStyleRule).selectorText).toContain("[data-acode-fullscreen]")); + await vi.waitFor(() => + expect((style.sheet!.cssRules[0] as CSSStyleRule).selectorText).toContain( + "[data-acode-fullscreen]", + ), + ); await document.exitFullscreen(); expect(rule.style.color).toBe("green"); expect(target.hasAttribute("data-acode-fullscreen")).toBe(false); }); -test.each([false, true])("Back consumes a native dialog close request, including cancellation: %s", async (prevented) => { +test.each([ + false, + true, +])("Back consumes a native dialog close request, including cancellation: %s", async (prevented) => { const target = document.createElement("div"); const dialog = document.createElement("dialog"); target.append(dialog); document.body.append(target); await target.requestFullscreen(); dialog.showModal(); - const cancel = vi.fn((event: Event) => { if (prevented) event.preventDefault(); }); + const cancel = vi.fn((event: Event) => { + if (prevented) event.preventDefault(); + }); dialog.addEventListener("cancel", cancel); const back = vi.fn(); document.addEventListener("fullscreenbackbutton", back); @@ -151,7 +208,10 @@ test.each([false, true])("Back consumes a native dialog close request, including expect(dialog.open).toBe(prevented); expect(back).not.toHaveBeenCalled(); expect(document.fullscreenElement).toBe(target); - } finally { document.removeEventListener("fullscreenbackbutton", back); dialog.close(); } + } finally { + document.removeEventListener("fullscreenbackbutton", back); + dialog.close(); + } }); afterAll(() => { descriptors.forEach(([target, key], index) => { @@ -213,7 +273,9 @@ test("retargets nested shadow owners and exits when an outer shadow ancestor is expect(outer.fullscreenElement).toBe(inner); expect(shadow.fullscreenElement).toBe(target); inner.remove(); - await vi.waitFor(() => expect(target.hasAttribute("data-acode-fullscreen")).toBe(false)); + await vi.waitFor(() => + expect(target.hasAttribute("data-acode-fullscreen")).toBe(false), + ); expect(document.fullscreenElement).toBeNull(); expect(native).toHaveBeenLastCalledWith(false); }); @@ -233,7 +295,11 @@ test("rolls back a failed native entry and rejects disconnected elements", async await vi.waitFor(() => expect(document.fullscreenElement).toBeNull()); }); -test.each(["menu", "body", "game"])("Back closes a shadow menu when focus is on %s", async (focus) => { +test.each([ + "menu", + "body", + "game", +])("Back closes a shadow menu when focus is on %s", async (focus) => { const owner = document.createElement("div"); document.body.append(owner); const shadow = owner.attachShadow({ mode: "open" }); diff --git a/tests/unit/iosGuestPath.test.js b/tests/unit/iosGuestPath.test.js index 136b0d1..096fae9 100644 --- a/tests/unit/iosGuestPath.test.js +++ b/tests/unit/iosGuestPath.test.js @@ -1,5 +1,5 @@ -import { beforeEach, describe, expect, it } from "vitest"; import iosGuestPath from "lib/iosGuestPath"; +import { beforeEach, describe, expect, it } from "vitest"; describe("iosGuestPath", () => { beforeEach(() => { diff --git a/tests/unit/iosInput.test.ts b/tests/unit/iosInput.test.ts index 715560f..9372c4a 100644 --- a/tests/unit/iosInput.test.ts +++ b/tests/unit/iosInput.test.ts @@ -9,7 +9,8 @@ afterEach(() => { }); test("disables suggestions without changing input types, autofill or selection, then restores field defaults", () => { - document.body.innerHTML = '
editor
'; + document.body.innerHTML = + '
editor
'; const input = document.querySelector("input")!; const area = document.querySelector("textarea")!; area.focus(); @@ -20,13 +21,19 @@ test("disables suggestions without changing input types, autofill or selection, expect(input.type).toBe("email"); expect(input.autocomplete).toBe("email"); expect(document.activeElement).toBe(area); - expect([area.selectionStart, area.selectionEnd, area.value]).toEqual([1, 3, "code"]); + expect([area.selectionStart, area.selectionEnd, area.value]).toEqual([ + 1, + 3, + "code", + ]); setInputType("NO_SUGGESTIONS"); setInputType("NORMAL"); expect(input.getAttribute("autocorrect")).toBe("on"); expect(input.hasAttribute("writingsuggestions")).toBe(false); expect(area.getAttribute("spellcheck")).toBe("false"); - expect(document.querySelector("div")!.getAttribute("autocorrect")).toBe("off"); + expect(document.querySelector("div")!.getAttribute("autocorrect")).toBe( + "off", + ); }); test("handles dynamic plugin fields, author attribute updates, removal and repeated mode changes", async () => { @@ -44,7 +51,7 @@ test("handles dynamic plugin fields, author attribute updates, removal and repea expect(field.getAttribute("autocorrect")).toBe("off"); setInputType("NORMAL"); expect(field.getAttribute("autocorrect")).toBe("on"); - await new Promise(resolve => setTimeout(resolve, 10)); + await new Promise((resolve) => setTimeout(resolve, 10)); expect(field.getAttribute("autocorrect")).toBe("on"); }); diff --git a/tests/unit/iosLaunch.test.js b/tests/unit/iosLaunch.test.js index 96e9a36..83792af 100644 --- a/tests/unit/iosLaunch.test.js +++ b/tests/unit/iosLaunch.test.js @@ -8,7 +8,12 @@ const require = createRequire(import.meta.url); const filename = path.resolve(import.meta.dirname, "../../dev/scripts/ios.js"); const source = fs.readFileSync(filename, "utf8"); -test.each([{ args: [] }, { args: ["--device"] }])("iOS run $args opens Xcode without requiring a booted simulator", ({ args }) => { +test.each([ + { args: [] }, + { args: ["--device"] }, +])("iOS run $args opens Xcode without requiring a booted simulator", ({ + args, +}) => { const result = runScript(["run", ...args]); expect(result.process.exitCode).toBeUndefined(); expect(result.spawn.mock.calls.map(([command]) => command)).toEqual([ @@ -37,13 +42,27 @@ test("an explicit simulator target still builds, boots, installs and launches", const result = runScript(["run", "--target=simulator", "--skip-web"]); expect(result.process.exitCode).toBeUndefined(); const calls = result.spawn.mock.calls; - expect(calls.find(([command]) => command === "xcodebuild")[1]).toContain("iphonesimulator"); + expect(calls.find(([command]) => command === "xcodebuild")[1]).toContain( + "iphonesimulator", + ); expect(calls.some(([command]) => command === "open")).toBe(false); - expect(calls.map(([, args]) => args)).toContainEqual(["simctl", "boot", "simulator"]); expect(calls.map(([, args]) => args)).toContainEqual([ - "simctl", "install", "simulator", expect.stringMatching(/Debug-iphonesimulator\/runner\.app$/), + "simctl", + "boot", + "simulator", + ]); + expect(calls.map(([, args]) => args)).toContainEqual([ + "simctl", + "install", + "simulator", + expect.stringMatching(/Debug-iphonesimulator\/runner\.app$/), + ]); + expect(calls.map(([, args]) => args)).toContainEqual([ + "simctl", + "launch", + "simulator", + "app.acode", ]); - expect(calls.map(([, args]) => args)).toContainEqual(["simctl", "launch", "simulator", "app.acode"]); }); test("a device build remains unsigned until the user runs it through Xcode", () => { @@ -51,9 +70,9 @@ test("a device build remains unsigned until the user runs it through Xcode", () expect(result.process.exitCode).toBeUndefined(); expect(result.spawn).toHaveBeenCalledOnce(); expect(result.spawn.mock.calls[0][0]).toBe("xcodebuild"); - expect(result.spawn.mock.calls[0][1]).toEqual(expect.arrayContaining([ - "iphoneos", "CODE_SIGNING_ALLOWED=NO", - ])); + expect(result.spawn.mock.calls[0][1]).toEqual( + expect.arrayContaining(["iphoneos", "CODE_SIGNING_ALLOWED=NO"]), + ); }); test("a simulator selector cannot silently override physical-device mode", () => { @@ -67,9 +86,12 @@ function runScript(args) { const spawn = vi.fn(() => ({ status: 0, stdout: "", stderr: "" })); spawn.mockImplementation((command, args) => ({ status: 0, - stdout: command === "xcrun" && args[1] === "list" - ? JSON.stringify({ devices: { iOS: [{ udid: "simulator", state: "Shutdown" }] } }) - : "", + stdout: + command === "xcrun" && args[1] === "list" + ? JSON.stringify({ + devices: { iOS: [{ udid: "simulator", state: "Shutdown" }] }, + }) + : "", stderr: "", })); const prepareAds = vi.fn(); @@ -83,7 +105,10 @@ function runScript(args) { }; const scriptRequire = (name) => { if (name === "node:child_process") return { spawnSync: spawn }; - if (name === "../config") return { getAppConfig: () => ({ variant: "free", targetId: "app.acode" }) }; + if (name === "../config") + return { + getAppConfig: () => ({ variant: "free", targetId: "app.acode" }), + }; if (name === "../sync") return { sync }; if (name === "./iosAds") return { prepareAds }; return require(name); @@ -92,12 +117,16 @@ function runScript(args) { scriptRequire.resolve = require.resolve; const log = vi.fn(); const error = vi.fn(); - vm.runInNewContext(source, { - require: scriptRequire, - module, - process: scriptProcess, - console: { log, error }, - __dirname: path.dirname(filename), - }, { filename }); + vm.runInNewContext( + source, + { + require: scriptRequire, + module, + process: scriptProcess, + console: { log, error }, + __dirname: path.dirname(filename), + }, + { filename }, + ); return { spawn, prepareAds, sync, process: scriptProcess, log, error }; } diff --git a/tests/unit/iosWebContextMenu.test.js b/tests/unit/iosWebContextMenu.test.js index ea0dfc9..47670ac 100644 --- a/tests/unit/iosWebContextMenu.test.js +++ b/tests/unit/iosWebContextMenu.test.js @@ -1,6 +1,6 @@ import fs from "node:fs"; -import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { Window } from "happy-dom"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; const source = fs.readFileSync( new URL("../../platforms/ios/runner/WebContextMenu.swift", import.meta.url), diff --git a/tests/unit/javascriptHighlighting.test.ts b/tests/unit/javascriptHighlighting.test.ts new file mode 100644 index 0000000..8299837 --- /dev/null +++ b/tests/unit/javascriptHighlighting.test.ts @@ -0,0 +1,261 @@ +import { + ensureSyntaxTree, + foldable, + getIndentation, + highlightingFor, +} from "@codemirror/language"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { highlightTree, type Tag, tags as t } from "@lezer/highlight"; +import { memberProperty } from "cm/highlightTags"; +import { javascriptSupport } from "cm/modes/javascript"; +import { getThemeExtensions, getThemes } from "cm/themes"; +import { describe, expect, it } from "vitest"; + +type Config = { jsx?: boolean; typescript?: boolean }; + +function createState(doc: string, config: Config = {}) { + return EditorState.create({ doc, extensions: javascriptSupport(config) }); +} + +/** Highlighted ranges as `[tags, text, from]`, skipping whitespace. */ +function highlights(state: EditorState) { + const doc = state.doc.toString(); + const tree = ensureSyntaxTree(state, doc.length, 5000); + if (!tree) throw new Error("parse did not finish"); + const out: [string, string, number][] = []; + highlightTree( + tree, + { style: (tags) => tags.map(String).join(" ") }, + (from, to, tags) => { + const text = doc.slice(from, to); + if (text.trim()) out.push([tags, text, from]); + }, + ); + return out; +} + +/** Texts highlighted with exactly `tags`. */ +function textsWith(state: EditorState, tags: string) { + return highlights(state) + .filter(([style]) => style === tags) + .map(([, text]) => text); +} + +const jsdoc = `const before = 1; +/** + * Adds numbers, mail me@example.com. + * @param {number} a - first + * @param {Array<{ + * id: string + * }> | null} list - multiline type + * @param {string} [text="["] quoted bracket, see {@link Foo.bar|bar} + * @param {'don\\'t' | "a\\"b"} quoted escaped quotes + * @returns {Promise} + * @typedef {Object} Shape + * @template T + */ +function add(a, list, text, quoted) {}`; + +describe.each([ + ["JavaScript", { jsx: true }], + ["TypeScript", { typescript: true }], +])("JSDoc highlighting in %s", (_name, config) => { + const state = createState(jsdoc, config); + + it("highlights block tags, types, parameter names and links", () => { + expect(textsWith(state, "keyword")).toEqual([ + "@param", + "@param", + "@param", + "@link", + "@param", + "@returns", + "@typedef", + "@template", + ]); + expect(textsWith(state, "typeName")).toEqual([ + "number", + "Array", + "id", + "string", + "null", + "string", + "'don\\'t'", + '"a\\"b"', + "Promise", + "void", + "Object", + "Shape", + "T", + ]); + expect(textsWith(state, "variableName")).toEqual([ + "a", + "list", + "text", + "quoted", + ]); + expect(textsWith(state, "link")).toEqual(["Foo.bar"]); + }); + + it("places ranges at the right document positions", () => { + const doc = state.doc.toString(); + for (const [, text, from] of highlights(state)) { + expect(doc.slice(from, from + text.length)).toBe(text); + } + const param = highlights(state).find(([, text]) => text === "@param"); + expect(param?.[2]).toBe(doc.indexOf("@param")); + }); + + it("leaves braces, punctuation and prose as doc comment text", () => { + const docText = textsWith(state, "docComment").join(" "); + expect(docText).toContain("me@example.com"); + expect(docText).toContain("{"); + expect(docText).toContain("["); + + const doc = state.doc.toString(); + const commentEnd = doc.indexOf("*/") + 2; + const inComment = highlights(state).filter( + ([, , from]) => from >= doc.indexOf("/**") && from < commentEnd, + ); + const commentTags = new Set(inComment.map(([tags]) => tags)); + expect([...commentTags].sort()).toEqual([ + "docComment", + "keyword", + "link", + "typeName", + "variableName", + ]); + }); + + it("keeps comment behaviour from the host language", () => { + const inside = state.doc.toString().indexOf("@returns"); + expect(state.languageDataAt("commentTokens", inside)).toEqual([ + { line: "//", block: { open: "/*", close: "*/" } }, + ]); + expect(getIndentation(state, state.doc.line(4).from)).toBeNull(); + const line = state.doc.line(2); + expect(foldable(state, line.from, line.to)).toEqual({ + from: line.from + 2, + to: state.doc.toString().indexOf("*/", line.from), + }); + }); +}); + +describe("JSDoc highlighting edge cases", () => { + it("leaves plain block comments alone", () => { + const state = createState( + "/* @param {T} a */ /**/ /*** @param {T} b */ let x;", + ); + expect(textsWith(state, "keyword")).toEqual([]); + expect(textsWith(state, "blockComment")).toEqual([ + "/* @param {T} a */", + "/**/", + "/*** @param {T} b */", + ]); + }); + + it("does not colour the rest of the comment after an unclosed type", () => { + const state = createState("/** @returns { unclosed\n * prose words */"); + expect(textsWith(state, "keyword")).toEqual(["@returns"]); + expect(textsWith(state, "typeName")).toEqual([]); + }); + + it("updates highlighting after an edit", () => { + let state = createState("/**\n * @param {string} a\n */\nlet x;"); + expect(textsWith(state, "typeName")).toEqual(["string"]); + + const at = state.doc.toString().indexOf("string"); + state = state.update({ + changes: { from: at, to: at + "string".length, insert: "number" }, + }).state; + expect(textsWith(state, "typeName")).toEqual(["number"]); + + state = state.update({ + changes: { from: 0, insert: "/** @see {@link Q} */\n" }, + }).state; + expect(textsWith(state, "link")).toEqual(["Q"]); + expect(textsWith(state, "typeName")).toEqual(["number"]); + }); +}); + +describe("method names", () => { + const fnDefProp = "function(definition(propertyName))"; + + it("tags class and interface methods as function definitions", () => { + const state = createState( + `class A { + constructor(a) {} + get size() { return 1 } + static create() {} + #secret() {} + field = 1; +} +interface I { draw(): void; area: number }`, + { typescript: true }, + ); + expect(textsWith(state, fnDefProp)).toEqual([ + "constructor", + "size", + "create", + "#secret", + "draw", + ]); + expect(textsWith(state, "definition(memberProperty)")).toEqual([ + "field", + "area", + ]); + }); + + it("keeps method calls tagged as function properties", () => { + const state = createState("options.uri.hashCode(); const { a: b } = o;"); + expect(textsWith(state, "function(propertyName)")).toEqual(["hashCode"]); + expect(textsWith(state, "memberProperty")).toEqual(["uri", "a"]); + }); +}); + +describe("built-in theme function colours", () => { + const functionTags = [ + t.function(t.propertyName), + t.function(t.definition(t.propertyName)), + t.function(t.definition(t.variableName)), + ]; + + /** The CSS colour a theme gives `tag`. */ + function colorOf(state: EditorState, tag: Tag) { + const classes = highlightingFor(state, [tag]); + if (!classes) return null; + const rules = state + .facet(EditorView.styleModule) + .map((module) => module.getRules()) + .join("\n"); + for (const cls of classes.split(" ")) { + const rule = rules.match(new RegExp(`\\.${cls}\\s*\\{([^}]*)\\}`)); + const color = rule?.[1].match(/(?:^|;)\s*color:\s*([^;]+)/); + if (color) return color[1].trim(); + } + return null; + } + + it.each( + getThemes().map((theme) => [theme.id]), + )("%s colours methods like functions", (id) => { + const state = EditorState.create({ extensions: getThemeExtensions(id) }); + const expected = colorOf(state, t.function(t.variableName)); + expect(expected).toBeTruthy(); + for (const tag of functionTags) { + expect(colorOf(state, tag)).toBe(expected); + } + }); + + it.each( + getThemes().map((theme) => [theme.id]), + )("%s colours JS properties differently from methods", (id) => { + // VS Code's Solarized colours them alike too. + if (id.startsWith("solarized")) return; + const state = EditorState.create({ extensions: getThemeExtensions(id) }); + const method = colorOf(state, t.function(t.propertyName)); + expect(colorOf(state, memberProperty)).not.toBe(method); + expect(colorOf(state, t.definition(memberProperty))).not.toBe(method); + }); +}); diff --git a/tests/unit/languageModeRecommendations.test.js b/tests/unit/languageModeRecommendations.test.js index c737c68..2a96162 100644 --- a/tests/unit/languageModeRecommendations.test.js +++ b/tests/unit/languageModeRecommendations.test.js @@ -1,8 +1,8 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import "cm/supportedModes"; import { getModeForPath } from "cm/modelist"; -import notificationManager from "lib/notificationManager"; import recommendLanguageModeExtension from "lib/languageModeRecommendations"; +import notificationManager from "lib/notificationManager"; vi.mock("lib/notificationManager", () => ({ default: { diff --git a/tests/unit/lspExternalWebSocketLifecycle.test.js b/tests/unit/lspExternalWebSocketLifecycle.test.js index 227061a..674c463 100644 --- a/tests/unit/lspExternalWebSocketLifecycle.test.js +++ b/tests/unit/lspExternalWebSocketLifecycle.test.js @@ -1,10 +1,10 @@ // @vitest-environment happy-dom -import {StateEffect} from "@codemirror/state"; -import {EditorView} from "@codemirror/view"; -import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import { StateEffect } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const registry = vi.hoisted(() => ({servers: []})); +const registry = vi.hoisted(() => ({ servers: [] })); // Keep the lifecycle test independent of app UI modules that use JSX in .js // files, which Vitest's native import analysis does not transform. @@ -12,7 +12,8 @@ vi.mock("cm/lsp/serverRegistry", () => ({ default: { getServersForLanguage: (languageId) => registry.servers.filter( - (server) => server.enabled !== false && server.languages.includes(languageId), + (server) => + server.enabled !== false && server.languages.includes(languageId), ), }, })); @@ -25,18 +26,18 @@ vi.mock("components/lspStatusBar", () => ({ }, })); -vi.mock("components/settingsPage", () => ({default: vi.fn()})); +vi.mock("components/settingsPage", () => ({ default: vi.fn() })); vi.mock("components/checkbox", () => ({ default: vi.fn(), updateSwitchHandle: vi.fn(), })); vi.mock("lib/notificationManager", () => ({ - default: {add: vi.fn()}, + default: { add: vi.fn() }, })); vi.mock("lib/settings", () => ({ - default: {value: {lsp: {}}}, + default: { value: { lsp: {} } }, })); vi.mock("cm/lsp/diagnostics", () => ({ @@ -57,7 +58,7 @@ vi.mock("cm/lsp/inlayHints", () => ({ inlayHintsExtension: () => [], })); -vi.mock("cm/lsp/logs", () => ({addLspLog: vi.fn()})); +vi.mock("cm/lsp/logs", () => ({ addLspLog: vi.fn() })); vi.mock("cm/lsp/tooltipExtensions", () => ({ hoverTooltips: () => [], @@ -99,7 +100,7 @@ class TestWebSocket { TestWebSocket.instances.push(this); queueMicrotask(() => { this.readyState = TestWebSocket.OPEN; - this.onopen?.({type: "open"}); + this.onopen?.({ type: "open" }); }); } @@ -115,7 +116,7 @@ class TestWebSocket { data: JSON.stringify({ jsonrpc: "2.0", id: message.id, - result: {capabilities: {}}, + result: { capabilities: {} }, }), }); }); @@ -124,7 +125,7 @@ class TestWebSocket { close(code = 1000) { this.closeCalls++; this.readyState = TestWebSocket.CLOSED; - this.onclose?.({code, wasClean: code === 1000}); + this.onclose?.({ code, wasClean: code === 1000 }); } } @@ -140,7 +141,7 @@ class TestTransport { JSON.stringify({ jsonrpc: "2.0", id: message.id, - result: {capabilities: {}}, + result: { capabilities: {} }, }), ); }); @@ -168,7 +169,7 @@ beforeEach(() => { originalWebSocket = globalThis.WebSocket; globalThis.WebSocket = TestWebSocket; TestWebSocket.instances = []; - registerRuntimeProvider(externalWebSocketRuntimeProvider, {replace: true}); + registerRuntimeProvider(externalWebSocketRuntimeProvider, { replace: true }); registry.servers = [ { id: SERVER_ID, @@ -182,7 +183,7 @@ beforeEach(() => { }, }, ]; - view = new EditorView({doc: "fn main() {}", parent: document.body}); + view = new EditorView({ doc: "fn main() {}", parent: document.body }); }); afterEach(async () => { @@ -211,13 +212,13 @@ describe("LSP client idle lifecycle", () => { .find((message) => message.method === "initialize"); expect(initialize.params.capabilities.textDocument.codeAction).toEqual({ dataSupport: true, - resolveSupport: {properties: ["edit"]}, + resolveSupport: { properties: ["edit"] }, }); }); it("reuses an external WebSocket client when a file attaches during the grace period", async () => { - const onClientIdle = vi.fn(({dispose}) => void dispose()); - manager = new LspClientManager({onClientIdle}); + const onClientIdle = vi.fn(({ dispose }) => void dispose()); + manager = new LspClientManager({ onClientIdle }); const rootUri = "file:///workspace"; await manager.getExtensionsForFile({ @@ -245,8 +246,8 @@ describe("LSP client idle lifecycle", () => { }); it("disposes an unused external WebSocket client after the grace period", async () => { - const onClientIdle = vi.fn(({dispose}) => void dispose()); - manager = new LspClientManager({onClientIdle}); + const onClientIdle = vi.fn(({ dispose }) => void dispose()); + manager = new LspClientManager({ onClientIdle }); const rootUri = "file:///workspace-one"; const uri = `${rootUri}/only.rs`; @@ -291,11 +292,11 @@ describe("LSP client idle lifecycle", () => { }; }, }, - {replace: true}, + { replace: true }, ); registry.servers[0].runtimes = [TRANSPORT_RUNTIME_ID]; - const onClientIdle = vi.fn(({dispose}) => void dispose()); - manager = new LspClientManager({onClientIdle}); + const onClientIdle = vi.fn(({ dispose }) => void dispose()); + manager = new LspClientManager({ onClientIdle }); const rootUri = "file:///workspace"; const uri = `${rootUri}/only.rs`; diff --git a/tests/unit/lspLocationUtils.test.ts b/tests/unit/lspLocationUtils.test.ts index 8a006dd..743b15f 100644 --- a/tests/unit/lspLocationUtils.test.ts +++ b/tests/unit/lspLocationUtils.test.ts @@ -30,9 +30,7 @@ describe("normalizeLocations", () => { it("falls back to the LocationLink target range", () => { expect( - normalizeLocations([ - { targetUri: "file:///project/a.ts", targetRange }, - ]), + normalizeLocations([{ targetUri: "file:///project/a.ts", targetRange }]), ).toEqual([{ uri: "file:///project/a.ts", range: targetRange }]); }); }); diff --git a/tests/unit/lspMultiClient.test.js b/tests/unit/lspMultiClient.test.js index f46f597..73da942 100644 --- a/tests/unit/lspMultiClient.test.js +++ b/tests/unit/lspMultiClient.test.js @@ -1,16 +1,16 @@ // @vitest-environment happy-dom -import {CompletionContext} from "@codemirror/autocomplete"; -import {forEachDiagnostic} from "@codemirror/lint"; -import {Compartment} from "@codemirror/state"; -import {EditorView} from "@codemirror/view"; +import { CompletionContext } from "@codemirror/autocomplete"; +import { forEachDiagnostic } from "@codemirror/lint"; import { LSPClient, LSPPlugin, - Workspace, serverCompletionSource, serverDiagnostics, + Workspace, } from "@codemirror/lsp-client"; +import { Compartment } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; import { getLspDiagnostics, lspDiagnosticsAutoSyncExtension, @@ -18,7 +18,7 @@ import { lspDiagnosticsUiExtension, schedulePullDiagnostics, } from "cm/lsp/diagnostics"; -import {afterEach, describe, expect, it, vi} from "vitest"; +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 () => { @@ -78,14 +78,16 @@ class TestWorkspace extends Workspace { file.doc = changes.apply(prevDoc); file.version++; plugin.clear(); - updates.push({file, prevDoc, changes}); + updates.push({ file, prevDoc, changes }); } return updates; } } function client() { - return new LSPClient({workspace: (lspClient) => new TestWorkspace(lspClient)}); + return new LSPClient({ + workspace: (lspClient) => new TestWorkspace(lspClient), + }); } function editor(doc, extensions) { @@ -104,10 +106,10 @@ describe("multiple LSP clients on one editor", () => { const supplemental = client(); const bindings = new Compartment(); const uri = "file:///workspace/example.ts"; - const primaryBinding = primary.plugin(uri, "typescript", {priority: 100}); + const primaryBinding = primary.plugin(uri, "typescript", { priority: 100 }); const supplementalBinding = supplemental.plugin(uri, "typescript", { priority: 20, - features: {formatting: false, rename: false}, + features: { formatting: false, rename: false }, }); const view = editor( "const value = 1", @@ -121,23 +123,29 @@ describe("multiple LSP clients on one editor", () => { expect(LSPPlugin.getForFeature(view, "formatting")?.client).toBe(primary); expect(LSPPlugin.get(view, supplemental)?.client).toBe(supplemental); - view.dispatch({changes: {from: view.state.doc.length, insert: ";"}}); + view.dispatch({ changes: { from: view.state.doc.length, insert: ";" } }); const firstText = view.state.doc.toString(); - expect(LSPPlugin.get(view, primary).unsyncedChanges.apply( - primary.workspace.files[0].doc, - ).toString()).toBe(firstText); - expect(LSPPlugin.get(view, supplemental).unsyncedChanges.apply( - supplemental.workspace.files[0].doc, - ).toString()).toBe(firstText); + expect( + LSPPlugin.get(view, primary) + .unsyncedChanges.apply(primary.workspace.files[0].doc) + .toString(), + ).toBe(firstText); + expect( + LSPPlugin.get(view, supplemental) + .unsyncedChanges.apply(supplemental.workspace.files[0].doc) + .toString(), + ).toBe(firstText); expect(primary.workspace.syncFiles()).toHaveLength(1); expect(LSPPlugin.get(view, primary).unsyncedChanges.empty).toBe(true); expect(LSPPlugin.get(view, supplemental).unsyncedChanges.empty).toBe(false); - view.dispatch({changes: {from: 0, insert: "export "}}); - expect(LSPPlugin.get(view, supplemental).unsyncedChanges.apply( - supplemental.workspace.files[0].doc, - ).toString()).toBe(view.state.doc.toString()); + view.dispatch({ changes: { from: 0, insert: "export " } }); + expect( + LSPPlugin.get(view, supplemental) + .unsyncedChanges.apply(supplemental.workspace.files[0].doc) + .toString(), + ).toBe(view.state.doc.toString()); expect(supplemental.workspace.syncFiles()).toHaveLength(1); expect(LSPPlugin.get(view, supplemental).unsyncedChanges.empty).toBe(true); @@ -158,21 +166,27 @@ describe("multiple LSP clients on one editor", () => { const primary = client(); const supplemental = client(); const failing = client(); - primary.serverCapabilities = {completionProvider: {}}; - supplemental.serverCapabilities = {completionProvider: {}}; - failing.serverCapabilities = {completionProvider: {}}; + primary.serverCapabilities = { completionProvider: {} }; + supplemental.serverCapabilities = { completionProvider: {} }; + failing.serverCapabilities = { completionProvider: {} }; primary.sync = vi.fn(); supplemental.sync = vi.fn(); failing.sync = vi.fn(); - primary.request = vi.fn().mockResolvedValue([ - {label: "typescriptItem", detail: "TypeScript"}, - {label: "sharedItem"}, - ]); - supplemental.request = vi.fn().mockResolvedValue([ - {label: "tailwindItem", detail: "Tailwind CSS"}, - {label: "sharedItem"}, - ]); - failing.request = vi.fn().mockRejectedValue(new Error("server unavailable")); + primary.request = vi + .fn() + .mockResolvedValue([ + { label: "typescriptItem", detail: "TypeScript" }, + { label: "sharedItem" }, + ]); + supplemental.request = vi + .fn() + .mockResolvedValue([ + { label: "tailwindItem", detail: "Tailwind CSS" }, + { label: "sharedItem" }, + ]); + failing.request = vi + .fn() + .mockRejectedValue(new Error("server unavailable")); const view = editor("di", [ primary.plugin("file:///workspace/example.tsx", "typescriptreact", { @@ -203,30 +217,34 @@ describe("multiple LSP clients on one editor", () => { expect(failing.request).toHaveBeenCalledOnce(); expect(warn).toHaveBeenCalledWith( "[lsp] Completion provider failed", - expect.objectContaining({message: "server unavailable"}), + expect.objectContaining({ message: "server unavailable" }), ); }); it("does not query a client when completion is disabled for its binding", async () => { const primary = client(); const diagnosticsOnly = client(); - primary.serverCapabilities = {completionProvider: {}}; - diagnosticsOnly.serverCapabilities = {completionProvider: {}}; + primary.serverCapabilities = { completionProvider: {} }; + diagnosticsOnly.serverCapabilities = { completionProvider: {} }; primary.sync = vi.fn(); diagnosticsOnly.sync = vi.fn(); - primary.request = vi.fn().mockResolvedValue([{label: "primaryItem"}]); - diagnosticsOnly.request = vi.fn().mockResolvedValue([{label: "wrongItem"}]); + primary.request = vi.fn().mockResolvedValue([{ label: "primaryItem" }]); + diagnosticsOnly.request = vi + .fn() + .mockResolvedValue([{ label: "wrongItem" }]); const view = editor("x", [ primary.plugin("file:///workspace/example.ts", "typescript"), diagnosticsOnly.plugin("file:///workspace/example.ts", "typescript", { - features: {completion: false}, + features: { completion: false }, }), ]); const result = await serverCompletionSource( new CompletionContext(view.state, 1, true, view), ); - expect(result.options.map((option) => option.label)).toEqual(["primaryItem"]); + expect(result.options.map((option) => option.label)).toEqual([ + "primaryItem", + ]); expect(diagnosticsOnly.request).not.toHaveBeenCalled(); }); @@ -247,8 +265,8 @@ describe("multiple LSP clients on one editor", () => { message, severity: 2, range: { - start: {line: 0, character}, - end: {line: 0, character: character + 1}, + start: { line: 0, character }, + end: { line: 0, character: character + 1 }, }, }); @@ -286,7 +304,7 @@ describe("multiple LSP clients on one editor", () => { const view = editor("const x = 1", [ enabled.plugin(uri, "typescript"), disabled.plugin(uri, "typescript", { - features: {diagnostics: false}, + features: { diagnostics: false }, }), lspDiagnosticsAutoSyncExtension(), ]); @@ -295,7 +313,7 @@ describe("multiple LSP clients on one editor", () => { expect(enabled.sync).toHaveBeenCalledOnce(); expect(disabled.sync).not.toHaveBeenCalled(); - view.dispatch({changes: {from: view.state.doc.length, insert: ";"}}); + view.dispatch({ changes: { from: view.state.doc.length, insert: ";" } }); await vi.advanceTimersByTimeAsync(500); expect(enabled.sync).toHaveBeenCalledTimes(2); expect(disabled.sync).not.toHaveBeenCalled(); @@ -303,12 +321,12 @@ describe("multiple LSP clients on one editor", () => { it("rejects pull and push diagnostics from a disabled provider", () => { const disabled = client(); - disabled.serverCapabilities = {diagnosticProvider: {}}; + disabled.serverCapabilities = { diagnosticProvider: {} }; disabled.request = vi.fn(); const uri = "file:///workspace/example.ts"; const view = editor("const x = 1", [ disabled.plugin(uri, "typescript", { - features: {diagnostics: false}, + features: { diagnostics: false }, }), lspDiagnosticsUiExtension(false), ]); @@ -328,8 +346,8 @@ describe("multiple LSP clients on one editor", () => { message: "must stay hidden", severity: 2, range: { - start: {line: 0, character: 6}, - end: {line: 0, character: 7}, + start: { line: 0, character: 6 }, + end: { line: 0, character: 7 }, }, }, ], diff --git a/tests/unit/lspTailwindServer.test.js b/tests/unit/lspTailwindServer.test.js index 3ba677e..1e72fb4 100644 --- a/tests/unit/lspTailwindServer.test.js +++ b/tests/unit/lspTailwindServer.test.js @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; import { tailwindServers } from "cm/lsp/servers/tailwind"; +import { describe, expect, it } from "vitest"; describe("built-in Tailwind CSS language server", () => { it("is available but disabled by default", () => { diff --git a/tests/unit/lspWorkspaceFolders.test.js b/tests/unit/lspWorkspaceFolders.test.js index e5e8db6..29e9da1 100644 --- a/tests/unit/lspWorkspaceFolders.test.js +++ b/tests/unit/lspWorkspaceFolders.test.js @@ -1,11 +1,11 @@ // @vitest-environment happy-dom -import { describe, expect, it } from "vitest"; import { addJsTsLanguageAliases, isTailwindCssServer, resolveJsTsLanguageId, } from "cm/lsp/servers/shared"; +import { describe, expect, it } from "vitest"; describe("Tailwind document language IDs", () => { it("recognizes a custom server and supplies its standard aliases", () => { diff --git a/tests/unit/modelist.test.js b/tests/unit/modelist.test.js index 30fece6..ada2cb3 100644 --- a/tests/unit/modelist.test.js +++ b/tests/unit/modelist.test.js @@ -1,11 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; import "cm/supportedModes"; -import { - addMode, - getModeForPath, - getModes, - removeMode, -} from "cm/modelist"; +import { addMode, getModeForPath, getModes, removeMode } from "cm/modelist"; function getModeSpecificityScore(modeInstance) { if (modeInstance.name.toLowerCase() === "text") { @@ -122,9 +117,7 @@ function collectParityPaths() { source.endsWith("$") && !/[|()[*+?]/.test(source.slice(1, -1).replace(/\\./g, "")) ) { - const name = source - .slice(1, -1) - .replace(/\\(.)/g, "$1"); + const name = source.slice(1, -1).replace(/\\(.)/g, "$1"); if (name) paths.add(name); } } diff --git a/tests/unit/nativeBridge.test.js b/tests/unit/nativeBridge.test.js index d9595c8..65f48eb 100644 --- a/tests/unit/nativeBridge.test.js +++ b/tests/unit/nativeBridge.test.js @@ -1,57 +1,103 @@ import fs from "node:fs"; import { createRequire } from "node:module"; -import { Window } from "happy-dom"; import os from "node:os"; import path from "node:path"; -import { beforeAll, afterAll, afterEach, describe, expect, test } from "vitest"; +import { Window } from "happy-dom"; +import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest"; const require = createRequire(import.meta.url); const { rspack } = require("@rspack/core"); -const directory = fs.mkdtempSync(path.join(os.tmpdir(), "acode-native-bundles-")); +const directory = fs.mkdtempSync( + path.join(os.tmpdir(), "acode-native-bundles-"), +); const bundles = new Map(); beforeAll(async () => { - for (const [variant, fdroid] of [["paid", false], ["free", false], ["paid", true]]) { - const filename = `${variant}-${fdroid}.js`; - const compiler = rspack({ - mode: "development", - devtool: false, - entry: path.resolve("src/native/index.ts"), - output: { path: directory, filename }, - resolve: { extensions: [".js", ".ts"] }, - module: { rules: [ - { test: /\.ts$/, loader: "builtin:swc-loader", options: { jsc: { parser: { syntax: "typescript" }, target: "es2015" } } }, - { test: /\.js$/, type: "javascript/auto", loader: "builtin:swc-loader", options: { jsc: { parser: { syntax: "ecmascript" }, target: "es2015" } } }, - ] }, - plugins: [new rspack.DefinePlugin({ __FREE__: JSON.stringify(variant === "free"), __FDROID__: JSON.stringify(fdroid) })], - }); - await new Promise((resolve, reject) => compiler.run((error, stats) => compiler.close(() => { - if (error || stats.hasErrors()) reject(error || new Error(stats.toString())); - else resolve(); - }))); - bundles.set(filename, fs.readFileSync(path.join(directory, filename), "utf8")); - } + for (const [variant, fdroid] of [ + ["paid", false], + ["free", false], + ["paid", true], + ]) { + const filename = `${variant}-${fdroid}.js`; + const compiler = rspack({ + mode: "development", + devtool: false, + entry: path.resolve("src/native/index.ts"), + output: { path: directory, filename }, + resolve: { extensions: [".js", ".ts"] }, + module: { + rules: [ + { + test: /\.ts$/, + loader: "builtin:swc-loader", + options: { + jsc: { parser: { syntax: "typescript" }, target: "es2015" }, + }, + }, + { + test: /\.js$/, + type: "javascript/auto", + loader: "builtin:swc-loader", + options: { + jsc: { parser: { syntax: "ecmascript" }, target: "es2015" }, + }, + }, + ], + }, + plugins: [ + new rspack.DefinePlugin({ + __FREE__: JSON.stringify(variant === "free"), + __FDROID__: JSON.stringify(fdroid), + }), + ], + }); + await new Promise((resolve, reject) => + compiler.run((error, stats) => + compiler.close(() => { + if (error || stats.hasErrors()) + reject(error || new Error(stats.toString())); + else resolve(); + }), + ), + ); + bundles.set( + filename, + fs.readFileSync(path.join(directory, filename), "utf8"), + ); + } }, 30000); afterAll(() => fs.rmSync(directory, { recursive: true, force: true })); const windows = []; -afterEach(() => { for (const window of windows.splice(0)) window.happyDOM.abort(); }); +afterEach(() => { + for (const window of windows.splice(0)) window.happyDOM.abort(); +}); describe("Acode bridge", () => { test("defers cold and listening intents until startup is visible, then delivers warm intents synchronously", async () => { const { window, pending } = await createBridge(); window.document.body.classList.add("loading"); const received = []; - const receive = intent => received.push(intent.data); + const receive = (intent) => received.push(intent.data); window.system.setIntentHandler(receive, () => {}); const listener = pending.at(-1); window.system.getIntent(receive, () => {}); respond(window, pending.at(-1), { data: "cold" }); - window.Android.callback({ id: listener.id, status: 1, keep: true, data: { data: "during startup" } }); - await new Promise(resolve => setTimeout(resolve, 0)); + window.Android.callback({ + id: listener.id, + status: 1, + keep: true, + data: { data: "during startup" }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); expect(received).toEqual([]); window.document.body.classList.remove("loading"); - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); expect(received).toEqual(["cold", "during startup"]); - window.Android.callback({ id: listener.id, status: 1, keep: true, data: { data: "warm" } }); + window.Android.callback({ + id: listener.id, + status: 1, + keep: true, + data: { data: "warm" }, + }); expect(received).toEqual(["cold", "during startup", "warm"]); }); @@ -61,18 +107,26 @@ describe("Acode bridge", () => { window.document.addEventListener("deviceready", () => events.push("ready")); expect(events).toEqual([]); for (const request of pending.splice(0)) { - const data = request.service === "Device" ? { platform: "Android", sdkVersion: 36 } - : request.service === "File" ? { dataDirectory: "file:///data/user/0/com.foxdebug.acode/files/", applicationDirectory: "file:///android_asset/" } - : {}; + const data = + request.service === "Device" + ? { platform: "Android", sdkVersion: 36 } + : request.service === "File" + ? { + dataDirectory: "file:///data/user/0/com.foxdebug.acode/files/", + applicationDirectory: "file:///android_asset/", + } + : {}; window.Android.callback({ id: request.id, status: 1, keep: false, data }); } window.document.dispatchEvent(new window.Event("DOMContentLoaded")); - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); expect(events).toEqual(["ready"]); expect(window.earlyReadyCount).toBe(1); window.document.addEventListener("deviceready", () => events.push("late")); expect(events).toEqual(["ready", "late"]); - expect(window.Bridge.file.dataDirectory).toContain("com.foxdebug.acode/files/"); + expect(window.Bridge.file.dataDirectory).toContain( + "com.foxdebug.acode/files/", + ); expect(window.device.sdkVersion).toBe(36); expect(typeof window.resolveLocalFileSystemURL).toBe("function"); expect(typeof window.Bridge.http.sendRequest).toBe("function"); @@ -80,36 +134,45 @@ describe("Acode bridge", () => { }); test("initializes free and F-Droid bundles with the expected native globals", async () => { - const free = (await createBridge("free")).window; - expect(typeof free.admob.BannerAd).toBe("function"); - expect(typeof free.iap).not.toBe("undefined"); - const fdroid = (await createBridge("paid", true)).window; - expect(fdroid.admob).toBeUndefined(); - expect(fdroid.iap).toBeUndefined(); - expect(typeof fdroid.resolveLocalFileSystemURL).toBe("function"); - }); + const free = (await createBridge("free")).window; + expect(typeof free.admob.BannerAd).toBe("function"); + expect(typeof free.iap).not.toBe("undefined"); + const fdroid = (await createBridge("paid", true)).window; + expect(fdroid.admob).toBeUndefined(); + expect(fdroid.iap).toBeUndefined(); + expect(typeof fdroid.resolveLocalFileSystemURL).toBe("function"); + }); - test("preserves system theme callbacks and automatic hardware-button subscriptions", async () => { - const { window, pending } = await createBridge(); - // Happy DOM does not normalize CSS hex colors to computed RGB as WebView does. - window.getComputedStyle = () => ({ getPropertyValue: () => "rgb(17, 34, 51)" }); - let themed = false; - window.system.setUiTheme("#112233", {}, () => { themed = true; }); - const request = pending.at(-1); - expect(request.service).toBe("System"); - window.Android.callback({ id: request.id, status: 1, keep: false, data: null }); - expect(themed).toBe(true); - expect(pending.at(-1).service).toBe("SystemBarPlugin"); - expect(pending.at(-1).action).toBe("setStatusBarBackgroundColor"); - const events = []; - const listener = () => events.push("volume"); - window.document.addEventListener("volumeupbutton", listener); - expect(JSON.parse(pending.at(-1).args)).toEqual(["volumeupbutton", true]); - window.Bridge.fireDocumentEvent("volumeupbutton"); - expect(events).toEqual(["volume"]); - window.document.removeEventListener("volumeupbutton", listener); - expect(JSON.parse(pending.at(-1).args)).toEqual(["volumeupbutton", false]); - }); + test("preserves system theme callbacks and automatic hardware-button subscriptions", async () => { + const { window, pending } = await createBridge(); + // Happy DOM does not normalize CSS hex colors to computed RGB as WebView does. + window.getComputedStyle = () => ({ + getPropertyValue: () => "rgb(17, 34, 51)", + }); + let themed = false; + window.system.setUiTheme("#112233", {}, () => { + themed = true; + }); + const request = pending.at(-1); + expect(request.service).toBe("System"); + window.Android.callback({ + id: request.id, + status: 1, + keep: false, + data: null, + }); + expect(themed).toBe(true); + expect(pending.at(-1).service).toBe("SystemBarPlugin"); + expect(pending.at(-1).action).toBe("setStatusBarBackgroundColor"); + const events = []; + const listener = () => events.push("volume"); + window.document.addEventListener("volumeupbutton", listener); + expect(JSON.parse(pending.at(-1).args)).toEqual(["volumeupbutton", true]); + window.Bridge.fireDocumentEvent("volumeupbutton"); + expect(events).toEqual(["volume"]); + window.document.removeEventListener("volumeupbutton", listener); + expect(JSON.parse(pending.at(-1).args)).toEqual(["volumeupbutton", false]); + }); test("registers synchronous callbacks first and retains streaming callbacks until completion", async () => { const { window } = await createBridge(); @@ -120,7 +183,13 @@ describe("Acode bridge", () => { window.Android.callback({ id, status: 1, keep: false, data: "stale" }); return true; }; - window.Bridge.exec((value) => received.push(value), null, "System", "stream", []); + window.Bridge.exec( + (value) => received.push(value), + null, + "System", + "stream", + [], + ); expect(received).toEqual(["chunk", "done"]); }); @@ -129,393 +198,841 @@ describe("Acode bridge", () => { const errors = []; const values = []; window.Bridge.exec(null, (error) => errors.push(error), "File", "read", []); - window.Android.callback({ id: pending.at(-1).id, status: 9, keep: false, data: "" }); + window.Android.callback({ + id: pending.at(-1).id, + status: 9, + keep: false, + data: "", + }); expect(errors).toEqual([""]); - window.Bridge.exec((...data) => values.push(data), null, "File", "read", []); - window.Android.callback({ id: pending.at(-1).id, status: 1, keep: false, data: { kind: "multipart", data: [{ kind: "arrayBuffer", data: "AP8=" }, 2] } }); + window.Bridge.exec( + (...data) => values.push(data), + null, + "File", + "read", + [], + ); + window.Android.callback({ + id: pending.at(-1).id, + status: 1, + keep: false, + data: { + kind: "multipart", + data: [{ kind: "arrayBuffer", data: "AP8=" }, 2], + }, + }); expect([...new Uint8Array(values[0][0])]).toEqual([0, 255]); expect(values[0][1]).toBe(2); }); test("excludes advertising from paid and billing from F-Droid bridges", async () => { - expect((await createBridge("paid")).window.admob).toBeUndefined(); - expect(typeof (await createBridge("free")).window.admob.BannerAd).toBe("function"); - expect((await createBridge("paid", true)).window.iap).toBeUndefined(); - expect(bundles.get("paid-false.js")).not.toContain("./src/native/admob/admob.ts"); - expect(bundles.get("paid-true.js")).not.toContain("./src/native/iap.ts"); + expect((await createBridge("paid")).window.admob).toBeUndefined(); + expect(typeof (await createBridge("free")).window.admob.BannerAd).toBe( + "function", + ); + expect((await createBridge("paid", true)).window.iap).toBeUndefined(); + expect(bundles.get("paid-false.js")).not.toContain( + "./src/native/admob/admob.ts", + ); + expect(bundles.get("paid-true.js")).not.toContain("./src/native/iap.ts"); }); }); describe("typed native API behavior", () => { - test("loads polyfills before native modules on older WebViews", async () => { - const { window } = await createBridge("paid", false, "android", true); - expect(typeof window.Object.fromEntries).toBe("function"); - expect(window.Bridge.file).toHaveProperty("dataDirectory"); - }); - test("reads file slices across chunks and ignores results after abort", async () => { - const { window, pending } = await createBridge(); - window.FileReader.READ_CHUNK_SIZE = 3; - const file = new window.File("test.txt", "file:///test.txt", "text/plain", 0, 8).slice(1, -1); - const reader = new window.FileReader(); - const events = []; - for (const type of ["loadstart", "progress", "load", "loadend", "abort"]) - reader[`on${type}`] = event => events.push([type, event.loaded]); - reader.readAsText(file); - expect(JSON.parse(pending.at(-1).args)).toEqual([file.localURL, "UTF-8", 1, 4]); - respond(window, pending.at(-1), "abc"); - expect(JSON.parse(pending.at(-1).args)).toEqual([file.localURL, "UTF-8", 4, 7]); - respond(window, pending.at(-1), "def"); - expect(reader.result).toBe("abcdef"); - expect(reader.readyState).toBe(window.FileReader.DONE); - expect(events.map(([type]) => type)).toEqual(["loadstart", "progress", "progress", "load", "loadend"]); - reader.readAsText(file); - const aborted = pending.at(-1); - reader.abort(); - respond(window, aborted, "late"); - expect(reader.result).toBeNull(); - expect(events.slice(-2).map(([type]) => type)).toEqual(["abort", "loadend"]); - }); + test("loads polyfills before native modules on older WebViews", async () => { + const { window } = await createBridge("paid", false, "android", true); + expect(typeof window.Object.fromEntries).toBe("function"); + expect(window.Bridge.file).toHaveProperty("dataDirectory"); + }); + test("reads file slices across chunks and ignores results after abort", async () => { + const { window, pending } = await createBridge(); + window.FileReader.READ_CHUNK_SIZE = 3; + const file = new window.File( + "test.txt", + "file:///test.txt", + "text/plain", + 0, + 8, + ).slice(1, -1); + const reader = new window.FileReader(); + const events = []; + for (const type of ["loadstart", "progress", "load", "loadend", "abort"]) + reader[`on${type}`] = (event) => events.push([type, event.loaded]); + reader.readAsText(file); + expect(JSON.parse(pending.at(-1).args)).toEqual([ + file.localURL, + "UTF-8", + 1, + 4, + ]); + respond(window, pending.at(-1), "abc"); + expect(JSON.parse(pending.at(-1).args)).toEqual([ + file.localURL, + "UTF-8", + 4, + 7, + ]); + respond(window, pending.at(-1), "def"); + expect(reader.result).toBe("abcdef"); + expect(reader.readyState).toBe(window.FileReader.DONE); + expect(events.map(([type]) => type)).toEqual([ + "loadstart", + "progress", + "progress", + "load", + "loadend", + ]); + reader.readAsText(file); + const aborted = pending.at(-1); + reader.abort(); + respond(window, aborted, "late"); + expect(reader.result).toBeNull(); + expect(events.slice(-2).map(([type]) => type)).toEqual([ + "abort", + "loadend", + ]); + }); - test("delegates Blob reads and preserves writer events, binary data, seeking and truncation", async () => { - const { window, pending } = await createBridge(); - const reader = new window.FileReader(); - const read = new Promise((resolve, reject) => { reader.onload = () => resolve(reader.result); reader.onerror = reject; }); - reader.readAsText(new window.Blob(["Unicode ✓"])); - expect(await read).toBe("Unicode ✓"); - const writer = new window.FileWriter(new window.File("test", "file:///test", null, 0, 12)); - const events = []; - for (const type of ["writestart", "write", "writeend"]) writer[`on${type}`] = () => events.push(type); - writer.seek(-2); - writer.write(window.eval("new Uint8Array([0, 255]).buffer")); - expect(JSON.parse(pending.at(-1).args)).toEqual(["file:///test", "AP8=", 10, true]); - respond(window, pending.at(-1), 2); - expect([writer.position, writer.length]).toEqual([12, 12]); - writer.truncate(4); - respond(window, pending.at(-1), 4); - expect([writer.position, writer.length]).toEqual([4, 4]); - expect(events).toEqual(["writestart", "write", "writeend", "writestart", "write", "writeend"]); - expect(new window.FileUploadResult(12, 201, "uploaded")).toMatchObject({ bytesSent: 12, responseCode: 201, response: "uploaded" }); - }); + test("delegates Blob reads and preserves writer events, binary data, seeking and truncation", async () => { + const { window, pending } = await createBridge(); + const reader = new window.FileReader(); + const read = new Promise((resolve, reject) => { + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + }); + reader.readAsText(new window.Blob(["Unicode ✓"])); + expect(await read).toBe("Unicode ✓"); + const writer = new window.FileWriter( + new window.File("test", "file:///test", null, 0, 12), + ); + const events = []; + for (const type of ["writestart", "write", "writeend"]) + writer[`on${type}`] = () => events.push(type); + writer.seek(-2); + writer.write(window.eval("new Uint8Array([0, 255]).buffer")); + expect(JSON.parse(pending.at(-1).args)).toEqual([ + "file:///test", + "AP8=", + 10, + true, + ]); + respond(window, pending.at(-1), 2); + expect([writer.position, writer.length]).toEqual([12, 12]); + writer.truncate(4); + respond(window, pending.at(-1), 4); + expect([writer.position, writer.length]).toEqual([4, 4]); + expect(events).toEqual([ + "writestart", + "write", + "writeend", + "writestart", + "write", + "writeend", + ]); + expect(new window.FileUploadResult(12, 201, "uploaded")).toMatchObject({ + bytesSent: 12, + responseCode: 201, + response: "uploaded", + }); + }); - test("keeps file URL mapping and one-shot directory readers", async () => { - const { window, pending } = await createBridge(); - const fs = new window.FileSystem("files"); - const file = new window.FileEntry("a #.txt", "/a #.txt", fs, "file:///private/a%20%23.txt"); - expect(file.toInternalURL()).toBe("https://localhost/__cdvfile_files__/a%20%23.txt"); - const reserved = new window.FileEntry("question ? %3F.txt", "/folder ?/question ? %3F.txt", fs, "file:///private/folder%20%3F/question%20%3F%20%253F.txt"); - const reservedURL = new URL(reserved.toInternalURL()); - expect(decodeURIComponent(reservedURL.pathname)).toBe("/__cdvfile_files__/folder ?/question ? %3F.txt"); - expect(reservedURL.search).toBe(""); - expect(reservedURL.hash).toBe(""); - const versioned = new window.FileEntry("worker.js", "/worker.js", fs, "file:///private/worker.js?revision=2"); - expect(versioned.toInternalURL()).toBe("https://localhost/__cdvfile_files__/worker.js?revision=2"); - const content = new window.FileEntry("test", "/ignored", new window.FileSystem("content"), "content://provider/tree/a%3Ab/document/a%3Ab%2Ftest"); - expect(content.toInternalURL()).toBe("https://localhost/__cdvfile_content__/provider/tree/a%3Ab/document/a%3Ab%2Ftest"); - const reader = fs.root.createReader(); - const batches = []; - reader.readEntries(entries => batches.push(entries)); - respond(window, pending.at(-1), [{ name: "folder", fullPath: "/folder/", nativeURL: "file:///folder/", isDirectory: true, filesystemName: "files" }]); - const count = pending.length; - reader.readEntries(entries => batches.push(entries)); - expect(batches[0][0].isDirectory).toBe(true); - expect(batches[1]).toEqual([]); - expect(pending.length).toBe(count); - }); + test("keeps file URL mapping and one-shot directory readers", async () => { + const { window, pending } = await createBridge(); + const fs = new window.FileSystem("files"); + const file = new window.FileEntry( + "a #.txt", + "/a #.txt", + fs, + "file:///private/a%20%23.txt", + ); + expect(file.toInternalURL()).toBe( + "https://localhost/__cdvfile_files__/a%20%23.txt", + ); + const reserved = new window.FileEntry( + "question ? %3F.txt", + "/folder ?/question ? %3F.txt", + fs, + "file:///private/folder%20%3F/question%20%3F%20%253F.txt", + ); + const reservedURL = new URL(reserved.toInternalURL()); + expect(decodeURIComponent(reservedURL.pathname)).toBe( + "/__cdvfile_files__/folder ?/question ? %3F.txt", + ); + expect(reservedURL.search).toBe(""); + expect(reservedURL.hash).toBe(""); + const versioned = new window.FileEntry( + "worker.js", + "/worker.js", + fs, + "file:///private/worker.js?revision=2", + ); + expect(versioned.toInternalURL()).toBe( + "https://localhost/__cdvfile_files__/worker.js?revision=2", + ); + const content = new window.FileEntry( + "test", + "/ignored", + new window.FileSystem("content"), + "content://provider/tree/a%3Ab/document/a%3Ab%2Ftest", + ); + expect(content.toInternalURL()).toBe( + "https://localhost/__cdvfile_content__/provider/tree/a%3Ab/document/a%3Ab%2Ftest", + ); + const reader = fs.root.createReader(); + const batches = []; + reader.readEntries((entries) => batches.push(entries)); + respond(window, pending.at(-1), [ + { + name: "folder", + fullPath: "/folder/", + nativeURL: "file:///folder/", + isDirectory: true, + filesystemName: "files", + }, + ]); + const count = pending.length; + reader.readEntries((entries) => batches.push(entries)); + expect(batches[0][0].isDirectory).toBe(true); + expect(batches[1]).toEqual([]); + expect(pending.length).toBe(count); + }); - test("retains stored cookies, scopes paths and domains, and accepts expiry commas", async () => { - const { window } = await createBridge(); - const http = window.Bridge.http; - window.localStorage.setItem("__advancedHttpCookieStore__", JSON.stringify({ "example.com": { "/": { legacy: { key: "legacy", value: "retained", domain: "example.com", path: "/", hostOnly: true, creation: "2024-01-01T00:00:00.000Z", lastAccessed: "2024-01-01T00:00:00.000Z" } } } })); - expect(http.getCookieString("https://example.com/")).toBe("legacy=retained"); - http.setCookie("https://example.com/private", "session=secret; Path=/private; Secure"); - http.setCookieFromString("https://example.com/", "future=yes; Expires=Wed, 01 Jan 2031 00:00:00 GMT, second=two; Path=/"); - expect(http.getCookieString("https://example.com/private")).toContain("session=secret"); - expect(http.getCookieString("http://example.com/private")).not.toContain("session="); - expect(http.getCookieString("https://other.example.com/")).toBe(""); - expect(http.getCookieString("https://example.com/")).toContain("future=yes"); - expect(http.getCookieString("https://example.com/")).toContain("second=two"); - http.setCookie("https://example.com/", "legacy=; Max-Age=0; Path=/"); - expect(http.getCookieString("https://example.com/")).not.toContain("legacy="); - await new Promise((resolve, reject) => http.removeCookies("https://example.com/", error => error ? reject(error) : resolve())); - expect(http.getCookieString("https://example.com/private")).toBe(""); - // Requests to preview servers must also work when there are no cookies. - expect(http.getCookieString("http://localhost:48123/")).toBe(""); - expect(http.getCookieString("http://127.0.0.1:48123/")).toBe(""); - }); + test("retains stored cookies, scopes paths and domains, and accepts expiry commas", async () => { + const { window } = await createBridge(); + const http = window.Bridge.http; + window.localStorage.setItem( + "__advancedHttpCookieStore__", + JSON.stringify({ + "example.com": { + "/": { + legacy: { + key: "legacy", + value: "retained", + domain: "example.com", + path: "/", + hostOnly: true, + creation: "2024-01-01T00:00:00.000Z", + lastAccessed: "2024-01-01T00:00:00.000Z", + }, + }, + }, + }), + ); + expect(http.getCookieString("https://example.com/")).toBe( + "legacy=retained", + ); + http.setCookie( + "https://example.com/private", + "session=secret; Path=/private; Secure", + ); + http.setCookieFromString( + "https://example.com/", + "future=yes; Expires=Wed, 01 Jan 2031 00:00:00 GMT, second=two; Path=/", + ); + expect(http.getCookieString("https://example.com/private")).toContain( + "session=secret", + ); + expect(http.getCookieString("http://example.com/private")).not.toContain( + "session=", + ); + expect(http.getCookieString("https://other.example.com/")).toBe(""); + expect(http.getCookieString("https://example.com/")).toContain( + "future=yes", + ); + expect(http.getCookieString("https://example.com/")).toContain( + "second=two", + ); + http.setCookie("https://example.com/", "legacy=; Max-Age=0; Path=/"); + expect(http.getCookieString("https://example.com/")).not.toContain( + "legacy=", + ); + await new Promise((resolve, reject) => + http.removeCookies("https://example.com/", (error) => + error ? reject(error) : resolve(), + ), + ); + expect(http.getCookieString("https://example.com/private")).toBe(""); + // Requests to preview servers must also work when there are no cookies. + expect(http.getCookieString("http://localhost:48123/")).toBe(""); + expect(http.getCookieString("http://127.0.0.1:48123/")).toBe(""); + }); - test("sends HTTP options and headers, decodes responses and keeps cookies on failures", async () => { - const { window, pending } = await createBridge(); - const http = window.Bridge.http; - const received = [], failures = []; - http.setHeader("X-Global", "global"); - http.setHeader("example.com", "X-Host", "host"); - http.setCookie("https://example.com/", "token=one; Path=/"); - const id = http.sendRequest("https://example.com/path", { responseType: "json", params: { q: "a b" }, followRedirect: false, connectTimeout: 0, readTimeout: 9, headers: { "X-Global": "request" } }, value => received.push(value), value => failures.push(value)); - expect(JSON.parse(pending.at(-1).args)).toEqual(["https://example.com/path?q=a%20b", { "X-Global": "request", "X-Host": "host", Cookie: "token=one" }, 0, 9, false, "json", id]); - respond(window, pending.at(-1), { status: 200, url: "https://example.com/path", data: '{"ok":true}', headers: {} }); - expect(received[0].data).toEqual({ ok: true }); - http.sendRequest("https://example.com/", { responseType: "json" }, value => received.push(value), value => failures.push(value)); - respond(window, pending.at(-1), { status: 200, url: "https://example.com/", data: "invalid json", headers: {} }); - expect(failures[0].status).toBe(http.ErrorCode.POST_PROCESSING_FAILED); - http.get("https://example.com/", {}, {}, () => {}, value => failures.push(value)); - respond(window, pending.at(-1), { status: 401, url: "https://example.com/", error: "denied", headers: { "Set-Cookie": "token=two; Path=/" } }, 9); - expect(http.getCookieString("https://example.com/")).toBe("token=two"); - http.abort(id, () => {}, () => {}); - expect([pending.at(-1).action, JSON.parse(pending.at(-1).args)]).toEqual(["abort", [id]]); - }); + test("sends HTTP options and headers, decodes responses and keeps cookies on failures", async () => { + const { window, pending } = await createBridge(); + const http = window.Bridge.http; + const received = [], + failures = []; + http.setHeader("X-Global", "global"); + http.setHeader("example.com", "X-Host", "host"); + http.setCookie("https://example.com/", "token=one; Path=/"); + const id = http.sendRequest( + "https://example.com/path", + { + responseType: "json", + params: { q: "a b" }, + followRedirect: false, + connectTimeout: 0, + readTimeout: 9, + headers: { "X-Global": "request" }, + }, + (value) => received.push(value), + (value) => failures.push(value), + ); + expect(JSON.parse(pending.at(-1).args)).toEqual([ + "https://example.com/path?q=a%20b", + { "X-Global": "request", "X-Host": "host", Cookie: "token=one" }, + 0, + 9, + false, + "json", + id, + ]); + respond(window, pending.at(-1), { + status: 200, + url: "https://example.com/path", + data: '{"ok":true}', + headers: {}, + }); + expect(received[0].data).toEqual({ ok: true }); + http.sendRequest( + "https://example.com/", + { responseType: "json" }, + (value) => received.push(value), + (value) => failures.push(value), + ); + respond(window, pending.at(-1), { + status: 200, + url: "https://example.com/", + data: "invalid json", + headers: {}, + }); + expect(failures[0].status).toBe(http.ErrorCode.POST_PROCESSING_FAILED); + http.get( + "https://example.com/", + {}, + {}, + () => {}, + (value) => failures.push(value), + ); + respond( + window, + pending.at(-1), + { + status: 401, + url: "https://example.com/", + error: "denied", + headers: { "Set-Cookie": "token=two; Path=/" }, + }, + 9, + ); + expect(http.getCookieString("https://example.com/")).toBe("token=two"); + http.abort( + id, + () => {}, + () => {}, + ); + expect([pending.at(-1).action, JSON.parse(pending.at(-1).args)]).toEqual([ + "abort", + [id], + ]); + }); - test("encodes multipart and binary HTTP bodies and returns download entries", async () => { - const { window, pending } = await createBridge(); - const http = window.Bridge.http; - const form = new http.ponyfills.FormData(); - form.append("name", "✓"); - form.append("file", new window.Blob(["hello"], { type: "text/plain" }), "hello.txt"); - http.sendRequest("https://example.com/", { method: "post", serializer: "multipart", data: form }, () => {}, error => { throw error; }); - await window.happyDOM.waitUntilComplete(); - const args = JSON.parse(pending.at(-1).args); - expect(args[1]).toEqual({ buffers: ["4pyT", "aGVsbG8="], names: ["name", "file"], fileNames: [null, "hello.txt"], types: ["text/plain", "text/plain"] }); - http.sendRequest("https://example.com/", { method: "post", serializer: "raw", data: window.eval("new Uint8Array([0,255])") }, () => {}, () => {}); - expect(JSON.parse(pending.at(-1).args)[1]).toBe("AP8="); - const download = new Promise((resolve, reject) => http.downloadFile("https://example.com/file", {}, {}, "file:///cache/file", (...values) => resolve(values), reject)); - respond(window, pending.at(-1), { status: 200, url: "https://example.com/file", headers: {}, file: { name: "file", fullPath: "/file", filesystemName: "cache", nativeURL: "file:///cache/file", isDirectory: false } }); - const [entry, response] = await download; - expect(entry.toInternalURL()).toBe("https://localhost/__cdvfile_cache__/file"); - expect(response.file).toBe(entry); - }); + test("encodes multipart and binary HTTP bodies and returns download entries", async () => { + const { window, pending } = await createBridge(); + const http = window.Bridge.http; + const form = new http.ponyfills.FormData(); + form.append("name", "✓"); + form.append( + "file", + new window.Blob(["hello"], { type: "text/plain" }), + "hello.txt", + ); + http.sendRequest( + "https://example.com/", + { method: "post", serializer: "multipart", data: form }, + () => {}, + (error) => { + throw error; + }, + ); + await window.happyDOM.waitUntilComplete(); + const args = JSON.parse(pending.at(-1).args); + expect(args[1]).toEqual({ + buffers: ["4pyT", "aGVsbG8="], + names: ["name", "file"], + fileNames: [null, "hello.txt"], + types: ["text/plain", "text/plain"], + }); + http.sendRequest( + "https://example.com/", + { + method: "post", + serializer: "raw", + data: window.eval("new Uint8Array([0,255])"), + }, + () => {}, + () => {}, + ); + expect(JSON.parse(pending.at(-1).args)[1]).toBe("AP8="); + const download = new Promise((resolve, reject) => + http.downloadFile( + "https://example.com/file", + {}, + {}, + "file:///cache/file", + (...values) => resolve(values), + reject, + ), + ); + respond(window, pending.at(-1), { + status: 200, + url: "https://example.com/file", + headers: {}, + file: { + name: "file", + fullPath: "/file", + filesystemName: "cache", + nativeURL: "file:///cache/file", + isDirectory: false, + }, + }); + const [entry, response] = await download; + expect(entry.toInternalURL()).toBe( + "https://localhost/__cdvfile_cache__/file", + ); + expect(response.file).toBe(entry); + }); - test("registers native WebSocket events and preserves binary views and close state", async () => { - const { window, pending } = await createBridge(); - const connection = window.Bridge.websocket.connect("ws://localhost/test", ["test"], { Authorization: "token" }, "arraybuffer"); - expect(JSON.parse(pending.at(-1).args)).toEqual(["ws://localhost/test", ["test"], { Authorization: "token" }, "arraybuffer"]); - respond(window, pending.at(-1), "socket-1"); - const socket = await connection; - const listener = pending.at(-1); - expect(listener.action).toBe("registerListener"); - window.Android.callback({ id: listener.id, status: 1, keep: true, data: { type: "open" } }); - const messages = []; - socket.addEventListener("message", event => messages.push(event.data)); - window.Android.callback({ id: listener.id, status: 1, keep: true, data: { type: "message", data: "AP8=", isBinary: true } }); - expect([...new Uint8Array(messages[0])]).toEqual([0, 255]); - socket.send(window.eval("new Uint8Array([1,2,3,4]).subarray(1,3)")); - expect(JSON.parse(pending.at(-1).args)).toEqual(["socket-1", "AgM=", true]); - socket.close(1000, "done"); - expect(socket.readyState).toBe(2); - const closes = []; - socket.addEventListener("close", event => closes.push([event.code, event.reason])); - window.Android.callback({ id: listener.id, status: 1, keep: false, data: { type: "close", data: JSON.stringify({ code: 1000, reason: "done" }) } }); - expect(closes).toEqual([[1000, "done"]]); - expect(socket.readyState).toBe(3); - expect(() => socket.send("late")).toThrow("not open"); - }); + test("registers native WebSocket events and preserves binary views and close state", async () => { + const { window, pending } = await createBridge(); + const connection = window.Bridge.websocket.connect( + "ws://localhost/test", + ["test"], + { Authorization: "token" }, + "arraybuffer", + ); + expect(JSON.parse(pending.at(-1).args)).toEqual([ + "ws://localhost/test", + ["test"], + { Authorization: "token" }, + "arraybuffer", + ]); + respond(window, pending.at(-1), "socket-1"); + const socket = await connection; + const listener = pending.at(-1); + expect(listener.action).toBe("registerListener"); + window.Android.callback({ + id: listener.id, + status: 1, + keep: true, + data: { type: "open" }, + }); + const messages = []; + socket.addEventListener("message", (event) => messages.push(event.data)); + window.Android.callback({ + id: listener.id, + status: 1, + keep: true, + data: { type: "message", data: "AP8=", isBinary: true }, + }); + expect([...new Uint8Array(messages[0])]).toEqual([0, 255]); + socket.send(window.eval("new Uint8Array([1,2,3,4]).subarray(1,3)")); + expect(JSON.parse(pending.at(-1).args)).toEqual(["socket-1", "AgM=", true]); + socket.close(1000, "done"); + expect(socket.readyState).toBe(2); + const closes = []; + socket.addEventListener("close", (event) => + closes.push([event.code, event.reason]), + ); + window.Android.callback({ + id: listener.id, + status: 1, + keep: false, + data: { + type: "close", + data: JSON.stringify({ code: 1000, reason: "done" }), + }, + }); + expect(closes).toEqual([[1000, "done"]]); + expect(socket.readyState).toBe(3); + expect(() => socket.send("late")).toThrow("not open"); + }); - test("restores iOS file references before publishing device readiness", async () => { - const { window, pending } = await createBridge("paid", false, "ios"); - window.localStorage.recentFiles = JSON.stringify(["file:///old/Documents/file.txt"]); - const ready = []; - window.document.addEventListener("deviceready", () => ready.push(JSON.parse(window.localStorage.recentFiles))); - for (const request of pending.splice(0)) { - const success = request.service === "File" ? { - dataDirectory: "file:///current/Library/NoCloud/", - } : request.service === "Device" ? { platform: "iOS" } : {}; - window.iOS.callback({ id: request.id, success }); - } - window.document.dispatchEvent(new window.Event("DOMContentLoaded")); - await new Promise(resolve => setTimeout(resolve, 0)); - expect(ready).toEqual([]); - const request = pending.find(item => item.action === "getPathReplacements"); - expect(request?.service).toBe("File"); - window.iOS.callback({ id: request.id, success: [{ from: "file:///old/Documents", to: "file:///current/Documents" }] }); - await new Promise(resolve => setTimeout(resolve, 0)); - expect(ready).toEqual([["file:///current/Documents/file.txt"]]); - expect(window.Bridge.file.pathReplacements).toBeUndefined(); - }); + test("restores iOS file references before publishing device readiness", async () => { + const { window, pending } = await createBridge("paid", false, "ios"); + window.localStorage.recentFiles = JSON.stringify([ + "file:///old/Documents/file.txt", + ]); + const ready = []; + window.document.addEventListener("deviceready", () => + ready.push(JSON.parse(window.localStorage.recentFiles)), + ); + for (const request of pending.splice(0)) { + const success = + request.service === "File" + ? { + dataDirectory: "file:///current/Library/NoCloud/", + } + : request.service === "Device" + ? { platform: "iOS" } + : {}; + window.iOS.callback({ id: request.id, success }); + } + window.document.dispatchEvent(new window.Event("DOMContentLoaded")); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(ready).toEqual([]); + const request = pending.find( + (item) => item.action === "getPathReplacements", + ); + expect(request?.service).toBe("File"); + window.iOS.callback({ + id: request.id, + success: [ + { from: "file:///old/Documents", to: "file:///current/Documents" }, + ], + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(ready).toEqual([["file:///current/Documents/file.txt"]]); + expect(window.Bridge.file.pathReplacements).toBeUndefined(); + }); - test("routes iOS callbacks and toast through the retained platform adapter", async () => { - const { window, pending } = await createBridge("paid", false, "ios"); - expect(window.Bridge.platformId).toBe("ios"); - const values = [], errors = []; - window.Bridge.exec(value => values.push(value), error => errors.push(error), "Native", "test", []); - const id = pending.at(-1).id; - window.iOS.callback({ id, keep: true, success: "\0ÿ", isBinary: true, length: 2 }); - window.iOS.callback({ id, error: "" }); - window.iOS.callback({ id, success: "stale" }); - expect([...new Uint8Array(values[0])]).toEqual([0, 255]); - expect(errors).toEqual([""]); - const toasts = []; - window.toast = value => toasts.push(value); - window.Bridge.exec(() => values.push("toast complete"), null, "Native", "showToast", ["hello"]); - expect(toasts).toEqual(["hello"]); - expect(values.at(-1)).toBe("toast complete"); - }); + test("routes iOS callbacks and toast through the retained platform adapter", async () => { + const { window, pending } = await createBridge("paid", false, "ios"); + expect(window.Bridge.platformId).toBe("ios"); + const values = [], + errors = []; + window.Bridge.exec( + (value) => values.push(value), + (error) => errors.push(error), + "Native", + "test", + [], + ); + const id = pending.at(-1).id; + window.iOS.callback({ + id, + keep: true, + success: "\0ÿ", + isBinary: true, + length: 2, + }); + window.iOS.callback({ id, error: "" }); + window.iOS.callback({ id, success: "stale" }); + expect([...new Uint8Array(values[0])]).toEqual([0, 255]); + expect(errors).toEqual([""]); + const toasts = []; + window.toast = (value) => toasts.push(value); + window.Bridge.exec( + () => values.push("toast complete"), + null, + "Native", + "showToast", + ["hello"], + ); + expect(toasts).toEqual(["hello"]); + expect(values.at(-1)).toBe("toast complete"); + }); }); describe("legacy plugin compatibility", () => { - test("exposes every former public plugin module through the existing native objects", async () => { - const { window } = await createBridge("free"); - const legacy = window.cordova; - expect(legacy).not.toBe(window.Bridge); - for (const [id, value] of [ - ["cordova", legacy], - ["cordova/exec", legacy.exec], - ["cordova/plugin/android/app", window.navigator.app], - ["cordova/plugin/android/statusbar", window.statusbar], - ["cordova/plugin/android/splashscreen", window.navigator.splashscreen], - ["cordova-clipboard.Clipboard", window.Bridge.clipboard], - ["cordova-plugin-device.device", window.device], - ["cordova-plugin-server.CreateServer", window.CreateServer], - ["cordova-plugin-ftp.ftp", window.ftp], - ["cordova-plugin-sdcard.sdcard", window.sdcard], - ["cordova-plugin-websocket.WebSocket", window.Bridge.websocket], - ["cordova-plugin-buildinfo.BuildInfo", window.BuildInfo], - ["cordova-plugin-sftp.sftp", window.sftp], - ["com.foxdebug.acode.rk.exec.terminal.Terminal", window.Terminal], - ["com.foxdebug.acode.rk.exec.terminal.Executor", window.Executor], - ["cordova-plugin-iap.iap", window.iap], - ["com.foxdebug.acode.rk.customtabs.CustomTabs", window.CustomTabs], - ["cordova-plugin-advanced-http.http", window.Bridge.http], - ["cordova-plugin-system.system", window.system], - ["admob-plus-cordova.AdMob", window.admob], - ]) { - expect(value, id).not.toBeUndefined(); - expect(legacy.require(id), id).toBe(value); - } - for (const name of ["DirectoryEntry", "DirectoryReader", "Entry", "File", "FileEntry", "FileError", "FileReader", "FileSystem", "FileUploadOptions", "FileUploadResult", "FileWriter", "Flags", "LocalFileSystem", "Metadata", "ProgressEvent", "requestFileSystem"]) - expect(legacy.require(`cordova-plugin-file.${name}`)).toBe(window[name]); - expect(legacy.file).toBe(window.Bridge.file); - expect(legacy.websocket).toBe(window.Bridge.websocket); - expect(legacy.plugin.http).toBe(window.Bridge.http); - expect(legacy.plugins.clipboard).toBe(window.Bridge.clipboard); - expect(legacy.require("cordova-plugin-file.fileSystemPaths").file).toBe(legacy.file); - expect(legacy.require("cordova-plugin-file.resolveLocalFileSystemURI").resolveLocalFileSystemURL).toBe(window.resolveLocalFileSystemURL); - expect(window.device.cordova).toBe(legacy.version); - expect(legacy.platformId).toBe("android"); - expect(legacy.platformVersion).toBe(legacy.version); - }); + test("exposes every former public plugin module through the existing native objects", async () => { + const { window } = await createBridge("free"); + const legacy = window.cordova; + expect(legacy).not.toBe(window.Bridge); + for (const [id, value] of [ + ["cordova", legacy], + ["cordova/exec", legacy.exec], + ["cordova/plugin/android/app", window.navigator.app], + ["cordova/plugin/android/statusbar", window.statusbar], + ["cordova/plugin/android/splashscreen", window.navigator.splashscreen], + ["cordova-clipboard.Clipboard", window.Bridge.clipboard], + ["cordova-plugin-device.device", window.device], + ["cordova-plugin-server.CreateServer", window.CreateServer], + ["cordova-plugin-ftp.ftp", window.ftp], + ["cordova-plugin-sdcard.sdcard", window.sdcard], + ["cordova-plugin-websocket.WebSocket", window.Bridge.websocket], + ["cordova-plugin-buildinfo.BuildInfo", window.BuildInfo], + ["cordova-plugin-sftp.sftp", window.sftp], + ["com.foxdebug.acode.rk.exec.terminal.Terminal", window.Terminal], + ["com.foxdebug.acode.rk.exec.terminal.Executor", window.Executor], + ["cordova-plugin-iap.iap", window.iap], + ["com.foxdebug.acode.rk.customtabs.CustomTabs", window.CustomTabs], + ["cordova-plugin-advanced-http.http", window.Bridge.http], + ["cordova-plugin-system.system", window.system], + ["admob-plus-cordova.AdMob", window.admob], + ]) { + expect(value, id).not.toBeUndefined(); + expect(legacy.require(id), id).toBe(value); + } + for (const name of [ + "DirectoryEntry", + "DirectoryReader", + "Entry", + "File", + "FileEntry", + "FileError", + "FileReader", + "FileSystem", + "FileUploadOptions", + "FileUploadResult", + "FileWriter", + "Flags", + "LocalFileSystem", + "Metadata", + "ProgressEvent", + "requestFileSystem", + ]) + expect(legacy.require(`cordova-plugin-file.${name}`)).toBe(window[name]); + expect(legacy.file).toBe(window.Bridge.file); + expect(legacy.websocket).toBe(window.Bridge.websocket); + expect(legacy.plugin.http).toBe(window.Bridge.http); + expect(legacy.plugins.clipboard).toBe(window.Bridge.clipboard); + expect(legacy.require("cordova-plugin-file.fileSystemPaths").file).toBe( + legacy.file, + ); + expect( + legacy.require("cordova-plugin-file.resolveLocalFileSystemURI") + .resolveLocalFileSystemURL, + ).toBe(window.resolveLocalFileSystemURL); + expect(window.device.cordova).toBe(legacy.version); + expect(legacy.platformId).toBe("android"); + expect(legacy.platformVersion).toBe(legacy.version); + }); - test("retains native callbacks, HTTP errors, streaming and clipboard calls through old names", async () => { - const { window, pending } = await createBridge(); - const values = [], errors = []; - window.cordova.exec(value => values.push(value), error => errors.push(error), "System", "stream", ["input"]); - const stream = pending.at(-1); - expect([stream.service, stream.action, JSON.parse(stream.args)]).toEqual(["System", "stream", ["input"]]); - window.Android.callback({ id: stream.id, status: 1, keep: true, data: "first" }); - respond(window, stream, "last"); - respond(window, stream, "stale"); - expect(values).toEqual(["first", "last"]); - window.cordova.plugin.http.sendRequest("https://example.com/token", { method: "post", serializer: "urlencoded", data: { code: "a b" }, followRedirect: false }, value => values.push(value), error => errors.push(error)); - expect(pending.at(-1).service).toBe("NativeHttpPlugin"); - expect(pending.at(-1).action).toBe("post"); - respond(window, pending.at(-1), { status: 401, error: "denied", headers: {}, url: "https://example.com/token" }, 9); - expect(errors.at(-1)).toMatchObject({ status: 401, error: "denied" }); - window.cordova.plugins.clipboard.copy("copied", () => values.push("copied"), error => errors.push(error)); - expect([pending.at(-1).service, pending.at(-1).action, JSON.parse(pending.at(-1).args)]).toEqual(["Clipboard", "copy", ["copied"]]); - respond(window, pending.at(-1), null); - window.cordova.plugins.clipboard.paste(value => values.push(value), error => errors.push(error)); - respond(window, pending.at(-1), "pasted"); - expect(values.slice(-2)).toEqual(["copied", "pasted"]); - expect(Reflect.set(window.cordova, "exec", () => {})).toBe(false); - expect(Object.getOwnPropertyDescriptor(window.cordova, "exec").configurable).toBe(false); - }); + test("retains native callbacks, HTTP errors, streaming and clipboard calls through old names", async () => { + const { window, pending } = await createBridge(); + const values = [], + errors = []; + window.cordova.exec( + (value) => values.push(value), + (error) => errors.push(error), + "System", + "stream", + ["input"], + ); + const stream = pending.at(-1); + expect([stream.service, stream.action, JSON.parse(stream.args)]).toEqual([ + "System", + "stream", + ["input"], + ]); + window.Android.callback({ + id: stream.id, + status: 1, + keep: true, + data: "first", + }); + respond(window, stream, "last"); + respond(window, stream, "stale"); + expect(values).toEqual(["first", "last"]); + window.cordova.plugin.http.sendRequest( + "https://example.com/token", + { + method: "post", + serializer: "urlencoded", + data: { code: "a b" }, + followRedirect: false, + }, + (value) => values.push(value), + (error) => errors.push(error), + ); + expect(pending.at(-1).service).toBe("NativeHttpPlugin"); + expect(pending.at(-1).action).toBe("post"); + respond( + window, + pending.at(-1), + { + status: 401, + error: "denied", + headers: {}, + url: "https://example.com/token", + }, + 9, + ); + expect(errors.at(-1)).toMatchObject({ status: 401, error: "denied" }); + window.cordova.plugins.clipboard.copy( + "copied", + () => values.push("copied"), + (error) => errors.push(error), + ); + expect([ + pending.at(-1).service, + pending.at(-1).action, + JSON.parse(pending.at(-1).args), + ]).toEqual(["Clipboard", "copy", ["copied"]]); + respond(window, pending.at(-1), null); + window.cordova.plugins.clipboard.paste( + (value) => values.push(value), + (error) => errors.push(error), + ); + respond(window, pending.at(-1), "pasted"); + expect(values.slice(-2)).toEqual(["copied", "pasted"]); + expect(Reflect.set(window.cordova, "exec", () => {})).toBe(false); + expect( + Object.getOwnPropertyDescriptor(window.cordova, "exec").configurable, + ).toBe(false); + }); - test("shares readiness, delayed paths and lifecycle events without running startup twice", async () => { - const { window, pending } = await createBridge(); - const channel = window.cordova.require("cordova/channel"); - const events = []; - window.cordova.addConstructor(() => events.push("constructor")); - channel.onCordovaReady.subscribe(() => events.push("bridge")); - channel.onPluginsReady.subscribe(() => events.push("plugins")); - window.document.addEventListener("deviceready", () => events.push("ready")); - expect(events).toEqual(["constructor", "bridge", "plugins"]); - channel.waitForInitialization("onCordovaReady"); - expect(channel.deviceReadyChannelsArray.filter(event => event.type === "onBridgeReady")).toHaveLength(1); - for (const request of pending.splice(0)) respond(window, request, request.service === "File" ? { dataDirectory: "file:///data/files/" } : {}); - window.document.dispatchEvent(new window.Event("DOMContentLoaded")); - await new Promise(resolve => setTimeout(resolve, 0)); - channel.onDeviceReady.subscribe(() => events.push("late")); - expect(events.slice(-2)).toEqual(["ready", "late"]); - expect(window.cordova.file.dataDirectory).toBe("file:///data/files/"); - const resume = event => events.push(event.type); - channel.onResume.subscribe(resume); - channel.onPause.subscribe(event => events.push(event.type)); - window.Bridge.fireDocumentEvent("pause"); - window.Bridge.fireDocumentEvent("resume"); - channel.onResume.unsubscribe(resume); - window.Bridge.fireDocumentEvent("resume"); - expect(events.slice(-2)).toEqual(["pause", "resume"]); - const custom = window.cordova.addStickyDocumentEventHandler("pluginready"); - window.cordova.fireDocumentEvent("pluginready", { value: 7 }); - custom.subscribe(event => events.push(event.value)); - expect(events.at(-1)).toBe(7); - window.cordova.removeDocumentEventHandler("pluginready"); - }); + test("shares readiness, delayed paths and lifecycle events without running startup twice", async () => { + const { window, pending } = await createBridge(); + const channel = window.cordova.require("cordova/channel"); + const events = []; + window.cordova.addConstructor(() => events.push("constructor")); + channel.onCordovaReady.subscribe(() => events.push("bridge")); + channel.onPluginsReady.subscribe(() => events.push("plugins")); + window.document.addEventListener("deviceready", () => events.push("ready")); + expect(events).toEqual(["constructor", "bridge", "plugins"]); + channel.waitForInitialization("onCordovaReady"); + expect( + channel.deviceReadyChannelsArray.filter( + (event) => event.type === "onBridgeReady", + ), + ).toHaveLength(1); + for (const request of pending.splice(0)) + respond( + window, + request, + request.service === "File" + ? { dataDirectory: "file:///data/files/" } + : {}, + ); + window.document.dispatchEvent(new window.Event("DOMContentLoaded")); + await new Promise((resolve) => setTimeout(resolve, 0)); + channel.onDeviceReady.subscribe(() => events.push("late")); + expect(events.slice(-2)).toEqual(["ready", "late"]); + expect(window.cordova.file.dataDirectory).toBe("file:///data/files/"); + const resume = (event) => events.push(event.type); + channel.onResume.subscribe(resume); + channel.onPause.subscribe((event) => events.push(event.type)); + window.Bridge.fireDocumentEvent("pause"); + window.Bridge.fireDocumentEvent("resume"); + channel.onResume.unsubscribe(resume); + window.Bridge.fireDocumentEvent("resume"); + expect(events.slice(-2)).toEqual(["pause", "resume"]); + const custom = window.cordova.addStickyDocumentEventHandler("pluginready"); + window.cordova.fireDocumentEvent("pluginready", { value: 7 }); + custom.subscribe((event) => events.push(event.value)); + expect(events.at(-1)).toBe(7); + window.cordova.removeDocumentEventHandler("pluginready"); + }); - test("does not expose advertising or billing in editions that exclude them", async () => { - const paid = (await createBridge()).window; - expect(() => paid.cordova.require("admob-plus-cordova.AdMob")).toThrow("not found"); - const fdroid = (await createBridge("paid", true)).window; - expect(() => fdroid.cordova.require("cordova-plugin-iap.iap")).toThrow("not found"); - expect(fdroid.cordova.plugin.http).toBe(fdroid.Bridge.http); - expect(bundles.get("paid-false.js")).not.toContain("./src/native/admob/admob.ts"); - expect(bundles.get("paid-true.js")).not.toContain("./src/native/iap.ts"); - }); + test("does not expose advertising or billing in editions that exclude them", async () => { + const paid = (await createBridge()).window; + expect(() => paid.cordova.require("admob-plus-cordova.AdMob")).toThrow( + "not found", + ); + const fdroid = (await createBridge("paid", true)).window; + expect(() => fdroid.cordova.require("cordova-plugin-iap.iap")).toThrow( + "not found", + ); + expect(fdroid.cordova.plugin.http).toBe(fdroid.Bridge.http); + expect(bundles.get("paid-false.js")).not.toContain( + "./src/native/admob/admob.ts", + ); + expect(bundles.get("paid-true.js")).not.toContain("./src/native/iap.ts"); + }); - test("loads plugin-defined modules once and resolves their relative native module imports", async () => { - const { window } = await createBridge(); - const { define, require } = window.cordova; - let loads = 0; - define("example.native", (require, exports, module) => { loads++; module.exports = require("cordova/exec"); }); - define("example.client", (require, exports) => { exports.exec = require("./native"); }); - expect(require("example.client").exec).toBe(window.cordova.exec); - expect(require("example.client")).toBe(require("example.client")); - expect(loads).toBe(1); - expect(() => require("missing")).toThrow("not found"); - expect(() => define("cordova/exec", () => {})).toThrow("already defined"); - const base64 = require("cordova/base64"); - expect(base64.fromArrayBuffer(base64.toArrayBuffer("AP8="))).toBe("AP8="); - }); + test("loads plugin-defined modules once and resolves their relative native module imports", async () => { + const { window } = await createBridge(); + const { define, require } = window.cordova; + let loads = 0; + define("example.native", (require, exports, module) => { + loads++; + module.exports = require("cordova/exec"); + }); + define("example.client", (require, exports) => { + exports.exec = require("./native"); + }); + expect(require("example.client").exec).toBe(window.cordova.exec); + expect(require("example.client")).toBe(require("example.client")); + expect(loads).toBe(1); + expect(() => require("missing")).toThrow("not found"); + expect(() => define("cordova/exec", () => {})).toThrow("already defined"); + const base64 = require("cordova/base64"); + expect(base64.fromArrayBuffer(base64.toArrayBuffer("AP8="))).toBe("AP8="); + }); - test("translates old CoreAndroid actions without changing the internal bridge", async () => { - const { window, pending } = await createBridge(); - window.cordova.exec(null, null, "CoreAndroid", "overrideBackbutton", [true]); - expect([pending.at(-1).service, pending.at(-1).action, JSON.parse(pending.at(-1).args)]).toEqual(["App", "overrideButton", ["backbutton", true]]); - window.cordova.exec(null, null, "CoreAndroid", "overrideButton", ["volumeup", true]); - expect(JSON.parse(pending.at(-1).args)).toEqual(["volumeupbutton", true]); - window.cordova.exec(null, null, "CoreAndroid", "clearHistory", []); - expect([pending.at(-1).service, pending.at(-1).action]).toEqual(["App", "clearHistory"]); - window.cordova.exec(null, null, "CordovaHttpPlugin", "abort", [123]); - expect([pending.at(-1).service, pending.at(-1).action, JSON.parse(pending.at(-1).args)]).toEqual(["NativeHttpPlugin", "abort", [123]]); - window.cordova.exec(null, null, "Device", "getDeviceInfo", null); - expect(JSON.parse(pending.at(-1).args)).toEqual([]); - window.Bridge.exec(null, null, "CoreAndroid", "overrideBackbutton", [true]); - expect([pending.at(-1).service, pending.at(-1).action]).toEqual(["CoreAndroid", "overrideBackbutton"]); - }); + test("translates old CoreAndroid actions without changing the internal bridge", async () => { + const { window, pending } = await createBridge(); + window.cordova.exec(null, null, "CoreAndroid", "overrideBackbutton", [ + true, + ]); + expect([ + pending.at(-1).service, + pending.at(-1).action, + JSON.parse(pending.at(-1).args), + ]).toEqual(["App", "overrideButton", ["backbutton", true]]); + window.cordova.exec(null, null, "CoreAndroid", "overrideButton", [ + "volumeup", + true, + ]); + expect(JSON.parse(pending.at(-1).args)).toEqual(["volumeupbutton", true]); + window.cordova.exec(null, null, "CoreAndroid", "clearHistory", []); + expect([pending.at(-1).service, pending.at(-1).action]).toEqual([ + "App", + "clearHistory", + ]); + window.cordova.exec(null, null, "CordovaHttpPlugin", "abort", [123]); + expect([ + pending.at(-1).service, + pending.at(-1).action, + JSON.parse(pending.at(-1).args), + ]).toEqual(["NativeHttpPlugin", "abort", [123]]); + window.cordova.exec(null, null, "Device", "getDeviceInfo", null); + expect(JSON.parse(pending.at(-1).args)).toEqual([]); + window.Bridge.exec(null, null, "CoreAndroid", "overrideBackbutton", [true]); + expect([pending.at(-1).service, pending.at(-1).action]).toEqual([ + "CoreAndroid", + "overrideBackbutton", + ]); + }); - test("keeps navigator app navigation and cancellation available to legacy plugins", async () => { - const { window, pending } = await createBridge(); - const jobs = new Map(); - let nextId = 0; - window.setTimeout = (callback, delay) => { jobs.set(++nextId, { callback, delay }); return nextId; }; - window.clearTimeout = id => jobs.delete(id); - window.navigator.app.loadUrl("https://example.com", { wait: 2000 }); - expect(jobs.get(nextId).delay).toBe(2000); - window.cordova.exec(null, null, "CoreAndroid", "cancelLoadUrl"); - expect(jobs.size).toBe(0); - window.cordova.exec(null, null, "CoreAndroid", "loadUrl", ["https://example.com", { openExternal: true, clearHistory: true }]); - jobs.get(nextId).callback(); - expect(pending.slice(-2).map(request => [request.service, request.action, JSON.parse(request.args)])).toEqual([ - ["App", "clearHistory", []], ["System", "open-in-browser", ["https://example.com"]], - ]); - window.navigator.app.loadUrl("https://localhost/plugin.html", null); - jobs.get(nextId).callback(); - expect(window.location.href).toBe("https://localhost/plugin.html"); - }); + test("keeps navigator app navigation and cancellation available to legacy plugins", async () => { + const { window, pending } = await createBridge(); + const jobs = new Map(); + let nextId = 0; + window.setTimeout = (callback, delay) => { + jobs.set(++nextId, { callback, delay }); + return nextId; + }; + window.clearTimeout = (id) => jobs.delete(id); + window.navigator.app.loadUrl("https://example.com", { wait: 2000 }); + expect(jobs.get(nextId).delay).toBe(2000); + window.cordova.exec(null, null, "CoreAndroid", "cancelLoadUrl"); + expect(jobs.size).toBe(0); + window.cordova.exec(null, null, "CoreAndroid", "loadUrl", [ + "https://example.com", + { openExternal: true, clearHistory: true }, + ]); + jobs.get(nextId).callback(); + expect( + pending + .slice(-2) + .map((request) => [ + request.service, + request.action, + JSON.parse(request.args), + ]), + ).toEqual([ + ["App", "clearHistory", []], + ["System", "open-in-browser", ["https://example.com"]], + ]); + window.navigator.app.loadUrl("https://localhost/plugin.html", null); + jobs.get(nextId).callback(); + expect(window.location.href).toBe("https://localhost/plugin.html"); + }); }); function respond(window, request, data, status = 1) { - window.Android.callback({ id: request.id, status, keep: false, data }); + window.Android.callback({ id: request.id, status, keep: false, data }); } -async function createBridge(variant = "paid", fdroid = false, platform = "android", missingFromEntries = false) { +async function createBridge( + variant = "paid", + fdroid = false, + platform = "android", + missingFromEntries = false, +) { const window = new Window({ url: "https://localhost/index.html" }); // Chromium exposes these FileSystem constants as read-only Window properties. Object.defineProperty(window, "TEMPORARY", { value: 0, writable: false }); @@ -524,9 +1041,27 @@ async function createBridge(variant = "paid", fdroid = false, platform = "androi const pending = []; window.browserFileReader = window.FileReader; window.earlyReadyCount = 0; - window.document.addEventListener("deviceready", () => window.earlyReadyCount++); - if (platform === "android") window.Android = { exec(service, action, args, id) { pending.push({ service, action, args, id }); return true; } }; - else window.webkit = { messageHandlers: { exec: { postMessage(request) { pending.push(request); } } } }; + window.document.addEventListener( + "deviceready", + () => window.earlyReadyCount++, + ); + if (platform === "android") + window.Android = { + exec(service, action, args, id) { + pending.push({ service, action, args, id }); + return true; + }, + }; + else + window.webkit = { + messageHandlers: { + exec: { + postMessage(request) { + pending.push(request); + }, + }, + }, + }; if (missingFromEntries) window.eval("Object.fromEntries = undefined"); window.eval(bundles.get(`${variant}-${fdroid}.js`)); await window.nativeReady; diff --git a/tests/unit/nativeSearchProcessing.test.js b/tests/unit/nativeSearchProcessing.test.js index 93bc8e6..54dd26e 100644 --- a/tests/unit/nativeSearchProcessing.test.js +++ b/tests/unit/nativeSearchProcessing.test.js @@ -1,7 +1,7 @@ -import { readFileSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { spawnSync } from "node:child_process"; import { expect, it } from "vitest"; const hasJava = spawnSync("javac", ["-version"]).status === 0; diff --git a/tests/unit/openFileCancellation.test.js b/tests/unit/openFileCancellation.test.js index 0f6f3ac..e4316d4 100644 --- a/tests/unit/openFileCancellation.test.js +++ b/tests/unit/openFileCancellation.test.js @@ -86,7 +86,8 @@ describe("openFile cancellation", () => { const exports = {}; new Function("require", "exports", "editorManager", outputText)( (name) => { - if (!(name in dependencies)) throw new Error(`Unexpected import: ${name}`); + if (!(name in dependencies)) + throw new Error(`Unexpected import: ${name}`); return dependencies[name]; }, exports, @@ -112,21 +113,18 @@ describe("openFile cancellation", () => { "pptx", "ppsx", "potx", - ])( - "requires a handler for external %s documents using the provider filename", - async (extension) => { - stat.mockResolvedValue({ - name: `Document.${extension.toUpperCase()}`, - canWrite: false, - }); - await expect( - openFile("content://provider/42", { external: true }), - ).rejects.toMatchObject({ code: "DOCUMENT_HANDLER_UNAVAILABLE" }); - expect(readFile).not.toHaveBeenCalled(); - expect(createEditor).not.toHaveBeenCalled(); - expect(loaderVisible).toBe(false); - }, - ); + ])("requires a handler for external %s documents using the provider filename", async (extension) => { + stat.mockResolvedValue({ + name: `Document.${extension.toUpperCase()}`, + canWrite: false, + }); + await expect( + openFile("content://provider/42", { external: true }), + ).rejects.toMatchObject({ code: "DOCUMENT_HANDLER_UNAVAILABLE" }); + expect(readFile).not.toHaveBeenCalled(); + expect(createEditor).not.toHaveBeenCalled(); + expect(loaderVisible).toBe(false); + }); it("routes granted read-only documents and propagates handler failures instead of decoding bytes", async () => { const handleFile = vi.fn(async () => {}); @@ -169,27 +167,24 @@ describe("openFile cancellation", () => { "application/vnd.openxmlformats-officedocument.presentationml.template", ], ["archive.zip", "type", "text/plain"], - ])( - "rejects external binary %s before reading or decoding", - async (name, field, mime) => { - stat.mockResolvedValue({ - name, - [field]: mime, - canWrite: true, - url: "content://provider/42", - }); - await expect( - openFile("content://provider/42", { external: true }), - ).rejects.toMatchObject({ - message: "Unsupported file", - filename: name, - }); - expect(readFile).not.toHaveBeenCalled(); - expect(decode).not.toHaveBeenCalled(); - expect(createEditor).not.toHaveBeenCalled(); - expect(loaderVisible).toBe(false); - }, - ); + ])("rejects external binary %s before reading or decoding", async (name, field, mime) => { + stat.mockResolvedValue({ + name, + [field]: mime, + canWrite: true, + url: "content://provider/42", + }); + await expect( + openFile("content://provider/42", { external: true }), + ).rejects.toMatchObject({ + message: "Unsupported file", + filename: name, + }); + expect(readFile).not.toHaveBeenCalled(); + expect(decode).not.toHaveBeenCalled(); + expect(createEditor).not.toHaveBeenCalled(); + expect(loaderVisible).toBe(false); + }); it.each([ ["README", "text/plain"], @@ -197,15 +192,12 @@ describe("openFile cancellation", () => { ["plain.txt", "application/octet-stream"], ["plain.csv", "application/octet-stream"], ["plain.tsv", "application/octet-stream"], - ])( - "retains the normal %s editor fallback for incoming files", - async (name, type) => { - stat.mockResolvedValue({ name, type, url: "content://provider/plain" }); - await openFile("content://provider/plain", { external: true }); - expect(createEditor).toHaveBeenCalledOnce(); - expect(manager.activeFile.text).toBe("target text"); - }, - ); + ])("retains the normal %s editor fallback for incoming files", async (name, type) => { + stat.mockResolvedValue({ name, type, url: "content://provider/plain" }); + await openFile("content://provider/plain", { external: true }); + expect(createEditor).toHaveBeenCalledOnce(); + expect(manager.activeFile.text).toBe("target text"); + }); it("preserves handlers and internal opens for extensionless files with binary metadata", async () => { stat.mockResolvedValue({ @@ -246,33 +238,35 @@ describe("openFile cancellation", () => { expect(file.makeActive).not.toHaveBeenCalled(); }); - it.each(["stat", "read", "detect", "decode"])( - "does not steal focus when cancelled during %s", - async (stage) => { - const waiting = deferred(); - const operation = { stat, read: readFile, detect: detectEncoding, decode }[ - stage - ]; - operation.mockReturnValueOnce(waiting.promise); - const opening = openFile("obsolete", { - render: true, - signal: controller.signal, - }); - await vi.waitFor(() => expect(operation).toHaveBeenCalledOnce()); - controller.abort(); + it.each([ + "stat", + "read", + "detect", + "decode", + ])("does not steal focus when cancelled during %s", async (stage) => { + const waiting = deferred(); + const operation = { stat, read: readFile, detect: detectEncoding, decode }[ + stage + ]; + operation.mockReturnValueOnce(waiting.promise); + const opening = openFile("obsolete", { + render: true, + signal: controller.signal, + }); + await vi.waitFor(() => expect(operation).toHaveBeenCalledOnce()); + controller.abort(); - // A newer open completes before the obsolete filesystem work does. - await openFile("latest", { render: true }); - const latest = manager.activeFile; - waiting.resolve( - stage === "stat" ? { name: "obsolete.txt", length: 10 } : "text", - ); - await opening; - expect(manager.activeFile).toBe(latest); - expect(createEditor).toHaveBeenCalledOnce(); - expect(recents.addFile).toHaveBeenCalledExactlyOnceWith("latest"); - }, - ); + // A newer open completes before the obsolete filesystem work does. + await openFile("latest", { render: true }); + const latest = manager.activeFile; + waiting.resolve( + stage === "stat" ? { name: "obsolete.txt", length: 10 } : "text", + ); + await opening; + expect(manager.activeFile).toBe(latest); + expect(createEditor).toHaveBeenCalledOnce(); + expect(recents.addFile).toHaveBeenCalledExactlyOnceWith("latest"); + }); it("guards a custom handler's delayed createEditor callback", async () => { const waiting = deferred(); @@ -311,24 +305,23 @@ describe("openFile cancellation", () => { expect(loaderVisible).toBe(false); }); - it.each([0, 1])( - "keeps the indicator until both concurrent opens finish (first to finish: %s)", - async (first) => { - const reads = [deferred(), deferred()]; - readFile.mockReturnValueOnce(reads[0].promise); - readFile.mockReturnValueOnce(reads[1].promise); - const opens = [openFile("one"), openFile("two")]; - await vi.waitFor(() => expect(readFile).toHaveBeenCalledTimes(2)); - reads[first].resolve("bytes"); - await opens[first]; - expect(loaderVisible).toBe(true); - expect(titleLoader.removeTitleLoader).not.toHaveBeenCalled(); - reads[1 - first].resolve("bytes"); - await opens[1 - first]; - expect(loaderVisible).toBe(false); - expect(titleLoader.removeTitleLoader).toHaveBeenCalledOnce(); - }, - ); + it.each([ + 0, 1, + ])("keeps the indicator until both concurrent opens finish (first to finish: %s)", async (first) => { + const reads = [deferred(), deferred()]; + readFile.mockReturnValueOnce(reads[0].promise); + readFile.mockReturnValueOnce(reads[1].promise); + const opens = [openFile("one"), openFile("two")]; + await vi.waitFor(() => expect(readFile).toHaveBeenCalledTimes(2)); + reads[first].resolve("bytes"); + await opens[first]; + expect(loaderVisible).toBe(true); + expect(titleLoader.removeTitleLoader).not.toHaveBeenCalled(); + reads[1 - first].resolve("bytes"); + await opens[1 - first]; + expect(loaderVisible).toBe(false); + expect(titleLoader.removeTitleLoader).toHaveBeenCalledOnce(); + }); it("releases a cancelled open's loader without waiting for its read", async () => { const reading = deferred(); diff --git a/tests/unit/paidAds.test.js b/tests/unit/paidAds.test.js index 4c114f5..4508b05 100644 --- a/tests/unit/paidAds.test.js +++ b/tests/unit/paidAds.test.js @@ -1,6 +1,13 @@ import { afterEach, expect, test, vi } from "vitest"; -import showRewardedAd, { isRewardedAdSupported, isWatchingRewardedAd } from "../../src/lib/paid/rewardedAd"; -import startAd, { getPrivacyState, showPrivacyOptions, subscribePrivacyState } from "../../src/lib/paid/startAd"; +import showRewardedAd, { + isRewardedAdSupported, + isWatchingRewardedAd, +} from "../../src/lib/paid/rewardedAd"; +import startAd, { + getPrivacyState, + showPrivacyOptions, + subscribePrivacyState, +} from "../../src/lib/paid/startAd"; afterEach(() => vi.unstubAllGlobals()); @@ -12,11 +19,19 @@ test("paid APIs preserve unavailable-ad behavior without a native advertising br await expect(showRewardedAd()).rejects.toThrow("Reward unavailable"); const controller = new AbortController(); controller.abort(); - await expect(showRewardedAd({ signal: controller.signal })).resolves.toBe(false); - await expect(showPrivacyOptions()).rejects.toThrow("Privacy Choices are unavailable"); + await expect(showRewardedAd({ signal: controller.signal })).resolves.toBe( + false, + ); + await expect(showPrivacyOptions()).rejects.toThrow( + "Privacy Choices are unavailable", + ); const listener = vi.fn(); const unsubscribe = subscribePrivacyState(listener); - expect(listener).toHaveBeenCalledWith({ consentStatus: "unknown", canRequestAds: false, privacyOptionsRequired: false }); + expect(listener).toHaveBeenCalledWith({ + consentStatus: "unknown", + canRequestAds: false, + privacyOptionsRequired: false, + }); expect(() => unsubscribe()).not.toThrow(); expect(() => subscribePrivacyState(null)).toThrow(TypeError); const state = getPrivacyState(); diff --git a/tests/unit/path.test.js b/tests/unit/path.test.js index d465d0d..ba142f1 100644 --- a/tests/unit/path.test.js +++ b/tests/unit/path.test.js @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; import Path from "utils/Path"; +import { describe, expect, it } from "vitest"; describe("Path.dirname", () => { it("returns the parent directory", () => { @@ -72,9 +72,7 @@ describe("Path.join / normalize", () => { describe("Path.resolve", () => { it("resolves relative segments against a base", () => { - expect(Path.resolve("path/to/some/dir/", "../../dir")).toBe( - "/path/to/dir", - ); + expect(Path.resolve("path/to/some/dir/", "../../dir")).toBe("/path/to/dir"); }); it("resets on absolute segments", () => { @@ -115,8 +113,6 @@ describe("Path.isAbsolute / convertToRelative", () => { }); it("computes a path relative to a base", () => { - expect(Path.convertToRelative("/foo/bar", "/foo/baz/qux")).toBe( - "baz/qux", - ); + expect(Path.convertToRelative("/foo/bar", "/foo/baz/qux")).toBe("baz/qux"); }); }); diff --git a/tests/unit/pluginContext.test.js b/tests/unit/pluginContext.test.js index 3179437..2375def 100644 --- a/tests/unit/pluginContext.test.js +++ b/tests/unit/pluginContext.test.js @@ -19,8 +19,14 @@ describe("plugin context native bridge", () => { const pluginContext = await import("lib/pluginContext"); await expect(pluginContext.connect()).resolves.toBe(true); - expect(Object.getOwnPropertyDescriptor(Bridge, "exec").writable).toBe(false); - expect(Object.getOwnPropertyDescriptor(Bridge, "exec").configurable).toBe(false); - await expect(pluginContext.default("example.plugin", "{}")).resolves.toBeTruthy(); + expect(Object.getOwnPropertyDescriptor(Bridge, "exec").writable).toBe( + false, + ); + expect(Object.getOwnPropertyDescriptor(Bridge, "exec").configurable).toBe( + false, + ); + await expect( + pluginContext.default("example.plugin", "{}"), + ).resolves.toBeTruthy(); }); }); diff --git a/tests/unit/pluginFileRestoration.test.js b/tests/unit/pluginFileRestoration.test.js index 84364fb..6b310c0 100644 --- a/tests/unit/pluginFileRestoration.test.js +++ b/tests/unit/pluginFileRestoration.test.js @@ -597,87 +597,88 @@ it("continues resume checks past unavailable, failing, and read-only HTTP provid expect(f.manager.files.every((file) => file.tab)).toBe(true); }); -it.each(["cached text", "", undefined])( - "keeps the loading view for cache %j until the session is ready", - (cached) => { - const source = managerSource; - const body = managerBody; - const names = [ - "showLoadingEditor", - "applyFileToEditor", - "recreateActiveEditorState", - "getRawEditorState", - "isReusableEditorState", - ]; - const editor = new EditorView({ parent: document.body }); - const file = { - type: "editor", - filename: "file.js", - loaded: false, - loading: false, - session: EditorState.create(), - __cmSessionReady: true, - __cmExtensionSignature: "test", - }; - const loadingPreviews = new WeakMap(); - if (cached !== undefined) loadingPreviews.set(file, cached); - const context = vm.createContext({ - editor, - EditorState, - placeholder, - createEditorReadOnlyExtension, - blurEditorIfReadOnly, - loadingPreviews, - manager: { activeFile: file }, - touchSelectionController: null, - themeCompartment: new Compartment(), - languageCompartment: new Compartment(), - lspCompartment: new Compartment(), - readOnlyCompartment: new Compartment(), - getConfiguredThemeExtension: () => [], - getBaseExtensionsFromOptions: () => [], - getEditorExtensionSignature: () => "test", - getFileLanguageSignature: () => "text", - applyCurrentEditorOptions: vi.fn(), - 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( - body - .filter( - (node) => - node.type === "FunctionDeclaration" && names.includes(node.id.name), - ) - .map((node) => source.slice(node.start, node.end)) - .join("\n"), - context, - ); - try { - const savedSession = file.session; - context.applyFileToEditor(file); - const preview = editor.state; - context.recreateActiveEditorState(); - expect(editor.state).toBe(preview); - context.applyFileToEditor(file, { forceRecreate: true }); - expect(editor.state.doc.toString()).toBe(cached ?? ""); - expect(editor.state.readOnly).toBe(true); - expect(editor.contentDOM.getAttribute("contenteditable")).toBe("false"); - expect( - editor.dom.querySelector(".cm-placeholder")?.textContent ?? null, - ).toBe(cached === undefined ? "Loading file.js..." : null); - expect(file.session).toBe(savedSession); - file.session = EditorState.create({ doc: cached ?? "loaded text" }); - file.loaded = true; - file.loading = false; - context.applyFileToEditor(file); - expect(editor.state).toBe(file.session); - expect(editor.state.readOnly).toBe(false); - expect(editor.dom.querySelector(".cm-placeholder")).toBeNull(); - } finally { - editor.destroy(); - } - }, -); +it.each([ + "cached text", + "", + undefined, +])("keeps the loading view for cache %j until the session is ready", (cached) => { + const source = managerSource; + const body = managerBody; + const names = [ + "showLoadingEditor", + "applyFileToEditor", + "recreateActiveEditorState", + "getRawEditorState", + "isReusableEditorState", + ]; + const editor = new EditorView({ parent: document.body }); + const file = { + type: "editor", + filename: "file.js", + loaded: false, + loading: false, + session: EditorState.create(), + __cmSessionReady: true, + __cmExtensionSignature: "test", + }; + const loadingPreviews = new WeakMap(); + if (cached !== undefined) loadingPreviews.set(file, cached); + const context = vm.createContext({ + editor, + EditorState, + placeholder, + createEditorReadOnlyExtension, + blurEditorIfReadOnly, + loadingPreviews, + manager: { activeFile: file }, + touchSelectionController: null, + themeCompartment: new Compartment(), + languageCompartment: new Compartment(), + lspCompartment: new Compartment(), + readOnlyCompartment: new Compartment(), + getConfiguredThemeExtension: () => [], + getBaseExtensionsFromOptions: () => [], + getEditorExtensionSignature: () => "test", + getFileLanguageSignature: () => "text", + applyCurrentEditorOptions: vi.fn(), + 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( + body + .filter( + (node) => + node.type === "FunctionDeclaration" && names.includes(node.id.name), + ) + .map((node) => source.slice(node.start, node.end)) + .join("\n"), + context, + ); + try { + const savedSession = file.session; + context.applyFileToEditor(file); + const preview = editor.state; + context.recreateActiveEditorState(); + expect(editor.state).toBe(preview); + context.applyFileToEditor(file, { forceRecreate: true }); + expect(editor.state.doc.toString()).toBe(cached ?? ""); + expect(editor.state.readOnly).toBe(true); + expect(editor.contentDOM.getAttribute("contenteditable")).toBe("false"); + expect( + editor.dom.querySelector(".cm-placeholder")?.textContent ?? null, + ).toBe(cached === undefined ? "Loading file.js..." : null); + expect(file.session).toBe(savedSession); + file.session = EditorState.create({ doc: cached ?? "loaded text" }); + file.loaded = true; + file.loading = false; + context.applyFileToEditor(file); + expect(editor.state).toBe(file.session); + expect(editor.state.readOnly).toBe(false); + expect(editor.dom.querySelector(".cm-placeholder")).toBeNull(); + } finally { + editor.destroy(); + } +}); diff --git a/tests/unit/pluginProvidedModes.test.js b/tests/unit/pluginProvidedModes.test.js new file mode 100644 index 0000000..959bf48 --- /dev/null +++ b/tests/unit/pluginProvidedModes.test.js @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it } from "vitest"; +import "cm/supportedModes"; +import { addMode, getMode, getModeForPath, removeMode } from "cm/modelist"; + +// Modes the Additional Language Modes plugin (acode.additional.langmodes) +// replaces with Lezer grammars, with the extensions it registers. +const pluginModes = [ + { name: "assembly", extensions: ["asm", "s", "S", "nasm"] }, + { name: "c", extensions: ["c", "h", "ino"] }, + { name: "clojure", extensions: ["clj", "cljs", "cljc", "cljx", "edn"] }, + { name: "kotlin", extensions: ["kt", "kts"] }, + { name: "latex", extensions: ["tex", "ltx", "sty", "cls", "latex"] }, + { name: "lua", extensions: ["lua"] }, + { name: "swift", extensions: ["swift"] }, + { name: "handlebars", extensions: ["hbs", "handlebars"] }, + { name: "mustache", extensions: ["mustache"] }, +]; + +/** The plugin's registration rule: keep Acode's mode, add "-enhanced". */ +function registerLikePlugin({ name, extensions }) { + const registered = getMode(name) ? `${name}-enhanced` : name; + addMode(registered, extensions, registered, () => []); + return registered; +} + +describe("legacy modes replaced by the Additional Language Modes plugin", () => { + const registered = []; + + afterEach(() => { + for (const name of registered.splice(0)) removeMode(name); + }); + + it.each([ + "clojure", + "clojurescript", + "edn", + "gas", + "kotlin", + "latex", + "tex", + "stex", + "lua", + "swift", + ])("does not ship a built-in %s mode", (name) => { + expect(getMode(name)).toBeNull(); + }); + + it.each([ + "a.kt", + "a.kts", + "a.lua", + "a.swift", + "a.clj", + "a.cljs", + "a.edn", + "a.s", + "a.tex", + "a.hbs", + "a.handlebars", + ])("falls back to plain text for %s without the plugin", (path) => { + expect(getModeForPath(path).name).toBe("text"); + }); + + it("keeps C, C++, HTML and Luau built in", () => { + expect(getModeForPath("a.html").name).toBe("html"); + expect(getModeForPath("a.c").name).toBe("c"); + expect(getModeForPath("a.cpp").name).toBe("cpp"); + expect(getModeForPath("a.luau").name).toBe("luau"); + }); + + it("opens .text files as plain text instead of LaTeX", () => { + expect(getModeForPath("notes.text").name).toBe("text"); + }); + + it("lets the plugin register the replaced modes under canonical names", () => { + for (const mode of pluginModes) registered.push(registerLikePlugin(mode)); + + expect(registered).toEqual([ + "assembly", + "c-enhanced", + "clojure", + "kotlin", + "latex", + "lua", + "swift", + "handlebars", + "mustache", + ]); + expect(getModeForPath("a.kt").name).toBe("kotlin"); + expect(getModeForPath("a.lua").name).toBe("lua"); + expect(getModeForPath("a.swift").name).toBe("swift"); + expect(getModeForPath("a.cljs").name).toBe("clojure"); + expect(getModeForPath("a.edn").name).toBe("clojure"); + expect(getModeForPath("a.s").name).toBe("assembly"); + expect(getModeForPath("a.tex").name).toBe("latex"); + expect(getModeForPath("a.c").name).toBe("c-enhanced"); + expect(getModeForPath("a.hbs").name).toBe("handlebars"); + expect(getModeForPath("a.handlebars").name).toBe("handlebars"); + expect(getModeForPath("a.mustache").name).toBe("mustache"); + expect(getModeForPath("a.html").name).toBe("html"); + // Luau keeps its own mode even though "lua" now comes from the plugin. + expect(getModeForPath("a.luau").name).toBe("luau"); + }); +}); diff --git a/tests/unit/pluginReview.test.js b/tests/unit/pluginReview.test.js index efb70b1..3e526dc 100644 --- a/tests/unit/pluginReview.test.js +++ b/tests/unit/pluginReview.test.js @@ -1,4 +1,3 @@ -import { describe, expect, it } from "vitest"; import { getIntlLocale, getLocaleDirection } from "lib/lang"; import { formatReviewDate, @@ -11,6 +10,7 @@ import { updateReviewStats, validateReviewDraft, } from "pages/plugin/reviewUtils"; +import { describe, expect, it } from "vitest"; describe("plugin review dates", () => { it("formats database timestamps as website-style relative dates", () => { @@ -104,12 +104,12 @@ describe("plugin review ordering", () => { describe("plugin review draft", () => { it("normalizes whitespace and preserves valid votes", () => { - expect(normalizeReviewDraft({ comment: " Very useful ", vote: "1" })).toEqual( - { - comment: "Very useful", - vote: 1, - }, - ); + expect( + normalizeReviewDraft({ comment: " Very useful ", vote: "1" }), + ).toEqual({ + comment: "Very useful", + vote: 1, + }); }); it("supports an upvote without text and validates negative feedback", () => { diff --git a/tests/unit/punctuationWrapping.test.ts b/tests/unit/punctuationWrapping.test.ts index 4bcd303..97c5b24 100644 --- a/tests/unit/punctuationWrapping.test.ts +++ b/tests/unit/punctuationWrapping.test.ts @@ -27,13 +27,11 @@ function editor(doc: string) { function breaks(view: EditorView) { const positions: number[] = []; - view.plugin(punctuationWrapping)?.decorations.between( - 0, - view.state.doc.length, - (from) => { + view + .plugin(punctuationWrapping) + ?.decorations.between(0, view.state.doc.length, (from) => { positions.push(from); - }, - ); + }); return positions; } diff --git a/tests/unit/quickToolsAdapter.test.js b/tests/unit/quickToolsAdapter.test.js index a882217..881458d 100644 --- a/tests/unit/quickToolsAdapter.test.js +++ b/tests/unit/quickToolsAdapter.test.js @@ -1,25 +1,42 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createQuickToolsAdapterRegistry } from "../../src/lib/quickToolsAdapter"; function setup(resolve) { let tab = { hideQuickTools: true }; const original = tab; const registry = createQuickToolsAdapterRegistry(() => tab, resolve); - let state = { enabled: true, busy: false }, notify; + let state = { enabled: true, busy: false }, + notify; const adapter = { getState: () => state, - canHandle: (action) => action.type !== "command" || action.command === "undo", - subscribe: (fn) => { notify = fn; return vi.fn(); }, + canHandle: (action) => + action.type !== "command" || action.command === "undo", + subscribe: (fn) => { + notify = fn; + return vi.fn(); + }, execute: vi.fn(async () => {}), captureSelection: vi.fn(async () => ({ anchor: 3 })), restoreSelection: vi.fn(async () => {}), - focus: vi.fn(), onError: vi.fn(), cancel: vi.fn(), + focus: vi.fn(), + onError: vi.fn(), + cancel: vi.fn(), }; const dispose = registry.register(tab, adapter); registry.sync(); - return { registry, adapter, original, dispose, - change: (value) => { tab = value; registry.sync(); }, - state: (value) => { state = { ...state, ...value }; notify(); }, + return { + registry, + adapter, + original, + dispose, + change: (value) => { + tab = value; + registry.sync(); + }, + state: (value) => { + state = { ...state, ...value }; + notify(); + }, }; } const insert = { type: "text", text: "x" }; @@ -29,7 +46,9 @@ describe("custom-tab quicktools", () => { it("uses editor availability, preserves configured opt-out for unadapted tabs, and never falls through unsupported actions", async () => { const f = setup(); expect(f.registry.visible()).toBe(true); - expect(f.registry.dispatch({ type: "command", command: "movelinesup" })).toBe(true); + expect( + f.registry.dispatch({ type: "command", command: "movelinesup" }), + ).toBe(true); f.state({ busy: true }); expect(f.registry.visible()).toBe(true); expect(f.registry.available(insert)).toBe(false); @@ -46,82 +65,143 @@ describe("custom-tab quicktools", () => { }); it("captures once before focus moves and preserves sequential repeated input", async () => { const f = setup(); - f.registry.capture(); f.registry.capture(); - for (let i = 0; i < 3; i++) f.registry.dispatch({ type: "text", text: String(i) }); + f.registry.capture(); + f.registry.capture(); + for (let i = 0; i < 3; i++) + f.registry.dispatch({ type: "text", text: String(i) }); await settle(); expect(f.adapter.captureSelection).toHaveBeenCalledOnce(); expect(f.adapter.restoreSelection).toHaveBeenCalledOnce(); - expect(f.adapter.execute.mock.calls.map(([action]) => action.text)).toEqual(["0", "1", "2"]); + expect(f.adapter.execute.mock.calls.map(([action]) => action.text)).toEqual( + ["0", "1", "2"], + ); expect(f.adapter.focus).not.toHaveBeenCalled(); }); it("drops clipboard work and queued repeats after switching away and back", async () => { let finish; - const f = setup(() => new Promise((resolve) => { finish = resolve; })); - f.registry.dispatch(insert); f.registry.dispatch(insert); + const f = setup( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + f.registry.dispatch(insert); + f.registry.dispatch(insert); await settle(); - f.change({ type: "editor" }); f.change(f.original); + f.change({ type: "editor" }); + f.change(f.original); finish(insert); await settle(); expect(f.adapter.execute).not.toHaveBeenCalled(); }); it("cancels pending selection restoration when a dialog opens or the adapter closes", async () => { for (const close of [false, true]) { - const f = setup(); let finish; - f.adapter.captureSelection.mockImplementation(() => new Promise((resolve) => { finish = resolve; })); - f.registry.capture(); f.registry.dispatch(insert); + const f = setup(); + let finish; + f.adapter.captureSelection.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + f.registry.capture(); + f.registry.dispatch(insert); + await settle(); + if (close) f.dispose(); + else f.registry.setBlocked(true); + finish({ anchor: 3 }); await settle(); - if (close) f.dispose(); else f.registry.setBlocked(true); - finish({ anchor: 3 }); await settle(); expect(f.adapter.restoreSelection).not.toHaveBeenCalled(); expect(f.adapter.execute).not.toHaveBeenCalled(); } }); it("isolates two custom tabs and makes stale cleanup harmless", async () => { - const f = setup(); const second = { hideQuickTools: true }, execute = vi.fn(); + const f = setup(); + const second = { hideQuickTools: true }, + execute = vi.fn(); f.registry.register(second, { ...f.adapter, execute }); - f.change(second); f.registry.dispatch(insert); await settle(); + f.change(second); + f.registry.dispatch(insert); + await settle(); expect(execute).toHaveBeenCalledOnce(); expect(f.adapter.execute).not.toHaveBeenCalled(); - f.dispose(); f.dispose(); + f.dispose(); + f.dispose(); expect(f.registry.has(second)).toBe(true); }); it("starts a fresh queue after cancellation even if the old clipboard never resolves", async () => { let first = true; - const f = setup(action => { if (first) { first = false; return new Promise(() => {}); } return action; }); - f.registry.dispatch(insert); await settle(); - f.change({ type: "editor" }); f.change(f.original); - f.registry.dispatch(insert); await settle(); + const f = setup((action) => { + if (first) { + first = false; + return new Promise(() => {}); + } + return action; + }); + f.registry.dispatch(insert); + await settle(); + f.change({ type: "editor" }); + f.change(f.original); + f.registry.dispatch(insert); + await settle(); expect(f.adapter.execute).toHaveBeenCalledOnce(); }); it("captures the updated caret for rapid taps and cancels deferred focus", async () => { - const f = setup(); let caret = 0; + const f = setup(); + let caret = 0; f.adapter.captureSelection.mockImplementation(async () => caret); - f.adapter.restoreSelection.mockImplementation(async (value) => { caret = value; }); - f.adapter.execute.mockImplementation(async () => { caret++; }); - for (let i = 0; i < 4; i++) { f.registry.capture(); f.registry.dispatch(insert); } - await settle(); expect(caret).toBe(4); - f.registry.focus(); f.registry.cancel(); await settle(); + f.adapter.restoreSelection.mockImplementation(async (value) => { + caret = value; + }); + f.adapter.execute.mockImplementation(async () => { + caret++; + }); + for (let i = 0; i < 4; i++) { + f.registry.capture(); + f.registry.dispatch(insert); + } + await settle(); + expect(caret).toBe(4); + f.registry.focus(); + f.registry.cancel(); + await settle(); expect(f.adapter.focus).not.toHaveBeenCalled(); }); it("discards unused captures without cancelling an already queued edit", async () => { - const f = setup(); let finish, caret = 3; + const f = setup(); + let finish, + caret = 3; f.adapter.captureSelection.mockImplementation(() => caret); - f.adapter.restoreSelection.mockImplementation(value => { caret = value; }); - f.adapter.execute.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); - f.registry.capture(); f.registry.dispatch(insert); + f.adapter.restoreSelection.mockImplementation((value) => { + caret = value; + }); + f.adapter.execute.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + f.registry.capture(); + f.registry.dispatch(insert); await settle(); const signal = f.adapter.execute.mock.calls[0][1].signal; - f.registry.capture(); f.registry.discardCapture(); - finish(); await settle(); + f.registry.capture(); + f.registry.discardCapture(); + finish(); + await settle(); caret = 20; - f.registry.capture(); f.registry.dispatch(insert); + f.registry.capture(); + f.registry.dispatch(insert); await settle(); expect(f.adapter.restoreSelection.mock.calls).toEqual([[3], [20]]); expect(f.adapter.execute).toHaveBeenCalledTimes(2); expect(signal.aborted).toBe(false); expect(f.adapter.cancel).not.toHaveBeenCalled(); }); - it.each([{ busy: true }, { enabled: false }])("guards cancellation that publishes state %o and permits later cancellation", async (state) => { + it.each([ + { busy: true }, + { enabled: false }, + ])("guards cancellation that publishes state %o and permits later cancellation", async (state) => { const f = setup(); f.registry.dispatch(insert); await settle(); @@ -139,10 +219,11 @@ describe("custom-tab quicktools", () => { }); it("releases the cancellation guard if the plugin throws", () => { const f = setup(); - f.adapter.cancel.mockImplementationOnce(() => { throw Error("plugin failure"); }); + f.adapter.cancel.mockImplementationOnce(() => { + throw Error("plugin failure"); + }); expect(() => f.registry.cancel()).toThrow("plugin failure"); expect(() => f.registry.cancel()).not.toThrow(); expect(f.adapter.cancel).toHaveBeenCalledTimes(2); }); - }); diff --git a/tests/unit/quickToolsModifierInput.test.ts b/tests/unit/quickToolsModifierInput.test.ts index 48742f7..790e585 100644 --- a/tests/unit/quickToolsModifierInput.test.ts +++ b/tests/unit/quickToolsModifierInput.test.ts @@ -7,12 +7,12 @@ import { } from "@codemirror/language"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; -import { afterEach, describe, expect, it } from "vitest"; import quickToolsModifierInput, { isSelectedRangeDeletion, - setQuickToolsModifierInputHandler, type QuickToolsModifierInputContext, + setQuickToolsModifierInputHandler, } from "cm/quickToolsModifierInput"; +import { afterEach, describe, expect, it } from "vitest"; function createView( doc = "abcdef", @@ -46,13 +46,7 @@ function applyInput( const handled = view.state .facet(EditorView.inputHandler) .some((handler) => - handler( - view, - input.from, - input.to, - input.text, - defaultInsert, - ), + handler(view, input.from, input.to, input.text, defaultInsert), ); if (!handled) view.dispatch(defaultInsert()); return handled; @@ -67,20 +61,20 @@ describe("quick-tools modifier input", () => { it("recognizes only empty changes that cover the active selection", () => { const { view } = createView(); - expect( - isSelectedRangeDeletion(view, { from: 1, to: 4, text: "" }), - ).toBe(true); - expect( - isSelectedRangeDeletion(view, { from: 2, to: 4, text: "" }), - ).toBe(false); - expect( - isSelectedRangeDeletion(view, { from: 1, to: 4, text: "c" }), - ).toBe(false); + expect(isSelectedRangeDeletion(view, { from: 1, to: 4, text: "" })).toBe( + true, + ); + expect(isSelectedRangeDeletion(view, { from: 2, to: 4, text: "" })).toBe( + false, + ); + expect(isSelectedRangeDeletion(view, { from: 1, to: 4, text: "c" })).toBe( + false, + ); view.dispatch({ selection: { anchor: 2 } }); - expect( - isSelectedRangeDeletion(view, { from: 1, to: 4, text: "" }), - ).toBe(false); + expect(isSelectedRangeDeletion(view, { from: 1, to: 4, text: "" })).toBe( + false, + ); view.destroy(); }); @@ -102,17 +96,13 @@ describe("quick-tools modifier input", () => { return true; }); - expect(applyInput(view, { from: 0, to: doc.length, text: "" })).toBe( - true, - ); + expect(applyInput(view, { from: 0, to: doc.length, text: "" })).toBe(true); expect(view.state.doc.toString()).toBe(doc); expect(view.state.selection.main.from).toBe(0); expect(view.state.selection.main.to).toBe(doc.length); expect(ctrlArmed).toBe(true); - expect(applyInput(view, { from: 0, to: doc.length, text: "c" })).toBe( - true, - ); + expect(applyInput(view, { from: 0, to: doc.length, text: "c" })).toBe(true); expect(copied).toBe(doc); expect(view.state.doc.toString()).toBe(doc); expect(view.state.selection.main.from).toBe(0); @@ -137,9 +127,9 @@ describe("quick-tools modifier input", () => { view.focus(); await new Promise((resolve) => setTimeout(resolve, 10)); const startState = view.state; - const stringSpan = Array.from(view.contentDOM.querySelectorAll("span")).find( - (element) => element.textContent?.includes("codemirror"), - ); + const stringSpan = Array.from( + view.contentDOM.querySelectorAll("span"), + ).find((element) => element.textContent?.includes("codemirror")); const textNode = stringSpan?.firstChild; expect(textNode).not.toBeNull(); diff --git a/tests/unit/quickToolsNavigation.test.ts b/tests/unit/quickToolsNavigation.test.ts index 3042ac3..2c9a786 100644 --- a/tests/unit/quickToolsNavigation.test.ts +++ b/tests/unit/quickToolsNavigation.test.ts @@ -2,11 +2,11 @@ import { EditorState } from "@codemirror/state"; import { EditorView, type KeyBinding, keymap } from "@codemirror/view"; -import { afterEach, expect, test, vi } from "vitest"; import { createQuickToolKeyEvent, runQuickToolKey, } from "cm/quickToolsNavigation"; +import { afterEach, expect, test, vi } from "vitest"; vi.hoisted(() => { Object.defineProperty(navigator, "platform", { diff --git a/tests/unit/quickToolsOverlays.test.js b/tests/unit/quickToolsOverlays.test.js index 99898b8..882fa7b 100644 --- a/tests/unit/quickToolsOverlays.test.js +++ b/tests/unit/quickToolsOverlays.test.js @@ -5,6 +5,7 @@ import { hasQuickToolsOverlay, watchQuickToolsOverlays, } from "../../src/lib/quickToolsOverlays"; + let dispose; afterEach(() => { dispose?.(); diff --git a/tests/unit/quickToolsState.test.js b/tests/unit/quickToolsState.test.js index 8b0981d..b525361 100644 --- a/tests/unit/quickToolsState.test.js +++ b/tests/unit/quickToolsState.test.js @@ -1,4 +1,3 @@ -import { describe, expect, it } from "vitest"; import { clearModifierState, clearQuickToolsButtonFeedback, @@ -6,6 +5,7 @@ import { removeActionStackEntries, shouldCaptureModifierInput, } from "handlers/quickToolsState"; +import { describe, expect, it } from "vitest"; describe("shouldCaptureModifierInput", () => { const modifiers = (overrides = {}) => ({ @@ -17,35 +17,33 @@ describe("shouldCaptureModifierInput", () => { }); it("captures command modifiers outside CodeMirror", () => { - expect( - shouldCaptureModifierInput(modifiers({ shift: true }), false), - ).toBe(true); - expect( - shouldCaptureModifierInput(modifiers({ ctrl: true }), false), - ).toBe(true); + expect(shouldCaptureModifierInput(modifiers({ shift: true }), false)).toBe( + true, + ); + expect(shouldCaptureModifierInput(modifiers({ ctrl: true }), false)).toBe( + true, + ); }); it("keeps Shift-only text in CodeMirror", () => { - expect( - shouldCaptureModifierInput(modifiers({ shift: true }), true), - ).toBe(false); + expect(shouldCaptureModifierInput(modifiers({ shift: true }), true)).toBe( + false, + ); }); - it.each(["ctrl", "alt", "meta"])( - "captures CodeMirror input when %s is active", - (key) => { - expect( - shouldCaptureModifierInput(modifiers({ [key]: true }), true), - ).toBe(true); - }, - ); + it.each([ + "ctrl", + "alt", + "meta", + ])("captures CodeMirror input when %s is active", (key) => { + expect(shouldCaptureModifierInput(modifiers({ [key]: true }), true)).toBe( + true, + ); + }); it("captures Shift combined with a command modifier", () => { expect( - shouldCaptureModifierInput( - modifiers({ shift: true, ctrl: true }), - true, - ), + shouldCaptureModifierInput(modifiers({ shift: true, ctrl: true }), true), ).toBe(true); }); diff --git a/tests/unit/readOnlyQuickToolsCapture.test.ts b/tests/unit/readOnlyQuickToolsCapture.test.ts index c11395c..366e1df 100644 --- a/tests/unit/readOnlyQuickToolsCapture.test.ts +++ b/tests/unit/readOnlyQuickToolsCapture.test.ts @@ -1,8 +1,8 @@ -import { describe, expect, it } from "vitest"; import { captureReadOnlyQuickToolsKey, createReadOnlyQuickToolsCaptureSession, } from "handlers/readOnlyQuickToolsCapture"; +import { describe, expect, it } from "vitest"; const modifiers = { shiftKey: false, diff --git a/tests/unit/remoteFilePreview.test.js b/tests/unit/remoteFilePreview.test.js index ed4debc..d4faf9b 100644 --- a/tests/unit/remoteFilePreview.test.js +++ b/tests/unit/remoteFilePreview.test.js @@ -1,5 +1,5 @@ -import { describe, expect, it, vi } from "vitest"; import { readRemoteFilePreview } from "utils/remoteFilePreview"; +import { describe, expect, it, vi } from "vitest"; function createCache({ exists = false, text = "", error = null } = {}) { return { diff --git a/tests/unit/removeAds.test.js b/tests/unit/removeAds.test.js index a3fccc5..a4b92d9 100644 --- a/tests/unit/removeAds.test.js +++ b/tests/unit/removeAds.test.js @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; + const mocks = vi.hoisted(() => ({ config: { HAS_PRO: false, BASE_URL: "https://acode.app" }, alert: vi.fn(), @@ -25,7 +26,9 @@ vi.mock("lib/startAd", () => ({ BANNER_SUPPRESSION_REASON: { PRO: "pro" }, setBannerSuppressed: mocks.suppress, })); + import removeAds, { requestProPurchase, restorePurchases } from "lib/removeAds"; + let purchaseUpdated; let purchaseError; beforeEach(() => { diff --git a/tests/unit/restoreFiles.test.js b/tests/unit/restoreFiles.test.js index 18a8eee..d312bea 100644 --- a/tests/unit/restoreFiles.test.js +++ b/tests/unit/restoreFiles.test.js @@ -53,18 +53,23 @@ describe("restored file loading", () => { expect(completed).toBe(true); }); - it.each(["ftp", "sftp", "http", "https", "gh", "plugin", "custom"])( - "does not block startup on an unresolved %s tab", - async (protocol) => { - await restoreFiles([ - { - id: "remote", - filename: "remote.js", - uri: `${protocol}://example.com/remote.js`, - }, - ]); + it.each([ + "ftp", + "sftp", + "http", + "https", + "gh", + "plugin", + "custom", + ])("does not block startup on an unresolved %s tab", async (protocol) => { + await restoreFiles([ + { + id: "remote", + filename: "remote.js", + uri: `${protocol}://example.com/remote.js`, + }, + ]); - expect(runtime.instances).toHaveLength(1); - }, - ); + expect(runtime.instances).toHaveLength(1); + }); }); diff --git a/tests/unit/saveCommands.test.js b/tests/unit/saveCommands.test.js index 7902949..10e0620 100644 --- a/tests/unit/saveCommands.test.js +++ b/tests/unit/saveCommands.test.js @@ -3,74 +3,150 @@ import platform from "../../src/lib/platform"; import { loadSourceModule } from "../helpers/loadSourceModule"; 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", "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, - ...dependencies, "dialogs/confirm": async () => true, "dialogs/select": async () => "save", "utils/helpers": { error }, - }, { editorManager: manager, strings: {}, toast }); + 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", + "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, + ...dependencies, + "dialogs/confirm": async () => true, + "dialogs/select": async () => "save", + "utils/helpers": { error }, + }, + { editorManager: manager, strings: {}, toast }, + ); return { ...module, manager, toast, error }; } function tab(id, type = "docs") { - const file = { id, type, canSave: type === "docs" || type === "editor", isUnsaved: true, remove: vi.fn(async () => true) }; - file.save = vi.fn(async () => { file.isUnsaved = false; return true; }); + const file = { + id, + type, + canSave: type === "docs" || type === "editor", + isUnsaved: true, + remove: vi.fn(async () => true), + }; + file.save = vi.fn(async () => { + file.isUnsaved = false; + return true; + }); file.saveAs = vi.fn(async () => true); file.hasUnsavedChanges = () => file.isUnsaved; return file; } it("routes standard Save and Save As to the captured tab and leaves terminals ineligible", async () => { - const one = tab("one"), two = tab("two"), code = tab("code", "editor"), terminal = tab("terminal", "terminal"); + const one = tab("one"), + two = tab("two"), + code = tab("code", "editor"), + terminal = tab("terminal", "terminal"); const f = setup([one, two, code, terminal]); for (const file of [one, two, code]) { f.manager.activeFile = file; expect(f.canSaveFile(file)).toBe(true); - await f.default.save(); await f.default["save-as"](); - expect(file.save).toHaveBeenCalledOnce(); expect(file.saveAs).toHaveBeenCalledOnce(); + await f.default.save(); + await f.default["save-as"](); + expect(file.save).toHaveBeenCalledOnce(); + expect(file.saveAs).toHaveBeenCalledOnce(); } f.manager.activeFile = terminal; - await f.default.save(); await f.default["save-as"](); + await f.default.save(); + await f.default["save-as"](); expect(terminal.save).not.toHaveBeenCalled(); }); it("does not report custom-tab success on cancellation or failure", async () => { - const file = tab("one"), f = setup([file]); + const file = tab("one"), + f = setup([file]); file.save.mockResolvedValueOnce(false).mockRejectedValueOnce(Error("full")); - await f.default.save(true); await f.default.save(true); - expect(f.toast).not.toHaveBeenCalled(); expect(f.error).toHaveBeenCalledOnce(); - await f.default.save(true); expect(f.toast).toHaveBeenCalledOnce(); + await f.default.save(true); + await f.default.save(true); + expect(f.toast).not.toHaveBeenCalled(); + expect(f.error).toHaveBeenCalledOnce(); + await f.default.save(true); + expect(f.toast).toHaveBeenCalledOnce(); }); it("does not save pending editors or report a cancelled editor save as successful", async () => { - const file = tab("pending", "editor"), f = setup([file]); + const file = tab("pending", "editor"), + f = setup([file]); file.canSave = false; - await f.default.save(true); await f.default["save-as"](true); - expect(file.save).not.toHaveBeenCalled(); expect(file.saveAs).not.toHaveBeenCalled(); + await f.default.save(true); + await f.default["save-as"](true); + expect(file.save).not.toHaveBeenCalled(); + expect(file.saveAs).not.toHaveBeenCalled(); file.canSave = true; - file.save.mockResolvedValue(false); file.saveAs.mockResolvedValue(false); - await f.default.save(true); await f.default["save-as"](true); + file.save.mockResolvedValue(false); + file.saveAs.mockResolvedValue(false); + await f.default.save(true); + await f.default["save-as"](true); expect(f.toast).not.toHaveBeenCalled(); }); it("saves sequentially without clearing flags and stops at cancellation or newer edits", async () => { - const one = tab("one"), two = tab("two"), code = tab("code", "editor"); + const one = tab("one"), + two = tab("two"), + code = tab("code", "editor"); const f = setup([one, two, code]); let release; - one.save.mockImplementationOnce(() => new Promise(resolve => { release = () => { one.isUnsaved = false; resolve(true); }; })); + one.save.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => { + one.isUnsaved = false; + resolve(true); + }; + }), + ); const saving = f.default["save-all-changes"](); await vi.waitFor(() => expect(one.save).toHaveBeenCalledOnce()); - expect(one.isUnsaved).toBe(true); expect(two.save).not.toHaveBeenCalled(); + expect(one.isUnsaved).toBe(true); + expect(two.save).not.toHaveBeenCalled(); two.save.mockResolvedValueOnce(false); - release(); expect(await saving).toBe(false); - expect(two.isUnsaved).toBe(true); expect(code.save).not.toHaveBeenCalled(); + release(); + expect(await saving).toBe(false); + expect(two.isUnsaved).toBe(true); + expect(code.save).not.toHaveBeenCalled(); two.save.mockResolvedValueOnce(true); // A newer edit remains dirty after the write. expect(await f.default["save-all-changes"]()).toBe(false); expect(code.save).not.toHaveBeenCalled(); expect(await f.default["save-all-changes"]()).toBe(true); expect(code.isUnsaved).toBe(false); }); -it.each([false, true, "failure"])("save-and-close keeps edits after an unsuccessful save (%s)", async outcome => { - const file = tab("one"), f = setup([file]); - if (outcome === "failure") file.save.mockRejectedValueOnce(Error("disk full")); +it.each([ + false, + true, + "failure", +])("save-and-close keeps edits after an unsuccessful save (%s)", async (outcome) => { + const file = tab("one"), + f = setup([file]); + if (outcome === "failure") + file.save.mockRejectedValueOnce(Error("disk full")); else file.save.mockResolvedValueOnce(outcome); const result = f.default["close-all-tabs"](); if (outcome === "failure") await expect(result).rejects.toThrow("disk full"); @@ -79,9 +155,14 @@ it.each([false, true, "failure"])("save-and-close keeps edits after an unsuccess expect(file.isUnsaved).toBe(true); }); -it.each(["terminal", "editor"])("skips an unsavable dirty %s while saving eligible tabs", async type => { +it.each([ + "terminal", + "editor", +])("skips an unsavable dirty %s while saving eligible tabs", async (type) => { for (const command of ["save-all-changes", "close-all-tabs"]) { - const unsupported = tab("unsupported", type), one = tab("one"), two = tab("two", "editor"); + const unsupported = tab("unsupported", type), + one = tab("one"), + two = tab("two", "editor"); unsupported.canSave = false; const files = [unsupported, one, two]; const f = setup(files); @@ -92,19 +173,29 @@ it.each(["terminal", "editor"])("skips an unsavable dirty %s while saving eligib for (const file of [one, two]) { expect(file.save).toHaveBeenCalledOnce(); expect(file.isUnsaved).toBe(false); - expect(file.remove).toHaveBeenCalledTimes(command === "close-all-tabs" ? 1 : 0); + expect(file.remove).toHaveBeenCalledTimes( + command === "close-all-tabs" ? 1 : 0, + ); } } }); -it.each([false, true, "failure"])("still stops after a savable write fails or remains dirty (%s)", async outcome => { +it.each([ + false, + true, + "failure", +])("still stops after a savable write fails or remains dirty (%s)", async (outcome) => { for (const command of ["save-all-changes", "close-all-tabs"]) { - const unsupported = tab("unsupported", "terminal"), failed = tab("failed"), later = tab("later", "editor"); + const unsupported = tab("unsupported", "terminal"), + failed = tab("failed"), + later = tab("later", "editor"); const f = setup([unsupported, failed, later]); - if (outcome === "failure") failed.save.mockRejectedValueOnce(Error("disk full")); + if (outcome === "failure") + failed.save.mockRejectedValueOnce(Error("disk full")); else failed.save.mockResolvedValueOnce(outcome); const saving = f.default[command](); - if (outcome === "failure") await expect(saving).rejects.toThrow("disk full"); + if (outcome === "failure") + await expect(saving).rejects.toThrow("disk full"); else expect(await saving).toBe(false); expect(failed.remove).not.toHaveBeenCalled(); expect(later.save).not.toHaveBeenCalled(); @@ -113,7 +204,8 @@ it.each([false, true, "failure"])("still stops after a savable write fails or re }); it("keeps pinned tabs untouched by save-and-close and reports complete success for eligible tabs", async () => { - const pinned = tab("pinned"), file = tab("file"); + const pinned = tab("pinned"), + file = tab("file"); pinned.pinned = true; const f = setup([pinned, file]); expect(await f.default["close-all-tabs"]()).toBe(true); @@ -122,10 +214,15 @@ it("keeps pinned tabs untouched by save-and-close and reports complete success f expect(file.remove).toHaveBeenCalledOnce(); }); -it.each([false, true])("exposes Android file commands only when supported: %s", androidIntents => { +it.each([ + false, + true, +])("exposes Android file commands only when supported: %s", (androidIntents) => { const commands = setup([], { ...platform, androidIntents }).default; for (const action of ["edit-with", "pin-file-shortcut"]) { - expect(typeof commands[action]).toBe(androidIntents ? "function" : "undefined"); + expect(typeof commands[action]).toBe( + androidIntents ? "function" : "undefined", + ); } expect(commands.share).toBeTypeOf("function"); expect(commands["open-with"]).toBeTypeOf("function"); diff --git a/tests/unit/scrollPastEnd.test.js b/tests/unit/scrollPastEnd.test.js index 11edc5f..d0c78dd 100644 --- a/tests/unit/scrollPastEnd.test.js +++ b/tests/unit/scrollPastEnd.test.js @@ -1,19 +1,15 @@ -import { describe, expect, it } from "vitest"; import { horizontalScrollPastEnd } from "cm/scrollPastEnd"; +import { describe, expect, it } from "vitest"; function themeRules(extension) { - return extension - .flatMap((part) => part?.value?.rules ?? []) - .join("\n"); + return extension.flatMap((part) => part?.value?.rules ?? []).join("\n"); } describe("horizontal scroll past end", () => { it("adds right-side space only to unwrapped editor content", () => { const rules = themeRules(horizontalScrollPastEnd(50)); - expect(rules).toContain( - ".cm-content:not(.cm-lineWrapping) .cm-line", - ); + expect(rules).toContain(".cm-content:not(.cm-lineWrapping) .cm-line"); expect(rules).toContain("box-sizing: border-box"); expect(rules).toContain("min-width: 100%"); expect(rules).toContain("padding-right: 52px"); diff --git a/tests/unit/searchMatchHighlighter.test.ts b/tests/unit/searchMatchHighlighter.test.ts index 7b4fb5a..40fe1be 100644 --- a/tests/unit/searchMatchHighlighter.test.ts +++ b/tests/unit/searchMatchHighlighter.test.ts @@ -2,8 +2,8 @@ import { openSearchPanel, - search, SearchQuery, + search, setSearchQuery, } from "@codemirror/search"; import { EditorSelection, EditorState } from "@codemirror/state"; diff --git a/tests/unit/searchResultNavigation.test.js b/tests/unit/searchResultNavigation.test.js index ace8ed8..bf85c3e 100644 --- a/tests/unit/searchResultNavigation.test.js +++ b/tests/unit/searchResultNavigation.test.js @@ -1,6 +1,6 @@ +import navigateToResult from "sidebarApps/searchInFiles/navigateToResult"; import { EditorState } from "@codemirror/state"; import openFile from "lib/openFile"; -import navigateToResult from "sidebarApps/searchInFiles/navigateToResult"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("lib/openFile", () => ({ default: vi.fn() })); @@ -61,21 +61,21 @@ describe("project search result navigation", () => { afterEach(() => vi.unstubAllGlobals()); - it.each([false, true])( - "reveals the exact match through the scroll-restore-safe API (existing tab: %s)", - async (existing) => { - if (existing) addFile("target"); - expect(await navigateToResult("target", match)).toBe(true); - expect(openFile).toHaveBeenCalledWith("target", { - render: true, - signal: expect.any(AbortSignal), - }); - expect(manager.revealRange).toHaveBeenCalledWith(8, 11, { - y: "center", - userEvent: "select.search", - }); - }, - ); + it.each([ + false, + true, + ])("reveals the exact match through the scroll-restore-safe API (existing tab: %s)", async (existing) => { + if (existing) addFile("target"); + expect(await navigateToResult("target", match)).toBe(true); + expect(openFile).toHaveBeenCalledWith("target", { + render: true, + signal: expect.any(AbortSignal), + }); + expect(manager.revealRange).toHaveBeenCalledWith(8, 11, { + y: "center", + userEvent: "select.search", + }); + }); it("waits for a restored tab's final document instead of its preview", async () => { const file = addFile("target", "preview"); @@ -173,38 +173,39 @@ describe("project search result navigation", () => { ); }); - it.each(["switched", "closed", "failed"])( - "does not reveal into another document after the target is %s during loading", - async (action) => { - const file = addFile("target"); - const loading = deferred(); - file.load.mockImplementation(() => loading.promise); - const navigation = navigateToResult("target", match); - await vi.waitFor(() => expect(file.load).toHaveBeenCalled()); - if (action === "switched") activate(addFile("other")); - if (action === "closed") files.delete("target"); - if (action === "failed") file.loaded = false; - loading.resolve(); - expect(await navigation).toBe(false); - expect(manager.revealRange).not.toHaveBeenCalled(); - }, - ); - - it.each(["missing", "image"])( - "does not navigate the previous editor when opening a %s target", - async (type) => { - activate(addFile("previous")); - openFile.mockImplementationOnce(async () => { - if (type === "image") { - const file = addFile("target"); - file.type = "image"; - activate(file); - } - }); - expect(await navigateToResult("target", match)).toBe(false); - expect(manager.revealRange).not.toHaveBeenCalled(); - }, - ); + it.each([ + "switched", + "closed", + "failed", + ])("does not reveal into another document after the target is %s during loading", async (action) => { + const file = addFile("target"); + const loading = deferred(); + file.load.mockImplementation(() => loading.promise); + const navigation = navigateToResult("target", match); + await vi.waitFor(() => expect(file.load).toHaveBeenCalled()); + if (action === "switched") activate(addFile("other")); + if (action === "closed") files.delete("target"); + if (action === "failed") file.loaded = false; + loading.resolve(); + expect(await navigation).toBe(false); + expect(manager.revealRange).not.toHaveBeenCalled(); + }); + + it.each([ + "missing", + "image", + ])("does not navigate the previous editor when opening a %s target", async (type) => { + activate(addFile("previous")); + openFile.mockImplementationOnce(async () => { + if (type === "image") { + const file = addFile("target"); + file.type = "image"; + activate(file); + } + }); + expect(await navigateToResult("target", match)).toBe(false); + expect(manager.revealRange).not.toHaveBeenCalled(); + }); it("handles multiline ranges and clamps stale coordinates", async () => { addFile("target", "abc\ndef"); @@ -212,17 +213,27 @@ describe("project search result navigation", () => { start: { row: 0, column: 1 }, end: { row: 1, column: 2 }, }); - expect(manager.revealRange).toHaveBeenLastCalledWith(1, 6, expect.anything()); + expect(manager.revealRange).toHaveBeenLastCalledWith( + 1, + 6, + expect.anything(), + ); await navigateToResult("target", { start: { row: -1, column: -2 }, end: { row: 100, column: 100 }, }); - expect(manager.revealRange).toHaveBeenLastCalledWith(0, 7, expect.anything()); + expect(manager.revealRange).toHaveBeenLastCalledWith( + 0, + 7, + expect.anything(), + ); }); it("continues navigating after an open rejects", async () => { openFile.mockRejectedValueOnce(new Error("Read failed")); - await expect(navigateToResult("broken", match)).rejects.toThrow("Read failed"); + await expect(navigateToResult("broken", match)).rejects.toThrow( + "Read failed", + ); expect(await navigateToResult("target", match)).toBe(true); expect(manager.activeFile.uri).toBe("target"); }); diff --git a/tests/unit/searchStreamingResults.test.js b/tests/unit/searchStreamingResults.test.js index 2ad8e63..6aea8bd 100644 --- a/tests/unit/searchStreamingResults.test.js +++ b/tests/unit/searchStreamingResults.test.js @@ -1,8 +1,10 @@ // @vitest-environment happy-dom + +import { createSearchResultView } from "sidebarApps/searchInFiles/cmResultView"; import { readFileSync } from "node:fs"; import vm from "node:vm"; import { expect, it, vi } from "vitest"; -import { createSearchResultView } from "sidebarApps/searchInFiles/cmResultView"; + vi.mock("lib/settings", () => ({ default: { value: {} } })); vi.mock("utils/helpers", () => ({ default: { getIconForFile: (name) => `icon-${name}` }, diff --git a/tests/unit/sessionPersistence.test.js b/tests/unit/sessionPersistence.test.js index 823b1a2..0082d19 100644 --- a/tests/unit/sessionPersistence.test.js +++ b/tests/unit/sessionPersistence.test.js @@ -109,9 +109,7 @@ describe("file session persistence", () => { saveState(); - const restoredUris = JSON.parse(localStorage.files).map( - (file) => file.uri, - ); + const restoredUris = JSON.parse(localStorage.files).map((file) => file.uri); expect(restoredUris).toEqual([selectedUri]); }); @@ -139,9 +137,7 @@ describe("file session persistence", () => { it("keeps restored folds until an inactive tab is first rendered", () => { const file = createOpenFile("file:///restored.js", {}); - file.restoredFolds = [ - { fromLine: 2, fromCol: 0, toLine: 5, toCol: 1 }, - ]; + file.restoredFolds = [{ fromLine: 2, fromCol: 0, toLine: 5, toCol: 1 }]; globalThis.editorManager.files.push(file); saveState(); diff --git a/tests/unit/sftpProfiles.test.js b/tests/unit/sftpProfiles.test.js index bae0b1a..fa7a269 100644 --- a/tests/unit/sftpProfiles.test.js +++ b/tests/unit/sftpProfiles.test.js @@ -30,7 +30,9 @@ describe("SFTP secure profiles", () => { const url = createSftpProfileUrl("profile-123", "/project/file.js"); expect(url).toBe("sftp://profile-123/project/file.js"); expect(getSftpProfileId(url)).toBe("profile-123"); - expect(getSftpProfileId("sftp://user:secret@example.com/project")).toBeNull(); + expect( + getSftpProfileId("sftp://user:secret@example.com/project"), + ).toBeNull(); expect( getSftpProfileId("sftp://user:secret@profile-server/project"), ).toBeNull(); @@ -149,8 +151,7 @@ describe("SFTP secure profiles", () => { const saveProfile = vi.fn((...args) => args.at(-2)("profile-folder")); globalThis.sftp = { saveProfile }; const root = "sftp://user:secret@example.com/project"; - const expandedFolder = - "sftp://user:stale-secret@example.com/project/src"; + const expandedFolder = "sftp://user:stale-secret@example.com/project/src"; localStorage.setItem( "folders", JSON.stringify([ diff --git a/tests/unit/shell.test.js b/tests/unit/shell.test.js index 6a7f5ef..000e40f 100644 --- a/tests/unit/shell.test.js +++ b/tests/unit/shell.test.js @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; import { quotePosixShellArg } from "utils/shell"; +import { describe, expect, it } from "vitest"; describe("quotePosixShellArg", () => { it("quotes spaces and shell metacharacters as literal text", () => { diff --git a/tests/unit/sync.test.js b/tests/unit/sync.test.js index 6aa087d..5a8d201 100644 --- a/tests/unit/sync.test.js +++ b/tests/unit/sync.test.js @@ -31,7 +31,9 @@ test("patchProjectFile syncs version, versionCode, bundle ids and display name", expect(content).toContain("MARKETING_VERSION = 2.3.4;"); expect(content).toContain("CURRENT_PROJECT_VERSION = 99;"); expect(content).toContain("PRODUCT_BUNDLE_IDENTIFIER = com.example.app;"); - expect(content).toContain("PRODUCT_BUNDLE_IDENTIFIER = com.example.app.tests;"); + expect(content).toContain( + "PRODUCT_BUNDLE_IDENTIFIER = com.example.app.tests;", + ); expect(content).toContain( 'PRODUCT_BUNDLE_IDENTIFIER = "com.example.app.ui-tests";', ); diff --git a/tests/unit/systemHttpStream.test.js b/tests/unit/systemHttpStream.test.js index c9fc4fa..2257f74 100644 --- a/tests/unit/systemHttpStream.test.js +++ b/tests/unit/systemHttpStream.test.js @@ -1,5 +1,5 @@ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; import http from "node:http"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import system from "../../src/native/system"; @@ -430,7 +430,10 @@ describe("system.httpStream", () => { bridge = createNativeBridge(); globalThis.Bridge = { exec: (...args) => bridge.exec(...args) }; const url = server.url("/network-error"); - const err = await system.httpStream(url).then(() => null, (e) => e); + const err = await system.httpStream(url).then( + () => null, + (e) => e, + ); expect(err).toBeInstanceOf(Error); }); @@ -448,8 +451,7 @@ describe("system.httpStream", () => { expect( bridge.calls.some( (c) => - c.action === "http-stream-cancel" && - typeof c.args[0] === "string", + c.action === "http-stream-cancel" && typeof c.args[0] === "string", ), ).toBe(true); @@ -487,12 +489,15 @@ describe("system.httpStream", () => { const err = await system .httpStream(server.url("/simple"), { signal: controller.signal }) - .then(() => null, (e) => e); + .then( + () => null, + (e) => e, + ); expect(err).toBeInstanceOf(Error); expect(err.name).toBe("AbortError"); - expect( - bridge.calls.some((c) => c.action === "http-stream-start"), - ).toBe(false); + expect(bridge.calls.some((c) => c.action === "http-stream-start")).toBe( + false, + ); }); it("10. aborting the signal cancels an in-flight stream and errors the body", async () => { @@ -509,15 +514,17 @@ describe("system.httpStream", () => { controller.abort(); - const err = await reader.read().then(() => null, (e) => e); + const err = await reader.read().then( + () => null, + (e) => e, + ); expect(err).toBeInstanceOf(Error); expect(err.name).toBe("AbortError"); expect( bridge.calls.some( (c) => - c.action === "http-stream-cancel" && - typeof c.args[0] === "string", + c.action === "http-stream-cancel" && typeof c.args[0] === "string", ), ).toBe(true); const entry = bridge.streams.values().next().value; @@ -542,9 +549,7 @@ describe("system.httpStream", () => { bridge = createNativeBridge(); globalThis.Bridge = { exec: (...args) => bridge.exec(...args) }; - const expected = Buffer.from([ - 0x00, 0x08, 0x1f, 0x7f, 0xff, 0x22, 0x00, - ]); + const expected = Buffer.from([0x00, 0x08, 0x1f, 0x7f, 0xff, 0x22, 0x00]); const response = await system.httpStream(server.url("/binary-ctrl")); const bytes = Buffer.from(concat(await collect(response))); expect(bytes.equals(expected)).toBe(true); diff --git a/tests/unit/taskManager.test.js b/tests/unit/taskManager.test.js index 987689c..c364f5f 100644 --- a/tests/unit/taskManager.test.js +++ b/tests/unit/taskManager.test.js @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; import TaskManager from "utils/taskManager"; +import { describe, expect, it } from "vitest"; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/tests/unit/uri.test.js b/tests/unit/uri.test.js index c13c7d3..86d7da5 100644 --- a/tests/unit/uri.test.js +++ b/tests/unit/uri.test.js @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; import Uri from "utils/Uri"; +import { describe, expect, it } from "vitest"; const TREE_URI = "content://com.android.externalstorage.documents/tree/primary%3ADCIM"; @@ -67,9 +67,9 @@ describe("Uri.getPrimaryAddress", () => { describe("Uri.getDisplayPath", () => { it("uses the storage name and a path relative to its SAF root", () => { const url = `${TREE_URI}::primary:DCIM/Camera/photo.jpg`; - expect( - Uri.getDisplayPath(url, [{ name: "Pictures", url: TREE_URI }]), - ).toBe("Pictures/Camera/photo.jpg"); + expect(Uri.getDisplayPath(url, [{ name: "Pictures", url: TREE_URI }])).toBe( + "Pictures/Camera/photo.jpg", + ); }); it("does not match aliases from a prefix-sharing SAF root", () => { @@ -79,9 +79,7 @@ describe("Uri.getDisplayPath", () => { "content://com.android.externalstorage.documents/tree/primary%3ACodesBackup"; expect( - Uri.getDisplayPath(codesBackupRoot, [ - { name: "Codes", url: codesRoot }, - ]), + Uri.getDisplayPath(codesBackupRoot, [{ name: "Codes", url: codesRoot }]), ).toBe("primary/CodesBackup"); }); @@ -89,15 +87,13 @@ describe("Uri.getDisplayPath", () => { const rootUri = "content://com.android.externalstorage.documents/tree/primary%3A"; const url = `${rootUri}::primary/file.txt`; - expect( - Uri.getDisplayPath(url, [{ name: "Internal", url: rootUri }]), - ).toBe("Internal/file.txt"); + expect(Uri.getDisplayPath(url, [{ name: "Internal", url: rootUri }])).toBe( + "Internal/file.txt", + ); }); it("falls back to a readable volume and document path", () => { - expect(Uri.getDisplayPath(SINGLE_URI, [])).toBe( - "primary/DCIM/file.txt", - ); + expect(Uri.getDisplayPath(SINGLE_URI, [])).toBe("primary/DCIM/file.txt"); }); it("includes the selected folder in a bare primary tree path", () => { diff --git a/tests/unit/url.test.js b/tests/unit/url.test.js index 0334ab0..083b5d5 100644 --- a/tests/unit/url.test.js +++ b/tests/unit/url.test.js @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; import Url from "utils/Url"; +import { describe, expect, it } from "vitest"; describe("Url.getProtocol", () => { it("extracts the protocol", () => { @@ -71,9 +71,7 @@ describe("Url.parse / pathname / dirname", () => { }); it("returns the parent url", () => { - expect(Url.dirname("ftp://localhost/foo/bar")).toBe( - "ftp://localhost/foo/", - ); + expect(Url.dirname("ftp://localhost/foo/bar")).toBe("ftp://localhost/foo/"); }); }); @@ -153,16 +151,16 @@ describe("Url.hidePassword / decodeUrl", () => { // NOTE: url-parse only splits the port for special protocols // (http/https/ws/...), so an http url is used to exercise the // port decoding branch of decodeUrl. - expect( - Url.decodeUrl("http://user:p%40ss@host.com:8080/a%20b?x=1"), - ).toEqual({ - username: "user", - password: "p@ss", - hostname: "host.com", - pathname: "/a b", - port: 8080, - query: { x: "1" }, - }); + expect(Url.decodeUrl("http://user:p%40ss@host.com:8080/a%20b?x=1")).toEqual( + { + username: "user", + password: "p@ss", + hostname: "host.com", + pathname: "/a b", + port: 8080, + query: { x: "1" }, + }, + ); }); it("decodes credentials for ftp urls", () => { diff --git a/tests/unit/variableVirtualList.test.js b/tests/unit/variableVirtualList.test.js index ff2a873..f659e8b 100644 --- a/tests/unit/variableVirtualList.test.js +++ b/tests/unit/variableVirtualList.test.js @@ -1,7 +1,7 @@ // @vitest-environment happy-dom -import { describe, expect, it, vi } from "vitest"; import VariableVirtualList from "components/virtualList/variable"; +import { describe, expect, it, vi } from "vitest"; function createList() { const container = document.createElement("div"); diff --git a/tests/unit/verifyProPurchase.test.js b/tests/unit/verifyProPurchase.test.js index 315b29b..7daec3e 100644 --- a/tests/unit/verifyProPurchase.test.js +++ b/tests/unit/verifyProPurchase.test.js @@ -12,42 +12,41 @@ function setup({ hasPro, cached = false }) { 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, + const { default: verifyProPurchase, PRO_CHECK_TIMEOUT_MS } = loadSourceModule( + "src/lib/verifyProPurchase.js", + { + "utils/helpers": { + __esModule: true, + default: { + promisify: (fn) => new Promise((resolve) => fn(resolve)), }, }, - { - 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; - }, + "./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, diff --git a/tests/unit/version.test.js b/tests/unit/version.test.js index 246522c..f5e719e 100644 --- a/tests/unit/version.test.js +++ b/tests/unit/version.test.js @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; import { compareVersions, isVersionGreater, parseVersion } from "utils/version"; +import { describe, expect, it } from "vitest"; describe("utils/version", () => { it("parses strict x.y.z versions", () => {