Compare commits

...
Author SHA1 Message Date
l0ng-ai cbb7bc952b fix(vim): draw the Normal-mode block cursor solid, with the character in the background colour
The focused block cursor was a 60% caret-coloured fill. On dark themes the caret is light
grey, so it read as a slightly brighter character and the cursor was easy to lose. It is now a
solid block in the caret colour with the character under it in the editor background colour,
as IdeaVim's block caret; the unfocused 1px outline and partial blocks are unchanged.

Fixes #3197
2026-11-14 01:27:00 +08:00
zhangdagouandPLZY 38bd8fbd16 fix(vcs): let the Commit diff go on past the last change into unversioned files (#3211)
A diff opened from the Commit tool window (double-click / Enter, and the preview that follows the selection) stopped at the last file of its group, so F7 never reached the new files under Unversioned Files. Its chain now covers every file of the tree in tree order, as the Commit diff preview does; Show Diff (Ctrl+D) still keeps to the file's group.

Co-authored-by: PLZY <[email protected]>
2026-11-14 01:22:54 +08:00
l0ng-ai 2859ec9106 feat(templates): New ▸ C# Class with the project's namespace (#3216)
Class, Interface, Struct, Enum and Record. The namespace is the nearest .csproj's
RootNamespace (else its file name) plus the folders below it. Projects on C# 10 or later
(.NET 6+, or LangVersion 10/latest) get a file-scoped namespace; .NET Framework, Unity,
.NET Standard and .NET Core projects get a namespace block.

Fixes #3190
2026-11-14 01:17:26 +08:00
11 changed files with 236 additions and 11 deletions

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>
+21
View File
@@ -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",
+33 -2
View 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,
+50
View File
@@ -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;
+5 -3
View File
@@ -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.
+37 -1
View File
@@ -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) };
+15 -1
View File
@@ -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") {
+8 -1
View File
@@ -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",
+5 -2
View File
@@ -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[];