Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 47 additions & 59 deletions src/lib/languageModeRecommendations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
Expand All @@ -76,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;
Expand All @@ -88,7 +93,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 (
Expand Down Expand Up @@ -116,52 +121,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;
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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}`),
},
],
});
Expand Down
35 changes: 34 additions & 1 deletion src/test/sanity.tests.js
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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) => {
Expand Down
93 changes: 93 additions & 0 deletions tests/unit/languageModeRecommendations.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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();
});
});

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();
});
});
});