Skip to content

Commit 4e94d73

Browse files
committed
feat(code-editor): add collapse unchanged lines extension with expand controls
1 parent 9adc809 commit 4e94d73

File tree

4 files changed

+522
-24
lines changed

4 files changed

+522
-24
lines changed
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { EditorState } from "@codemirror/state";
2+
import { describe, expect, it } from "vitest";
3+
import {
4+
applyExpandEffect,
5+
buildDecorations,
6+
type CollapsedRange,
7+
expandAll,
8+
expandDown,
9+
expandUp,
10+
mapPosBetweenSides,
11+
} from "./collapseUnchangedExtension";
12+
13+
function makeState(lineCount: number): EditorState {
14+
const lines = Array.from({ length: lineCount }, (_, i) => `line ${i + 1}`);
15+
return EditorState.create({ doc: lines.join("\n") });
16+
}
17+
18+
describe("mapPosBetweenSides", () => {
19+
const chunks = [
20+
{ fromA: 10, toA: 20, fromB: 10, toB: 25 },
21+
{ fromA: 50, toA: 60, fromB: 55, toB: 70 },
22+
];
23+
24+
it("maps position before first chunk", () => {
25+
expect(mapPosBetweenSides(5, chunks, true)).toBe(5);
26+
expect(mapPosBetweenSides(5, chunks, false)).toBe(5);
27+
});
28+
29+
it("maps position between chunks from side A", () => {
30+
// After first chunk: startOur=20, startOther=25
31+
// pos=30 → 25 + (30 - 20) = 35
32+
expect(mapPosBetweenSides(30, chunks, true)).toBe(35);
33+
});
34+
35+
it("maps position between chunks from side B", () => {
36+
// After first chunk: startOur=25, startOther=20
37+
// pos=35 → 20 + (35 - 25) = 30
38+
expect(mapPosBetweenSides(35, chunks, false)).toBe(30);
39+
});
40+
41+
it("maps position after last chunk from side A", () => {
42+
// After second chunk: startOur=60, startOther=70
43+
// pos=80 → 70 + (80 - 60) = 90
44+
expect(mapPosBetweenSides(80, chunks, true)).toBe(90);
45+
});
46+
47+
it("handles empty chunks array", () => {
48+
expect(mapPosBetweenSides(42, [], true)).toBe(42);
49+
expect(mapPosBetweenSides(42, [], false)).toBe(42);
50+
});
51+
52+
it("maps position at exact chunk boundary", () => {
53+
// pos=10 equals fromA of first chunk → startOur=0, startOther=0
54+
// 10 >= 10, so returns 0 + (10 - 0) = 10
55+
expect(mapPosBetweenSides(10, chunks, true)).toBe(10);
56+
});
57+
});
58+
59+
describe("applyExpandEffect", () => {
60+
// 20-line doc: each line is "line N\n", line 1 starts at pos 0
61+
const state = makeState(20);
62+
63+
const ranges: CollapsedRange[] = [
64+
{ fromLine: 1, toLine: 5 },
65+
{ fromLine: 12, toLine: 18 },
66+
];
67+
68+
it("expandAll removes the targeted range", () => {
69+
// pos inside range 1 (fromLine=1 → pos=0, toLine=5)
70+
const pos = state.doc.line(3).from;
71+
const effect = expandAll.of(pos);
72+
const result = applyExpandEffect(ranges, state, effect);
73+
74+
expect(result).toEqual([{ fromLine: 12, toLine: 18 }]);
75+
});
76+
77+
it("expandAll leaves non-targeted ranges intact", () => {
78+
// pos outside both ranges
79+
const pos = state.doc.line(8).from;
80+
const effect = expandAll.of(pos);
81+
const result = applyExpandEffect(ranges, state, effect);
82+
83+
expect(result).toEqual(ranges);
84+
});
85+
86+
it("expandUp reveals lines from the top of the range", () => {
87+
const pos = state.doc.line(14).from;
88+
const effect = expandUp.of({ pos, lines: 3 });
89+
const result = applyExpandEffect(ranges, state, effect);
90+
91+
expect(result).toEqual([
92+
{ fromLine: 1, toLine: 5 },
93+
{ fromLine: 12, toLine: 15 },
94+
]);
95+
});
96+
97+
it("expandDown reveals lines from the bottom of the range", () => {
98+
const pos = state.doc.line(14).from;
99+
const effect = expandDown.of({ pos, lines: 3 });
100+
const result = applyExpandEffect(ranges, state, effect);
101+
102+
expect(result).toEqual([
103+
{ fromLine: 1, toLine: 5 },
104+
{ fromLine: 15, toLine: 18 },
105+
]);
106+
});
107+
108+
it("expandUp removes range when lines exceed range size", () => {
109+
const pos = state.doc.line(3).from;
110+
const effect = expandUp.of({ pos, lines: 100 });
111+
const result = applyExpandEffect(ranges, state, effect);
112+
113+
expect(result).toEqual([{ fromLine: 12, toLine: 18 }]);
114+
});
115+
116+
it("expandDown removes range when lines exceed range size", () => {
117+
const pos = state.doc.line(3).from;
118+
const effect = expandDown.of({ pos, lines: 100 });
119+
const result = applyExpandEffect(ranges, state, effect);
120+
121+
expect(result).toEqual([{ fromLine: 12, toLine: 18 }]);
122+
});
123+
});
124+
125+
describe("buildDecorations", () => {
126+
it("skips ranges where fromLine > toLine", () => {
127+
const state = makeState(10);
128+
const ranges: CollapsedRange[] = [{ fromLine: 5, toLine: 3 }];
129+
const deco = buildDecorations(state, ranges);
130+
131+
expect(deco.size).toBe(0);
132+
});
133+
134+
it("creates decorations for valid ranges", () => {
135+
const state = makeState(20);
136+
const ranges: CollapsedRange[] = [
137+
{ fromLine: 3, toLine: 7 },
138+
{ fromLine: 15, toLine: 18 },
139+
];
140+
const deco = buildDecorations(state, ranges);
141+
142+
expect(deco.size).toBe(2);
143+
});
144+
145+
it("handles empty ranges array", () => {
146+
const state = makeState(10);
147+
const deco = buildDecorations(state, []);
148+
149+
expect(deco.size).toBe(0);
150+
});
151+
152+
it("creates single-line range decoration", () => {
153+
const state = makeState(10);
154+
const ranges: CollapsedRange[] = [{ fromLine: 5, toLine: 5 }];
155+
const deco = buildDecorations(state, ranges);
156+
157+
expect(deco.size).toBe(1);
158+
});
159+
});

0 commit comments

Comments
 (0)