From 7bcc8374b28e9b12bcfa78a95d92bbd41c9d7e17 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:28:03 +0530 Subject: [PATCH 1/2] fix(editor): prevent false language mode recommendations - recommend only language-mode plugins available in the registry - suppress built-in, extensionless, and arbitrary file false positives - remove the direct plugin-request issue action - add regression tests for unknown extensions --- src/lib/languageModeRecommendations.js | 90 +++++++------------ src/test/sanity.tests.js | 35 +++++++- .../unit/languageModeRecommendations.test.js | 65 ++++++++++++++ 3 files changed, 132 insertions(+), 58 deletions(-) create mode 100644 tests/unit/languageModeRecommendations.test.js diff --git a/src/lib/languageModeRecommendations.js b/src/lib/languageModeRecommendations.js index d534e2bf13..3292f0d373 100644 --- a/src/lib/languageModeRecommendations.js +++ b/src/lib/languageModeRecommendations.js @@ -23,32 +23,12 @@ export function getLanguageModeRecommendationSearchKeyword(filename) { return keyword; } -function getIssueUrl(keyword) { - const params = new URLSearchParams({ - template: "1_feature_request.yml", - labels: "new plugin idea,enhancement", - title: `Plugin request: ${keyword} syntax highlighting`, - }); - - return `${config.GITHUB_URL}/issues/new?${params}`; -} - function formatString(value, replacements) { return String(value || "").replace(/\{(\w+)\}/g, (_, key) => { return replacements[key] ?? ""; }); } -async function openUrl(url) { - if (window.cordova?.exec) { - const { default: customTab } = await import("./customTab"); - await customTab(url); - return; - } - - window.open(url, "_blank", "noopener,noreferrer"); -} - async function openExtensions(keyword) { const { openWithSearch } = await import("sidebarApps/extensions"); openWithSearch(keyword); @@ -58,6 +38,19 @@ function hasPlainTextFallback(modeInfo, filename) { return modeInfo?.name === "text" && !modeInfo.supportsFile(filename); } +export function shouldRecommendLanguageModeExtension(filename, modeInfo) { + if (!hasPlainTextFallback(modeInfo, filename)) return false; + + const keyword = getLanguageModeRecommendationSearchKeyword(filename); + if (!keyword) return false; + + // Probe the normalized extension independently of the original filename. + // This prevents a strangely formatted path from producing requests for core + // modes such as HTML or Python. + const probeFilename = `file.${keyword}`; + return hasPlainTextFallback(getModeForPath(probeFilename), probeFilename); +} + class LanguageModeRecommendations { notifiedKeywords = new Set(); pendingKeywords = new Set(); @@ -88,7 +81,7 @@ class LanguageModeRecommendations { if (!file || file.type !== "editor") return; const filename = file.filename || ""; - if (!hasPlainTextFallback(modeInfo, filename)) return; + if (!shouldRecommendLanguageModeExtension(filename, modeInfo)) return; const keyword = getLanguageModeRecommendationSearchKeyword(filename); if ( @@ -116,52 +109,35 @@ class LanguageModeRecommendations { const hasPlugins = await this.getPluginAvailability(keyword); // If a plugin registered the mode while the lookup was pending, suppress // this stale recommendation and leave the keyword eligible for future checks. - if (!hasPlainTextFallback(getModeForPath(filename), filename)) return false; - - const displayExt = `.${keyword}`; - - if (hasPlugins) { - notificationManager.pushNotification({ - title: formatString(strings["extension recommendation title"], { - extension: displayExt, - keyword: `mode:${keyword}`, - }), - message: formatString(strings["extension recommendation message"], { - extension: displayExt, - keyword: `mode:${keyword}`, - }), - icon: "extension", - type: "info", - action: () => openExtensions(`mode:${keyword}`), - actions: [ - { - text: strings["search plugins"], - icon: "search", - action: () => openExtensions(`mode:${keyword}`), - }, - ], - }); - return true; + if ( + !shouldRecommendLanguageModeExtension(filename, getModeForPath(filename)) + ) { + return false; } - const issueUrl = getIssueUrl(keyword); + // An unknown extension is not enough evidence that the file contains a + // programming language. Stay silent unless the registry has a matching + // language-mode plugin to recommend. + if (!hasPlugins) return false; + + const displayExt = `.${keyword}`; notificationManager.pushNotification({ - title: formatString(strings["extension request title"], { + title: formatString(strings["extension recommendation title"], { extension: displayExt, - keyword, + keyword: `mode:${keyword}`, }), - message: formatString(strings["extension request message"], { + message: formatString(strings["extension recommendation message"], { extension: displayExt, - keyword, + keyword: `mode:${keyword}`, }), icon: "extension", - type: "warning", - action: () => openUrl(issueUrl), + type: "info", + action: () => openExtensions(`mode:${keyword}`), actions: [ { - text: strings["request plugin"], - icon: "open_in_new", - action: () => openUrl(issueUrl), + text: strings["search plugins"], + icon: "search", + action: () => openExtensions(`mode:${keyword}`), }, ], }); diff --git a/src/test/sanity.tests.js b/src/test/sanity.tests.js index 8fc86fca5b..63451a6d02 100644 --- a/src/test/sanity.tests.js +++ b/src/test/sanity.tests.js @@ -1,9 +1,13 @@ +import { getModeForPath } from "../cm/modelist"; import { clearModifierState, clearQuickToolsButtonFeedback, removeActionStackEntries, } from "../handlers/quickToolsState"; -import { getLanguageModeRecommendationSearchKeyword } from "../lib/languageModeRecommendations"; +import { + getLanguageModeRecommendationSearchKeyword, + shouldRecommendLanguageModeExtension, +} from "../lib/languageModeRecommendations"; import { isVersionGreater } from "../utils/version"; import { TestRunner } from "./tester"; @@ -86,6 +90,35 @@ export async function runSanityTests(writeOutput) { "", "Extensionless non-dotfiles should not request plugin recommendations", ); + test.assertEqual( + getLanguageModeRecommendationSearchKeyword("example"), + "", + "Arbitrary extensionless names should not request plugin recommendations", + ); + }); + + runner.test("Language mode recommendation candidates", (test) => { + test.assert( + !shouldRecommendLanguageModeExtension( + "example.html ", + getModeForPath("example.html "), + ), + "Built-in language extensions should not request plugins", + ); + test.assert( + !shouldRecommendLanguageModeExtension( + "example.py ", + getModeForPath("example.py "), + ), + "Built-in Python support should not request a plugin", + ); + test.assert( + shouldRecommendLanguageModeExtension( + "example.acode-unknown-mode", + getModeForPath("example.acode-unknown-mode"), + ), + "Unknown language extensions should remain eligible for recommendations", + ); }); runner.test("Quick tools modifier cleanup emits inactive state", (test) => { diff --git a/tests/unit/languageModeRecommendations.test.js b/tests/unit/languageModeRecommendations.test.js new file mode 100644 index 0000000000..0b2f82f793 --- /dev/null +++ b/tests/unit/languageModeRecommendations.test.js @@ -0,0 +1,65 @@ +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"; + +vi.mock("lib/notificationManager", () => ({ + default: { + pushNotification: vi.fn(), + }, +})); + +const originalFetch = globalThis.fetch; + +globalThis.strings = { + "extension recommendation title": "Extensions available for {extension}", + "extension recommendation message": "Search for {keyword}", + "search plugins": "Search plugins", +}; + +function recommend(filename) { + recommendLanguageModeExtension( + { type: "editor", filename }, + getModeForPath(filename), + ); +} + +describe("language mode recommendations", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterAll(() => { + globalThis.fetch = originalFetch; + }); + + it("stays silent for arbitrary extensions without a matching plugin", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => [], + }); + + for (const filename of ["test.random", "dhd.sdocx", "hdh.glsl"]) { + recommend(filename); + } + + await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(notificationManager.pushNotification).not.toHaveBeenCalled(); + }); + + it("recommends a language-mode plugin that exists in the registry", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => [{ id: "example-language-mode" }], + }); + + recommend("test.acodepluginmode"); + + await vi.waitFor(() => { + expect(notificationManager.pushNotification).toHaveBeenCalledOnce(); + }); + }); +}); From ab0e26eeec6e5d1307e0787dab8b169b637697cd Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:34:23 +0530 Subject: [PATCH 2/2] fix the stale network cache things --- src/lib/languageModeRecommendations.js | 16 +++++++++-- .../unit/languageModeRecommendations.test.js | 28 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/lib/languageModeRecommendations.js b/src/lib/languageModeRecommendations.js index 3292f0d373..2783cc2b26 100644 --- a/src/lib/languageModeRecommendations.js +++ b/src/lib/languageModeRecommendations.js @@ -69,9 +69,21 @@ class LanguageModeRecommendations { ), ), ) - .then((response) => (response.ok ? response.json() : [])) + .then((response) => { + if (!response.ok) { + throw new Error(`Plugin registry request failed: ${response.status}`); + } + return response.json(); + }) .then((plugins) => Array.isArray(plugins) && plugins.length > 0) - .catch(() => false); + .catch(() => { + // Do not let a temporary network or server failure suppress this + // recommendation for the rest of the app session. + if (this.availabilityCache.get(keyword) === availability) { + this.availabilityCache.delete(keyword); + } + return false; + }); this.availabilityCache.set(keyword, availability); return availability; diff --git a/tests/unit/languageModeRecommendations.test.js b/tests/unit/languageModeRecommendations.test.js index 0b2f82f793..c737c68e42 100644 --- a/tests/unit/languageModeRecommendations.test.js +++ b/tests/unit/languageModeRecommendations.test.js @@ -62,4 +62,32 @@ describe("language mode recommendations", () => { expect(notificationManager.pushNotification).toHaveBeenCalledOnce(); }); }); + + it.each([ + ["network errors", () => Promise.reject(new Error("offline"))], + ["server errors", () => Promise.resolve({ ok: false, status: 503 })], + ])("retries after transient %s", async (_, failedResponse) => { + globalThis.fetch = vi + .fn() + .mockImplementationOnce(failedResponse) + .mockResolvedValueOnce({ + ok: true, + json: async () => [{ id: "recovered-language-mode" }], + }); + + const keyword = `retryable-${crypto.randomUUID()}`; + recommend(`test.${keyword}`); + + await vi.waitFor(() => { + expect(globalThis.fetch).toHaveBeenCalledOnce(); + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + recommend(`test.${keyword}`); + + await vi.waitFor(() => { + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + expect(notificationManager.pushNotification).toHaveBeenCalledOnce(); + }); + }); });