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; + }, + }, + }), + ]; +}