Compare commits

...
Author SHA1 Message Date
l0ng-ai 0e92c629ae test(plugins): focus an absolute path in the git-toolbox schedule test
Editor focus events are routed by the focused file's project, so a relative path is dropped before it reaches the plugin and the test never woke Git Toolbox. Editors always report absolute paths; the test now does too.
2026-11-14 01:46:16 +08:00
zhangdagouandPLZY 01a60ef164 fix(editor): make usage highlighting follow the caret at once and paint multi-line usages as a block (#3220)
Usage highlighting at the caret waited a fixed 300 ms after every caret move, so clicking around lagged. A click now asks at once and keyboard moves wait 100 ms; with the document unchanged, a caret landing on usages found recently shows them immediately while they are refreshed. A highlight over several lines (a return statement's exit point) is painted as a block: from its start to the right edge, whole lines between, and the last line up to its end.

Co-authored-by: PLZY <[email protected]>
2026-11-14 01:33:42 +08:00
zhangdagouandPLZY b0b768d25e feat(editor): highlight the caret's indent guide and select a block by double-clicking its brace (#3213)
The indent guide in the caret's column, within the block the caret line belongs to, takes the selected indent guide colour, and guides sit slightly left of their column so a caret there is to the right of the line. With the caret at a brace of a multi-line block, that block's guide takes the matched-brace colour. A double-click that puts the caret next to { or } (also past the end of a line ending in {) selects the block, both braces included; a double-click on a word still selects the word.

Co-authored-by: PLZY <[email protected]>
2026-11-14 01:30:56 +08:00
l0ng-ai 3240bfc423 fix(vim): draw the Normal-mode block cursor solid, with the character in the background colour (#3218)
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:32 +08:00
Mansur Akhmadiandmansur-akhmadi 15182507cf fix(vcs): confirm force pushes in the multi-repository Push dialog (#3215)
With Force (with lease) ticked, the multi-repository Push dialog force pushed every picked repository without the Force Push confirmation the single-repository dialog shows. Each forced target is now confirmed first, once when several repositories push to the same target; protected targets are never forced and not asked about, "Don't warn about this target" is respected, and declining pushes nothing and keeps the dialog open.

Closes #3214

Co-authored-by: mansur-akhmadi <[email protected]>
2026-11-14 01:24:29 +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
23 changed files with 816 additions and 59 deletions

No files matched your search

+2 -1
View File
@@ -2554,7 +2554,8 @@ fn official_git_toolbox_fetches_on_schedule_after_idle_reclaim() {
f.install(&official("git-toolbox"));
// Focusing an editor wakes it to show the branch (nothing fetched yet).
let now = Instant::now();
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some("src/notes.txt".into()))]);
let focused = f.project.join("src/notes.txt").to_string_lossy().into_owned();
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some(focused))]);
f.host.flush_events_at(now + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(10)));
let item = |host: &PluginHost| {
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { EditorState } from "@codemirror/state";
import { braceBlock, braceNextTo } from "./bracketBlockSelect";
const DOC = "int f(Object raw) {\n if (a) {\n g(b[0]);\n }\n}";
const state = EditorState.create({ doc: DOC });
describe("double-clicking a curly brace", () => {
it("selects the block from the opening brace to its match, both included (IDEA)", () => {
const open = DOC.indexOf("{");
expect(braceBlock(state, open)).toEqual({ from: open, to: DOC.length });
});
it("selects the same block from the closing brace", () => {
const inner = DOC.indexOf("{", DOC.indexOf("if"));
const close = DOC.indexOf("}");
expect(braceBlock(state, close)).toEqual({ from: inner, to: close + 1 });
});
it("takes the brace next to the caret the click gives, past the end of the line too", () => {
const open = DOC.indexOf("{");
const lineEnd = state.doc.line(1).to;
expect(braceNextTo(state, lineEnd, false)).toBe(open);
expect(braceNextTo(state, open, false)).toBe(open);
const close = DOC.lastIndexOf("}");
expect(braceNextTo(state, close, false)).toBe(close);
// On a word, or away from braces, the double-click selects the word.
expect(braceNextTo(state, open, true)).toBeNull();
expect(braceNextTo(state, DOC.indexOf("raw"), false)).toBeNull();
});
it("leaves parentheses, brackets, other characters and unmatched braces to the word selection", () => {
expect(braceBlock(state, DOC.indexOf("("))).toBeNull();
expect(braceBlock(state, DOC.indexOf("["))).toBeNull();
expect(braceBlock(state, DOC.indexOf("raw"))).toBeNull();
expect(braceBlock(EditorState.create({ doc: "f {" }), 2)).toBeNull();
});
});
+53
View File
@@ -0,0 +1,53 @@
/**
* A double-click that puts the caret next to a curly brace selects the block it opens or
* closes, both braces included, as IDEA does: past the end of a line ending in `{` too.
* A double-click on a word still selects the word.
*/
import { matchBrackets } from "@codemirror/language";
import { EditorSelection, Prec, type EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
/** The range from the brace at `at` to its match, both included, or null when it has none. */
export function braceBlock(state: EditorState, at: number): { from: number; to: number } | null {
const ch = state.sliceDoc(at, at + 1);
if (ch !== "{" && ch !== "}") return null;
const match = ch === "{" ? matchBrackets(state, at, 1) : matchBrackets(state, at + 1, -1);
if (!match?.matched || !match.end) return null;
return { from: Math.min(match.start.from, match.end.from), to: Math.max(match.start.to, match.end.to) };
}
const isBrace = (ch: string) => ch === "{" || ch === "}";
/**
* The brace next to the caret position `pos` a click at `x` gives, unless the click is on a
* word character (that double-click selects the word).
*/
export function braceNextTo(state: EditorState, pos: number, onWord: boolean): number | null {
if (onWord) return null;
if (pos > 0 && isBrace(state.sliceDoc(pos - 1, pos))) return pos - 1;
if (isBrace(state.sliceDoc(pos, pos + 1))) return pos;
return null;
}
/** Whether the click at `x` is on a word character around `pos`. */
function clickOnWord(view: EditorView, pos: number, x: number): boolean {
for (const at of [pos - 1, pos]) {
if (at < 0 || at >= view.state.doc.length) continue;
const box = view.coordsForChar(at);
if (box && x >= box.left && x <= box.right) return /[\p{L}\p{N}_$]/u.test(view.state.sliceDoc(at, at + 1));
}
return false;
}
export const bracketBlockSelect = Prec.high(
EditorView.mouseSelectionStyle.of((view, event) => {
if (event.detail !== 2 || event.button !== 0 || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey)
return null;
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
const at = pos == null ? null : braceNextTo(view.state, pos, clickOnWord(view, pos, event.clientX));
const block = at == null ? null : braceBlock(view.state, at);
if (!block) return null;
const selection = EditorSelection.single(block.from, block.to);
return { get: () => selection, update: () => false };
}),
);
+58 -10
View File
@@ -5,7 +5,7 @@ import { indentUnit } from "@codemirror/language";
import { codeLenses } from "@/editor/api";
import type { EditorSettings } from "@/bindings";
import { codeLensExtension } from "./codeLens";
import { guideVars, indentGuides, rainbowIndentGuides } from "./indentGuides";
import { braceGuide, caretGuide, guideVars, indentGuides, rainbowIndentGuides } from "./indentGuides";
import { heavyExtension } from "./setup";
const SOURCE = [
@@ -46,23 +46,23 @@ describe("indent guides", () => {
expect(guideVars(1, 4)).toBeNull();
expect(guideVars(3, 4)).toEqual({
images: "var(--openidea-guide), var(--openidea-guide)",
positions: "calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0",
positions: "calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0",
});
});
it("positions each guide the same way on lines of different depth", () => {
const v = mount(SOURCE);
expect(line(v, 1).classList.contains("cm-openidea-guides")).toBe(false);
expect(line(v, 2).style.getPropertyValue("--openidea-guide-positions")).toBe("calc(2.5px + 4ch) 0");
expect(line(v, 2).style.getPropertyValue("--openidea-guide-positions")).toBe("calc(0.5px + 4ch) 0");
expect(line(v, 3).style.getPropertyValue("--openidea-guide-positions")).toBe(
"calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0",
"calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0",
);
});
it("keeps guides through blank lines inside a block", () => {
const v = mount(SOURCE);
expect(line(v, 4).style.getPropertyValue("--openidea-guide-positions")).toBe(
"calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0",
"calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0",
);
});
@@ -77,18 +77,66 @@ describe("indent guides", () => {
]);
const [implLens, fnLens] = [...v.contentDOM.querySelectorAll<HTMLElement>(".cm-openidea-lens")];
expect(v.contentDOM.classList.contains("cm-openidea-guidesOn")).toBe(true);
expect(fnLens!.style.getPropertyValue("--openidea-guide-positions")).toBe("calc(2.5px + 4ch) 0");
expect(fnLens!.style.getPropertyValue("--openidea-guide-positions")).toBe("calc(0.5px + 4ch) 0");
expect(implLens!.style.getPropertyValue("--openidea-guide-positions")).toBe("");
});
});
describe("the caret's indent guide", () => {
const at = (lineNo: number, col: number) => {
const state = EditorState.create({ doc: SOURCE });
return state.update({ selection: { anchor: state.doc.line(lineNo).from + col } }).state;
};
it("is the guide in the caret's column whose block holds the caret line (IDEA)", () => {
// `for` opens the guide at column 4, drawn on lines 3-7 (`if` … its `}`).
expect(caretGuide(at(2, 4), 4)).toEqual({ level: 1, from: 3, to: 7 });
expect(caretGuide(at(4, 4), 4)).toEqual({ level: 1, from: 3, to: 7 });
expect(caretGuide(at(4, 8), 4)).toEqual({ level: 2, from: 4, to: 6 });
});
it("is none in column 0, between indent units, in text, or on the closing line", () => {
expect(caretGuide(at(4, 0), 4)).toBeNull();
expect(caretGuide(at(4, 6), 4)).toBeNull();
expect(caretGuide(at(2, 12), 4)).toBeNull();
expect(caretGuide(at(8, 4), 4)).toBeNull();
});
it("is the matched brace colour's guide while the caret is at a brace of a multi-line block", () => {
const state = (pos: number) => EditorState.create({ doc: SOURCE, selection: { anchor: pos } });
const forOpen = SOURCE.indexOf("{", SOURCE.indexOf("for"));
// `for x in y {`: its guide is at column 4, on lines 3-7.
expect(braceGuide(state(forOpen + 1), 4)).toEqual({ level: 1, from: 3, to: 7 });
expect(braceGuide(state(forOpen), 4)).toEqual({ level: 1, from: 3, to: 7 });
const forClose = SOURCE.lastIndexOf(" }") + 4;
expect(braceGuide(state(forClose + 1), 4)).toEqual({ level: 1, from: 3, to: 7 });
expect(braceGuide(state(SOURCE.indexOf("push")), 4)).toBeNull();
expect(braceGuide(EditorState.create({ doc: "a {\n b { c }\n}", selection: { anchor: 9 } }), 4)).toBeNull();
});
it("draws the brace guide over the caret's", () => {
const v = mount(SOURCE);
v.dispatch({ selection: { anchor: SOURCE.indexOf("{", SOURCE.indexOf("for")) + 1 } });
expect(images(line(v, 3))).toBe("var(--openidea-guide-brace), var(--openidea-guide)");
});
it("draws that guide on its lines in the selected colour", () => {
const v = mount(SOURCE);
v.dispatch({ selection: { anchor: v.state.doc.line(4).from + 8 } });
expect(images(line(v, 3))).toBe("var(--openidea-guide), var(--openidea-guide-active)");
expect(images(line(v, 6))).toBe("var(--openidea-guide)");
v.dispatch({ selection: { anchor: v.state.doc.line(4).from + 12 } });
expect(images(line(v, 3))).toBe("var(--openidea-guide), var(--openidea-guide)");
});
});
describe("rainbow indent guides", () => {
it("gives each depth its own image, cycling through the five colours", () => {
expect(guideVars(0, 4, true)).toBeNull();
expect(guideVars(1, 4, true)).toBeNull();
expect(guideVars(3, 4, true)).toEqual({
images: "var(--openidea-guide-1), var(--openidea-guide-2)",
positions: "calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0",
positions: "calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0",
});
const deep = guideVars(8, 2, true)!;
expect(deep.images.split(", ")).toEqual([1, 2, 3, 4, 5, 1, 2].map((n) => `var(--openidea-guide-${n})`));
@@ -108,7 +156,7 @@ describe("rainbow indent guides", () => {
expect(images(line(v, 2))).toBe("var(--openidea-guide-1)");
expect(images(line(v, 3))).toBe("var(--openidea-guide-1), var(--openidea-guide-2)");
expect(images(line(v, 4))).toBe("var(--openidea-guide-1), var(--openidea-guide-2)");
expect(positions(line(v, 4))).toBe("calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0");
expect(positions(line(v, 4))).toBe("calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0");
expect(images(line(v, 6))).toBe("var(--openidea-guide-1)");
});
@@ -122,7 +170,7 @@ describe("rainbow indent guides", () => {
spaces.destroy();
const tabs = mount(body("\t"), [EditorState.tabSize.of(4)], rainbowIndentGuides, "\t");
expect(images(deepest(tabs))).toBe(expected);
expect(positions(deepest(tabs)).split(", ")[1]).toBe("calc(2.5px + 8ch) 0");
expect(positions(deepest(tabs)).split(", ")[1]).toBe("calc(0.5px + 8ch) 0");
});
it("does not count a partial indent unit as a level", () => {
@@ -163,7 +211,7 @@ describe("rainbow indent guides", () => {
});
const lens = () => view!.contentDOM.querySelector<HTMLElement>(".cm-openidea-lens")!;
expect(images(lens())).toBe("var(--openidea-guide-1), var(--openidea-guide-2)");
expect(positions(lens())).toBe("calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0");
expect(positions(lens())).toBe("calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0");
view.dispatch({ effects: guides.reconfigure(indentGuides) });
expect(images(lens())).toBe("var(--openidea-guide), var(--openidea-guide)");
});
+99 -12
View File
@@ -6,13 +6,17 @@
* so a guide stays straight across lines of different depth. Blank lines take
* the smaller level of their neighbours and code lens rows the level of the
* line they annotate (`guideVars`), so guides stay continuous. With rainbow
* guides each depth gets its own image, coloured from `--syn-rainbow-*`.
* guides each depth gets its own image, coloured from `--syn-rainbow-*`. The
* guide the caret is on takes the selected colour (`caretGuide`).
*/
import { Facet, RangeSetBuilder, type EditorState, type Extension } from "@codemirror/state";
import { countColumn, Facet, RangeSetBuilder, type EditorState, type Extension } from "@codemirror/state";
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from "@codemirror/view";
import { getIndentUnit } from "@codemirror/language";
import { getIndentUnit, matchBrackets } from "@codemirror/language";
import { LINE_INSET } from "./lineInset";
/** How far (px) a guide sits left of its column, so a caret in that column is right of it (IDEA's indent guide shift). */
const GUIDE_SHIFT = 2;
/** Number of `--openidea-guide-<n>` rainbow colours; deeper guides repeat them. */
export const GUIDE_COLORS = 5;
@@ -22,26 +26,41 @@ export const rainbowGuides = Facet.define<boolean, boolean>({ combine: (values)
/**
* Values of `--openidea-guide-images` / `--openidea-guide-positions` for an
* indentation `levels` deep (in units of `step` columns), or null when it has
* no guides. `.cm-openidea-guides` lines and code lens rows turn them into
* backgrounds (theme.ts).
* no guides; the guide at level `active` takes the selected colour, the one at
* level `brace` the matched brace one. `.cm-openidea-guides` lines and code lens
* rows turn them into backgrounds (theme.ts).
*/
export function guideVars(levels: number, step: number, rainbow = false): { images: string; positions: string } | null {
export function guideVars(
levels: number,
step: number,
rainbow = false,
active = 0,
brace = 0,
): { images: string; positions: string } | null {
if (levels < 2) return null;
const images: string[] = [];
const positions: string[] = [];
for (let level = 1; level < levels; level++) {
images.push(rainbow ? `var(--openidea-guide-${((level - 1) % GUIDE_COLORS) + 1})` : "var(--openidea-guide)");
positions.push(`calc(${LINE_INSET}px + ${level * step}ch) 0`);
images.push(
level === brace
? "var(--openidea-guide-brace)"
: level === active
? "var(--openidea-guide-active)"
: rainbow
? `var(--openidea-guide-${((level - 1) % GUIDE_COLORS) + 1})`
: "var(--openidea-guide)",
);
positions.push(`calc(${LINE_INSET - GUIDE_SHIFT}px + ${level * step}ch) 0`);
}
return { images: images.join(", "), positions: positions.join(", ") };
}
const cache = new Map<string, Decoration | null>();
function guideDeco(levels: number, step: number, rainbow: boolean) {
const key = `${levels}:${step}:${rainbow ? 1 : 0}`;
function guideDeco(levels: number, step: number, rainbow: boolean, active: number, brace: number) {
const key = `${levels}:${step}:${rainbow ? 1 : 0}:${active}:${brace}`;
let d = cache.get(key);
if (d === undefined) {
const vars = guideVars(levels, step, rainbow);
const vars = guideVars(levels, step, rainbow, active, brace);
const style = vars && `--openidea-guide-images: ${vars.images}; --openidea-guide-positions: ${vars.positions}`;
d = style ? Decoration.line({ attributes: { class: "cm-openidea-guides", style } }) : null;
cache.set(key, d);
@@ -71,10 +90,77 @@ function levelOf(state: EditorState, lineNo: number, step: number, dir: 1 | -1):
return 0;
}
const BLOCK_SCAN = 10_000;
/** A guide: its level (in indent units) and the lines (`from`..`to`) it is drawn on. */
export interface Guide {
level: number;
from: number;
to: number;
}
/** The guide line `start` opens at column `col` (its indentation): down to the last deeper line. */
function blockGuide(state: EditorState, start: number, col: number, step: number): Guide | null {
if (col === 0 || col % step) return null;
let end = start;
for (let n = start + 1; n <= state.doc.lines && n - start < BLOCK_SCAN; n++) {
const cols = indentColumns(state.doc.line(n).text, state.tabSize);
if (cols === null) continue;
if (cols <= col) break;
end = n;
}
return end === start ? null : { level: col / step, from: start + 1, to: end };
}
/**
* The guide the caret is on, as IDEA's `IndentsModel.getCaretIndentGuide`: the caret's
* column is the guide's and its line lies in the guide's block, from the line opening it
* to the last deeper one.
*/
export function caretGuide(state: EditorState, step: number): Guide | null {
const head = state.selection.main.head;
const caretLine = state.doc.lineAt(head);
const col = countColumn(caretLine.text.slice(0, head - caretLine.from), state.tabSize);
if (col === 0 || col % step) return null;
let start = caretLine.number;
for (let i = 0; ; start--, i++) {
if (start < 1 || i > BLOCK_SCAN) return null;
const cols = indentColumns(state.doc.line(start).text, state.tabSize);
if (cols === null || cols > col) continue;
if (cols !== col) return null;
break;
}
const guide = blockGuide(state, start, col, step);
return guide && caretLine.number <= guide.to ? guide : null;
}
/**
* The guide of the block whose braces the caret is at, while they match on different lines:
* IDEA draws it in the matched brace colour (`MATCHED_BRACES_INDENT_GUIDE_COLOR`).
*/
export function braceGuide(state: EditorState, step: number): Guide | null {
const head = state.selection.main.head;
const match =
matchBrackets(state, head, -1) ??
(head > 0 ? matchBrackets(state, head - 1, 1) : null) ??
matchBrackets(state, head, 1) ??
(head < state.doc.length ? matchBrackets(state, head + 1, -1) : null);
if (!match?.matched || !match.end) return null;
const open = Math.min(match.start.from, match.end.from);
if (state.sliceDoc(open, open + 1) !== "{") return null;
const openLine = state.doc.lineAt(open);
if (openLine.number === state.doc.lineAt(Math.max(match.start.from, match.end.from)).number) return null;
const col = indentColumns(openLine.text, state.tabSize) ?? 0;
return blockGuide(state, openLine.number, col, step);
}
function build(view: EditorView): DecorationSet {
const state = view.state;
const step = Math.max(1, getIndentUnit(state));
const rainbow = state.facet(rainbowGuides);
const caret = caretGuide(state, step);
const brace = braceGuide(state, step);
const on = (g: Guide | null, n: number) => (g && n >= g.from && n <= g.to ? g.level : 0);
const b = new RangeSetBuilder<Decoration>();
for (const { from, to } of view.visibleRanges) {
let pos = from;
@@ -88,7 +174,7 @@ function build(view: EditorView): DecorationSet {
} else {
levels = Math.floor(cols / step);
}
const deco = guideDeco(levels, step, rainbow);
const deco = guideDeco(levels, step, rainbow, on(caret, line.number), on(brace, line.number));
if (deco) b.add(line.from, line.from, deco);
}
pos = line.to + 1;
@@ -107,6 +193,7 @@ export const indentGuides: Extension = [
update(u: ViewUpdate) {
if (
u.docChanged ||
u.selectionSet ||
u.viewportChanged ||
u.startState.tabSize !== u.state.tabSize ||
u.startState.facet(rainbowGuides) !== u.state.facet(rainbowGuides)
+2
View File
@@ -47,6 +47,7 @@ import { caretExtension, fontZoom, methodLinesField, prefsExtension, softWrapAct
import { commentTokens } from "./languages";
import { multiCaretMouse } from "./multiCaret";
import { columnModeExtension } from "./columnMode";
import { bracketBlockSelect } from "./bracketBlockSelect";
import { visibleGoalColumns } from "./verticalMotion";
import { tabKeymap } from "./smartKeys";
import { editorBaseKeymap } from "./baseKeymap";
@@ -376,6 +377,7 @@ export function baseExtensions(o: SetupOptions): Extension {
EditorState.allowMultipleSelections.of(true),
multiCaretMouse,
columnModeExtension,
bracketBlockSelect,
visibleGoalColumns,
occurrenceSearch,
overwriteField,
+7
View File
@@ -137,6 +137,13 @@ export const openideaTheme = EditorView.theme({
"var(--bg-editor-selection-inactive, color-mix(in srgb, var(--accent) 14%, transparent))",
"--editor-indent-guide": "var(--border-indent-guide, var(--border))",
"--openidea-guide": "linear-gradient(var(--editor-indent-guide), var(--editor-indent-guide))",
// The guide the caret is on (IDEA's Selected indent guide).
"--editor-indent-guide-active": "var(--border-indent-guide-active, var(--editor-indent-guide))",
"--openidea-guide-active": "linear-gradient(var(--editor-indent-guide-active), var(--editor-indent-guide-active))",
// The guide of the block whose braces match at the caret (IDEA's Matched braces indent guide).
"--editor-indent-guide-brace":
"var(--border-indent-guide-brace, var(--bg-matched-brace, var(--editor-indent-guide-active)))",
"--openidea-guide-brace": "linear-gradient(var(--editor-indent-guide-brace), var(--editor-indent-guide-brace))",
// Rainbow indent guides: one image per depth, so they follow the colour scheme.
...Object.fromEntries(
Array.from({ length: GUIDE_COLORS }, (_, i) => {
+105 -21
View File
@@ -10,7 +10,16 @@ import {
* click), the multiple-targets chooser, and document highlights (occurrences
* of the symbol at the caret, plus the sticky find.highlightUsages variant).
*/
import { EditorSelection, Prec, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import {
countColumn,
EditorSelection,
Prec,
StateEffect,
StateField,
type Extension,
type Text,
} from "@codemirror/state";
import { LINE_INSET } from "@/editor/core/cm/lineInset";
import {
Decoration,
EditorView,
@@ -1031,6 +1040,8 @@ function navigateFromPointer(view: EditorView, e: MouseEvent): boolean {
interface HighlightState {
deco: DecorationSet;
/** The highlighted usages (the decorations also hold whole-line backgrounds). */
ranges: readonly { from: number; to: number }[];
sticky: boolean;
}
@@ -1038,31 +1049,93 @@ type HighlightItem = { from: number; to: number; kind: Highlight["kind"] };
const setHighlights = StateEffect.define<{ items: HighlightItem[]; sticky: boolean }>();
const KIND_CLASS = {
text: "cm-lsp-occurrence",
read: "cm-lsp-occurrence cm-lsp-occurrence-read",
write: "cm-lsp-occurrence cm-lsp-occurrence-write",
};
const marks = {
text: Decoration.mark({ class: "cm-lsp-occurrence" }),
read: Decoration.mark({ class: "cm-lsp-occurrence cm-lsp-occurrence-read" }),
write: Decoration.mark({ class: "cm-lsp-occurrence cm-lsp-occurrence-write" }),
text: Decoration.mark({ class: KIND_CLASS.text }),
read: Decoration.mark({ class: KIND_CLASS.read }),
write: Decoration.mark({ class: KIND_CLASS.write }),
};
const fullLines = {
text: Decoration.line({ class: KIND_CLASS.text }),
read: Decoration.line({ class: KIND_CLASS.read }),
write: Decoration.line({ class: KIND_CLASS.write }),
};
/**
* Highlights as IDEA paints a text range: one on a single line marks its text; one over
* several lines (a `return` statement's exit point) is a block, from its start to the right
* edge, whole lines between, and its last line up to its end.
*/
function highlightDeco(doc: Text, tabSize: number, items: readonly HighlightItem[]): DecorationSet {
const out = [];
for (const i of items) {
if (i.from >= i.to) continue;
const first = doc.lineAt(i.from);
const last = doc.lineAt(i.to);
if (first.number === last.number) {
out.push(marks[i.kind].range(i.from, i.to));
continue;
}
const col = countColumn(first.text.slice(0, i.from - first.from), tabSize);
const tail = Decoration.line({
class: i.kind === "write" ? "cm-lsp-occurrence-tail cm-lsp-occurrence-tail-write" : "cm-lsp-occurrence-tail",
attributes: { style: `--lsp-occurrence-from: calc(${LINE_INSET}px + ${col}ch)` },
});
out.push(tail.range(first.from));
for (let n = first.number + 1; n < last.number; n++) out.push(fullLines[i.kind].range(doc.line(n).from));
if (i.to > last.from) out.push(marks[i.kind].range(last.from, i.to));
}
return Decoration.set(out, true);
}
const highlightField = StateField.define<HighlightState>({
create: () => ({ deco: Decoration.none, sticky: false }),
create: () => ({ deco: Decoration.none, ranges: [], sticky: false }),
update(value, tr) {
for (const e of tr.effects)
if (e.is(setHighlights))
if (e.is(setHighlights)) {
const items = e.value.items.filter((i) => i.from < i.to);
return {
deco: Decoration.set(
e.value.items.filter((i) => i.from < i.to).map((i) => marks[i.kind].range(i.from, i.to)),
true,
),
deco: highlightDeco(tr.state.doc, tr.state.tabSize, items),
ranges: items.map(({ from, to }) => ({ from, to })),
sticky: e.value.sticky,
};
}
// Kept (mapped) until recomputed; ⌘⇧F7 highlights survive edits until Escape.
if (tr.docChanged) return { deco: value.deco.map(tr.changes), sticky: value.sticky };
if (tr.docChanged)
return {
deco: value.deco.map(tr.changes),
ranges: value.ranges.map((r) => ({ from: tr.changes.mapPos(r.from, 1), to: tr.changes.mapPos(r.to, -1) })),
sticky: value.sticky,
};
return value;
},
provide: (f) => EditorView.decorations.from(f, (v) => v.deco),
});
/**
* The latest usages found per editor, for its current document: a caret landing in one shows
* them at once while the server is asked again, as IDEA's highlighting follows the caret.
*/
const recentHighlights = new WeakMap<EditorView, { doc: Text; sets: HighlightItem[][] }>();
const RECENT_SETS = 8;
function rememberHighlights(view: EditorView, doc: Text, items: HighlightItem[]) {
if (!items.length) return;
let recent = recentHighlights.get(view);
if (recent?.doc !== doc) recentHighlights.set(view, (recent = { doc, sets: [] }));
recent.sets = [items, ...recent.sets].slice(0, RECENT_SETS);
}
function recentHighlightsAt(view: EditorView, doc: Text, head: number): HighlightItem[] | null {
const recent = recentHighlights.get(view);
if (recent?.doc !== doc) return null;
return recent.sets.find((set) => set.some((i) => i.from <= head && head <= i.to)) ?? null;
}
/** IDEA's status bar text while ⌘⇧F7 highlights are shown. */
export const useUsagesStatus = create<{ text: string | null; owner: EditorView | null }>(() => ({
text: null,
@@ -1092,6 +1165,7 @@ async function fetchHighlights(view: EditorView, sticky: boolean) {
const stale = () => view.state.doc !== state.doc || view.state.selection.main.head !== head;
const apply = (items: HighlightItem[]) => {
view.dispatch({ effects: setHighlights.of({ items, sticky: sticky && items.length > 0 }) });
rememberHighlights(view, state.doc, items);
if (!sticky) return;
if (!items.length) {
if (useUsagesStatus.getState().owner === view) setUsagesStatus(null, null);
@@ -1129,7 +1203,7 @@ async function fetchHighlights(view: EditorView, sticky: boolean) {
if (lspError) reportLspError(view, lspError, sticky);
else if (sticky) showHint(view, NO_SERVER);
// Drop stale marks from an earlier caret position.
if (view.state.field(highlightField).deco.size) apply([]);
if (view.state.field(highlightField).ranges.length) apply([]);
}
async function syntaxHighlights(view: EditorView, doc: EditorDocContext, head: number) {
@@ -1148,10 +1222,7 @@ async function syntaxHighlights(view: EditorView, doc: EditorDocContext, head: n
export function highlightUsages(view: EditorView) {
const st = view.state.field(highlightField, false);
const head = view.state.selection.main.head;
let onUsage = false;
st?.deco.between(head, head, (from, to) => {
if (from <= head && head <= to) onUsage = true;
});
const onUsage = !!st?.ranges.some(({ from, to }) => from <= head && head <= to);
if (st?.sticky && onUsage) {
clearHighlights(view);
return;
@@ -1165,8 +1236,7 @@ export function highlightUsages(view: EditorView) {
*/
export function gotoHighlightedUsage(view: EditorView, dir: 1 | -1): boolean {
const st = view.state.field(highlightField, false);
const ranges: { from: number; to: number }[] = [];
st?.deco.between(0, view.state.doc.length, (from, to) => void ranges.push({ from, to }));
const ranges = [...(st?.ranges ?? [])].sort((a, b) => a.from - b.from);
const head = view.state.selection.main.head;
const current = ranges.findIndex((r) => r.from <= head && head <= r.to);
if (ranges.length < 2 || current < 0) return false;
@@ -1178,7 +1248,8 @@ export function gotoHighlightedUsage(view: EditorView, dir: 1 | -1): boolean {
const highlightPlugin = ViewPlugin.fromClass(
class {
private request = debounce(() => void fetchHighlights(this.view, false), 300);
// Keyboard moves wait a little, so holding an arrow key does not ask for every line.
private request = debounce(() => void fetchHighlights(this.view, false), 100);
constructor(private view: EditorView) {}
update(u: ViewUpdate) {
// Recomputed after caret moves and, like IDEA's daemon, after edits.
@@ -1190,7 +1261,7 @@ const highlightPlugin = ViewPlugin.fromClass(
}
const clear = () => {
this.request.cancel();
if (st.deco.size)
if (st.ranges.length)
queueMicrotask(() => this.view.dispatch({ effects: setHighlights.of({ items: [], sticky: false }) }));
};
// Code Editing › "Usages of element at caret".
@@ -1198,7 +1269,20 @@ const highlightPlugin = ViewPlugin.fromClass(
clear();
return;
}
this.request();
const doc = u.state.doc;
const head = u.state.selection.main.head;
const known = u.docChanged ? null : recentHighlightsAt(this.view, doc, head);
if (known)
queueMicrotask(() => {
const now = this.view.state;
if (now.doc === doc && now.selection.main.head === head)
this.view.dispatch({ effects: setHighlights.of({ items: known, sticky: false }) });
});
// A click asks at once (IDEA highlights as the caret lands).
if (u.transactions.some((tr) => tr.isUserEvent("select.pointer"))) {
this.request.cancel();
void fetchHighlights(this.view, false);
} else this.request();
}
destroy() {
this.request.cancel();
+11 -4
View File
@@ -288,11 +288,18 @@ export const lspTheme = EditorView.theme({
textUnderlineOffset: "2px",
cursor: "pointer",
},
".cm-lsp-occurrence": {
backgroundColor: "var(--bg-identifier-under-caret, color-mix(in srgb, var(--accent) 14%, transparent))",
".cm-lsp-occurrence, .cm-lsp-occurrence-tail": {
"--lsp-occurrence-bg": "var(--bg-identifier-under-caret, color-mix(in srgb, var(--accent) 14%, transparent))",
},
".cm-lsp-occurrence-write": {
backgroundColor: "var(--bg-identifier-under-caret-write, color-mix(in srgb, var(--danger) 12%, transparent))",
".cm-lsp-occurrence-write, .cm-lsp-occurrence-tail-write": {
"--lsp-occurrence-bg": "var(--bg-identifier-under-caret-write, color-mix(in srgb, var(--danger) 12%, transparent))",
},
".cm-lsp-occurrence": { backgroundColor: "var(--lsp-occurrence-bg)" },
// First line of a highlight over several lines: from its start column to the right edge,
// over the line's own background (the caret row's).
".cm-lsp-occurrence-tail": {
backgroundImage:
"linear-gradient(to right, transparent var(--lsp-occurrence-from), var(--lsp-occurrence-bg) var(--lsp-occurrence-from))",
},
".cm-lsp-nav-info": { padding: "4px 8px" },
// Chooser highlight (tempHighlight.ts): the expression / occurrences a choice would take.
+125
View File
@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { Highlight, IdeError, Range } from "@/bindings";
type R<T> = Promise<{ status: "ok"; data: T } | { status: "error"; error: IdeError }>;
const ok = <T>(data: T) => Promise.resolve({ status: "ok" as const, data });
const cmd = {
lspHover: vi.fn((): R<unknown> => ok(null)),
lspDocumentLinks: vi.fn(() => ok([])),
cancelRequest: vi.fn(() => ok(null)),
lspDocumentHighlights: vi.fn((): R<Highlight[]> => ok([])),
};
vi.mock("@/platform/ipc", async (orig) => {
const actual = await orig<typeof import("@/platform/ipc")>();
return { ...actual, isTauri: () => true, commands: { ...actual.commands, ...cmd } };
});
const { navigationExtension, highlightUsages } = await import("./navigation");
const { lspDoc } = await import("./lsp");
const range = (line: number, col: number, endLine: number, endCol: number): Range => ({
start: { line, col },
end: { line: endLine, col: endCol },
});
// 0: int f() { 4: }
// 1: return a 5: int g(String a) {
// 2: .b() 6: return a;
// 3: .c(); 7: }
const DOC = "int f() {\n return a\n .b()\n .c();\n}\nint g(String a) {\n return a;\n}";
const views: EditorView[] = [];
function editor(anchor = 0) {
const view = new EditorView({
state: EditorState.create({
doc: DOC,
selection: { anchor },
extensions: [
lspDoc.of({ doc: 7, path: "/p/A.java", language: "java", uiMode: null, readOnly: false }),
navigationExtension(),
],
}),
parent: document.body,
});
views.push(view);
return view;
}
const lines = (v: EditorView) => [...v.contentDOM.querySelectorAll<HTMLElement>(".cm-line")];
const at = (line: number, col: number) => DOC.split("\n").slice(0, line).join("\n").length + (line ? 1 : 0) + col;
const click = (v: EditorView, pos: number) => v.dispatch({ selection: { anchor: pos }, userEvent: "select.pointer" });
beforeEach(() => {
cmd.lspDocumentHighlights.mockReset().mockImplementation(() => ok([]));
});
afterEach(() => views.splice(0).forEach((v) => v.destroy()));
describe("highlights of the element at the caret", () => {
it("paints a highlight over several lines as one block, as IDEA paints a text range", async () => {
cmd.lspDocumentHighlights.mockImplementation(() => ok([{ range: range(1, 4, 3, 13), kind: "text" }]));
const v = editor(at(1, 4));
highlightUsages(v);
await vi.waitFor(() => expect(lines(v)[1]!.classList.contains("cm-lsp-occurrence-tail")).toBe(true));
// From `return` (column 4) to the right edge, the whole middle line, the last line up to `;`.
expect(lines(v)[1]!.style.getPropertyValue("--lsp-occurrence-from")).toBe("calc(2.5px + 4ch)");
expect(lines(v)[2]!.classList.contains("cm-lsp-occurrence")).toBe(true);
const last = lines(v)[3]!.querySelector(".cm-lsp-occurrence")!;
expect(last.textContent).toBe(" .c();");
expect(lines(v)[0]!.className).not.toContain("cm-lsp-occurrence");
expect(lines(v)[4]!.className).not.toContain("cm-lsp-occurrence");
});
it("keeps a single-line highlight on its text", async () => {
cmd.lspDocumentHighlights.mockImplementation(() => ok([{ range: range(6, 11, 6, 12), kind: "read" }]));
const v = editor(at(6, 11));
highlightUsages(v);
await vi.waitFor(() => expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence-read")).toHaveLength(1));
expect(lines(v)[6]!.classList.contains("cm-lsp-occurrence")).toBe(false);
});
it("asks at once after a click, and after a pause for keyboard moves", async () => {
const v = editor(0);
click(v, at(6, 11));
await vi.waitFor(() => expect(cmd.lspDocumentHighlights).toHaveBeenCalledTimes(1));
v.dispatch({ selection: { anchor: at(6, 4) }, userEvent: "select" });
await new Promise((r) => setTimeout(r, 20));
expect(cmd.lspDocumentHighlights).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(cmd.lspDocumentHighlights).toHaveBeenCalledTimes(2));
});
it("shows the usages found before at once when the caret comes back to one", async () => {
const a = [
{ range: range(5, 13, 5, 14), kind: "write" as const },
{ range: range(6, 11, 6, 12), kind: "read" as const },
];
cmd.lspDocumentHighlights.mockImplementationOnce(() => ok(a));
const v = editor(0);
click(v, at(6, 11));
await vi.waitFor(() => expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(2));
click(v, at(1, 4));
await vi.waitFor(() => expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(0));
// The server is slow now: the remembered usages of `a` show without waiting for it.
cmd.lspDocumentHighlights.mockImplementation(() => new Promise(() => {}));
click(v, at(5, 13));
await vi.waitFor(() => expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(2));
});
it("forgets them after an edit", async () => {
cmd.lspDocumentHighlights.mockImplementationOnce(() => ok([{ range: range(6, 11, 6, 12), kind: "read" }]));
const v = editor(0);
click(v, at(6, 11));
await vi.waitFor(() => expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(1));
v.dispatch({ changes: { from: 0, insert: "\n" } });
click(v, 0);
await vi.waitFor(() => expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(0));
// Back on `a` with a slow server: the results of the document before the edit are not reused.
cmd.lspDocumentHighlights.mockImplementation(() => new Promise(() => {}));
click(v, at(6, 11) + 1);
await vi.waitFor(() => expect(cmd.lspDocumentHighlights).toHaveBeenCalledTimes(3));
await new Promise((r) => setTimeout(r, 20));
expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(0);
});
});
@@ -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) };
@@ -5,6 +5,12 @@ import type { RemoteOp } from "@/bindings";
const mocks = vi.hoisted(() => ({
ops: [] as Array<{ repo: string | null; op: RemoteOp }>,
failing: new Set<string>(),
ask: vi.fn(),
}));
vi.mock("@/workbench/messages", async (importOriginal) => ({
...(await importOriginal<typeof import("@/workbench/messages")>()),
ask: mocks.ask,
}));
vi.mock("@tauri-apps/api/core", async (importOriginal) => ({
@@ -50,6 +56,8 @@ let notes: Note[] = [];
beforeEach(() => {
mocks.ops = [];
mocks.failing = new Set();
mocks.ask.mockReset();
mocks.ask.mockResolvedValue({ button: "force", checked: false });
notes = [];
useVcs.setState({ root: "/p", roots: ["/p/a", "/p/b", "/p/c"], repo: "/p/a", remoteBusy: null, available: false });
provideNotificationService({ notify: (n) => void notes.push(n as Note), expire: () => {} });
@@ -110,3 +118,66 @@ it("pushes each repository to its own remote and target branch, reporting the on
await waitFor(() => expect(notes.map((n) => n.title)).toContain("Push partially failed"));
expect(notes.find((n) => n.title === "Push partially failed")?.body).toContain("Not pushed: b.");
});
const forceOf = (ops: typeof mocks.ops) =>
ops.map(({ repo, op }) => [repo, "force_with_lease" in op ? op.force_with_lease : null]);
const askedTargets = () => mocks.ask.mock.calls.map(([r]) => (r as { body: string }).body.match(/to '(.*)'/)?.[1]);
async function openPushDialog() {
render(<VcsDialogHost />);
await act(() => showPushDialog());
await screen.findByRole("textbox", { name: "Target branch of a" });
}
it("asks to confirm each forced target and pushes nothing when one is declined", async () => {
mocks.ask.mockResolvedValueOnce({ button: "force", checked: false }).mockResolvedValueOnce({ button: null });
await openPushDialog();
fireEvent.change(screen.getByRole("textbox", { name: "Target branch of a" }), { target: { value: "feat-a" } });
fireEvent.click(screen.getByRole("checkbox", { name: "Force (with lease)" }));
fireEvent.click(screen.getByRole("button", { name: "Force Push" }));
await waitFor(() => expect(mocks.ask).toHaveBeenCalledTimes(2));
expect(askedTargets()).toEqual(["origin/feat-a", "origin/feat-x"]);
expect(mocks.ops).toEqual([]);
// Declining keeps the dialog open, as in the single-repository dialog.
expect(screen.getByRole("button", { name: "Force Push" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
});
it("force pushes with lease once every target is confirmed", async () => {
await openPushDialog();
fireEvent.click(screen.getByRole("checkbox", { name: "Force (with lease)" }));
fireEvent.click(screen.getByRole("button", { name: "Force Push" }));
await waitFor(() => expect(mocks.ops).toHaveLength(3));
// The three repositories push to the same target: one confirmation.
expect(askedTargets()).toEqual(["origin/feat-x"]);
expect(forceOf(mocks.ops)).toEqual([
["/p/a", true],
["/p/b", true],
["/p/c", true],
]);
});
it("does not ask about a protected target, which is never forced", async () => {
await openPushDialog();
fireEvent.change(screen.getByRole("textbox", { name: "Target branch of a" }), { target: { value: "main" } });
fireEvent.click(screen.getByRole("checkbox", { name: "Force (with lease)" }));
fireEvent.click(screen.getByRole("button", { name: "Force Push" }));
await waitFor(() => expect(mocks.ops).toHaveLength(3));
expect(askedTargets()).toEqual(["origin/feat-x"]);
expect(forceOf(mocks.ops)).toEqual([
["/p/a", false],
["/p/b", true],
["/p/c", true],
]);
});
it("does not ask without Force", async () => {
await openPushDialog();
fireEvent.click(screen.getByRole("button", { name: "Push" }));
await waitFor(() => expect(mocks.ops).toHaveLength(3));
expect(mocks.ask).not.toHaveBeenCalled();
});
+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") {
+9
View File
@@ -342,6 +342,15 @@ function MultiPushDialog({ only, onClose }: MultiPushDialogProps) {
// Like the single-repository dialog: tags go only with plain pushes, so a retargeted repository hides them.
const retargeted = picked.some((r) => pushOperation(choiceOf(r, null)).op === "pushBranch");
const push = async () => {
// Like the single-repository dialog: each forced target is confirmed first (once, when several
// repositories push to the same one), and declining one pushes nothing.
const forced = new Set<string>();
for (const r of force ? picked : []) {
const target = targetNameOf(r);
const remote = shownRemoteOf(r);
if (!isProtectedBranch(target)) forced.add(remote ? `${remote}/${target}` : target);
}
for (const target of forced) if (!(await confirmForcePush(target))) return;
onClose();
// Every picked repository is pushed even when another fails; the outcome is summed up.
const session: PushSession = { method: null };
+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[];