@luan.sh/pi-libtui 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/native-revision +1 -0
- package/package.json +58 -0
- package/src/activity-presentation.ts +75 -0
- package/src/appearance.ts +632 -0
- package/src/background-surface.ts +125 -0
- package/src/color/palette.ts +186 -0
- package/src/color/preview.ts +157 -0
- package/src/color/resolver.ts +75 -0
- package/src/color/theme.ts +531 -0
- package/src/component-stack.ts +247 -0
- package/src/content/markdown-code.ts +60 -0
- package/src/content/terminal-text.ts +135 -0
- package/src/content/text.ts +105 -0
- package/src/controls/action-panel.ts +420 -0
- package/src/controls/dialog-button-bar.ts +287 -0
- package/src/controls/multi-select.ts +467 -0
- package/src/controls/picker-panel.ts +447 -0
- package/src/controls/screen-icon-actions.ts +134 -0
- package/src/controls/searchable-select.ts +394 -0
- package/src/controls/select-box.ts +224 -0
- package/src/controls/selectable-list.ts +433 -0
- package/src/controls/selection-action-bar.ts +382 -0
- package/src/controls/semantic-input.ts +23 -0
- package/src/controls/tab.ts +189 -0
- package/src/cursor.ts +288 -0
- package/src/decoration/editor-pills.ts +156 -0
- package/src/decoration/glyphs.ts +326 -0
- package/src/decoration/pointer-interaction.ts +123 -0
- package/src/decoration/powerline-pill.ts +104 -0
- package/src/decoration/status.ts +185 -0
- package/src/decoration/transient-pill.ts +176 -0
- package/src/diff/index.ts +21 -0
- package/src/diff/model.ts +76 -0
- package/src/diff/parse.ts +310 -0
- package/src/diff/pierre-syntax.ts +86 -0
- package/src/diff/render.ts +504 -0
- package/src/diff/structured.ts +264 -0
- package/src/diff/view.ts +75 -0
- package/src/editor/chrome.ts +341 -0
- package/src/editor/composition.ts +53 -0
- package/src/editor/layout.ts +81 -0
- package/src/editor/presentation.ts +35 -0
- package/src/editor/protocol.ts +188 -0
- package/src/editor.ts +41 -0
- package/src/extension.ts +30 -0
- package/src/folding.ts +192 -0
- package/src/host/cursor-bridge.ts +210 -0
- package/src/host/editor-bridge.ts +148 -0
- package/src/host/extension-host.ts +231 -0
- package/src/host/mouse-bridge.ts +212 -0
- package/src/host/mouse-dispatch.ts +400 -0
- package/src/host/mouse-input.ts +85 -0
- package/src/host/mouse-targets.ts +238 -0
- package/src/host/pi-layout-adapter.ts +298 -0
- package/src/host/pi-native-selection.ts +227 -0
- package/src/host/split-pane-bridge.ts +720 -0
- package/src/host/transcript-bridge.ts +236 -0
- package/src/index.ts +334 -0
- package/src/line-layout.ts +6 -0
- package/src/motion.ts +1409 -0
- package/src/mouse/events.ts +55 -0
- package/src/mouse/fullscreen-layout.ts +260 -0
- package/src/mouse/registry.ts +348 -0
- package/src/mouse.ts +37 -0
- package/src/native-binary.ts +189 -0
- package/src/overlay/anchored.ts +91 -0
- package/src/overlay/detail-card.ts +158 -0
- package/src/overlay/dialog.ts +266 -0
- package/src/overlay/floating.ts +106 -0
- package/src/overlay/framed-editor.ts +236 -0
- package/src/overlay/fullscreen.ts +170 -0
- package/src/overlay/hover-tooltip.ts +109 -0
- package/src/overlay/modal-mount.ts +118 -0
- package/src/panels.ts +207 -0
- package/src/render-cache.ts +80 -0
- package/src/render-epoch.ts +48 -0
- package/src/request-animation.ts +160 -0
- package/src/scrollbar.ts +27 -0
- package/src/selection.ts +148 -0
- package/src/split-pane.ts +232 -0
- package/src/status-presentation.ts +411 -0
- package/src/stream.ts +543 -0
- package/src/syntax.ts +316 -0
- package/src/terminal/bridge-client.ts +243 -0
- package/src/terminal/embedding.ts +6 -0
- package/src/terminal/output.ts +151 -0
- package/src/terminal/projection.ts +362 -0
- package/src/terminal/pty-host.ts +263 -0
- package/src/terminal/pty-pane.ts +308 -0
- package/src/terminal-colors.ts +334 -0
- package/src/tool/action.ts +170 -0
- package/src/tool/activity.ts +500 -0
- package/src/tool/call-preview.ts +44 -0
- package/src/tool/disclosure-action.ts +89 -0
- package/src/tool/expanded-region-viewport.ts +103 -0
- package/src/tool/index.ts +26 -0
- package/src/tool/output.ts +271 -0
- package/src/tool/transcript.ts +150 -0
- package/src/tool/view-region.ts +581 -0
- package/test/appearance.test.ts +183 -0
- package/test/bounded-stream.test.ts +150 -0
- package/test/bridge-client.test.ts +42 -0
- package/test/color-preview.test.ts +23 -0
- package/test/component-stack.test.ts +206 -0
- package/test/components.test.ts +1165 -0
- package/test/content-text.test.ts +21 -0
- package/test/cursor-bridge.test.ts +151 -0
- package/test/cursor.test.ts +147 -0
- package/test/editor-chrome.test.ts +119 -0
- package/test/editor-layout.test.ts +30 -0
- package/test/editor-registry.test.ts +203 -0
- package/test/editor-theme.test.ts +33 -0
- package/test/editor-token-pill.test.ts +72 -0
- package/test/floating-overlay.test.ts +57 -0
- package/test/folding.test.ts +147 -0
- package/test/harmonious-theme.test.ts +23 -0
- package/test/interaction.test.ts +104 -0
- package/test/key-icons.test.ts +62 -0
- package/test/markdown-code.test.ts +24 -0
- package/test/modal-overlay-mount.test.ts +94 -0
- package/test/motion.test.ts +649 -0
- package/test/mouse/bridge.test.ts +1302 -0
- package/test/mouse/extension.test.ts +205 -0
- package/test/mouse/registry.test.ts +140 -0
- package/test/mouse/screen-decoration.test.ts +167 -0
- package/test/native-binary.test.ts +52 -0
- package/test/palette.test.ts +32 -0
- package/test/panels.test.ts +74 -0
- package/test/pointer-components.test.ts +151 -0
- package/test/progress.test.ts +54 -0
- package/test/pty-host.test.ts +126 -0
- package/test/pty-pane.test.ts +62 -0
- package/test/public-surface.test.ts +75 -0
- package/test/render-cache.test.ts +25 -0
- package/test/render-epoch.test.ts +119 -0
- package/test/request-animation.test.ts +193 -0
- package/test/screen-icon-actions.test.ts +69 -0
- package/test/select-box.test.ts +86 -0
- package/test/select-pointer.test.ts +369 -0
- package/test/selectable-list.test.ts +258 -0
- package/test/selection/registry.test.ts +44 -0
- package/test/selection-action-bar.test.ts +204 -0
- package/test/split-pane-bridge.test.ts +944 -0
- package/test/split-pane.test.ts +134 -0
- package/test/status-presentation.test.ts +39 -0
- package/test/syntax.test.ts +59 -0
- package/test/terminal-colors.test.ts +115 -0
- package/test/terminal-embedding.test.ts +12 -0
- package/test/terminal-projection.test.ts +312 -0
- package/test/theme-colors.test.ts +175 -0
- package/test/tool-output.test.ts +160 -0
- package/test/tool-transcript.test.ts +1190 -0
- package/test/unified-diff.test.ts +691 -0
- package/themes/harmonious.json +61 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { type RgbColor, xtermColor } from "../src/color/palette.ts";
|
|
4
|
+
import {
|
|
5
|
+
parseBackgroundAnsi,
|
|
6
|
+
type TuiBackgroundPaint,
|
|
7
|
+
type TuiColor,
|
|
8
|
+
type TuiForegroundPaint,
|
|
9
|
+
type TuiTheme,
|
|
10
|
+
tuiTheme,
|
|
11
|
+
} from "../src/color/theme.ts";
|
|
12
|
+
import { createUnifiedDiffModel, parseUnifiedDiff, renderUnifiedDiff, UnifiedDiffView } from "../src/diff/index.ts";
|
|
13
|
+
import { whenSyntaxReady } from "../src/syntax.ts";
|
|
14
|
+
import { terminalColorsRegistry } from "../src/terminal-colors.ts";
|
|
15
|
+
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
await new Promise<void>((resolve) => whenSyntaxReady(resolve));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function recordingTheme(name: string): TuiTheme {
|
|
21
|
+
const colors = tuiTheme({ name, getColorMode: () => "truecolor" } as never);
|
|
22
|
+
return {
|
|
23
|
+
color: colors.color,
|
|
24
|
+
mixForeground: colors.mixForeground,
|
|
25
|
+
adjustForegroundBrightness: colors.adjustForegroundBrightness,
|
|
26
|
+
fg: (paint: TuiForegroundPaint, text: string) =>
|
|
27
|
+
`\x1b]1337;${name}:fg:${typeof paint === "string" ? paint : "hue" in paint ? paint.hue : "color"}\x07${text}\x1b[39m`,
|
|
28
|
+
bg: (paint: TuiBackgroundPaint, text: string) =>
|
|
29
|
+
`<${name}:bg:${typeof paint === "string" ? paint : "color"}>${text}</bg>`,
|
|
30
|
+
fgAnsi: (paint: TuiForegroundPaint) =>
|
|
31
|
+
`\x1b]1337;${name}:fg:${typeof paint === "string" ? paint : "hue" in paint ? paint.hue : "color"}\x07`,
|
|
32
|
+
bgAnsi: (paint: TuiBackgroundPaint) => `\x1b]1337;${name}:bg:${typeof paint === "string" ? paint : "color"}\x07`,
|
|
33
|
+
contrastBackground: colors.contrastBackground,
|
|
34
|
+
strongestForegroundContrast: colors.strongestForegroundContrast,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function contrastRecordingTheme(name: string): { theme: TuiTheme; contrastLabels: readonly string[] } {
|
|
39
|
+
const base = recordingTheme(name);
|
|
40
|
+
const labels = new WeakMap<object, string>();
|
|
41
|
+
const contrastLabels: string[] = [];
|
|
42
|
+
let nextUnknown = 0;
|
|
43
|
+
const labelFor = (color: TuiColor): string => {
|
|
44
|
+
const existing = labels.get(color);
|
|
45
|
+
if (existing) return existing;
|
|
46
|
+
const label = `unknown-${nextUnknown++}`;
|
|
47
|
+
labels.set(color, label);
|
|
48
|
+
return label;
|
|
49
|
+
};
|
|
50
|
+
const theme: TuiTheme = {
|
|
51
|
+
...base,
|
|
52
|
+
color(token) {
|
|
53
|
+
const color = base.color(token);
|
|
54
|
+
labels.set(color, `semantic:${typeof token === "string" ? token : token.hue}`);
|
|
55
|
+
return color;
|
|
56
|
+
},
|
|
57
|
+
contrastBackground(color) {
|
|
58
|
+
const contrast = base.contrastBackground(color);
|
|
59
|
+
const label = `contrast:${contrastLabels.length}`;
|
|
60
|
+
contrastLabels.push(label);
|
|
61
|
+
labels.set(contrast, label);
|
|
62
|
+
return contrast;
|
|
63
|
+
},
|
|
64
|
+
fg(color, text) {
|
|
65
|
+
const handle = typeof color !== "string" && !("hue" in color);
|
|
66
|
+
return `\x1b]1337;${name}:${handle ? "fg:color" : "fg"}:${typeof color === "string" ? color : "hue" in color ? color.hue : labelFor(color)}\x07${text}\x1b[39m`;
|
|
67
|
+
},
|
|
68
|
+
fgAnsi(token) {
|
|
69
|
+
return `\x1b]1337;${name}:fg:${typeof token === "string" ? token : "hue" in token ? token.hue : labelFor(token)}\x07`;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
return { theme, contrastLabels };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function ansiRgb(ansi: string, foreground: boolean): RgbColor | undefined {
|
|
76
|
+
const prefix = foreground ? 38 : 48;
|
|
77
|
+
const match = new RegExp(`\\x1b\\[${prefix};(?:2;(\\d+);(\\d+);(\\d+)|5;(\\d+))m`, "u").exec(ansi);
|
|
78
|
+
if (!match) return undefined;
|
|
79
|
+
return match[1] === undefined
|
|
80
|
+
? xtermColor(Number(match[4]))
|
|
81
|
+
: { r: Number(match[1]), g: Number(match[2]), b: Number(match[3]) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function contrastRatio(left: RgbColor, right: RgbColor): number {
|
|
85
|
+
const channel = (value: number): number => {
|
|
86
|
+
const normalized = value / 255;
|
|
87
|
+
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
|
88
|
+
};
|
|
89
|
+
const luminance = (color: RgbColor): number =>
|
|
90
|
+
channel(color.r) * 0.2126 + channel(color.g) * 0.7152 + channel(color.b) * 0.0722;
|
|
91
|
+
const [lighter, darker] = [luminance(left), luminance(right)].sort((a, b) => b - a);
|
|
92
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const SIMPLE = [
|
|
96
|
+
"diff --git a/café.ts b/café.ts",
|
|
97
|
+
"--- a/café.ts",
|
|
98
|
+
"+++ b/café.ts",
|
|
99
|
+
"@@ -10,2 +10,2 @@ export function x()",
|
|
100
|
+
"-const value = 'old';",
|
|
101
|
+
"+const value = 'new';",
|
|
102
|
+
" unchanged 🦀",
|
|
103
|
+
].join("\n");
|
|
104
|
+
|
|
105
|
+
describe("unified diff parser", () => {
|
|
106
|
+
test("parses files, hunks, line numbers, stable refs, and aggregate stats", () => {
|
|
107
|
+
const model = parseUnifiedDiff(
|
|
108
|
+
`${SIMPLE}\ndiff --git a/old b/new\n--- /dev/null\n+++ b/new\n@@ -0,0 +1 @@\n+created`,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
expect(model.files).toHaveLength(2);
|
|
112
|
+
expect(model.files[0]).toMatchObject({ oldPath: "café.ts", newPath: "café.ts", additions: 1, removals: 1 });
|
|
113
|
+
expect(model.files[1]).toMatchObject({ oldPath: undefined, newPath: "new", additions: 1, removals: 0 });
|
|
114
|
+
expect(model).toMatchObject({ additions: 2, removals: 1, truncated: false });
|
|
115
|
+
expect(model.files[0]!.hunks[0]!.lines.map((line) => [line.ref, line.oldLine, line.newLine])).toEqual([
|
|
116
|
+
["f0:h0:l0", 10, undefined],
|
|
117
|
+
["f0:h0:l1", undefined, 10],
|
|
118
|
+
["f0:h0:l2", 11, 11],
|
|
119
|
+
]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("keeps multiple plain unified diff files separate", () => {
|
|
123
|
+
const model = parseUnifiedDiff(
|
|
124
|
+
[
|
|
125
|
+
"diff -ruN old/one.ts new/one.ts",
|
|
126
|
+
"--- old/one.ts",
|
|
127
|
+
"+++ new/one.ts",
|
|
128
|
+
"@@ -1 +1 @@",
|
|
129
|
+
"-old one",
|
|
130
|
+
"+new one",
|
|
131
|
+
"diff -ruN old/two.ts new/two.ts",
|
|
132
|
+
"--- old/two.ts",
|
|
133
|
+
"+++ new/two.ts",
|
|
134
|
+
"@@ -1 +1 @@",
|
|
135
|
+
"-old two",
|
|
136
|
+
"+new two",
|
|
137
|
+
].join("\n"),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(model.files).toHaveLength(2);
|
|
141
|
+
expect(model.files.map((file) => [file.oldPath, file.newPath, file.additions, file.removals])).toEqual([
|
|
142
|
+
["old/one.ts", "new/one.ts", 1, 1],
|
|
143
|
+
["old/two.ts", "new/two.ts", 1, 1],
|
|
144
|
+
]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("resets plain unified file sides before an added file", () => {
|
|
148
|
+
const model = parseUnifiedDiff(
|
|
149
|
+
[
|
|
150
|
+
"--- old/one.ts",
|
|
151
|
+
"+++ new/one.ts",
|
|
152
|
+
"@@ -1 +1 @@",
|
|
153
|
+
"-old one",
|
|
154
|
+
"+new one",
|
|
155
|
+
"--- /dev/null",
|
|
156
|
+
"+++ new/two.ts",
|
|
157
|
+
"@@ -0,0 +1 @@",
|
|
158
|
+
"+created",
|
|
159
|
+
].join("\n"),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
expect(model.files.map((file) => [file.oldPath, file.newPath])).toEqual([
|
|
163
|
+
["old/one.ts", "new/one.ts"],
|
|
164
|
+
[undefined, "new/two.ts"],
|
|
165
|
+
]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("keeps file sides aligned after a headerless Git diff", () => {
|
|
169
|
+
const model = parseUnifiedDiff(
|
|
170
|
+
[
|
|
171
|
+
"diff --git a/image.png b/image.png",
|
|
172
|
+
"new file mode 100644",
|
|
173
|
+
"Binary files /dev/null and b/image.png differ",
|
|
174
|
+
"diff --git a/two.ts b/two.ts",
|
|
175
|
+
"new file mode 100644",
|
|
176
|
+
"--- /dev/null",
|
|
177
|
+
"+++ b/two.ts",
|
|
178
|
+
"@@ -0,0 +1 @@",
|
|
179
|
+
"+created",
|
|
180
|
+
].join("\n"),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
expect(model.files.map((file) => [file.oldPath, file.newPath])).toEqual([
|
|
184
|
+
[undefined, "image.png"],
|
|
185
|
+
[undefined, "two.ts"],
|
|
186
|
+
]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("keeps the valid Pierre prefix of a malformed streaming hunk", () => {
|
|
190
|
+
const partial = parseUnifiedDiff("--- a/x\n+++ b/x\n@@ -1 +1,2 @@\n-old\n+new\nunfinished");
|
|
191
|
+
const lines = partial.files[0]!.hunks[0]!.lines;
|
|
192
|
+
expect(lines.map((line) => line.kind)).toEqual(["removed", "added"]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("keeps header-looking removed and added lines inside active hunks", () => {
|
|
196
|
+
const model = parseUnifiedDiff("--- a/x\n+++ b/x\n@@ -1 +1 @@\n--- old heading\n+++ new heading");
|
|
197
|
+
expect(model.files[0]!.hunks[0]!.lines.map((line) => [line.kind, line.text])).toEqual([
|
|
198
|
+
["removed", "-- old heading"],
|
|
199
|
+
["added", "++ new heading"],
|
|
200
|
+
]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("keeps /dev/null side markers scoped to file headers", () => {
|
|
204
|
+
const model = parseUnifiedDiff(
|
|
205
|
+
[
|
|
206
|
+
"diff --git a/created.txt b/created.txt",
|
|
207
|
+
"--- /dev/null\t1970-01-01 00:00:00 +0000",
|
|
208
|
+
"+++ b/created.txt",
|
|
209
|
+
"@@ -1 +1 @@",
|
|
210
|
+
"--- /dev/null",
|
|
211
|
+
"+++ /dev/null",
|
|
212
|
+
"diff --git a/removed.txt b/removed.txt",
|
|
213
|
+
"--- a/removed.txt",
|
|
214
|
+
"+++ /dev/null",
|
|
215
|
+
"@@ -1 +1 @@",
|
|
216
|
+
"-old",
|
|
217
|
+
"+new",
|
|
218
|
+
].join("\n"),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
expect(model.files.map((file) => [file.oldPath, file.newPath])).toEqual([
|
|
222
|
+
[undefined, "created.txt"],
|
|
223
|
+
["removed.txt", undefined],
|
|
224
|
+
]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("leaves unsupported range-free patches empty and accepts structured rows", () => {
|
|
228
|
+
const parsed = parseUnifiedDiff("--- a/x\n+++ b/x\n@@\n-old\n+new");
|
|
229
|
+
expect(parsed.files[0]?.hunks).toEqual([]);
|
|
230
|
+
const structured = createUnifiedDiffModel(
|
|
231
|
+
[
|
|
232
|
+
{
|
|
233
|
+
newPath: "x",
|
|
234
|
+
hunks: [
|
|
235
|
+
{
|
|
236
|
+
rows: [
|
|
237
|
+
{ kind: "removed", text: "old" },
|
|
238
|
+
{ kind: "added", text: "new" },
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
"patch-action-7",
|
|
245
|
+
);
|
|
246
|
+
expect(structured).toMatchObject({ additions: 1, removals: 1 });
|
|
247
|
+
expect(structured.revision).not.toContain("patch-action-7");
|
|
248
|
+
expect(structured.files[0]!.hunks[0]!.oldStart).toBeUndefined();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("converts structured files without losing headers, line numbers, or row kinds", () => {
|
|
252
|
+
const model = createUnifiedDiffModel([
|
|
253
|
+
{
|
|
254
|
+
newPath: "created.ts",
|
|
255
|
+
hunks: [
|
|
256
|
+
{
|
|
257
|
+
header: "@@ -0,0 +1,2 @@",
|
|
258
|
+
oldStart: 0,
|
|
259
|
+
oldCount: 0,
|
|
260
|
+
newStart: 1,
|
|
261
|
+
newCount: 2,
|
|
262
|
+
rows: [
|
|
263
|
+
{ kind: "added", text: "const created = true;", newLine: 1 },
|
|
264
|
+
{ kind: "metadata", text: "\" },
|
|
265
|
+
],
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
oldPath: "removed.ts",
|
|
271
|
+
headerLines: ["file deleted"],
|
|
272
|
+
hunks: [],
|
|
273
|
+
},
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
expect(model.files.map((file) => [file.ref, file.oldPath, file.newPath, file.headerLines])).toEqual([
|
|
277
|
+
["f0", undefined, "created.ts", ["--- /dev/null", "+++ b/created.ts"]],
|
|
278
|
+
["f1", "removed.ts", undefined, ["file deleted"]],
|
|
279
|
+
]);
|
|
280
|
+
expect(model.files[0]!.hunks[0]!.lines.map((line) => [line.ref, line.kind, line.text, line.newLine])).toEqual([
|
|
281
|
+
["f0:h0:l0", "added", "const created = true;", 1],
|
|
282
|
+
["f0:h0:l1", "metadata", "\", undefined],
|
|
283
|
+
]);
|
|
284
|
+
expect(model.files[0]!.hunks[0]).toMatchObject({
|
|
285
|
+
header: "@@ -0,0 +1,2 @@",
|
|
286
|
+
oldStart: 0,
|
|
287
|
+
oldCount: 0,
|
|
288
|
+
newStart: 1,
|
|
289
|
+
newCount: 2,
|
|
290
|
+
});
|
|
291
|
+
expect(model).toMatchObject({ additions: 1, removals: 0, truncated: false });
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("derives omitted structured ranges from Pierre's hunk header", () => {
|
|
295
|
+
const model = createUnifiedDiffModel([
|
|
296
|
+
{
|
|
297
|
+
newPath: "changed.ts",
|
|
298
|
+
hunks: [
|
|
299
|
+
{
|
|
300
|
+
header: "@@ -77,14 +81,2 @@ context",
|
|
301
|
+
rows: [
|
|
302
|
+
{ kind: "removed", text: "old" },
|
|
303
|
+
{ kind: "added", text: "new" },
|
|
304
|
+
],
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
]);
|
|
309
|
+
|
|
310
|
+
expect(model.files[0]?.hunks[0]).toMatchObject({ oldStart: 77, oldCount: 14, newStart: 81, newCount: 2 });
|
|
311
|
+
expect(model.files[0]?.hunks[0]?.lines.map((line) => [line.oldLine, line.newLine])).toEqual([
|
|
312
|
+
[77, undefined],
|
|
313
|
+
[undefined, 81],
|
|
314
|
+
]);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("bounds structured conversion at file, character, and row limits", () => {
|
|
318
|
+
const model = createUnifiedDiffModel(
|
|
319
|
+
[
|
|
320
|
+
{
|
|
321
|
+
newPath: "first.ts",
|
|
322
|
+
hunks: [{ rows: [{ kind: "added", text: "a".repeat(100) }] }],
|
|
323
|
+
},
|
|
324
|
+
{ newPath: "second.ts", hunks: [{ rows: [{ kind: "added", text: "unseen" }] }] },
|
|
325
|
+
],
|
|
326
|
+
{ maxCharacters: 80, maxRows: 5 },
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
expect(model.truncated).toBe(true);
|
|
330
|
+
expect(model.sourceRows).toBeLessThanOrEqual(5);
|
|
331
|
+
expect(model.files.length).toBeLessThan(2);
|
|
332
|
+
expect(model.files.flatMap((file) => file.hunks.flatMap((hunk) => hunk.lines)).length).toBeLessThan(2);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("does not report internal path and header caps as global truncation", () => {
|
|
336
|
+
const model = createUnifiedDiffModel([
|
|
337
|
+
{
|
|
338
|
+
newPath: "p".repeat(4_097),
|
|
339
|
+
headerLines: ["h".repeat(16_385), "later header"],
|
|
340
|
+
hunks: [{ rows: [{ kind: "added", text: "kept" }] }],
|
|
341
|
+
},
|
|
342
|
+
]);
|
|
343
|
+
|
|
344
|
+
expect(model.truncated).toBe(false);
|
|
345
|
+
expect(model.files[0]?.newPath).toHaveLength(4_096);
|
|
346
|
+
expect(model.files[0]?.headerLines[0]).toHaveLength(16_384);
|
|
347
|
+
expect(model.files[0]?.headerLines[1]).toBe("later header");
|
|
348
|
+
expect(model.files[0]?.hunks[0]?.lines[0]?.text).toBe("kept");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("applies character and row escape hatches and reuses parsed models", () => {
|
|
352
|
+
const source = `${SIMPLE}\n${" context\n".repeat(100)}`;
|
|
353
|
+
const first = parseUnifiedDiff(source, { maxCharacters: 200, maxRows: 6 });
|
|
354
|
+
const second = parseUnifiedDiff(source, { maxCharacters: 200, maxRows: 6 });
|
|
355
|
+
expect(first).toBe(second);
|
|
356
|
+
expect(first.truncated).toBe(true);
|
|
357
|
+
expect(first.sourceRows).toBe(6);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("bounds structured models and hashes caller revisions", () => {
|
|
361
|
+
const model = createUnifiedDiffModel(
|
|
362
|
+
[{ newPath: "huge", hunks: [{ rows: [{ kind: "added", text: "x".repeat(100_000) }] }] }],
|
|
363
|
+
{ revision: "secret-raw-revision", maxCharacters: 128, maxRows: 2 },
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
expect(model.truncated).toBe(true);
|
|
367
|
+
expect(model.sourceRows).toBeLessThanOrEqual(2);
|
|
368
|
+
expect(model.revision).not.toContain("secret-raw-revision");
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("charges structured paths to the hard character budget", () => {
|
|
372
|
+
const model = createUnifiedDiffModel([{ oldPath: "a".repeat(8_000), newPath: "b".repeat(8_000), hunks: [] }], {
|
|
373
|
+
maxCharacters: 16,
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
expect((model.files[0]?.oldPath?.length ?? 0) + (model.files[0]?.newPath?.length ?? 0)).toBeLessThanOrEqual(16);
|
|
377
|
+
expect(model.truncated).toBe(true);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("bounds structured files even when every file is empty", () => {
|
|
381
|
+
const files = Array.from({ length: 10_000 }, (_, index) => ({ newPath: `file-${index}`, hunks: [] }));
|
|
382
|
+
const model = createUnifiedDiffModel(files, { maxRows: 20_000, maxCharacters: 1_000_000 });
|
|
383
|
+
expect(model.files.length).toBeLessThan(files.length);
|
|
384
|
+
expect(model.truncated).toBe(true);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("does not count a final newline as an extra truncated row", () => {
|
|
388
|
+
const model = parseUnifiedDiff("--- a/x\n+++ b/x\n", { maxRows: 2 });
|
|
389
|
+
expect(model.sourceRows).toBe(2);
|
|
390
|
+
expect(model.truncated).toBe(false);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
describe("unified diff rendering", () => {
|
|
395
|
+
test("uses semantic full-row paint and intraline emphasis", () => {
|
|
396
|
+
const rendered = renderUnifiedDiff(parseUnifiedDiff(SIMPLE), {
|
|
397
|
+
width: 40,
|
|
398
|
+
theme: recordingTheme("dark"),
|
|
399
|
+
surface: "surface.raised",
|
|
400
|
+
});
|
|
401
|
+
const output = rendered.lines.join("\n");
|
|
402
|
+
expect(output).toContain("dark:bg:diff.removed");
|
|
403
|
+
expect(output).toContain("dark:bg:diff.added");
|
|
404
|
+
expect(output).toContain("dark:bg:diff.removedGutter");
|
|
405
|
+
expect(output).toContain("dark:bg:diff.addedGutter");
|
|
406
|
+
expect(output).toContain("dark:bg:diff.removedEmphasis");
|
|
407
|
+
expect(output).toContain("dark:bg:diff.addedEmphasis");
|
|
408
|
+
expect(output).toContain("dark:bg:diff.hunk");
|
|
409
|
+
expect(output).toContain("dark:bg:diff.hunkGutter");
|
|
410
|
+
expect(output).toContain("dark:fg:gray");
|
|
411
|
+
expect(output).toContain("dark:fg:red");
|
|
412
|
+
expect(output).toContain("dark:fg:text.primary");
|
|
413
|
+
expect(output).toContain("dark:bg:surface.raised");
|
|
414
|
+
expect(rendered.lines.every((line) => visibleWidth(line) === 40)).toBe(true);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("uses semantic changed-line colors and contrasting hunk text", () => {
|
|
418
|
+
const { theme, contrastLabels } = contrastRecordingTheme("contrast");
|
|
419
|
+
const model = parseUnifiedDiff(
|
|
420
|
+
["--- a/x", "+++ b/x", "@@ -1,4 +1,4 @@", "-old", "+new", " keep", " tail"].join("\n"),
|
|
421
|
+
);
|
|
422
|
+
const full = renderUnifiedDiff(model, { width: 60, theme });
|
|
423
|
+
const hunk = full.lines.find((line) => stripTerminalSequences(line).includes("@@"));
|
|
424
|
+
const removed = full.lines.find((line) => stripTerminalSequences(line).includes("old"));
|
|
425
|
+
const added = full.lines.find((line) => stripTerminalSequences(line).includes("new"));
|
|
426
|
+
expect(hunk).toContain("contrast:fg:color:contrast:");
|
|
427
|
+
expect(hunk).not.toContain("contrast:fg:text.secondary");
|
|
428
|
+
expect(removed).toContain("contrast:fg:negative");
|
|
429
|
+
expect(added).toContain("contrast:fg:positive");
|
|
430
|
+
|
|
431
|
+
const folded = renderUnifiedDiff(model, {
|
|
432
|
+
width: 60,
|
|
433
|
+
theme,
|
|
434
|
+
viewport: { maxRows: 3, selection: "head-tail" },
|
|
435
|
+
});
|
|
436
|
+
const fold = folded.lines[folded.omissionRow ?? -1];
|
|
437
|
+
expect(fold).toContain("contrast:fg:color:contrast:");
|
|
438
|
+
expect(fold).not.toContain("contrast:fg:text.secondary");
|
|
439
|
+
expect(contrastLabels).toHaveLength(4);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("keeps null-side metadata in the model but omits it from presentation", () => {
|
|
443
|
+
const model = parseUnifiedDiff("--- /dev/null\n+++ b/new.txt\n@@ -0,0 +1 @@\n+created");
|
|
444
|
+
expect(model.files[0]?.headerLines).toContain("--- /dev/null");
|
|
445
|
+
const rendered = stripTerminalSequences(
|
|
446
|
+
renderUnifiedDiff(model, { width: 40, theme: recordingTheme("new-file") }).lines.join("\n"),
|
|
447
|
+
);
|
|
448
|
+
expect(rendered).not.toContain("/dev/null");
|
|
449
|
+
expect(rendered).toContain("+++ b/new.txt");
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("marks separate changed words instead of one broad changed range", () => {
|
|
453
|
+
const model = parseUnifiedDiff(
|
|
454
|
+
"--- a/x.ts\n+++ b/x.ts\n@@ -1 +1 @@\n-return oldValue + first;\n+return newValue + second;",
|
|
455
|
+
);
|
|
456
|
+
const output = renderUnifiedDiff(model, { width: 60, theme: recordingTheme("words") }).lines.join("\n");
|
|
457
|
+
expect(output.match(/words:bg:diff\.removedEmphasis/gu)).toHaveLength(2);
|
|
458
|
+
expect(output.match(/words:bg:diff\.addedEmphasis/gu)).toHaveLength(2);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("uses Pierre's bounded change-block alignment when insertions differ", () => {
|
|
462
|
+
const model = parseUnifiedDiff(
|
|
463
|
+
"--- a/x.ts\n+++ b/x.ts\n@@ -1 +1,2 @@\n-return oldValue;\n+const trimmed = value.trim();\n+return trimmed;",
|
|
464
|
+
);
|
|
465
|
+
const output = renderUnifiedDiff(model, { width: 80, theme: recordingTheme("aligned") }).lines.join("\n");
|
|
466
|
+
expect(output.match(/aligned:bg:diff\.removedEmphasis/gu)?.length ?? 0).toBeGreaterThan(0);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("theme identity is part of the cache and repeated renders are stable", () => {
|
|
470
|
+
const model = parseUnifiedDiff(SIMPLE);
|
|
471
|
+
const dark = renderUnifiedDiff(model, { width: 32, theme: recordingTheme("dark") });
|
|
472
|
+
const darkAgain = renderUnifiedDiff(model, { width: 32, theme: recordingTheme("dark") });
|
|
473
|
+
const stableTheme = recordingTheme("stable");
|
|
474
|
+
const stable = renderUnifiedDiff(model, { width: 32, theme: stableTheme });
|
|
475
|
+
const stableAgain = renderUnifiedDiff(model, { width: 32, theme: stableTheme });
|
|
476
|
+
const light = renderUnifiedDiff(model, { width: 32, theme: recordingTheme("light") });
|
|
477
|
+
expect(darkAgain).not.toBe(dark);
|
|
478
|
+
expect(stableAgain).toBe(stable);
|
|
479
|
+
expect(light.lines[0]).not.toBe(dark.lines[0]);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("wraps Unicode safely and never exceeds narrow terminal widths", () => {
|
|
483
|
+
const model = parseUnifiedDiff(`${SIMPLE}\n+界界界界界\n+\x1b[31munsafe\x1b[0m\n+safe\u009d52;c;secret\u0007tail`);
|
|
484
|
+
for (const width of [1, 2, 9, 17]) {
|
|
485
|
+
const result = renderUnifiedDiff(model, {
|
|
486
|
+
width,
|
|
487
|
+
theme: recordingTheme("width"),
|
|
488
|
+
viewport: { maxRows: 2_000 },
|
|
489
|
+
});
|
|
490
|
+
expect(result.lines.every((line) => visibleWidth(line) <= width)).toBe(true);
|
|
491
|
+
expect(stripTerminalSequences(result.lines.join("\n"))).not.toContain("\x1b");
|
|
492
|
+
expect(stripTerminalSequences(result.lines.join("\n"))).not.toContain("secret");
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("head-tail keeps both ends while tail selection keeps the newest rows", () => {
|
|
497
|
+
const body = Array.from({ length: 30 }, (_, index) => ` line ${index}`).join("\n");
|
|
498
|
+
const model = parseUnifiedDiff(`--- a/x\n+++ b/x\n@@ -1,30 +1,30 @@\n${body}`);
|
|
499
|
+
const headTail = renderUnifiedDiff(model, {
|
|
500
|
+
width: 40,
|
|
501
|
+
theme: recordingTheme("c"),
|
|
502
|
+
viewport: { maxRows: 8, selection: "head-tail" },
|
|
503
|
+
});
|
|
504
|
+
const tail = renderUnifiedDiff(model, {
|
|
505
|
+
width: 40,
|
|
506
|
+
theme: recordingTheme("s"),
|
|
507
|
+
viewport: { maxRows: 5, selection: "tail" },
|
|
508
|
+
});
|
|
509
|
+
const headTailText = stripTerminalSequences(headTail.lines.join("\n"));
|
|
510
|
+
const tailText = stripTerminalSequences(tail.lines.join("\n"));
|
|
511
|
+
expect(headTailText).toContain("line 0");
|
|
512
|
+
expect(headTailText).toContain("line 29");
|
|
513
|
+
expect(tailText).toContain("line 0"); // fold context keeps one omitted sample visible
|
|
514
|
+
expect(tailText).toContain("line 29");
|
|
515
|
+
expect(headTail.omittedRows).toBeGreaterThan(0);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("uses the omitted hunk summary instead of a generic row count", () => {
|
|
519
|
+
const model = parseUnifiedDiff(
|
|
520
|
+
[
|
|
521
|
+
"--- a/Cargo.toml",
|
|
522
|
+
"+++ b/Cargo.toml",
|
|
523
|
+
"@@ -1,2 +1,2 @@",
|
|
524
|
+
" one",
|
|
525
|
+
" two",
|
|
526
|
+
'@@ -77,1 +77,1 @@ objc2-foundation = { version = "0.3", default-features = false, features = [',
|
|
527
|
+
" keep",
|
|
528
|
+
].join("\n"),
|
|
529
|
+
);
|
|
530
|
+
const rendered = renderUnifiedDiff(model, {
|
|
531
|
+
width: 100,
|
|
532
|
+
theme: recordingTheme("omitted-summary"),
|
|
533
|
+
viewport: { maxRows: 1, selection: "tail" },
|
|
534
|
+
});
|
|
535
|
+
const text = stripTerminalSequences(rendered.lines[0] ?? "");
|
|
536
|
+
expect(text).toContain("@@ -1,2 +1,2 @@");
|
|
537
|
+
expect(text).toContain("one");
|
|
538
|
+
expect(text).not.toContain("rows omitted");
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test("bounds wrapped output and component viewport changes", () => {
|
|
542
|
+
const model = parseUnifiedDiff(`--- a/x\n+++ b/x\n@@ -1 +1 @@\n-${"a".repeat(1_000)}\n+${"b".repeat(1_000)}`);
|
|
543
|
+
const options = {
|
|
544
|
+
model,
|
|
545
|
+
theme: recordingTheme("bounded"),
|
|
546
|
+
maxRenderedRows: 10,
|
|
547
|
+
viewport: { maxRows: 100 },
|
|
548
|
+
} as const;
|
|
549
|
+
const view = new UnifiedDiffView(options);
|
|
550
|
+
expect(view.render(12)).toHaveLength(10);
|
|
551
|
+
view.setViewport({ maxRows: 5, selection: "tail" });
|
|
552
|
+
expect(view.render(12)).toHaveLength(10);
|
|
553
|
+
expect(renderUnifiedDiff(model, { ...options, width: 12 }).truncated).toBe(true);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("sizes gutters from explicit structured line numbers", () => {
|
|
557
|
+
const model = createUnifiedDiffModel([
|
|
558
|
+
{
|
|
559
|
+
newPath: "x.ts",
|
|
560
|
+
hunks: [{ rows: [{ kind: "added", text: "value", newLine: 1_000 }] }],
|
|
561
|
+
},
|
|
562
|
+
]);
|
|
563
|
+
const output = stripTerminalSequences(
|
|
564
|
+
renderUnifiedDiff(model, { width: 30, theme: recordingTheme("numbers") }).lines.at(-1) ?? "",
|
|
565
|
+
);
|
|
566
|
+
expect(output).toContain("┃ 1000 value");
|
|
567
|
+
expect(output).not.toContain("+");
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("uses distinct semantic rails and one relevant line number", () => {
|
|
571
|
+
const model = parseUnifiedDiff("--- a/x\n+++ b/x\n@@ -16,2 +16,2 @@\n-old\n-stale\n+new\n+fresh");
|
|
572
|
+
const rendered = renderUnifiedDiff(model, { width: 30, theme: contrastRecordingTheme("rail").theme }).lines.join(
|
|
573
|
+
"\n",
|
|
574
|
+
);
|
|
575
|
+
const output = stripTerminalSequences(rendered);
|
|
576
|
+
expect(output).toContain("┋ 16 old");
|
|
577
|
+
expect(output).toContain("┋ 17 stale");
|
|
578
|
+
expect(output).toContain("┃ 16 new");
|
|
579
|
+
expect(output).toContain("┃ 17 fresh");
|
|
580
|
+
expect(output).not.toContain(" 16 16");
|
|
581
|
+
expect(rendered.match(/rail:fg:negative\x07┋/gu)).toHaveLength(2);
|
|
582
|
+
expect(rendered.match(/rail:fg:positive\x07┃/gu)).toHaveLength(2);
|
|
583
|
+
expect(rendered.match(/rail:fg:text\.primary\x0716/gu)).toHaveLength(2);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test("keeps changed line numbers readable on generated dark and light palettes", () => {
|
|
587
|
+
const model = parseUnifiedDiff("--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new");
|
|
588
|
+
const hostTheme = (
|
|
589
|
+
name: string,
|
|
590
|
+
background: readonly [number, number, number],
|
|
591
|
+
text: readonly [number, number, number],
|
|
592
|
+
) =>
|
|
593
|
+
({
|
|
594
|
+
name,
|
|
595
|
+
getColorMode: () => "truecolor",
|
|
596
|
+
getFgAnsi: (token: string) => (token === "text" ? `\\x1b[38;2;${text.join(";")}m` : "\\x1b[39m"),
|
|
597
|
+
getBgAnsi: () => `\\x1b[48;2;${background.join(";")}m`,
|
|
598
|
+
}) as never;
|
|
599
|
+
const registry = terminalColorsRegistry();
|
|
600
|
+
registry.publish({ scheme: "dark", indexedPalette: "generated" });
|
|
601
|
+
try {
|
|
602
|
+
for (const theme of [
|
|
603
|
+
hostTheme("generated-dark", [26, 27, 38], [203, 203, 203]),
|
|
604
|
+
hostTheme("generated-light", [235, 238, 242], [25, 25, 25]),
|
|
605
|
+
{ name: "harmonious", getColorMode: () => "truecolor" } as never,
|
|
606
|
+
]) {
|
|
607
|
+
const colors = tuiTheme(theme);
|
|
608
|
+
const addedGutter = parseBackgroundAnsi(colors.bgAnsi("diff.addedGutter"));
|
|
609
|
+
const removedGutter = parseBackgroundAnsi(colors.bgAnsi("diff.removedGutter"));
|
|
610
|
+
const foreground = ansiRgb(colors.fgAnsi("text.primary"), true);
|
|
611
|
+
expect(addedGutter).toBeDefined();
|
|
612
|
+
expect(removedGutter).toBeDefined();
|
|
613
|
+
expect(foreground).toBeDefined();
|
|
614
|
+
expect(contrastRatio(addedGutter!, foreground!)).toBeGreaterThanOrEqual(4.5);
|
|
615
|
+
expect(contrastRatio(removedGutter!, foreground!)).toBeGreaterThanOrEqual(4.5);
|
|
616
|
+
const rendered = renderUnifiedDiff(model, { width: 40, theme: colors }).lines.join("\\n");
|
|
617
|
+
expect(rendered).toContain(`${colors.bgAnsi("diff.addedGutter")}${colors.fgAnsi("positive")}`);
|
|
618
|
+
expect(rendered).toContain(`${colors.fgAnsi("text.primary")}1`);
|
|
619
|
+
}
|
|
620
|
+
} finally {
|
|
621
|
+
registry.publish(undefined);
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("keeps the omission action centered with its fold glyph in the number lane", () => {
|
|
626
|
+
const model = parseUnifiedDiff(
|
|
627
|
+
["--- a/x", "+++ b/x", "@@ -1,20 +1,20 @@", ...Array.from({ length: 20 }, (_, index) => ` line ${index}`)].join(
|
|
628
|
+
"\n",
|
|
629
|
+
),
|
|
630
|
+
);
|
|
631
|
+
const rendered = renderUnifiedDiff(model, {
|
|
632
|
+
width: 30,
|
|
633
|
+
theme: recordingTheme("fold-lane"),
|
|
634
|
+
viewport: { maxRows: 5, selection: "head-tail" },
|
|
635
|
+
});
|
|
636
|
+
expect(rendered.omissionRow).toBeGreaterThan(0);
|
|
637
|
+
expect(rendered.omissionRow).toBeLessThan(rendered.lines.length - 1);
|
|
638
|
+
const omission = stripTerminalSequences(rendered.lines[rendered.omissionRow!]!);
|
|
639
|
+
expect(omission.slice(0, 5)).toBe(" ↕ ");
|
|
640
|
+
expect(omission[0]).toBe(" ");
|
|
641
|
+
expect(stripTerminalSequences(rendered.lines[2] ?? "").slice(0, 5)).toBe(" ≋ ");
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test("hovers the omission action across its hunk gutter and body", () => {
|
|
645
|
+
const model = parseUnifiedDiff(
|
|
646
|
+
["--- a/x", "+++ b/x", "@@ -1,12 +1,12 @@", ...Array.from({ length: 12 }, (_, index) => ` line ${index}`)].join(
|
|
647
|
+
"\n",
|
|
648
|
+
),
|
|
649
|
+
);
|
|
650
|
+
const theme = recordingTheme("hover");
|
|
651
|
+
const rest = renderUnifiedDiff(model, {
|
|
652
|
+
width: 40,
|
|
653
|
+
theme,
|
|
654
|
+
viewport: { maxRows: 3, selection: "head-tail" },
|
|
655
|
+
});
|
|
656
|
+
const hovered = renderUnifiedDiff(model, {
|
|
657
|
+
width: 40,
|
|
658
|
+
theme,
|
|
659
|
+
viewport: { maxRows: 3, selection: "head-tail" },
|
|
660
|
+
omissionRowHovered: true,
|
|
661
|
+
});
|
|
662
|
+
const row = hovered.lines[hovered.omissionRow!]!;
|
|
663
|
+
expect(row).toContain("hover:bg:diff.hunkGutterHover");
|
|
664
|
+
expect(row).toContain("hover:bg:diff.hunkHover");
|
|
665
|
+
expect(row).not.toBe(rest.lines[rest.omissionRow!]!);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
test("expands monotonically across the omission threshold", () => {
|
|
669
|
+
const model = parseUnifiedDiff(
|
|
670
|
+
["--- a/x", "+++ b/x", "@@ -1,12 +1,12 @@", ...Array.from({ length: 12 }, (_, index) => ` line ${index}`)].join(
|
|
671
|
+
"\n",
|
|
672
|
+
),
|
|
673
|
+
);
|
|
674
|
+
const theme = recordingTheme("threshold");
|
|
675
|
+
const full = renderUnifiedDiff(model, { width: 80, theme, viewport: { maxRows: 100 } });
|
|
676
|
+
const exact = renderUnifiedDiff(model, {
|
|
677
|
+
width: 80,
|
|
678
|
+
theme,
|
|
679
|
+
viewport: { maxRows: full.lines.length },
|
|
680
|
+
});
|
|
681
|
+
const preview = renderUnifiedDiff(model, {
|
|
682
|
+
width: 80,
|
|
683
|
+
theme,
|
|
684
|
+
viewport: { maxRows: full.lines.length - 1, selection: "head-tail" },
|
|
685
|
+
});
|
|
686
|
+
expect(full.omissionRow).toBeUndefined();
|
|
687
|
+
expect(exact.omissionRow).toBeUndefined();
|
|
688
|
+
expect(preview.omissionRow).toBeDefined();
|
|
689
|
+
expect(full.lines.length).toBeGreaterThanOrEqual(preview.lines.length);
|
|
690
|
+
});
|
|
691
|
+
});
|