Порівняти коміти

...
Автор SHA1 Повідомлення Дата
l0ng-ai decb41b00e feat(run): run icons on the profiles of launchSettings.json
A .NET project's Properties/launchSettings.json shows a run icon on each
profile a run can use (commandName "Project"): Run / Debug launch the
project's configuration for that profile, or a temporary copy of the
project's configuration for a profile added since detection ran. The
package.json scripts gutter is generalised into entryGutter for this.
2026-11-15 01:04:44 +08:00
5 змінених файлів з 213 додано та 20 видалено

No files matched your search

+2
Переглянути файл
@@ -64,6 +64,7 @@ import { isDebugOnly, registerBuiltinTypes } from "./types";
import { junitType } from "./junitType";
import { listenForApplicationDeactivation, showApplicationUpdate, updatableSessions } from "./applicationUpdate";
import { npmGutter } from "./npmGutter";
import { launchSettingsGutter } from "./launchSettingsGutter";
import { runGutter } from "./runGutter";
import { gradleDelegation } from "./gradleRun";
import { activateStackTrace, useStackTraces } from "./stackTrace";
@@ -446,6 +447,7 @@ export function activate(): () => void {
}),
registerEditorContribution({ id: "run.targets", order: 55, extensions: runGutter }),
registerEditorContribution({ id: "run.npmScripts", order: 58, extensions: npmGutter }),
registerEditorContribution({ id: "run.launchSettings", order: 58, extensions: launchSettingsGutter }),
registerMenuGroup({
id: "run.context",
menus: ["editor", "projectView", "editorTab"],
+70
Переглянути файл
@@ -0,0 +1,70 @@
import { afterEach, describe, expect, it } from "vitest";
import type { RunConfig } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { profileConfig, projectProfiles } from "./launchSettingsGutter";
const SETTINGS = `{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"applicationUrl": "http://localhost:5170"
},
"IIS Express": {
"commandName": "IISExpress"
},
"https": {
"commandName": "Project"
}
}
}`;
const config = (id: string, name: string, launchProfile: string): RunConfig =>
({
id,
name,
kind: "dotnet",
command: "dotnet",
args: ["run", "--project", "src/Api/Api.csproj"],
params: { project: "src/Api/Api.csproj", framework: "net9.0", launchProfile },
}) as unknown as RunConfig;
afterEach(() => useProject.setState({ project: null }));
describe("launchSettings.json run icons", () => {
it("mark the profiles a run can use, on their lines", () => {
expect(projectProfiles(SETTINGS)).toEqual([
{ name: "http", line: 3 },
{ name: "https", line: 10 },
]);
// While the file doesn't parse, every profile keeps its icon.
expect(projectProfiles(SETTINGS.replace('"https": {', '"https": {,')).map((e) => e.name)).toEqual([
"http",
"IIS Express",
]);
});
it("run the project's configuration for the profile, or a temporary copy of it", () => {
useProject.setState({ project: { root: "/repo" } as never });
const path = "/repo/src/Api/Properties/launchSettings.json";
const configs = [
config("dotnet:src/Api/Api.csproj:run:", "Api (.NET Project)", ""),
config("dotnet:src/Api/Api.csproj:run:http", "Api: http", "http"),
];
expect(profileConfig(configs, path, "http")).toEqual({ config: configs[1], saved: true });
const added = profileConfig(configs, path, "https")!;
expect(added.saved).toBe(false);
expect(added.config.name).toBe("Api: https");
expect(added.config.params?.launchProfile).toBe("https");
expect(added.config.args).toEqual([
"run",
"--project",
"src/Api/Api.csproj",
"--framework",
"net9.0",
"--launch-profile",
"https",
]);
expect(profileConfig(configs, "/repo/src/Other/Properties/launchSettings.json", "http")).toBeNull();
});
});
+103
Переглянути файл
@@ -0,0 +1,103 @@
/**
* Gutter ▶ icons on the profiles of a .NET project's `Properties/launchSettings.json`:
* Run / Debug launch the project's configuration for that profile.
*/
import type { Extension } from "@codemirror/state";
import type { EditorDocContext } from "@/editor/api";
import { executeAction } from "@/platform/actions";
import { tx } from "@/platform/i18n";
import type { RunConfig } from "@/platform/ipc";
import { shortcutLabel } from "@/platform/keymap";
import { useProject } from "@/platform/project";
import type { MenuItem } from "@/ui";
import { dotnetCommand } from "./dotnetTypes";
import { entryGutter, objectEntries, type ScriptEntry } from "./npmGutter";
import { loadConfigs, useRun } from "./store";
const FILE = /(^|\/)Properties\/launchSettings\.json$/;
/** The project directory of a `launchSettings.json`, relative to the project root when inside it. */
function projectDir(path: string): string {
const dir = path.replace(FILE, "");
const root = useProject.getState().project?.root;
return root && dir.startsWith(`${root}/`) ? dir.slice(root.length + 1) : root === dir ? "" : dir;
}
const dirOf = (project: string) => project.replace(/\\/g, "/").replace(/\/?[^/]*$/, "");
/**
* The project's configuration for `profile`: the one already set to it, else a temporary copy of
* the project's configuration with that launch profile (a profile added since detection ran).
*/
export function profileConfig(
configs: readonly RunConfig[],
path: string,
profile: string,
): { config: RunConfig; saved: boolean } | null {
const dir = projectDir(path);
const ofProject = configs.filter((c) => c.kind === "dotnet" && dirOf(c.params?.project ?? "") === dir);
const saved = ofProject.find((c) => c.params?.launchProfile === profile);
if (saved) return { config: saved, saved: true };
const base = ofProject.find((c) => !c.params?.launchProfile) ?? ofProject[0];
if (!base) return null;
const params = { ...base.params, launchProfile: profile };
return {
config: {
...base,
id: `context:dotnet:${base.params?.project ?? dir}:${profile}`,
name: `${base.name.replace(/ \(.NET Project\)$|: .*$/, "")}: ${profile}`,
args: dotnetCommand(params).args,
params,
},
saved: false,
};
}
/**
* The profiles a run can use: `commandName: "Project"` (IIS and Docker profiles are run by other
* tools). While the text doesn't parse, every profile key keeps its icon.
*/
export function projectProfiles(text: string): ScriptEntry[] {
const entries = objectEntries(text, "profiles");
type Profiles = Record<string, { commandName?: unknown } | undefined>;
let profiles: Profiles;
try {
profiles = (JSON.parse(text.replace(/^\uFEFF/, "")) as { profiles?: Profiles }).profiles ?? {};
} catch {
return entries;
}
return entries.filter((e) => profiles[e.name]?.commandName === "Project");
}
async function profileMenu(entry: ScriptEntry, path: string): Promise<MenuItem[]> {
try {
// Detection reads the disk: a profile added moments ago is already there.
await loadConfigs();
} catch {
// Use what is loaded.
}
const found = profileConfig(useRun.getState().configs, path, entry.name);
if (!found) return [{ label: tx`No .NET run configuration for this project`, disabled: true }];
const { config, saved } = found;
return [
{
label: tx`Run '${config.name}'`,
icon: "run",
shortcut: shortcutLabel("run.runContext"),
run: () =>
saved
? void executeAction("run.runConfig", config.id)
: void executeAction("run.runConfig", { config, temporary: true }),
},
{
label: tx`Debug '${config.name}'`,
icon: "debug",
shortcut: shortcutLabel("run.debugContext"),
run: () => void executeAction("run.debugConfig", config),
},
];
}
export function launchSettingsGutter(ctx: EditorDocContext): Extension {
return entryGutter(ctx, FILE, projectProfiles, profileMenu);
}
+37 -20
Переглянути файл
@@ -21,13 +21,15 @@ export interface ScriptEntry {
line: number;
}
/** The top-level `scripts` entries of `package.json` text, in document order. */
export const scriptEntries = (text: string): ScriptEntry[] => objectEntries(text, "scripts");
/**
* 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.
* The keys of the top-level object `section` of JSON text, in document order
* (a small JSON scanner: values may contain braces, colons, escapes). Entries
* parsed so far survive malformed text, so a trailing comma keeps earlier ones visible.
*/
export function scriptEntries(text: string): ScriptEntry[] {
export function objectEntries(text: string, section: string): ScriptEntry[] {
const entries: ScriptEntry[] = [];
let i = 0;
let line = 0;
@@ -132,7 +134,7 @@ export function scriptEntries(text: string): ScriptEntry[] {
if (text[i] !== ":") break;
i++;
ws();
if (key === "scripts" && text[i] === "{") {
if (key === section && text[i] === "{") {
i++;
ws();
if (text[i] !== "}") {
@@ -153,7 +155,7 @@ export function scriptEntries(text: string): ScriptEntry[] {
break;
}
}
i++; // the closing `}` of `scripts`
i++; // the closing `}` of the section
} else {
value();
}
@@ -302,19 +304,13 @@ interface Placed extends ScriptEntry {
const setEntries = StateEffect.define<Placed[]>();
const entriesField = StateField.define<Placed[]>({
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 }));
},
});
type EntryMenu = (entry: ScriptEntry, path: string, content: string) => Promise<MenuItem[]>;
class ScriptMarker extends GutterMarker {
constructor(
readonly entry: ScriptEntry,
readonly path: string,
readonly menu: EntryMenu,
) {
super();
}
@@ -330,7 +326,7 @@ class ScriptMarker extends GutterMarker {
el.innerHTML = gutterIconMarkup("gutter-run") ?? "";
el.addEventListener("click", () => {
const rect = el.getBoundingClientRect();
void scriptMenuItems(this.entry, this.path, view.state.doc.toString()).then((items) =>
void this.menu(this.entry, this.path, view.state.doc.toString()).then((items) =>
showContextMenu({ x: rect.left, y: rect.bottom + 2 }, items),
);
});
@@ -343,8 +339,29 @@ const scriptSpacer = gutterSpacer(styles.runMarker ?? "");
const DEBOUNCE = 300;
export function npmGutter(ctx: EditorDocContext): Extension {
return entryGutter(ctx, /(^|\/)package\.json$/, scriptEntries, scriptMenuItems);
}
/**
* Run ▶ icons on the entries `entries` finds in files matching `file`; a click opens `menu`.
* Shared by `package.json` scripts and `launchSettings.json` profiles.
*/
export function entryGutter(
ctx: EditorDocContext,
file: RegExp,
entries: (text: string) => ScriptEntry[],
menu: EntryMenu,
): Extension {
const path = ctx.path;
if (ctx.virtual || !/(^|\/)package\.json$/.test(path) || !useProject.getState().project?.root) return [];
if (ctx.virtual || !file.test(path) || !useProject.getState().project?.root) return [];
const entriesField = StateField.define<Placed[]>({
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 }));
},
});
const detector = ViewPlugin.fromClass(
class {
timer: ReturnType<typeof setTimeout> | undefined;
@@ -358,10 +375,10 @@ export function npmGutter(ctx: EditorDocContext): Extension {
clearTimeout(this.timer);
this.timer = setTimeout(() => {
const view = this.view;
const entries = scriptEntries(view.state.doc.toString())
const found = entries(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) });
view.dispatch({ effects: setEntries.of(found) });
}, delay);
}
destroy() {
@@ -377,7 +394,7 @@ export function npmGutter(ctx: EditorDocContext): Extension {
class: `${styles.runGutter} cm-openidea-gutter-run`,
markers: (view) =>
RangeSet.of(
view.state.field(entriesField).map((e) => new ScriptMarker(e, path).range(e.pos)),
view.state.field(entriesField).map((e) => new ScriptMarker(e, path, menu).range(e.pos)),
true,
),
// Reserve width only while the file has scripts, like the run gutter.
+1
Переглянути файл
@@ -1775,6 +1775,7 @@
"No Maven projects": "没有 Maven 项目",
"No profile for '{name}'": "没有 '{name}' 的分析数据",
"No Python found": "未找到 Python",
"No .NET run configuration for this project": "此项目没有 .NET 运行配置",
"No recent repository URLs": "没有最近使用的仓库 URL",
"No recent searches": "无最近搜索",
"No TODO items found": "未找到 TODO 项",