дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-15 14:15:39 +02:00
Порівняти коміти
1
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
decb41b00e |
No files matched your search
@@ -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"],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 项",
|
||||
|
||||
Reference in new issue
Заблокувати користувача