From eb633f6ca60fcae71033c3fdc3df9a834f583e62 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 00:37:14 +0800 Subject: [PATCH] feat(run): run npm scripts from package.json gutter icons (#898) Every entry under the top-level `scripts` of a package.json gets a run gutter icon with Run/Debug for that script. It uses the detected npm run configuration (re-detected at click time) and otherwise a temporary configuration built with the same package-manager precedence as backend detection: the `packageManager` field, then the lockfile, inheriting the workspace root's manager for members. Workspace member configuration ids use the full relative directory, as the backend does. The "Show run icons" preference hides these icons too, and the markers are real buttons with translated titles. Fixes #894 Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- src/editor/core/cm/theme.ts | 2 +- src/features/run/index.ts | 2 + src/features/run/npmGutter.test.ts | 311 +++++++++++++++++++++++ src/features/run/npmGutter.ts | 392 +++++++++++++++++++++++++++++ 4 files changed, 706 insertions(+), 1 deletion(-) create mode 100644 src/features/run/npmGutter.test.ts create mode 100644 src/features/run/npmGutter.ts diff --git a/src/editor/core/cm/theme.ts b/src/editor/core/cm/theme.ts index 0390d1f1..3bfd8427 100644 --- a/src/editor/core/cm/theme.ts +++ b/src/editor/core/cm/theme.ts @@ -105,7 +105,7 @@ export const openideaTheme = EditorView.theme({ boxShadow: "inset 0 1px 0 var(--border-method-separator, var(--border))", }, "&.cm-openidea-hideGutter-run .cm-openidea-gutter-run, &.cm-openidea-hideGutter-bookmarks .cm-openidea-bookmarks": { - display: "none", + display: "none !important", }, // Inheritance markers share one gutter: each group hides its own icons. "&.cm-openidea-hideGutter-inheritors :is(.cm-openidea-lineMarker-implemented, .cm-openidea-lineMarker-overridden), &.cm-openidea-hideGutter-supers :is(.cm-openidea-lineMarker-implementing, .cm-openidea-lineMarker-overriding)": diff --git a/src/features/run/index.ts b/src/features/run/index.ts index a07662b1..cd2fcd0d 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -45,6 +45,7 @@ import { compoundChildren } from "./configs"; import { remoteDebugBlocked } from "./remote"; import { isDebugOnly, registerBuiltinTypes } from "./types"; import { listenForApplicationDeactivation, showApplicationUpdate, updatableSessions } from "./applicationUpdate"; +import { npmGutter } from "./npmGutter"; import { runGutter } from "./runGutter"; import { activateStackTrace, useStackTraces } from "./stackTrace"; import { clearRuntimeCache } from "./runtimes"; @@ -283,6 +284,7 @@ export function activate(): () => void { listenForApplicationDeactivation(), useDebugRunSessions.subscribe(() => setContext("run.updatableRunning", updatableSessions().length > 0)), registerEditorContribution({ id: "run.targets", order: 55, extensions: runGutter }), + registerEditorContribution({ id: "run.npmScripts", order: 58, extensions: npmGutter }), registerMenuGroup({ id: "run.context", menus: ["editor", "projectView", "editorTab"], diff --git a/src/features/run/npmGutter.test.ts b/src/features/run/npmGutter.test.ts new file mode 100644 index 00000000..cff1da5c --- /dev/null +++ b/src/features/run/npmGutter.test.ts @@ -0,0 +1,311 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorView } from "@codemirror/view"; +import { commands, type RunConfig } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { + npmGutter, + scriptConfigFor, + scriptConfigIds, + scriptEntries, + scriptMenuItems, + scriptPackageManager, +} from "./npmGutter"; +import * as ui from "@/ui"; +import * as store from "./store"; + +function cfg(over: Partial): RunConfig { + return { + id: "x", + name: "x", + kind: "npm", + command: "npm", + args: [], + cwd: null, + env: {}, + detected: true, + isTest: false, + debugAdapter: null, + ...over, + }; +} + +describe("scriptEntries", () => { + it("lists scripts with their lines in document order", () => { + const text = [ + "{", + ' "name": "app",', + ' "scripts": {', + ' "dev": "vite",', + ' "build": "tsc && vite build",', + ' "test": "vitest run"', + " }", + "}", + ].join("\n"); + expect(scriptEntries(text)).toEqual([ + { name: "dev", line: 3 }, + { name: "build", line: 4 }, + { name: "test", line: 5 }, + ]); + }); + + it("parses names with escapes and values containing braces or colons", () => { + const text = [ + "{", + ' "scripts": {', + ' "say \\"hi\\"": "echo \\"{\\" done",', + ' "a\\u0041b": "node -e \\"x:{y}\\""', + " }", + "}", + ].join("\n"); + expect(scriptEntries(text).map((e) => e.name)).toEqual(['say "hi"', "aAb"]); + expect(scriptEntries(text).map((e) => e.line)).toEqual([2, 3]); + }); + + it("ignores scripts nested in other objects", () => { + const text = [ + "{", + ' "a": {', + ' "scripts": {', + ' "inner": "x"', + " }", + " },", + ' "scripts": {', + ' "outer": "y"', + " }", + "}", + ].join("\n"); + expect(scriptEntries(text)).toEqual([{ name: "outer", line: 7 }]); + }); + + it("tracks lines through object, array and scalar values", () => { + const text = [ + "{", + ' "scripts": {', + ' "a": 1,', + ' "b": true,', + ' "c": null,', + ' "d": [1, 2],', + ' "e": { "x": 1 },', + ' "f": "ok"', + " }", + "}", + ].join("\n"); + expect(scriptEntries(text).map((e) => e.name)).toEqual(["a", "b", "c", "d", "e", "f"]); + expect(scriptEntries(text).map((e) => e.line)).toEqual([2, 3, 4, 5, 6, 7]); + }); + + it("returns nothing without a scripts key", () => { + expect(scriptEntries('{\n "name": "x"\n}\n')).toEqual([]); + }); + + it("returns an empty scripts object as no entries", () => { + expect(scriptEntries('{\n "scripts": {}\n}\n')).toEqual([]); + }); + + it("ignores a scripts value that is not an object", () => { + expect(scriptEntries('{\n "scripts": "dev"\n}\n')).toEqual([]); + }); + + it("keeps entries parsed before malformed text", () => { + const text = ["{", ' "scripts": {', ' "a": "x",', ' "b": '].join("\n"); + expect(scriptEntries(text)).toEqual([{ name: "a", line: 2 }]); + }); + + it("tolerates a trailing comma", () => { + expect(scriptEntries('{\n "scripts": {\n "a": "x",\n },\n}\n')).toEqual([{ name: "a", line: 2 }]); + }); + + it("skips a BOM", () => { + expect(scriptEntries('{\n "scripts": {\n "dev": "vite"\n }\n}\n')).toEqual([{ name: "dev", line: 2 }]); + }); + + it("tracks CRLF lines after non-ASCII and astral characters", () => { + const text = [ + "{", + ' "description": "中文 🚀 café",', + ' "scripts": {', + ' "构建🚀": "echo 完成",', + ' "test": "echo ok"', + " }", + "}", + ].join("\r\n"); + expect(scriptEntries(text)).toEqual([ + { name: "构建🚀", line: 3 }, + { name: "test", line: 4 }, + ]); + }); + + it("decodes escaped unicode and backslashes in keys and values", () => { + const text = String.raw`{"scripts":{"\u6784\u5efa\ud83d\ude80":"echo \"ok\" \\path","say\\hi":"echo ok"}}`; + expect(scriptEntries(text)).toEqual([ + { name: "构建🚀", line: 0 }, + { name: "say\\hi", line: 0 }, + ]); + }); + + it("produces no entries for scripts nested in an object or array", () => { + expect(scriptEntries('{"config":{"scripts":{"nested":"echo no"}}}')).toEqual([]); + expect(scriptEntries('{"config":[{"scripts":{"nested":"echo no"}}]}')).toEqual([]); + }); + + it("skips long numeric metadata before scripts", () => { + expect(scriptEntries('{"metadata":1234567890123456,"scripts":{"dev":"echo ok"}}')).toEqual([ + { name: "dev", line: 0 }, + ]); + }); +}); + +describe("scriptPackageManager", () => { + it.each(["npm", "pnpm", "yarn", "bun"])("prefers the declared %s over lockfiles", (pm) => { + expect(scriptPackageManager({ packageManager: `${pm}@1.0.0` }, ["pnpm-lock.yaml", "yarn.lock", "bun.lock"])).toBe( + pm, + ); + }); + + it.each([ + ["pnpm-lock.yaml", "pnpm"], + ["yarn.lock", "yarn"], + ["bun.lock", "bun"], + ["bun.lockb", "bun"], + ["package-lock.json", "npm"], + ])("uses %s when no manager is declared", (file, pm) => { + expect(scriptPackageManager({}, [file])).toBe(pm); + expect(scriptPackageManager({ packageManager: "unknown@1" }, [file])).toBe(pm); + }); + + it("uses backend lockfile precedence and defaults to npm", () => { + expect(scriptPackageManager(null, ["bun.lock", "yarn.lock", "pnpm-lock.yaml"])).toBe("pnpm"); + expect(scriptPackageManager(null, ["bun.lock", "yarn.lock"])).toBe("yarn"); + expect(scriptPackageManager(null, [])).toBe("npm"); + }); +}); + +describe("scriptMenuItems", () => { + beforeEach(() => { + vi.spyOn(store, "loadConfigs").mockResolvedValue(); + useProject.setState({ project: { root: "/project" } as never }); + store.useRun.setState({ configs: [] }); + vi.spyOn(commands, "fsReadDir").mockResolvedValue({ + status: "ok", + data: [{ name: "pnpm-lock.yaml", path: "/project/pnpm-lock.yaml", kind: "file" } as never], + }); + vi.spyOn(commands, "fsReadText").mockRejectedValue(new Error("not found")); + }); + afterEach(() => { + vi.restoreAllMocks(); + useProject.setState({ project: null }); + store.useRun.setState({ configs: [] }); + }); + + it("uses the package lockfile for an undetected script", async () => { + const items = await scriptMenuItems({ name: "new", line: 0 }, "/project/package.json", "{}"); + expect(items).toMatchObject([{ label: "Run 'pnpm run new'" }, { label: "Debug 'pnpm run new'" }]); + expect(commands.fsReadDir).toHaveBeenCalledWith("/project/"); + }); + + it("inherits the backend's workspace manager from a sibling script", async () => { + store.useRun.setState({ configs: [cfg({ command: "yarn", cwd: "packages/web" })] }); + const items = await scriptMenuItems( + { name: "new", line: 0 }, + "/project/packages/web/package.json", + '{"packageManager":"pnpm@9"}', + ); + expect(items).toMatchObject([{ label: "Run 'yarn run new'" }, { label: "Debug 'yarn run new'" }]); + expect(commands.fsReadDir).not.toHaveBeenCalled(); + }); + + it("does not inherit an unrelated package's manager or a custom executable", async () => { + store.useRun.setState({ + configs: [cfg({ command: "yarn", cwd: "other" }), cfg({ command: "echo", cwd: null })], + }); + const items = await scriptMenuItems({ name: "new", line: 0 }, "/project/package.json", "{}"); + expect(items).toMatchObject([{ label: "Run 'pnpm run new'" }, { label: "Debug 'pnpm run new'" }]); + }); + + it("uses root detection for a workspace member's first script", async () => { + vi.mocked(commands.fsReadText).mockResolvedValue({ status: "ok", data: '{"packageManager":"yarn@4"}' }); + const items = await scriptMenuItems({ name: "new", line: 0 }, "/project/packages/web/package.json", "{}"); + expect(items).toMatchObject([{ label: "Run 'yarn run new'" }, { label: "Debug 'yarn run new'" }]); + expect(commands.fsReadDir).toHaveBeenCalledWith("/project/"); + }); + + it("uses local detection for a polyglot package without a root manifest", async () => { + const items = await scriptMenuItems({ name: "new", line: 0 }, "/project/web/package.json", "{}"); + expect(items).toMatchObject([{ label: "Run 'pnpm run new'" }, { label: "Debug 'pnpm run new'" }]); + expect(commands.fsReadDir).toHaveBeenCalledWith("/project/web/"); + }); + + it("preserves a matching user configuration", async () => { + store.useRun.setState({ configs: [cfg({ id: "npm:new", name: "Custom new", command: "custom" })] }); + const items = await scriptMenuItems({ name: "new", line: 0 }, "/project/package.json", "{}"); + expect(items).toMatchObject([{ label: "Run 'Custom new'" }, { label: "Debug 'Custom new'" }]); + expect(commands.fsReadDir).not.toHaveBeenCalled(); + }); + + it("renders a native button and opens its menu on keyboard-style activation after Unicode text", async () => { + const show = vi.spyOn(ui, "showContextMenu").mockImplementation(() => {}); + const text = '{\r\n "description": "中文 🚀",\r\n "scripts": {\r\n "dev": "echo ok"\r\n }\r\n}'; + const view = new EditorView({ + doc: text, + parent: document.body, + extensions: npmGutter({ doc: 1, path: "/project/package.json", language: "json", uiMode: null, readOnly: false }), + }); + try { + await vi.waitFor(() => expect(view.dom.querySelector("button[aria-label=\"Run 'dev'\"]")).not.toBeNull()); + expect(view.state.doc.line(4).from).toBe(text.replaceAll("\r\n", "\n").indexOf(' "dev"')); + view.dom.querySelector("button[aria-label=\"Run 'dev'\"]")!.click(); + await vi.waitFor(() => + expect(show).toHaveBeenCalledWith(expect.anything(), [ + expect.objectContaining({ label: "Run 'pnpm run dev'" }), + expect.objectContaining({ label: "Debug 'pnpm run dev'" }), + ]), + ); + } finally { + view.destroy(); + } + }); +}); + +describe("scriptConfigFor", () => { + const root = cfg({ id: "npm:dev", name: "pnpm run dev", command: "pnpm", args: ["run", "dev"], cwd: null }); + + it("matches the root package's detected id", () => { + expect(scriptConfigFor([root], "dev", "", null)).toBe(root); + }); + + it("matches a workspace member by its package name or directory", () => { + const web = cfg({ + id: "npm:web:dev", + name: "web: pnpm run dev", + command: "pnpm", + args: ["run", "dev"], + cwd: "web", + }); + expect(scriptConfigFor([web], "dev", "web", "web")).toBe(web); + expect(scriptConfigFor([web], "dev", "web", null)).toBe(web); + }); + + it("falls back to npm run args in the same directory", () => { + const custom = cfg({ id: "user-cfg", name: "my dev", args: ["run", "dev"], cwd: "./web/" }); + expect(scriptConfigFor([custom], "dev", "web", null)).toBe(custom); + }); + + it("matches an unnamed nested member's overridden config by its full relative directory", () => { + const custom = cfg({ id: "npm:packages/web:dev", command: "custom", cwd: "packages/web" }); + expect(scriptConfigFor([custom], "dev", "packages/web", null)).toBe(custom); + }); + + it("prefers an id match over an args match", () => { + const byArgs = cfg({ id: "user-cfg", args: ["run", "dev"], cwd: null }); + expect(scriptConfigFor([byArgs, root], "dev", "", null)).toBe(root); + }); + + it("finds nothing in another directory", () => { + expect(scriptConfigFor([root], "dev", "web", null)).toBeUndefined(); + }); + + it("derives the backend configuration ids", () => { + expect(scriptConfigIds("dev", null, "")).toEqual(["npm:dev"]); + expect(scriptConfigIds("dev", "web", "packages/web")).toEqual(["npm:dev", "npm:web:dev", "npm:packages/web:dev"]); + }); +}); diff --git a/src/features/run/npmGutter.ts b/src/features/run/npmGutter.ts new file mode 100644 index 00000000..cf75cbb2 --- /dev/null +++ b/src/features/run/npmGutter.ts @@ -0,0 +1,392 @@ +/** + * Gutter ▶ icons on `package.json` `scripts` entries (like IDEA's package.json + * gutter): Run / Debug launch the npm run configuration the project detected + * for the script, or a temporary one when it has none. + */ +import { RangeSet, StateEffect, StateField, type Extension } from "@codemirror/state"; +import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view"; +import type { EditorDocContext } from "@/editor/api"; +import { executeAction } from "@/platform/actions"; +import { tx } from "@/platform/i18n"; +import { call, commands, type RunConfig } from "@/platform/ipc"; +import { shortcutLabel } from "@/platform/keymap"; +import { useProject } from "@/platform/project"; +import { gutterIconMarkup, showContextMenu, type MenuItem } from "@/ui"; +import { loadConfigs, useRun } from "./store"; +import styles from "./Run.module.css"; + +/** One `scripts` entry: the script name and its 0-based line. */ +export interface ScriptEntry { + name: string; + line: number; +} + +/** + * The top-level `scripts` entries of `package.json` text, in document order + * (a small JSON scanner: script values may contain braces, colons, escapes). + * Entries parsed so far survive malformed text, so a trailing comma keeps + * earlier scripts visible. + */ +export function scriptEntries(text: string): ScriptEntry[] { + const entries: ScriptEntry[] = []; + let i = 0; + let line = 0; + const BROKEN = new Error("broken"); + + const ws = () => { + while (i < text.length) { + const c = text[i]!; + if (c === "\n") { + line++; + i++; + } else if (c === " " || c === "\t" || c === "\r" || c === "") { + i++; + } else { + break; + } + } + }; + /** The JSON string at `i`, with escapes; advances past it. */ + const str = (): string => { + if (text[i] !== '"') throw BROKEN; + i++; + let out = ""; + for (;;) { + const c = text[i]; + if (c === undefined) throw BROKEN; + if (c === '"') { + i++; + return out; + } + if (c === "\\") { + const e = text[i + 1]; + if (e === undefined) throw BROKEN; + if (e === "\n") line++; + if (e === "u") { + const hex = text.slice(i + 2, i + 6); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw BROKEN; + out += String.fromCodePoint(Number.parseInt(hex, 16)); + i += 6; + } else { + out += { n: "\n", t: "\t", r: "\r", b: "\b", f: "\f" }[e] ?? e; + i += 2; + } + continue; + } + if (c === "\n") line++; + out += c; + i++; + } + }; + /** Skips one JSON value (objects and arrays recursively), tracking lines. */ + const value = () => { + ws(); + const c = text[i]; + if (c === '"') { + str(); + return; + } + if (c === "{" || c === "[") { + const close = c === "{" ? "}" : "]"; + i++; + ws(); + if (text[i] === close) { + i++; + return; + } + for (;;) { + if (c === "{") { + ws(); + str(); + ws(); + if (text[i] !== ":") throw BROKEN; + i++; + } + value(); + ws(); + if (text[i] === ",") { + i++; + continue; + } + if (text[i] === close) { + i++; + return; + } + throw BROKEN; + } + } + const m = /^(?:-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?|true|false|null)/.exec(text.slice(i)); + if (!m) throw BROKEN; + i += m[0].length; + }; + try { + ws(); + if (text[i] !== "{") return entries; + i++; + ws(); + if (text[i] !== "}") { + for (;;) { + ws(); + const key = str(); + ws(); + if (text[i] !== ":") break; + i++; + ws(); + if (key === "scripts" && text[i] === "{") { + i++; + ws(); + if (text[i] !== "}") { + for (;;) { + ws(); + const scriptLine = line; + const name = str(); + ws(); + if (text[i] !== ":") throw BROKEN; + i++; + value(); + entries.push({ name, line: scriptLine }); + ws(); + if (text[i] === ",") { + i++; + continue; + } + break; + } + } + i++; // the closing `}` of `scripts` + } else { + value(); + } + ws(); + if (text[i] === ",") { + i++; + continue; + } + break; + } + } + } catch (e) { + if (e !== BROKEN) throw e; + } + return entries; +} + +type PackageJson = { name?: unknown; packageManager?: unknown }; + +function safeParse(text: string): PackageJson | null { + try { + return JSON.parse(text) as PackageJson; + } catch { + return null; + } +} + +/** Same precedence as backend detection for a package: declaration, then lockfile. */ +export function scriptPackageManager(pkg: PackageJson | null, files: readonly string[]): string { + const pm = typeof pkg?.packageManager === "string" ? pkg.packageManager.split("@")[0] : ""; + if (pm === "pnpm" || pm === "yarn" || pm === "bun" || pm === "npm") return pm; + if (files.includes("pnpm-lock.yaml")) return "pnpm"; + if (files.includes("yarn.lock")) return "yarn"; + if (files.includes("bun.lock") || files.includes("bun.lockb")) return "bun"; + return "npm"; +} + +const normCwd = (cwd: string | null) => (cwd ?? "").replace(/^\.\/?/, "").replace(/\/+$/, ""); + +/** Backend ids for a script: the root package's, the member's by its `name` or its directory. */ +export function scriptConfigIds(name: string, pkgName: string | null, dir: string): string[] { + const ids = new Set([`npm:${name}`]); + if (pkgName) ids.add(`npm:${pkgName}:${name}`); + if (dir) ids.add(`npm:${dir}:${name}`); + return [...ids]; +} + +/** + * The project's configuration for the script in the package at `dir` (relative + * to the project root): the detected id first (a user override keeps it), then + * `npm run ` args in the same working directory. + */ +export function scriptConfigFor( + configs: readonly RunConfig[], + name: string, + dir: string, + pkgName: string | null, +): RunConfig | undefined { + const inDir = (c: RunConfig) => normCwd(c.cwd) === dir; + const ids = new Set(scriptConfigIds(name, pkgName, dir)); + return ( + configs.find((c) => inDir(c) && ids.has(c.id)) ?? + configs.find((c) => inDir(c) && c.kind === "npm" && c.args[0] === "run" && c.args[1] === name) + ); +} + +/** What the backend would detect (`detect.rs`): test scripts render results in the test tree. */ +const isTestScript = (name: string) => + name === "test" || name === "vitest" || name === "jest" || name.startsWith("test:"); + +export async function scriptMenuItems(entry: ScriptEntry, path: string, content: string): Promise { + const root = useProject.getState().project?.root ?? null; + const rel = root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path; + const dir = rel.endsWith("/package.json") ? rel.slice(0, -"/package.json".length) : ""; + try { + // Detection reads the disk: a script typed moments ago is already there. + await loadConfigs(); + } catch { + // Fall back to a temporary configuration below. + } + const pkg = safeParse(content.slice(0, 400_000)); + const matched = scriptConfigFor( + useRun.getState().configs, + entry.name, + dir, + typeof pkg?.name === "string" ? pkg.name : null, + ); + // Reuse detection for sibling scripts, including a workspace's inherited manager. + const sibling = useRun + .getState() + .configs.find( + (c) => + c.kind === "npm" && c.detected && normCwd(c.cwd) === dir && ["npm", "pnpm", "yarn", "bun"].includes(c.command), + ); + let pm = matched?.command ?? sibling?.command; + if (!pm) { + // Workspace detection uses the root package's manager, even for a member's first script. + const rootPkg = + root && dir + ? await call(commands.fsReadText(`${root}/package.json`)) + .then(safeParse) + .catch(() => null) + : null; + const packageDir = rootPkg ? `${root}/` : path.slice(0, -"package.json".length); + const files = await call(commands.fsReadDir(packageDir)).catch(() => []); + pm = scriptPackageManager( + rootPkg ?? pkg, + files.filter((f) => f.kind === "file" || f.targetKind === "file").map((f) => f.name), + ); + } + const label = matched?.name ?? `${pm} run ${entry.name}`; + const config = matched ?? { + id: `context:npm:${dir || "root"}:${entry.name}`, + name: label, + kind: "npm", + command: pm, + args: ["run", entry.name], + cwd: dir || null, + env: {}, + detected: true, + isTest: isTestScript(entry.name), + debugAdapter: "js-debug", + }; + return [ + { + label: tx`Run '${label}'`, + icon: "run", + shortcut: shortcutLabel("run.runContext"), + run: () => + matched + ? void executeAction("run.runConfig", matched.id) + : void executeAction("run.runConfig", { config, temporary: true }), + }, + { + label: tx`Debug '${label}'`, + icon: "debug", + shortcut: shortcutLabel("run.debugContext"), + run: () => void executeAction("run.debugConfig", config), + }, + ]; +} + +interface Placed extends ScriptEntry { + pos: number; +} + +const setEntries = StateEffect.define(); + +const entriesField = StateField.define({ + create: () => [], + update(value, tr) { + for (const e of tr.effects) if (e.is(setEntries)) return e.value; + if (!tr.docChanged) return value; + return value.map((p) => ({ ...p, pos: tr.state.doc.lineAt(tr.changes.mapPos(p.pos, -1)).from })); + }, +}); + +class ScriptMarker extends GutterMarker { + constructor( + readonly entry: ScriptEntry, + readonly path: string, + ) { + super(); + } + override eq(other: GutterMarker): boolean { + return other instanceof ScriptMarker && other.entry.name === this.entry.name; + } + override toDOM(view: EditorView): Node { + const el = document.createElement("button"); + el.type = "button"; + el.className = styles.runMarker ?? ""; + el.title = tx`Run '${this.entry.name}'`; + el.setAttribute("aria-label", el.title); + el.innerHTML = gutterIconMarkup("gutter-run") ?? ""; + el.addEventListener("click", () => { + const rect = el.getBoundingClientRect(); + void scriptMenuItems(this.entry, this.path, view.state.doc.toString()).then((items) => + showContextMenu({ x: rect.left, y: rect.bottom + 2 }, items), + ); + }); + return el; + } +} + +const DEBOUNCE = 300; + +export function npmGutter(ctx: EditorDocContext): Extension { + const path = ctx.path; + if (ctx.virtual || !/(^|\/)package\.json$/.test(path) || !useProject.getState().project?.root) return []; + const detector = ViewPlugin.fromClass( + class { + timer: ReturnType | undefined; + constructor(readonly view: EditorView) { + this.schedule(0); + } + update(u: ViewUpdate) { + if (u.docChanged) this.schedule(DEBOUNCE); + } + schedule(delay: number) { + clearTimeout(this.timer); + this.timer = setTimeout(() => { + const view = this.view; + const entries = scriptEntries(view.state.doc.toString()) + .filter((e) => e.line < view.state.doc.lines) + .map((e) => ({ ...e, pos: view.state.doc.line(e.line + 1).from })); + view.dispatch({ effects: setEntries.of(entries) }); + }, delay); + } + destroy() { + clearTimeout(this.timer); + } + }, + ); + return [ + entriesField, + detector, + gutter({ + // Shares the run gutter's class so "Show run icons" hides these too. + class: `${styles.runGutter} cm-openidea-gutter-run`, + markers: (view) => + RangeSet.of( + view.state.field(entriesField).map((e) => new ScriptMarker(e, path).range(e.pos)), + true, + ), + initialSpacer: () => new ScriptMarker({ name: "", line: 0 }, path), + domEventHandlers: { + mousedown: (view, block, event) => { + const entry = view.state.field(entriesField).find((e) => e.pos === block.from); + if (!entry || (event as MouseEvent).button !== 0) return false; + event.preventDefault(); + return true; + }, + }, + }), + ]; +}