mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 20:13:56 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbb7bc952b | ||
|
|
38bd8fbd16 | ||
|
|
2859ec9106 | ||
|
|
a4b62ad27f |
No files matched your search
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
blockScopedNamespace,
|
||||
caretAfterReformat,
|
||||
csprojFileScoped,
|
||||
csprojRootNamespace,
|
||||
inferCsharpNamespace,
|
||||
expandIncludes,
|
||||
fragmentResolver,
|
||||
composerPsr4,
|
||||
@@ -180,6 +184,43 @@ describe("PHP namespaces", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("C# namespaces", () => {
|
||||
it("come from the project's root namespace and the folders below the .csproj", () => {
|
||||
expect(csprojRootNamespace('<Project Sdk="Microsoft.NET.Sdk"></Project>', "My.App.csproj")).toBe("My.App");
|
||||
expect(csprojRootNamespace("<RootNamespace>Acme.Shop</RootNamespace>", "Web.csproj")).toBe("Acme.Shop");
|
||||
expect(csprojRootNamespace("", "my-app.csproj")).toBe("my_app");
|
||||
expect(inferCsharpNamespace("/Services/Billing", "Acme.Shop")).toBe("Acme.Shop.Services.Billing");
|
||||
expect(inferCsharpNamespace("", "Acme.Shop")).toBe("Acme.Shop");
|
||||
expect(inferCsharpNamespace("/2024-data", "App")).toBe("App._2024_data");
|
||||
});
|
||||
|
||||
it("are file-scoped from C# 10 and blocks before it (.NET Framework and Unity projects)", () => {
|
||||
const sdk = (tf: string) => `<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup>${tf}</PropertyGroup></Project>`;
|
||||
expect(csprojFileScoped(sdk("<TargetFramework>net8.0</TargetFramework>"))).toBe(true);
|
||||
expect(csprojFileScoped(sdk("<TargetFrameworks>net6.0;net8.0</TargetFrameworks>"))).toBe(true);
|
||||
expect(csprojFileScoped(sdk("<TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>"))).toBe(false);
|
||||
expect(csprojFileScoped(sdk("<TargetFramework>net48</TargetFramework>"))).toBe(false);
|
||||
expect(csprojFileScoped(sdk("<TargetFramework>net5.0</TargetFramework>"))).toBe(false);
|
||||
expect(
|
||||
csprojFileScoped(sdk("<TargetFramework>netstandard2.1</TargetFramework><LangVersion>latest</LangVersion>")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
csprojFileScoped("<TargetFrameworkVersion>v4.7.1</TargetFrameworkVersion><LangVersion>9.0</LangVersion>"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("render C# classes with a file-scoped namespace, a block, or none", () => {
|
||||
const cls = tpl("csharp").kinds[0]!.body;
|
||||
const vars = { name: "OrderService", fileName: "OrderService.cs", packageName: "Acme.Shop" };
|
||||
expect(renderFileTemplate(cls, vars).text).toBe("namespace Acme.Shop;\n\npublic class OrderService\n{\n \n}\n");
|
||||
expect(renderFileTemplate(blockScopedNamespace(cls), vars).text).toBe(
|
||||
"namespace Acme.Shop\n{\n public class OrderService\n {\n \n }\n}\n",
|
||||
);
|
||||
expect(renderFileTemplate(cls, { name: "Foo", fileName: "Foo.cs" }).text).toBe("public class Foo\n{\n \n}\n");
|
||||
expect(tpl("csharp").kinds.map((k) => k.label)).toEqual(["Class", "Interface", "Struct", "Enum", "Record"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rendering", () => {
|
||||
it("substitutes variables and places the caret", () => {
|
||||
const kind = tpl("java").kinds[0]!;
|
||||
|
||||
@@ -53,6 +53,22 @@ describe("plugin package inference uses built-in rules", () => {
|
||||
);
|
||||
expect(await packageFor({ ...plugin("java"), fileName: ".dockerignore" }, "/p/src/main/java/com/acme")).toBe("");
|
||||
});
|
||||
it("uses the nearest .csproj for C#", async () => {
|
||||
useProject.setState({ project: { root: "/p", name: "p" } as never });
|
||||
const csproj = { kind: "file", name: "Shop.csproj", path: "/p/src/Shop/Shop.csproj" };
|
||||
const readDir = vi
|
||||
.mocked(commands.fsReadDir)
|
||||
.mockImplementation((async (dir: string) => (dir === "/p/src/Shop" ? [csproj] : [])) as never);
|
||||
mocks.read.mockResolvedValue(
|
||||
"<Project><PropertyGroup><RootNamespace>Acme.Shop</RootNamespace></PropertyGroup></Project>",
|
||||
);
|
||||
try {
|
||||
expect(await packageFor(plugin("cs"), "/p/src/Shop/Orders/Api")).toBe("Acme.Shop.Orders.Api");
|
||||
expect(await packageFor(plugin("cs"), "/p/tools")).toBe("");
|
||||
} finally {
|
||||
readDir.mockReset();
|
||||
}
|
||||
});
|
||||
it("uses an existing Go package", async () => {
|
||||
useProject.setState({ project: { root: "/p", name: "p" } as never });
|
||||
mocks.read.mockResolvedValue("package service\n");
|
||||
|
||||
@@ -302,7 +302,11 @@ function NameStep({
|
||||
{error ?? (
|
||||
<>
|
||||
{where}
|
||||
{packageStyle && pkg ? (packageStyle === "php" ? ` · namespace ${pkg}` : ` · package ${pkg}`) : ""}
|
||||
{packageStyle && pkg
|
||||
? packageStyle === "php" || packageStyle === "csharp"
|
||||
? ` · namespace ${pkg}`
|
||||
: ` · package ${pkg}`
|
||||
: ""}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -294,6 +294,27 @@ export const BUILTIN_FILE_TEMPLATES: readonly FileTemplate[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "csharp",
|
||||
name: "C# Class",
|
||||
icon: "class",
|
||||
extension: "cs",
|
||||
nameStyle: "pascal",
|
||||
packageStyle: "csharp",
|
||||
kinds: (
|
||||
[
|
||||
["class", "Class", "class"],
|
||||
["interface", "Interface", "interface"],
|
||||
["struct", "Struct", "struct"],
|
||||
["enum", "Enum", "enum"],
|
||||
["record", "Record", "record"],
|
||||
] as const
|
||||
).map(([id, label, keyword]) => ({
|
||||
id,
|
||||
label,
|
||||
body: `namespace $PACKAGE$;\n\n#parse("File Header")\npublic ${keyword} $NAME$\n{\n $END$\n}\n`,
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: "kotlin",
|
||||
name: "Kotlin Class/File",
|
||||
|
||||
@@ -10,8 +10,12 @@ import { activeEditor, editorsFor, type TextEditorHandle } from "@/editor/api";
|
||||
import { editorService, maybeEditorService, notifications, resolveFileEditor } from "@/workbench/contributions";
|
||||
import {
|
||||
GO_PACKAGE_RE,
|
||||
blockScopedNamespace,
|
||||
caretAfterReformat,
|
||||
composerPsr4,
|
||||
csprojFileScoped,
|
||||
csprojRootNamespace,
|
||||
inferCsharpNamespace,
|
||||
inferGoPackage,
|
||||
inferJvmPackage,
|
||||
inferPhpNamespace,
|
||||
@@ -68,6 +72,26 @@ async function composerNamespace(directory: string, root: string | null): Promis
|
||||
}
|
||||
}
|
||||
|
||||
/** C# namespace from the nearest .csproj between `directory` and the project root. */
|
||||
async function csharpNamespace(directory: string, root: string | null): Promise<{ name: string; fileScoped: boolean }> {
|
||||
const none = { name: "", fileScoped: true };
|
||||
if (!isTauri()) return none;
|
||||
const start = directory.replace(/\/+$/, "");
|
||||
const top = root?.replace(/\/+$/, "") ?? null;
|
||||
for (let dir = start; ; dir = dirname(dir)) {
|
||||
const csproj = await call(commands.fsReadDir(dir)).then(
|
||||
(entries) => entries.find((e) => e.kind === "file" && /\.csproj$/i.test(e.name)),
|
||||
() => undefined,
|
||||
);
|
||||
if (csproj) {
|
||||
const text = await call(commands.fsReadText(csproj.path)).catch(() => "");
|
||||
const rootNamespace = csprojRootNamespace(text, csproj.name);
|
||||
return { name: inferCsharpNamespace(start.slice(dir.length), rootNamespace), fileScoped: csprojFileScoped(text) };
|
||||
}
|
||||
if (!top || dir === top || !dir.startsWith(`${top}/`)) return none;
|
||||
}
|
||||
}
|
||||
|
||||
export function packageStyleFor(t: FileTemplate): FileTemplate["packageStyle"] {
|
||||
const dot = t.fileName?.lastIndexOf(".") ?? -1;
|
||||
const extension = t.fileName === undefined ? t.extension : dot > 0 ? t.fileName.slice(dot + 1) : "";
|
||||
@@ -86,6 +110,7 @@ export async function packageFor(t: FileTemplate, directory: string): Promise<st
|
||||
if (style === "jvm") return inferJvmPackage(directory, root, packageRoots());
|
||||
if (style === "go") return inferGoPackage(directory, root, await siblingGoPackage(directory));
|
||||
if (style === "php") return composerNamespace(directory, root);
|
||||
if (style === "csharp") return (await csharpNamespace(directory, root)).name;
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -143,9 +168,15 @@ export async function createFromTemplate(
|
||||
const plan = planFile(withKind(t, kind), directory, input);
|
||||
if (!plan) throw new Error(tx("Invalid file name"));
|
||||
const project = useProject.getState().project;
|
||||
const packageName = await packageFor(withKind(t, kind), plan.directory);
|
||||
let body = kind.body;
|
||||
let packageName: string;
|
||||
if (packageStyleFor(withKind(t, kind)) === "csharp") {
|
||||
const ns = await csharpNamespace(plan.directory, project?.root ?? null);
|
||||
packageName = ns.name;
|
||||
if (ns.name && !ns.fileScoped) body = blockScopedNamespace(body);
|
||||
} else packageName = await packageFor(withKind(t, kind), plan.directory);
|
||||
const { text, caret } = renderFileTemplate(
|
||||
kind.body,
|
||||
body,
|
||||
{
|
||||
variables,
|
||||
name: plan.name,
|
||||
|
||||
@@ -254,6 +254,56 @@ export function inferPhpNamespace(directory: string, psr4: ReadonlyArray<readonl
|
||||
return parts.every((p) => /^[A-Za-z_\x80-\uffff][\w\x80-\uffff]*$/.test(p)) ? parts.join("\\") : "";
|
||||
}
|
||||
|
||||
/** A folder or project name as a C# identifier: other characters become `_`, as Rider does. */
|
||||
function csharpIdentifier(s: string): string {
|
||||
const id = s.replace(/[^\p{L}\p{N}_]/gu, "_");
|
||||
return /^\p{N}/u.test(id) ? `_${id}` : id;
|
||||
}
|
||||
|
||||
/** A .csproj's `<RootNamespace>`, else its file name (`My.App.csproj` → `My.App`). */
|
||||
export function csprojRootNamespace(text: string, fileName: string): string {
|
||||
const explicit = /<RootNamespace>\s*([^<]*?)\s*<\/RootNamespace>/i.exec(text)?.[1];
|
||||
const base = explicit || fileName.replace(/\.csproj$/i, "");
|
||||
return base.split(".").filter(Boolean).map(csharpIdentifier).join(".");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a .csproj's C# version has file-scoped namespaces (C# 10): its `<LangVersion>`,
|
||||
* else every target framework is .NET 6 or later. .NET Framework (Unity), .NET Standard and
|
||||
* .NET Core projects stay on C# 9 or earlier.
|
||||
*/
|
||||
export function csprojFileScoped(text: string): boolean {
|
||||
const lang = /<LangVersion>\s*([^<]*?)\s*<\/LangVersion>/i.exec(text)?.[1]?.toLowerCase();
|
||||
if (lang && /^(latest|latestmajor|preview)$/.test(lang)) return true;
|
||||
if (lang && /^\d+(\.\d+)?$/.test(lang)) return Number(lang) >= 10;
|
||||
if (/<TargetFrameworkVersion>/i.test(text)) return false;
|
||||
const frameworks = /<TargetFrameworks?>\s*([^<]*?)\s*<\/TargetFrameworks?>/i.exec(text)?.[1];
|
||||
if (!frameworks) return true;
|
||||
return frameworks
|
||||
.split(";")
|
||||
.map((f) => f.trim())
|
||||
.filter(Boolean)
|
||||
.every((f) => Number(/^net(\d+)\.\d/.exec(f)?.[1] ?? 0) >= 6);
|
||||
}
|
||||
|
||||
/** The namespace of a folder `relDir` below the .csproj: the root namespace plus each folder. */
|
||||
export function inferCsharpNamespace(relDir: string, rootNamespace: string): string {
|
||||
const folders = relDir.split("/").filter(Boolean).map(csharpIdentifier);
|
||||
return [...rootNamespace.split(".").filter(Boolean), ...folders].join(".");
|
||||
}
|
||||
|
||||
/** A body with `namespace $PACKAGE$;` rewritten as a namespace block, for C# before 10. */
|
||||
export function blockScopedNamespace(body: string): string {
|
||||
const m = /^namespace \$PACKAGE\$;\n\n?/m.exec(body);
|
||||
if (!m) return body;
|
||||
const rest = body.slice(m.index + m[0].length).replace(/\n+$/, "");
|
||||
const indented = rest
|
||||
.split("\n")
|
||||
.map((l) => (l ? ` ${l}` : l))
|
||||
.join("\n");
|
||||
return `${body.slice(0, m.index)}namespace $PACKAGE$\n{\n${indented}\n}\n`;
|
||||
}
|
||||
|
||||
export interface FileTemplateVars {
|
||||
name: string;
|
||||
fileName: string;
|
||||
|
||||
@@ -82,6 +82,7 @@ import {
|
||||
DIFF_KIND,
|
||||
changeNodeDiff,
|
||||
changeNodeDiffData,
|
||||
changeTreeDiffData,
|
||||
diffInput,
|
||||
openDiff,
|
||||
openVcsDiff,
|
||||
@@ -205,7 +206,8 @@ const bottomLeft = (el: HTMLElement) => {
|
||||
|
||||
/**
|
||||
* Activates a change (Enter, double-click, Show Diff); F7 continues through the other
|
||||
* files of its group, in tree order. Enter and double-click follow IDEA's Show on
|
||||
* files of its group, in tree order (Enter and double-click: of the whole tree, on into the
|
||||
* unversioned files, as IDEA's Commit diff preview). Enter and double-click follow IDEA's Show on
|
||||
* Double-Click (opensDiffOnActivate): the source file, or the diff where the commit
|
||||
* interface puts it (openVcsDiff: a preview editor tab, or under the modal interface the
|
||||
* front diff window, behind the modal Commit dialog, #1863). `forceDiff` is Show Diff and
|
||||
@@ -217,7 +219,7 @@ function openNode(n: ChangeNode, roots: readonly ChangeNode[], forceDiff = false
|
||||
void editorService().openFile(n.path, { focus: true });
|
||||
return;
|
||||
}
|
||||
void openVcsDiff(changeNodeDiffData(roots, n), { preview: true });
|
||||
void openVcsDiff((forceDiff ? changeNodeDiffData : changeTreeDiffData)(roots, n), { preview: true });
|
||||
}
|
||||
|
||||
/** The setting behind Show on Double-Click (`None` follows the commit interface). */
|
||||
@@ -630,7 +632,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) {
|
||||
const active = maybeEditorService()?.activeInput?.();
|
||||
if (active?.kind !== DIFF_KIND || active.key !== followedDiff.current) return;
|
||||
if (diffInput(changeNodeDiff(node)).key === active.key) return;
|
||||
void openDiff(changeNodeDiffData(roots, node), { preview: true, focus: false });
|
||||
void openDiff(changeTreeDiffData(roots, node), { preview: true, focus: false });
|
||||
};
|
||||
|
||||
// Reveal only after the ancestors have expanded, without moving keyboard focus.
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { chainNeighbour, revisionChangeDiffData, withChain, type DiffData } from "../diffInput";
|
||||
import type { FileStatus } from "@/bindings";
|
||||
import { buildChangeTree, changeFileNodes } from "../changes";
|
||||
import {
|
||||
chainNeighbour,
|
||||
changeNodeDiffData,
|
||||
changeTreeDiffData,
|
||||
revisionChangeDiffData,
|
||||
withChain,
|
||||
type DiffData,
|
||||
} from "../diffInput";
|
||||
|
||||
const diff = (path: string): DiffData => ({ path, left: { type: "rev", rev: "HEAD" }, right: { type: "worktree" } });
|
||||
|
||||
@@ -24,6 +33,33 @@ describe("diff chain", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the changes tree's diff chain", () => {
|
||||
const fs = (rel: string, staged: FileStatus["staged"], unstaged: FileStatus["unstaged"]): FileStatus => ({
|
||||
path: `/r/${rel}`,
|
||||
relPath: rel,
|
||||
staged,
|
||||
unstaged,
|
||||
renamedFrom: null,
|
||||
});
|
||||
const status = [fs("a.ts", null, "modified"), fs("b.ts", "modified", null), fs("new.ts", null, "untracked")];
|
||||
|
||||
it("goes on past the last change into the unversioned files, as IDEA's Commit diff does", () => {
|
||||
const tree = buildChangeTree(status, "/r", false);
|
||||
const last = changeFileNodes(tree).find((n) => n.path === "/r/b.ts")!;
|
||||
const data = changeTreeDiffData(tree, last);
|
||||
expect(data.chain?.map((d) => d.path)).toEqual(["/r/a.ts", "/r/b.ts", "/r/new.ts"]);
|
||||
expect(chainNeighbour(data, 1)).toMatchObject({ path: "/r/new.ts" });
|
||||
// Show Diff keeps to the file's group.
|
||||
expect(chainNeighbour(changeNodeDiffData(tree, last), 1)).toBeNull();
|
||||
});
|
||||
|
||||
it("walks every staging group in tree order", () => {
|
||||
const tree = buildChangeTree(status, "/r", false, undefined, [], true);
|
||||
const staged = changeFileNodes(tree).find((n) => n.group === "staged")!;
|
||||
expect(chainNeighbour(changeTreeDiffData(tree, staged), 1)).toMatchObject({ path: "/r/a.ts" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("combined changes of several commits", () => {
|
||||
const base = { oldPath: null, before: "b".repeat(40), after: "a".repeat(40) };
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import { editorService, type EditorInput } from "@/workbench/contributions";
|
||||
import { baseName, effectiveKind } from "./model";
|
||||
import { readWorkingText } from "./ops";
|
||||
import { groupFileNodes, renamedFromPath, stagingSide, type ChangeNode } from "./changes";
|
||||
import { changeFileNodes, groupFileNodes, renamedFromPath, stagingSide, type ChangeNode } from "./changes";
|
||||
import { currentRepoRoot, statusOf } from "./store";
|
||||
import { repositoryPath } from "./repositoryPaths";
|
||||
|
||||
@@ -188,6 +188,20 @@ export function changeNodeDiffData(roots: readonly ChangeNode[], node: ChangeNod
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Commit window's diff of a file: every file of the tree (in tree order) as the F7 chain,
|
||||
* so past the last change it goes on into the unversioned files, as IDEA's Commit diff
|
||||
* preview does (`ChangesViewDiffPreviewProcessor.iterateAllChanges`).
|
||||
*/
|
||||
export function changeTreeDiffData(roots: readonly ChangeNode[], node: ChangeNode): DiffData {
|
||||
const all = changeFileNodes(roots);
|
||||
const nodes = all.includes(node) ? all : [node];
|
||||
return withChain(
|
||||
nodes.map((n) => changeNodeDiff(n)),
|
||||
nodes.indexOf(node),
|
||||
);
|
||||
}
|
||||
|
||||
/** Default local-changes diff for a working file: HEAD (or empty) vs the working copy. */
|
||||
export function workingDiffData(path: string, line?: number, side?: "staged" | "unstaged" | null): DiffData {
|
||||
if (side === "staged") {
|
||||
|
||||
@@ -269,8 +269,15 @@ const caret = "var(--caret, var(--fg-strong))";
|
||||
|
||||
/** Block caret and command line in theme tokens (the package's defaults are fixed colours). */
|
||||
const vimTheme = EditorView.theme({
|
||||
// A solid block with the character under it in the editor's background colour, as IdeaVim's
|
||||
// block caret: a translucent fill over light text on a dark theme read as a brighter letter.
|
||||
"& .cm-vimCursorLayer .cm-fat-cursor": {
|
||||
background: `color-mix(in srgb, ${caret} 60%, transparent)`,
|
||||
background: caret,
|
||||
},
|
||||
// The package sets the letter's own colour inline; a partial block (replace, operator-pending)
|
||||
// draws no letter.
|
||||
"&.cm-focused .cm-vimCursorLayer .cm-fat-cursor:not([style*='color: transparent'])": {
|
||||
color: "var(--bg-editor, var(--bg-island, var(--bg-app))) !important",
|
||||
},
|
||||
"&:not(.cm-focused) .cm-vimCursorLayer .cm-fat-cursor": {
|
||||
background: "none",
|
||||
|
||||
@@ -16,8 +16,11 @@ export interface FileTemplate {
|
||||
/** Without the dot; empty for "File…" (the name carries the extension). */
|
||||
extension: string;
|
||||
nameStyle: NameStyle;
|
||||
/** Package inference: `jvm` (Java/Kotlin source roots) or `go` (directory). */
|
||||
packageStyle?: "jvm" | "go" | "php";
|
||||
/**
|
||||
* Package inference: `jvm` (Java/Kotlin source roots), `go` (directory), `php` (composer.json
|
||||
* PSR-4) or `csharp` (the nearest .csproj's root namespace and the folders below it).
|
||||
*/
|
||||
packageStyle?: "jvm" | "go" | "php" | "csharp";
|
||||
/** Dotted names (`com.acme.Foo`) create package directories. */
|
||||
dottedPackages?: boolean;
|
||||
kinds: FileTemplateKind[];
|
||||
|
||||
Reference in new issue
Block a user