mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 20:13:56 -10:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e92c629ae | ||
|
|
01a60ef164 | ||
|
|
b0b768d25e | ||
|
|
3240bfc423 | ||
|
|
15182507cf | ||
|
|
38bd8fbd16 | ||
|
|
2859ec9106 | ||
|
|
a4b62ad27f |
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}),
|
||||
);
|
||||
@@ -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)");
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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();
|
||||
});
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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