mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 20:13:56 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e92c629ae | ||
|
|
01a60ef164 | ||
|
|
b0b768d25e | ||
|
|
3240bfc423 | ||
|
|
15182507cf |
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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user