@celestia-island/hikari 0.55.148 → 0.55.149
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/package.json +1 -1
- package/src/components/HkDiffViewer.scss +204 -0
- package/src/components/HkDiffViewer.test.tsx +240 -0
- package/src/components/HkDiffViewer.tsx +276 -0
- package/src/components/diffParse.test.ts +164 -0
- package/src/components/diffParse.ts +180 -0
- package/src/i18n/locales/ar/components.json +3 -1
- package/src/i18n/locales/de/components.json +3 -1
- package/src/i18n/locales/en/components.json +3 -1
- package/src/i18n/locales/es/components.json +3 -1
- package/src/i18n/locales/fr/components.json +3 -1
- package/src/i18n/locales/ja/components.json +3 -1
- package/src/i18n/locales/ko/components.json +3 -1
- package/src/i18n/locales/pt/components.json +3 -1
- package/src/i18n/locales/ru/components.json +3 -1
- package/src/i18n/locales/zh-Hans/components.json +3 -1
- package/src/i18n/locales/zh-Hant/components.json +3 -1
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// HkDiffViewer — grouped, collapsible unified-diff rendering.
|
|
2
|
+
// Sibling of the markdown code-block chrome: same subtle surface, same
|
|
3
|
+
// mono stack, same hidden native scrollbars (overlay rails are attached
|
|
4
|
+
// by the consumer's scroll container).
|
|
5
|
+
|
|
6
|
+
@use "./hikari-vars" as vars;
|
|
7
|
+
|
|
8
|
+
.hk-diff-viewer {
|
|
9
|
+
border: 1px solid var(--hi-color-border, #646464);
|
|
10
|
+
border-radius: var(--radius-sm);
|
|
11
|
+
background: var(--hi-color-bg-subtle, #21262d);
|
|
12
|
+
overflow: hidden; // clip hunk/gap bands to the rounded corners
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.hk-diff-viewer__body {
|
|
16
|
+
overflow-x: auto;
|
|
17
|
+
font-size: var(--text-xs);
|
|
18
|
+
line-height: 1.55;
|
|
19
|
+
scrollbar-width: none;
|
|
20
|
+
|
|
21
|
+
&::-webkit-scrollbar {
|
|
22
|
+
display: none;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// One diff row: old number | new number | sign | content. The row is at
|
|
27
|
+
// least the container width (so tints span edge to edge) and grows with
|
|
28
|
+
// the longest content (so the container can scroll horizontally).
|
|
29
|
+
.hk-diff-viewer__line {
|
|
30
|
+
display: grid;
|
|
31
|
+
grid-template-columns: 4.2ch 4.2ch 2ch 1fr;
|
|
32
|
+
width: 100%;
|
|
33
|
+
min-width: max-content;
|
|
34
|
+
font-family: var(--font-mono, vars.$hikari-font-family-mono);
|
|
35
|
+
white-space: pre;
|
|
36
|
+
color: var(--hi-color-text-primary, #1e1e1e);
|
|
37
|
+
|
|
38
|
+
&[data-kind="add"] {
|
|
39
|
+
background: rgb(var(--color-success) / 12%);
|
|
40
|
+
|
|
41
|
+
.hk-diff-viewer__sign {
|
|
42
|
+
color: rgb(var(--color-success));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
&[data-kind="del"] {
|
|
47
|
+
background: rgb(var(--color-error) / 12%);
|
|
48
|
+
|
|
49
|
+
.hk-diff-viewer__sign {
|
|
50
|
+
color: rgb(var(--color-error));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.hk-diff-viewer__no {
|
|
56
|
+
padding: 0 0.8ch;
|
|
57
|
+
text-align: end;
|
|
58
|
+
color: var(--hi-color-text-tertiary, #8b949e);
|
|
59
|
+
opacity: 0.75;
|
|
60
|
+
user-select: none;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.hk-diff-viewer__sign {
|
|
64
|
+
user-select: none;
|
|
65
|
+
text-align: center;
|
|
66
|
+
font-weight: 600;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.hk-diff-viewer__code {
|
|
70
|
+
padding-inline-end: 1ch;
|
|
71
|
+
min-width: 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── hunk groups ────────────────────────────────────────────────────────────
|
|
75
|
+
.hk-diff-viewer__hunk + .hk-diff-viewer__hunk,
|
|
76
|
+
.hk-diff-viewer__hunk-body {
|
|
77
|
+
border-top: 1px solid var(--hi-color-border, #646464);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.hk-diff-viewer__hunk-head {
|
|
81
|
+
display: flex;
|
|
82
|
+
align-items: center;
|
|
83
|
+
gap: 0.5em;
|
|
84
|
+
width: 100%;
|
|
85
|
+
padding: 0.3em 0.8em;
|
|
86
|
+
border: 0;
|
|
87
|
+
background: var(--hi-color-bg-subtle, #21262d);
|
|
88
|
+
color: var(--hi-color-text-secondary, #666666);
|
|
89
|
+
font: inherit;
|
|
90
|
+
text-align: start;
|
|
91
|
+
cursor: pointer;
|
|
92
|
+
user-select: none;
|
|
93
|
+
|
|
94
|
+
&:hover {
|
|
95
|
+
color: var(--hi-color-text-primary, #1e1e1e);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
&:focus-visible {
|
|
99
|
+
outline: 2px solid var(--hi-color-primary, #7aa2f7);
|
|
100
|
+
outline-offset: -2px;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.hk-diff-viewer__hunk-range {
|
|
105
|
+
font-weight: 600;
|
|
106
|
+
letter-spacing: 0.01em;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.hk-diff-viewer__hunk-section {
|
|
110
|
+
color: var(--hi-color-text-tertiary, #8b949e);
|
|
111
|
+
overflow: hidden;
|
|
112
|
+
text-overflow: ellipsis;
|
|
113
|
+
white-space: nowrap;
|
|
114
|
+
min-width: 0;
|
|
115
|
+
flex: 1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.hk-diff-viewer__hunk-stats {
|
|
119
|
+
display: inline-flex;
|
|
120
|
+
gap: 0.6em;
|
|
121
|
+
margin-inline-start: auto;
|
|
122
|
+
font-style: normal;
|
|
123
|
+
|
|
124
|
+
em {
|
|
125
|
+
font-style: normal;
|
|
126
|
+
font-weight: 600;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
em[data-kind="add"] {
|
|
130
|
+
color: rgb(var(--color-success));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
em[data-kind="del"] {
|
|
134
|
+
color: rgb(var(--color-error));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── collapsed unchanged regions ────────────────────────────────────────────
|
|
139
|
+
.hk-diff-viewer__gap {
|
|
140
|
+
display: flex;
|
|
141
|
+
align-items: center;
|
|
142
|
+
justify-content: center;
|
|
143
|
+
gap: 0.5em;
|
|
144
|
+
width: 100%;
|
|
145
|
+
padding: 0.35em 0.8em;
|
|
146
|
+
border: 0;
|
|
147
|
+
border-top: 1px dashed var(--hi-color-border, #646464);
|
|
148
|
+
background: rgb(var(--color-primary) / 5%);
|
|
149
|
+
color: var(--hi-color-text-tertiary, #8b949e);
|
|
150
|
+
font: inherit;
|
|
151
|
+
cursor: pointer;
|
|
152
|
+
user-select: none;
|
|
153
|
+
|
|
154
|
+
&:hover:not(:disabled) {
|
|
155
|
+
background: rgb(var(--color-primary) / 10%);
|
|
156
|
+
color: var(--hi-color-text-primary, #1e1e1e);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
&:focus-visible {
|
|
160
|
+
outline: 2px solid var(--hi-color-primary, #7aa2f7);
|
|
161
|
+
outline-offset: -2px;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
&:disabled {
|
|
165
|
+
cursor: progress;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
&[data-state="failed"] {
|
|
169
|
+
background: rgb(var(--color-error) / 8%);
|
|
170
|
+
color: rgb(var(--color-error));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Without a loadContext fetcher the row is a passive divider.
|
|
174
|
+
&[data-state="static"] {
|
|
175
|
+
cursor: default;
|
|
176
|
+
|
|
177
|
+
&:hover {
|
|
178
|
+
background: rgb(var(--color-primary) / 5%);
|
|
179
|
+
color: var(--hi-color-text-tertiary, #8b949e);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.hk-diff-viewer__gap-label {
|
|
185
|
+
font-size: 0.95em;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// A gap directly after the last hunk still gets its dashed separator.
|
|
189
|
+
.hk-diff-viewer__hunk + .hk-diff-viewer__gap,
|
|
190
|
+
.hk-diff-viewer__gap + .hk-diff-viewer__hunk .hk-diff-viewer__hunk-head {
|
|
191
|
+
border-top: 1px dashed var(--hi-color-border, #646464);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── raw fallback (unparseable input) ───────────────────────────────────────
|
|
195
|
+
.hk-diff-viewer__raw {
|
|
196
|
+
margin: 0;
|
|
197
|
+
padding: 0.6em 0.8em;
|
|
198
|
+
font-family: var(--font-mono, vars.$hikari-font-family-mono);
|
|
199
|
+
font-size: var(--text-xs);
|
|
200
|
+
line-height: 1.55;
|
|
201
|
+
white-space: pre-wrap;
|
|
202
|
+
word-break: break-word;
|
|
203
|
+
color: var(--hi-color-text-primary, #1e1e1e);
|
|
204
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkDiffViewer from "./HkDiffViewer";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HkDiffViewer — the rendered half: hunk grouping, collapsed unchanged
|
|
8
|
+
* regions and the async expander contract (loadContext round-trips,
|
|
9
|
+
* anchor policy, failure/retry, reset on diff change).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const apps: App[] = [];
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
for (const app of apps.splice(0)) app.unmount();
|
|
15
|
+
document.body.innerHTML = "";
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function mount(props: Record<string, unknown>) {
|
|
19
|
+
const container = document.createElement("div");
|
|
20
|
+
document.body.appendChild(container);
|
|
21
|
+
const Root = defineComponent({
|
|
22
|
+
setup() {
|
|
23
|
+
return () => h(HkDiffViewer, props as never);
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
const app = createApp(Root);
|
|
27
|
+
app.mount(container);
|
|
28
|
+
apps.push(app);
|
|
29
|
+
return container;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Diff with a 9-line leading gap, one mixed hunk, and a 5-line trailing gap. */
|
|
33
|
+
const DIFF = [
|
|
34
|
+
"--- a/a.toml",
|
|
35
|
+
"+++ b/a.toml",
|
|
36
|
+
"@@ -10,4 +10,5 @@",
|
|
37
|
+
" ctx-a",
|
|
38
|
+
"-old",
|
|
39
|
+
"+new-1",
|
|
40
|
+
"+new-2",
|
|
41
|
+
" ctx-b",
|
|
42
|
+
" ctx-c",
|
|
43
|
+
"",
|
|
44
|
+
].join("\n");
|
|
45
|
+
|
|
46
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
47
|
+
|
|
48
|
+
describe("HkDiffViewer", () => {
|
|
49
|
+
it("groups the diff into hunk cards with per-hunk stats and both-side numbers", async () => {
|
|
50
|
+
const root = mount({ diff: DIFF, totalLines: 19 });
|
|
51
|
+
await nextTick();
|
|
52
|
+
const head = root.querySelector(".hk-diff-viewer__hunk-head")!;
|
|
53
|
+
expect(head.textContent).toContain("@@ -10,4 +10,5 @@");
|
|
54
|
+
expect(head.querySelector('[data-kind="add"]')!.textContent).toBe("+2");
|
|
55
|
+
expect(head.querySelector('[data-kind="del"]')!.textContent).toBe("−1");
|
|
56
|
+
const lines = [...root.querySelectorAll(".hk-diff-viewer__line")];
|
|
57
|
+
// 6 hunk lines (3 context + 1 del + 2 add); gap rows are not .line
|
|
58
|
+
expect(lines).toHaveLength(6);
|
|
59
|
+
const kinds = lines.map((l) => l.getAttribute("data-kind"));
|
|
60
|
+
expect(kinds).toEqual(["context", "del", "add", "add", "context", "context"]);
|
|
61
|
+
const delLine = lines[1];
|
|
62
|
+
expect(delLine.querySelectorAll(".hk-diff-viewer__no")[0].textContent).toBe("11");
|
|
63
|
+
expect(delLine.querySelectorAll(".hk-diff-viewer__no")[1].textContent).toBe("");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("renders unchanged regions as static dividers without a loadContext fetcher", async () => {
|
|
67
|
+
const root = mount({ diff: DIFF, totalLines: 19 });
|
|
68
|
+
await nextTick();
|
|
69
|
+
const gaps = [...root.querySelectorAll(".hk-diff-viewer__gap")];
|
|
70
|
+
expect(gaps).toHaveLength(2); // leading 9 + trailing 5
|
|
71
|
+
expect(gaps[0].tagName).toBe("DIV"); // not a button — nothing to fetch with
|
|
72
|
+
expect(gaps[0].textContent).toContain("9");
|
|
73
|
+
expect(gaps[1].textContent).toContain("5");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("expands an unchanged region asynchronously via loadContext", async () => {
|
|
77
|
+
const seen: Array<[number, number]> = [];
|
|
78
|
+
const loadContext = vi.fn(async (start: number, count: number) => {
|
|
79
|
+
seen.push([start, count]);
|
|
80
|
+
return Array.from({ length: count }, (_, i) => `line-${start + i}`);
|
|
81
|
+
});
|
|
82
|
+
const root = mount({ diff: DIFF, totalLines: 19, loadContext, contextChunk: 4 });
|
|
83
|
+
await nextTick();
|
|
84
|
+
|
|
85
|
+
// Trailing gap [15..19] (5 lines, anchor start): first click fetches 15..18.
|
|
86
|
+
const trailing = root.querySelectorAll(".hk-diff-viewer__gap")[1] as HTMLButtonElement;
|
|
87
|
+
expect(trailing.tagName).toBe("BUTTON");
|
|
88
|
+
trailing.click();
|
|
89
|
+
await tick();
|
|
90
|
+
await nextTick();
|
|
91
|
+
expect(seen).toEqual([[15, 4]]);
|
|
92
|
+
let text = root.textContent!;
|
|
93
|
+
expect(text).toContain("line-15");
|
|
94
|
+
expect(text).toContain("line-18");
|
|
95
|
+
// One line remains hidden → the expander stays, now counting 1.
|
|
96
|
+
const after = root.querySelectorAll(".hk-diff-viewer__gap")[1] as HTMLButtonElement;
|
|
97
|
+
expect(after.textContent).toContain("1");
|
|
98
|
+
|
|
99
|
+
// Second click completes the region; the divider disappears entirely.
|
|
100
|
+
after.click();
|
|
101
|
+
await tick();
|
|
102
|
+
await nextTick();
|
|
103
|
+
expect(seen[1]).toEqual([19, 1]);
|
|
104
|
+
expect(root.querySelectorAll(".hk-diff-viewer__gap")).toHaveLength(1); // only the leading gap
|
|
105
|
+
text = root.textContent!;
|
|
106
|
+
expect(text).toContain("line-19");
|
|
107
|
+
// Revealed context lines carry both-side numbers (shift = 0 here).
|
|
108
|
+
const revealed = [...root.querySelectorAll(".hk-diff-viewer__line[data-kind='context']")];
|
|
109
|
+
expect(revealed.length).toBeGreaterThan(4);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("expands the LEADING gap bottom-up (lines nearest the hunk first)", async () => {
|
|
113
|
+
const seen: Array<[number, number]> = [];
|
|
114
|
+
const loadContext = vi.fn(async (start: number, count: number) => {
|
|
115
|
+
seen.push([start, count]);
|
|
116
|
+
return Array.from({ length: count }, (_, i) => `L${start + i}`);
|
|
117
|
+
});
|
|
118
|
+
const root = mount({ diff: DIFF, totalLines: 19, loadContext, contextChunk: 4 });
|
|
119
|
+
await nextTick();
|
|
120
|
+
const leading = root.querySelectorAll(".hk-diff-viewer__gap")[0] as HTMLButtonElement;
|
|
121
|
+
leading.click();
|
|
122
|
+
await tick();
|
|
123
|
+
await nextTick();
|
|
124
|
+
expect(seen).toEqual([[6, 4]]); // bottom 4 of the 9-line leading gap
|
|
125
|
+
const text = root.textContent!;
|
|
126
|
+
expect(text).toContain("L6");
|
|
127
|
+
expect(text).toContain("L9");
|
|
128
|
+
expect(text).not.toContain("L5");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("marks a failed fetch inline and retries on the next click", async () => {
|
|
132
|
+
let calls = 0;
|
|
133
|
+
const loadContext = vi.fn(async () => {
|
|
134
|
+
calls += 1;
|
|
135
|
+
if (calls === 1) return null; // transport failure
|
|
136
|
+
return ["recovered"];
|
|
137
|
+
});
|
|
138
|
+
const root = mount({
|
|
139
|
+
diff: DIFF,
|
|
140
|
+
totalLines: 19,
|
|
141
|
+
loadContext,
|
|
142
|
+
contextChunk: 1, // trailing gap is 5 lines; irrelevant — leading used
|
|
143
|
+
});
|
|
144
|
+
await nextTick();
|
|
145
|
+
const gap = root.querySelectorAll(".hk-diff-viewer__gap")[1] as HTMLButtonElement;
|
|
146
|
+
gap.click();
|
|
147
|
+
await tick();
|
|
148
|
+
await nextTick();
|
|
149
|
+
expect(root.querySelector(".hk-diff-viewer__gap[data-state='failed']")).toBeTruthy();
|
|
150
|
+
expect(loadContext).toHaveBeenCalledTimes(1);
|
|
151
|
+
|
|
152
|
+
(root.querySelector(".hk-diff-viewer__gap[data-state='failed']") as HTMLButtonElement).click();
|
|
153
|
+
await tick();
|
|
154
|
+
await nextTick();
|
|
155
|
+
expect(loadContext).toHaveBeenCalledTimes(2);
|
|
156
|
+
expect(root.querySelector(".hk-diff-viewer__gap[data-state='failed']")).toBeNull();
|
|
157
|
+
expect(root.textContent).toContain("recovered");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("bottom-aligns a SHORT response in the leading gap (R1)", async () => {
|
|
161
|
+
// A clipping host answers fewer lines than requested: for the
|
|
162
|
+
// bottom-anchored leading gap the arrived lines are the ones NEAREST
|
|
163
|
+
// the hunk — storing them top-aligned would permanently hide those.
|
|
164
|
+
const seen: Array<[number, number]> = [];
|
|
165
|
+
const loadContext = vi.fn(async (start: number, count: number) => {
|
|
166
|
+
seen.push([start, count]);
|
|
167
|
+
// Clips to 2 lines regardless of the requested window.
|
|
168
|
+
return ["L8", "L9"].slice(0, Math.min(2, count));
|
|
169
|
+
});
|
|
170
|
+
const root = mount({ diff: DIFF, totalLines: 19, loadContext, contextChunk: 4 });
|
|
171
|
+
await nextTick();
|
|
172
|
+
const leading = root.querySelectorAll(".hk-diff-viewer__gap")[0] as HTMLButtonElement;
|
|
173
|
+
leading.click();
|
|
174
|
+
await tick();
|
|
175
|
+
await nextTick();
|
|
176
|
+
expect(seen).toEqual([[6, 4]]); // bottom 4 of the 9-line gap requested…
|
|
177
|
+
const text = root.textContent!;
|
|
178
|
+
expect(text).toContain("L8"); // …but the 2 that arrived render…
|
|
179
|
+
expect(text).toContain("L9"); // …nearest the hunk (bottom-aligned)
|
|
180
|
+
expect(text).not.toContain("L6");
|
|
181
|
+
// 7 lines remain hidden; the expander persists with the new count.
|
|
182
|
+
const after = root.querySelectorAll(".hk-diff-viewer__gap")[0] as HTMLButtonElement;
|
|
183
|
+
expect(after.textContent).toContain("7");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("collapses and re-expands a hunk via its header", async () => {
|
|
187
|
+
const root = mount({ diff: DIFF, totalLines: 19 });
|
|
188
|
+
await nextTick();
|
|
189
|
+
const head = root.querySelector(".hk-diff-viewer__hunk-head") as HTMLButtonElement;
|
|
190
|
+
expect(root.querySelectorAll(".hk-diff-viewer__line")).toHaveLength(6);
|
|
191
|
+
head.click();
|
|
192
|
+
await nextTick();
|
|
193
|
+
expect(root.querySelectorAll(".hk-diff-viewer__line")).toHaveLength(0);
|
|
194
|
+
head.click();
|
|
195
|
+
await nextTick();
|
|
196
|
+
expect(root.querySelectorAll(".hk-diff-viewer__line")).toHaveLength(6);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("resets gap state when the diff prop changes", async () => {
|
|
200
|
+
const loadContext = vi.fn(async (start: number, count: number) =>
|
|
201
|
+
Array.from({ length: count }, (_, i) => `line-${start + i}`),
|
|
202
|
+
);
|
|
203
|
+
const container = document.createElement("div");
|
|
204
|
+
document.body.appendChild(container);
|
|
205
|
+
const diffRef = ref(DIFF);
|
|
206
|
+
const Root = defineComponent({
|
|
207
|
+
setup() {
|
|
208
|
+
return () =>
|
|
209
|
+
h(HkDiffViewer, {
|
|
210
|
+
diff: diffRef.value,
|
|
211
|
+
totalLines: 19,
|
|
212
|
+
loadContext,
|
|
213
|
+
contextChunk: 100,
|
|
214
|
+
} as never);
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
const app = createApp(Root);
|
|
218
|
+
app.mount(container);
|
|
219
|
+
apps.push(app);
|
|
220
|
+
await nextTick();
|
|
221
|
+
|
|
222
|
+
(container.querySelector(".hk-diff-viewer__gap") as HTMLButtonElement).click();
|
|
223
|
+
await tick();
|
|
224
|
+
await nextTick();
|
|
225
|
+
expect(container.textContent).toContain("line-1");
|
|
226
|
+
|
|
227
|
+
// A different diff must drop the fetched lines and recompute gaps.
|
|
228
|
+
diffRef.value = "@@ -3,2 +3,2 @@\n-a\n+b\n";
|
|
229
|
+
await nextTick();
|
|
230
|
+
expect(container.textContent).not.toContain("line-1");
|
|
231
|
+
expect(container.textContent).toContain("15"); // trailing gap of the new diff
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("degrades unparseable input to a raw <pre>", async () => {
|
|
235
|
+
const root = mount({ diff: "not a diff\nat all" });
|
|
236
|
+
await nextTick();
|
|
237
|
+
expect(root.querySelector(".hk-diff-viewer__raw")!.textContent).toContain("not a diff");
|
|
238
|
+
expect(root.querySelector(".hk-diff-viewer__hunk")).toBeNull();
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { computed, defineComponent, ref, watch } from "vue";
|
|
2
|
+
import { AlertTriangle, ChevronDown, ChevronRight, UnfoldVertical } from "lucide-vue-next";
|
|
3
|
+
import HkSpinner from "./HkSpinner";
|
|
4
|
+
import { useI18n } from "../i18n/context";
|
|
5
|
+
import { highlight } from "../composables/useHighlight";
|
|
6
|
+
import { computeGaps, parseUnifiedDiff, type DiffLine, type UnchangedGap } from "./diffParse";
|
|
7
|
+
import "./HkDiffViewer.scss";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* HkDiffViewer — unified-diff rendering as grouped, collapsible hunks.
|
|
11
|
+
*
|
|
12
|
+
* Born from the shittim-chest node-insight modal (2026-10-12 user
|
|
13
|
+
* direction): the previous renderer was a flat <pre> with tinted lines —
|
|
14
|
+
* no grouping, no way to see the unchanged neighbourhood of a change.
|
|
15
|
+
* This component parses the diff into hunks (the groups), renders every
|
|
16
|
+
* hunk as a collapsible card, and collapses the UNCHANGED regions
|
|
17
|
+
* between hunks into expander rows.
|
|
18
|
+
*
|
|
19
|
+
* Unchanged content is not part of a diff, so expansion is asynchronous:
|
|
20
|
+
* pass `loadContext(start, count)` (1-based, new-side numbering) and the
|
|
21
|
+
* component fetches the hidden lines on demand — over whatever transport
|
|
22
|
+
* the host app rides (chest: a WS RPC that the server relays into the
|
|
23
|
+
* container). Without `loadContext` the expander rows render as static
|
|
24
|
+
* dividers. Failures render inline and the next click retries.
|
|
25
|
+
*
|
|
26
|
+
* Line highlighting reuses the lazy highlight.js infrastructure
|
|
27
|
+
* (useHighlight): grammars load on demand and rows re-color once the
|
|
28
|
+
* grammar lands, exactly like the markdown code blocks.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
interface GapState {
|
|
32
|
+
/** Lines already revealed. */
|
|
33
|
+
revealed: number;
|
|
34
|
+
loading: boolean;
|
|
35
|
+
failed: boolean;
|
|
36
|
+
/** Fetched context lines, keyed by new-side line number. */
|
|
37
|
+
lines: Map<number, string>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default defineComponent({
|
|
41
|
+
name: "HkDiffViewer",
|
|
42
|
+
props: {
|
|
43
|
+
/** Unified-diff text (file headers allowed; they are skipped). */
|
|
44
|
+
diff: { type: String, required: true },
|
|
45
|
+
/** highlight.js language id for line highlighting (e.g. "rust"). */
|
|
46
|
+
language: { type: String, default: "" },
|
|
47
|
+
/**
|
|
48
|
+
* Current (new-side) total line count. Enables the trailing collapsed
|
|
49
|
+
* region after the last hunk; omit when unknown.
|
|
50
|
+
*/
|
|
51
|
+
totalLines: { type: Number, default: null },
|
|
52
|
+
/** Lines revealed per expand click. */
|
|
53
|
+
contextChunk: { type: Number, default: 40 },
|
|
54
|
+
/**
|
|
55
|
+
* Async fetcher for unchanged lines: `(start, count) => lines | null`,
|
|
56
|
+
* 1-based new-side numbering. Null/throw = failure (inline + retry).
|
|
57
|
+
*/
|
|
58
|
+
loadContext: { type: Function, default: null },
|
|
59
|
+
},
|
|
60
|
+
setup(props) {
|
|
61
|
+
const { t } = useI18n();
|
|
62
|
+
|
|
63
|
+
const parsed = computed(() => parseUnifiedDiff(props.diff));
|
|
64
|
+
const gaps = computed(() => computeGaps(parsed.value, props.totalLines ?? null));
|
|
65
|
+
const collapsedHunks = ref<Set<number>>(new Set());
|
|
66
|
+
/** Gap states keyed by the gap's new-side start line (stable identity). */
|
|
67
|
+
const gapStates = ref<Map<number, GapState>>(new Map());
|
|
68
|
+
|
|
69
|
+
// A fresh diff invalidates every cached context line and fold state.
|
|
70
|
+
watch(
|
|
71
|
+
() => [props.diff, props.totalLines],
|
|
72
|
+
() => {
|
|
73
|
+
collapsedHunks.value = new Set();
|
|
74
|
+
gapStates.value = new Map();
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
function gapState(gap: UnchangedGap): GapState {
|
|
79
|
+
let st = gapStates.value.get(gap.start);
|
|
80
|
+
if (!st) {
|
|
81
|
+
st = { revealed: 0, loading: false, failed: false, lines: new Map() };
|
|
82
|
+
gapStates.value.set(gap.start, st);
|
|
83
|
+
}
|
|
84
|
+
return st;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function toggleHunk(idx: number) {
|
|
88
|
+
const next = new Set(collapsedHunks.value);
|
|
89
|
+
if (next.has(idx)) next.delete(idx);
|
|
90
|
+
else next.add(idx);
|
|
91
|
+
collapsedHunks.value = next;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function expandGap(gap: UnchangedGap) {
|
|
95
|
+
const load = props.loadContext as
|
|
96
|
+
| ((start: number, count: number) => Promise<string[] | null>)
|
|
97
|
+
| null;
|
|
98
|
+
if (!load) return;
|
|
99
|
+
const st = gapState(gap);
|
|
100
|
+
if (st.loading) return;
|
|
101
|
+
const remaining = gap.count - st.revealed;
|
|
102
|
+
if (remaining <= 0) return;
|
|
103
|
+
const take = Math.min(Math.max(1, props.contextChunk), remaining);
|
|
104
|
+
// Anchor policy: the leading gap reveals bottom-up (lines nearest
|
|
105
|
+
// the first hunk first); every later gap reveals top-down.
|
|
106
|
+
const fetchStart =
|
|
107
|
+
gap.anchor === "end" ? gap.start + remaining - take : gap.start + st.revealed;
|
|
108
|
+
st.loading = true;
|
|
109
|
+
st.failed = false;
|
|
110
|
+
try {
|
|
111
|
+
const lines = await load(fetchStart, take);
|
|
112
|
+
// The diff may have changed mid-flight; this gap state is then
|
|
113
|
+
// already discarded and writing into it is harmless.
|
|
114
|
+
if (!lines) {
|
|
115
|
+
st.failed = true;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// A host may answer a SHORT window (clipped at EOF). For
|
|
119
|
+
// anchor="end" the render window is bottom-anchored, so the
|
|
120
|
+
// arrived lines must bottom-align too — treating them as the
|
|
121
|
+
// lines nearest the hunk (R1 finding: top-aligning them here
|
|
122
|
+
// permanently hides the lines closest to the first hunk).
|
|
123
|
+
const firstLine =
|
|
124
|
+
gap.anchor === "end" ? fetchStart + (take - lines.length) : fetchStart;
|
|
125
|
+
lines.forEach((text, i) => st.lines.set(firstLine + i, text));
|
|
126
|
+
st.revealed += lines.length;
|
|
127
|
+
if (lines.length === 0) st.revealed = gap.count; // nothing there — done
|
|
128
|
+
} catch {
|
|
129
|
+
st.failed = true;
|
|
130
|
+
} finally {
|
|
131
|
+
st.loading = false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function renderLine(line: DiffLine, key: string) {
|
|
136
|
+
const sign = line.kind === "add" ? "+" : line.kind === "del" ? "−" : "";
|
|
137
|
+
const html = props.language ? highlight(line.text, props.language) : null;
|
|
138
|
+
return (
|
|
139
|
+
<div class="hk-diff-viewer__line" data-kind={line.kind} key={key}>
|
|
140
|
+
<span class="hk-diff-viewer__no">{line.oldNo ?? ""}</span>
|
|
141
|
+
<span class="hk-diff-viewer__no">{line.newNo ?? ""}</span>
|
|
142
|
+
<span class="hk-diff-viewer__sign">{sign}</span>
|
|
143
|
+
{html !== null ? (
|
|
144
|
+
<span class="hk-diff-viewer__code" v-html={html} />
|
|
145
|
+
) : (
|
|
146
|
+
<span class="hk-diff-viewer__code">{line.text}</span>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function renderContextLine(newNo: number, text: string, gap: UnchangedGap, key: string) {
|
|
153
|
+
return renderLine(
|
|
154
|
+
{ kind: "context", oldNo: newNo - gap.shift, newNo, text },
|
|
155
|
+
key,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function renderGap(gap: UnchangedGap) {
|
|
160
|
+
const st = gapState(gap);
|
|
161
|
+
const remaining = gap.count - st.revealed;
|
|
162
|
+
const rows = [];
|
|
163
|
+
|
|
164
|
+
// Revealed context lines, in file order.
|
|
165
|
+
const revealedRows = [];
|
|
166
|
+
const first =
|
|
167
|
+
gap.anchor === "end" ? gap.start + gap.count - st.revealed : gap.start;
|
|
168
|
+
for (let no = first; no < first + st.revealed; no++) {
|
|
169
|
+
const text = st.lines.get(no);
|
|
170
|
+
if (text !== undefined) {
|
|
171
|
+
revealedRows.push(renderContextLine(no, text, gap, `g${gap.start}:${no}`));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const interactive = !!props.loadContext && remaining > 0;
|
|
176
|
+
const label = st.failed
|
|
177
|
+
? t("hikari::diff.loadFailed", "Load failed, click to retry")
|
|
178
|
+
: t("hikari::diff.unchangedLines", "{count} unchanged lines").replace(
|
|
179
|
+
"{count}",
|
|
180
|
+
String(remaining),
|
|
181
|
+
);
|
|
182
|
+
const divider = interactive ? (
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
key={`gap-${gap.start}`}
|
|
186
|
+
class="hk-diff-viewer__gap"
|
|
187
|
+
data-state={st.loading ? "loading" : st.failed ? "failed" : "idle"}
|
|
188
|
+
disabled={st.loading}
|
|
189
|
+
onClick={() => void expandGap(gap)}
|
|
190
|
+
>
|
|
191
|
+
{st.loading ? (
|
|
192
|
+
<HkSpinner size="xs" />
|
|
193
|
+
) : st.failed ? (
|
|
194
|
+
<AlertTriangle size={13} />
|
|
195
|
+
) : (
|
|
196
|
+
<UnfoldVertical size={13} />
|
|
197
|
+
)}
|
|
198
|
+
<span class="hk-diff-viewer__gap-label">{label}</span>
|
|
199
|
+
</button>
|
|
200
|
+
) : remaining > 0 ? (
|
|
201
|
+
<div class="hk-diff-viewer__gap" data-state="static" key={`gap-${gap.start}`}>
|
|
202
|
+
<UnfoldVertical size={13} />
|
|
203
|
+
<span class="hk-diff-viewer__gap-label">{label}</span>
|
|
204
|
+
</div>
|
|
205
|
+
) : null;
|
|
206
|
+
|
|
207
|
+
// anchor=end: the remaining (button) part sits above the revealed
|
|
208
|
+
// lines; anchor=start: revealed lines first.
|
|
209
|
+
if (gap.anchor === "end") rows.push(divider, ...revealedRows);
|
|
210
|
+
else rows.push(...revealedRows, divider);
|
|
211
|
+
return rows;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function renderHunk(idx: number) {
|
|
215
|
+
const hunk = parsed.value.hunks[idx];
|
|
216
|
+
const collapsed = collapsedHunks.value.has(idx);
|
|
217
|
+
const adds = hunk.lines.filter((l) => l.kind === "add").length;
|
|
218
|
+
const dels = hunk.lines.filter((l) => l.kind === "del").length;
|
|
219
|
+
// The section heading after the second @@ (e.g. the enclosing fn).
|
|
220
|
+
const section = hunk.header.replace(/^@@\s+.*?@@\s*/, "");
|
|
221
|
+
return (
|
|
222
|
+
<div class="hk-diff-viewer__hunk" key={`h${idx}`}>
|
|
223
|
+
<button
|
|
224
|
+
type="button"
|
|
225
|
+
class="hk-diff-viewer__hunk-head"
|
|
226
|
+
aria-expanded={!collapsed}
|
|
227
|
+
onClick={() => toggleHunk(idx)}
|
|
228
|
+
>
|
|
229
|
+
{collapsed ? <ChevronRight size={13} /> : <ChevronDown size={13} />}
|
|
230
|
+
<span class="hk-diff-viewer__hunk-range">{hunk.header.match(/^@@\s+.*?@@/)?.[0] ?? "@@"}</span>
|
|
231
|
+
{section && <span class="hk-diff-viewer__hunk-section">{section}</span>}
|
|
232
|
+
<span class="hk-diff-viewer__hunk-stats">
|
|
233
|
+
{adds > 0 && <em data-kind="add">+{adds}</em>}
|
|
234
|
+
{dels > 0 && <em data-kind="del">−{dels}</em>}
|
|
235
|
+
</span>
|
|
236
|
+
</button>
|
|
237
|
+
{!collapsed && (
|
|
238
|
+
<div class="hk-diff-viewer__hunk-body">
|
|
239
|
+
{hunk.lines.map((line, li) => renderLine(line, `h${idx}:${li}`))}
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return () => {
|
|
247
|
+
const { hunks } = parsed.value;
|
|
248
|
+
if (hunks.length === 0) {
|
|
249
|
+
// Unparseable input: degrade to the honest raw text rather than
|
|
250
|
+
// rendering nothing.
|
|
251
|
+
return (
|
|
252
|
+
<div class="hk-diff-viewer">
|
|
253
|
+
<pre class="hk-diff-viewer__raw">{props.diff}</pre>
|
|
254
|
+
</div>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
// Interleave: gaps are tagged with the hunk they precede
|
|
258
|
+
// (beforeHunk === hunks.length marks the trailing gap).
|
|
259
|
+
const gapsByHunk = new Map<number, UnchangedGap>();
|
|
260
|
+
for (const gap of gaps.value) gapsByHunk.set(gap.beforeHunk, gap);
|
|
261
|
+
const blocks = [];
|
|
262
|
+
for (let i = 0; i < hunks.length; i++) {
|
|
263
|
+
const gap = gapsByHunk.get(i);
|
|
264
|
+
if (gap) blocks.push(renderGap(gap));
|
|
265
|
+
blocks.push(renderHunk(i));
|
|
266
|
+
}
|
|
267
|
+
const trailing = gapsByHunk.get(hunks.length);
|
|
268
|
+
if (trailing) blocks.push(renderGap(trailing));
|
|
269
|
+
return (
|
|
270
|
+
<div class="hk-diff-viewer">
|
|
271
|
+
<div class="hk-diff-viewer__body">{blocks}</div>
|
|
272
|
+
</div>
|
|
273
|
+
);
|
|
274
|
+
};
|
|
275
|
+
},
|
|
276
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { computeGaps, parseUnifiedDiff } from "./diffParse";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* diffParse — the pure half of HkDiffViewer. The pins that matter:
|
|
7
|
+
* line numbering on both sides (the renderer's fetched context lines
|
|
8
|
+
* must slot into exactly these numbers), zero-count hunk starts, and
|
|
9
|
+
* gap computation (leading / inter-hunk / trailing, with the old-side
|
|
10
|
+
* shift that lets context lines show both numbers).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const SAMPLE = [
|
|
14
|
+
"--- a/config/app.toml",
|
|
15
|
+
"+++ b/config/app.toml",
|
|
16
|
+
"@@ -10,4 +10,5 @@ fn main() {",
|
|
17
|
+
" context-a",
|
|
18
|
+
"-old-line",
|
|
19
|
+
"+new-line-a",
|
|
20
|
+
"+new-line-b",
|
|
21
|
+
" context-b",
|
|
22
|
+
" context-b2",
|
|
23
|
+
"@@ -30,3 +31,3 @@",
|
|
24
|
+
" context-c",
|
|
25
|
+
"-gone",
|
|
26
|
+
"+here",
|
|
27
|
+
" context-d",
|
|
28
|
+
"",
|
|
29
|
+
].join("\n");
|
|
30
|
+
|
|
31
|
+
describe("parseUnifiedDiff", () => {
|
|
32
|
+
it("parses hunks with both-side line numbers", () => {
|
|
33
|
+
const { hunks, endCovered } = parseUnifiedDiff(SAMPLE);
|
|
34
|
+
expect(hunks).toHaveLength(2);
|
|
35
|
+
|
|
36
|
+
const [h0, h1] = hunks;
|
|
37
|
+
expect(h0.header).toBe("@@ -10,4 +10,5 @@ fn main() {");
|
|
38
|
+
expect(h0.lines.map((l) => [l.kind, l.oldNo, l.newNo, l.text])).toEqual([
|
|
39
|
+
["context", 10, 10, "context-a"],
|
|
40
|
+
["del", 11, null, "old-line"],
|
|
41
|
+
["add", null, 11, "new-line-a"],
|
|
42
|
+
["add", null, 12, "new-line-b"],
|
|
43
|
+
["context", 12, 13, "context-b"],
|
|
44
|
+
["context", 13, 14, "context-b2"],
|
|
45
|
+
]);
|
|
46
|
+
expect(h1.oldStart).toBe(30);
|
|
47
|
+
expect(h1.newStart).toBe(31);
|
|
48
|
+
expect(endCovered).toBe(33);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("treats an omitted count as 1 and a zero count as an empty range", () => {
|
|
52
|
+
const { hunks } = parseUnifiedDiff("@@ -0,0 +1 @@\n+only\n");
|
|
53
|
+
expect(hunks[0].oldStart).toBe(0);
|
|
54
|
+
expect(hunks[0].oldCount).toBe(0);
|
|
55
|
+
expect(hunks[0].lines).toEqual([{ kind: "add", oldNo: null, newNo: 1, text: "only" }]);
|
|
56
|
+
|
|
57
|
+
const del = parseUnifiedDiff("@@ -5 +4,0 @@\n-gone\n");
|
|
58
|
+
expect(del.hunks[0].lines).toEqual([{ kind: "del", oldNo: 5, newNo: null, text: "gone" }]);
|
|
59
|
+
// A deletion positions after new line 4 (the zero-count anchor rule),
|
|
60
|
+
// so the covered new-side range is 1..4 — NOT 1..3 (R2: the raw-start
|
|
61
|
+
// formula baked an off-by-one into this pin once).
|
|
62
|
+
expect(del.endCovered).toBe(4);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("computes gaps for a pure insertion hunk like git -U0 (R2)", () => {
|
|
66
|
+
// `@@ -4,0 +5,2 @@` = insertion AFTER old line 4: the leading gap is
|
|
67
|
+
// new 1..4 with shift 0, and the trailing side resumes at old 5.
|
|
68
|
+
const parsed = parseUnifiedDiff("@@ -4,0 +5,2 @@\n+a\n+b\n");
|
|
69
|
+
expect(computeGaps(parsed, 8)).toEqual([
|
|
70
|
+
{ start: 1, count: 4, shift: 0, anchor: "end", beforeHunk: 0 },
|
|
71
|
+
{ start: 7, count: 2, shift: 2, anchor: "start", beforeHunk: 1 },
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("computes gaps for a pure deletion hunk like git -U0 (R2)", () => {
|
|
76
|
+
// `@@ -5,2 +4,0 @@` = old 5..6 deleted, positioned after new line 4:
|
|
77
|
+
// the unchanged new line 5 must expand ABOVE as part of no gap and
|
|
78
|
+
// resume numbering at old 7 after the hunk (shift −2).
|
|
79
|
+
const parsed = parseUnifiedDiff("@@ -5,2 +4,0 @@\n-gone-a\n-gone-b\n");
|
|
80
|
+
expect(parsed.endCovered).toBe(4);
|
|
81
|
+
expect(computeGaps(parsed, 6)).toEqual([
|
|
82
|
+
{ start: 1, count: 4, shift: 0, anchor: "end", beforeHunk: 0 },
|
|
83
|
+
{ start: 5, count: 2, shift: -2, anchor: "start", beforeHunk: 1 },
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("skips file headers and \\-markers, and tolerates stripped blank context lines", () => {
|
|
88
|
+
const diff = [
|
|
89
|
+
"--- a/x",
|
|
90
|
+
"+++ b/x",
|
|
91
|
+
"@@ -1,4 +1,4 @@",
|
|
92
|
+
" a",
|
|
93
|
+
"", // a context line whose trailing space got stripped
|
|
94
|
+
"-b",
|
|
95
|
+
"+c",
|
|
96
|
+
"\",
|
|
97
|
+
" d",
|
|
98
|
+
].join("\n");
|
|
99
|
+
const { hunks } = parseUnifiedDiff(diff);
|
|
100
|
+
expect(hunks).toHaveLength(1);
|
|
101
|
+
expect(hunks[0].lines.map((l) => l.kind)).toEqual(["context", "context", "del", "add", "context"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("returns no hunks for empty or non-diff input", () => {
|
|
105
|
+
expect(parseUnifiedDiff("").hunks).toEqual([]);
|
|
106
|
+
expect(parseUnifiedDiff("hello\nworld").hunks).toEqual([]);
|
|
107
|
+
expect(parseUnifiedDiff("").endCovered).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("never invents line 0 for a whole-file deletion (R1)", () => {
|
|
111
|
+
const parsed = parseUnifiedDiff("@@ -1,3 +0,0 @@\n-a\n-b\n-c\n");
|
|
112
|
+
expect(parsed.endCovered).toBe(0);
|
|
113
|
+
expect(computeGaps(parsed, 0)).toEqual([]);
|
|
114
|
+
expect(computeGaps(parsed, null)).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("lands a stray context line on line 1 inside a zero-count old range (R1)", () => {
|
|
118
|
+
// Malformed body (a context line where the old side is empty): the
|
|
119
|
+
// defensive +1 on zero-count ranges is the only thing keeping this
|
|
120
|
+
// line on the 1-based contract. Pins the behavior Mutation A broke.
|
|
121
|
+
const { hunks } = parseUnifiedDiff("@@ -0,0 +1,2 @@\n ctx\n+a\n");
|
|
122
|
+
expect(hunks[0].lines).toEqual([
|
|
123
|
+
{ kind: "context", oldNo: 1, newNo: 1, text: "ctx" },
|
|
124
|
+
{ kind: "add", oldNo: null, newNo: 2, text: "a" },
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("tolerates CRLF line endings without leaking \\r into text", () => {
|
|
129
|
+
const { hunks } = parseUnifiedDiff("@@ -1,2 +1,2 @@\r\n-old\r\n+new\r\n ctx\r\n");
|
|
130
|
+
expect(hunks[0].lines).toEqual([
|
|
131
|
+
{ kind: "del", oldNo: 1, newNo: null, text: "old" },
|
|
132
|
+
{ kind: "add", oldNo: null, newNo: 1, text: "new" },
|
|
133
|
+
{ kind: "context", oldNo: 2, newNo: 2, text: "ctx" },
|
|
134
|
+
]);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("computeGaps", () => {
|
|
139
|
+
it("computes leading, inter-hunk and trailing gaps with old-side shifts", () => {
|
|
140
|
+
const parsed = parseUnifiedDiff(SAMPLE);
|
|
141
|
+
const gaps = computeGaps(parsed, 60);
|
|
142
|
+
expect(gaps).toEqual([
|
|
143
|
+
// Lines 1–9 precede the first hunk (newStart 10, oldStart 10 → shift 0).
|
|
144
|
+
{ start: 1, count: 9, shift: 0, anchor: "end", beforeHunk: 0 },
|
|
145
|
+
// Hunk 0 covers new 10–14, hunk 1 starts at 31 → gap 15–30.
|
|
146
|
+
// Old-side shift at hunk 1: newStart 31 − oldStart 30 = 1.
|
|
147
|
+
{ start: 15, count: 16, shift: 1, anchor: "start", beforeHunk: 1 },
|
|
148
|
+
// Trailing: 34–60, shift from hunk 1's end: (31+3)−(30+3) = 1.
|
|
149
|
+
{ start: 34, count: 27, shift: 1, anchor: "start", beforeHunk: 2 },
|
|
150
|
+
]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("omits the trailing gap when the total line count is unknown", () => {
|
|
154
|
+
const parsed = parseUnifiedDiff(SAMPLE);
|
|
155
|
+
const gaps = computeGaps(parsed, null);
|
|
156
|
+
expect(gaps).toHaveLength(2);
|
|
157
|
+
expect(gaps.every((g) => g.beforeHunk < 2)).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("produces no gaps for a whole-file addition", () => {
|
|
161
|
+
const parsed = parseUnifiedDiff("@@ -0,0 +1,3 @@\n+a\n+b\n+c\n");
|
|
162
|
+
expect(computeGaps(parsed, 3)).toEqual([]);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified-diff parsing for HkDiffViewer — pure functions, no Vue.
|
|
3
|
+
*
|
|
4
|
+
* Input is classic unified-diff text (`--- a/<path>` / `+++ b/<path>`
|
|
5
|
+
* headers followed by `@@ -oldStart[,oldCount] +newStart[,newCount @@`
|
|
6
|
+
* hunks). Only the hunk bodies are modeled; file headers are skipped
|
|
7
|
+
* (the consumer already knows the path and renders it itself).
|
|
8
|
+
*
|
|
9
|
+
* Line numbering follows the unified format contract: 1-based, and a
|
|
10
|
+
* `,count` of zero pins the start to the line BEFORE the range (so a
|
|
11
|
+
* pure-addition hunk reads `@@ -0,0 +1,31 @@`).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type DiffLineKind = "context" | "add" | "del";
|
|
15
|
+
|
|
16
|
+
export interface DiffLine {
|
|
17
|
+
kind: DiffLineKind;
|
|
18
|
+
/** 1-based old-side line number (null for additions). */
|
|
19
|
+
oldNo: number | null;
|
|
20
|
+
/** 1-based new-side line number (null for deletions). */
|
|
21
|
+
newNo: number | null;
|
|
22
|
+
/** Line content without the +/-/space prefix. */
|
|
23
|
+
text: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DiffHunk {
|
|
27
|
+
/** The raw `@@ … @@` line including any trailing section heading. */
|
|
28
|
+
header: string;
|
|
29
|
+
oldStart: number;
|
|
30
|
+
oldCount: number;
|
|
31
|
+
newStart: number;
|
|
32
|
+
newCount: number;
|
|
33
|
+
lines: DiffLine[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ParsedDiff {
|
|
37
|
+
hunks: DiffHunk[];
|
|
38
|
+
/**
|
|
39
|
+
* Highest new-side line number covered by the last hunk (0 when the
|
|
40
|
+
* diff carries no hunks). The collapsed region after the last hunk
|
|
41
|
+
* spans `endCovered + 1 … totalLines`.
|
|
42
|
+
*/
|
|
43
|
+
endCovered: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse unified-diff text into hunks. Malformed hunks degrade gracefully:
|
|
50
|
+
* a line that is neither prefix-tagged nor a new hunk header terminates
|
|
51
|
+
* the current hunk instead of corrupting line numbering.
|
|
52
|
+
*/
|
|
53
|
+
export function parseUnifiedDiff(diff: string): ParsedDiff {
|
|
54
|
+
const hunks: DiffHunk[] = [];
|
|
55
|
+
let cur: DiffHunk | null = null;
|
|
56
|
+
let oldNo = 0;
|
|
57
|
+
let newNo = 0;
|
|
58
|
+
|
|
59
|
+
// A trailing newline is the last line's terminator, not an extra line.
|
|
60
|
+
const text = diff.endsWith("\n") ? diff.slice(0, -1) : diff;
|
|
61
|
+
for (const rawLine of text.split("\n")) {
|
|
62
|
+
// Tolerate CRLF streams: a stray \r must not leak into line text.
|
|
63
|
+
const raw = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
64
|
+
const h = HUNK_RE.exec(raw);
|
|
65
|
+
if (h) {
|
|
66
|
+
cur = {
|
|
67
|
+
header: raw,
|
|
68
|
+
oldStart: Number(h[1]),
|
|
69
|
+
oldCount: h[2] === undefined ? 1 : Number(h[2]),
|
|
70
|
+
newStart: Number(h[3]),
|
|
71
|
+
newCount: h[4] === undefined ? 1 : Number(h[4]),
|
|
72
|
+
lines: [],
|
|
73
|
+
};
|
|
74
|
+
hunks.push(cur);
|
|
75
|
+
// A zero-count range starts one line earlier (the range is empty);
|
|
76
|
+
// the +1 below lands the first tagged line on the declared start.
|
|
77
|
+
oldNo = cur.oldStart + (cur.oldCount === 0 ? 1 : 0);
|
|
78
|
+
newNo = cur.newStart + (cur.newCount === 0 ? 1 : 0);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!cur) continue; // file headers (---/+++), index lines, …
|
|
82
|
+
const tag = raw[0];
|
|
83
|
+
if (tag === "\\") continue; // ""
|
|
84
|
+
const text = raw.slice(1);
|
|
85
|
+
if (tag === "+") {
|
|
86
|
+
cur.lines.push({ kind: "add", oldNo: null, newNo, text });
|
|
87
|
+
newNo += 1;
|
|
88
|
+
} else if (tag === "-") {
|
|
89
|
+
cur.lines.push({ kind: "del", oldNo, newNo: null, text });
|
|
90
|
+
oldNo += 1;
|
|
91
|
+
} else if (tag === " " || raw === "") {
|
|
92
|
+
// A truly empty line is a context line whose trailing space was
|
|
93
|
+
// stripped by an overeager serializer — accept it as context.
|
|
94
|
+
cur.lines.push({ kind: "context", oldNo, newNo, text: raw === "" ? "" : text });
|
|
95
|
+
oldNo += 1;
|
|
96
|
+
newNo += 1;
|
|
97
|
+
} else {
|
|
98
|
+
// Unknown line: end the hunk rather than corrupt numbering.
|
|
99
|
+
cur = null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const last = hunks[hunks.length - 1];
|
|
104
|
+
// Same zero-count anchor rule as computeGaps (R2): a deletion hunk
|
|
105
|
+
// `@@ -5,2 +4,0 @@` positions after new line 4, so it covers 1..4 —
|
|
106
|
+
// and a whole-file deletion still floors at 0 (nothing covered).
|
|
107
|
+
const endCovered = last
|
|
108
|
+
? Math.max(last.newStart + (last.newCount === 0 ? 1 : 0) + last.newCount - 1, 0)
|
|
109
|
+
: 0;
|
|
110
|
+
return { hunks, endCovered };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One collapsed stretch of unchanged lines, addressed on the new side. */
|
|
114
|
+
export interface UnchangedGap {
|
|
115
|
+
/** 1-based new-side line number of the first hidden line. */
|
|
116
|
+
start: number;
|
|
117
|
+
/** Number of hidden lines. */
|
|
118
|
+
count: number;
|
|
119
|
+
/**
|
|
120
|
+
* Shift from new-side to old-side numbering at this point in the file
|
|
121
|
+
* (oldNo = newNo − shift), so fetched context lines render with both
|
|
122
|
+
* line numbers.
|
|
123
|
+
*/
|
|
124
|
+
shift: number;
|
|
125
|
+
/** Expansion anchor: "end" for the leading gap (reveal near the first
|
|
126
|
+
* hunk first), "start" for every later gap. */
|
|
127
|
+
anchor: "start" | "end";
|
|
128
|
+
/** Index of the hunk that follows this gap (`hunks.length` = trailing). */
|
|
129
|
+
beforeHunk: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Compute the collapsed unchanged regions around `hunks`, each tagged
|
|
134
|
+
* with the hunk it precedes so the renderer can interleave hunks and
|
|
135
|
+
* gaps without index arithmetic. `totalLines` is the current file's line
|
|
136
|
+
* count; pass undefined/null when unknown — the trailing region is then
|
|
137
|
+
* omitted (it cannot be sized).
|
|
138
|
+
*/
|
|
139
|
+
export function computeGaps(parsed: ParsedDiff, totalLines?: number | null): UnchangedGap[] {
|
|
140
|
+
const gaps: UnchangedGap[] = [];
|
|
141
|
+
let prevEnd = 0; // highest new-side line covered so far
|
|
142
|
+
parsed.hunks.forEach((hunk, i) => {
|
|
143
|
+
// Zero-count ranges anchor one line into the existing side — the SAME
|
|
144
|
+
// adjustment the parser applies to line numbering (R2 finding: a
|
|
145
|
+
// `@@ -4,0 +5,2 @@` insertion sits after old line 4, so the leading
|
|
146
|
+
// gap is new 1..4 with shift 0, not 1..4-with-shift-1; using the raw
|
|
147
|
+
// starts rendered an "old 0" and mislabelled expanded context).
|
|
148
|
+
const oldAnchor = hunk.oldStart + (hunk.oldCount === 0 ? 1 : 0);
|
|
149
|
+
const newAnchor = hunk.newStart + (hunk.newCount === 0 ? 1 : 0);
|
|
150
|
+
const gapStart = prevEnd + 1;
|
|
151
|
+
const gapEnd = newAnchor - 1;
|
|
152
|
+
if (gapEnd >= gapStart) {
|
|
153
|
+
gaps.push({
|
|
154
|
+
start: gapStart,
|
|
155
|
+
count: gapEnd - gapStart + 1,
|
|
156
|
+
shift: newAnchor - oldAnchor,
|
|
157
|
+
anchor: i === 0 && prevEnd === 0 ? "end" : "start",
|
|
158
|
+
beforeHunk: i,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
prevEnd = newAnchor + hunk.newCount - 1;
|
|
162
|
+
});
|
|
163
|
+
// A whole-file deletion ends at new-side 0 under the anchor rule; clamp
|
|
164
|
+
// before sizing anything so the trailing region can never invent
|
|
165
|
+
// line 0 (R1 finding: loadContext(0, …) is off the 1-based contract).
|
|
166
|
+
const covered = Math.max(prevEnd, 0);
|
|
167
|
+
if (totalLines != null && totalLines > covered) {
|
|
168
|
+
const last = parsed.hunks[parsed.hunks.length - 1];
|
|
169
|
+
const lastOldAnchor = last.oldStart + (last.oldCount === 0 ? 1 : 0);
|
|
170
|
+
const lastNewAnchor = last.newStart + (last.newCount === 0 ? 1 : 0);
|
|
171
|
+
gaps.push({
|
|
172
|
+
start: covered + 1,
|
|
173
|
+
count: totalLines - covered,
|
|
174
|
+
shift: last ? lastNewAnchor + last.newCount - (lastOldAnchor + last.oldCount) : 0,
|
|
175
|
+
anchor: "start",
|
|
176
|
+
beforeHunk: parsed.hunks.length,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return gaps;
|
|
180
|
+
}
|
|
@@ -274,5 +274,7 @@
|
|
|
274
274
|
"hikari::userPicker.confirm": "تأكيد",
|
|
275
275
|
"hikari::userPicker.selectedCount": "تم اختيار {count}",
|
|
276
276
|
"hikari::tokenField.editValue": "تحرير القيمة",
|
|
277
|
-
"hikari::tokenField.clear": "مسح"
|
|
277
|
+
"hikari::tokenField.clear": "مسح",
|
|
278
|
+
"hikari::diff.unchangedLines": "{count} أسطر غير معدّلة",
|
|
279
|
+
"hikari::diff.loadFailed": "فشل التحميل، انقر لإعادة المحاولة"
|
|
278
280
|
}
|
|
@@ -274,5 +274,7 @@
|
|
|
274
274
|
"hikari::userPicker.confirm": "Bestätigen",
|
|
275
275
|
"hikari::userPicker.selectedCount": "{count} ausgewählt",
|
|
276
276
|
"hikari::tokenField.editValue": "Wert bearbeiten",
|
|
277
|
-
"hikari::tokenField.clear": "Leeren"
|
|
277
|
+
"hikari::tokenField.clear": "Leeren",
|
|
278
|
+
"hikari::diff.unchangedLines": "{count} unveränderte Zeilen",
|
|
279
|
+
"hikari::diff.loadFailed": "Laden fehlgeschlagen, zum Wiederholen klicken"
|
|
278
280
|
}
|
|
@@ -274,5 +274,7 @@
|
|
|
274
274
|
"hikari::userPicker.confirm": "Confirm",
|
|
275
275
|
"hikari::userPicker.selectedCount": "{count} selected",
|
|
276
276
|
"hikari::tokenField.editValue": "Edit value",
|
|
277
|
-
"hikari::tokenField.clear": "Clear"
|
|
277
|
+
"hikari::tokenField.clear": "Clear",
|
|
278
|
+
"hikari::diff.unchangedLines": "{count} unchanged lines",
|
|
279
|
+
"hikari::diff.loadFailed": "Load failed, click to retry"
|
|
278
280
|
}
|
|
@@ -274,5 +274,7 @@
|
|
|
274
274
|
"hikari::userPicker.confirm": "Confirmar",
|
|
275
275
|
"hikari::userPicker.selectedCount": "{count} seleccionados",
|
|
276
276
|
"hikari::tokenField.editValue": "Editar el valor",
|
|
277
|
-
"hikari::tokenField.clear": "Borrar"
|
|
277
|
+
"hikari::tokenField.clear": "Borrar",
|
|
278
|
+
"hikari::diff.unchangedLines": "{count} líneas sin cambios",
|
|
279
|
+
"hikari::diff.loadFailed": "Error al cargar, haz clic para reintentar"
|
|
278
280
|
}
|
|
@@ -274,5 +274,7 @@
|
|
|
274
274
|
"hikari::userPicker.confirm": "Confirmer",
|
|
275
275
|
"hikari::userPicker.selectedCount": "{count} sélectionnés",
|
|
276
276
|
"hikari::tokenField.editValue": "Modifier la valeur",
|
|
277
|
-
"hikari::tokenField.clear": "Effacer"
|
|
277
|
+
"hikari::tokenField.clear": "Effacer",
|
|
278
|
+
"hikari::diff.unchangedLines": "{count} lignes inchangées",
|
|
279
|
+
"hikari::diff.loadFailed": "Échec du chargement, cliquez pour réessayer"
|
|
278
280
|
}
|
|
@@ -274,5 +274,7 @@
|
|
|
274
274
|
"hikari::userPicker.confirm": "確認",
|
|
275
275
|
"hikari::userPicker.selectedCount": "{count} 件選択中",
|
|
276
276
|
"hikari::tokenField.editValue": "値を編集",
|
|
277
|
-
"hikari::tokenField.clear": "クリア"
|
|
277
|
+
"hikari::tokenField.clear": "クリア",
|
|
278
|
+
"hikari::diff.unchangedLines": "未変更の {count} 行",
|
|
279
|
+
"hikari::diff.loadFailed": "読み込みに失敗しました。クリックで再試行"
|
|
278
280
|
}
|
|
@@ -274,5 +274,7 @@
|
|
|
274
274
|
"hikari::userPicker.confirm": "확인",
|
|
275
275
|
"hikari::userPicker.selectedCount": "{count}명 선택됨",
|
|
276
276
|
"hikari::tokenField.editValue": "값 편집",
|
|
277
|
-
"hikari::tokenField.clear": "지우기"
|
|
277
|
+
"hikari::tokenField.clear": "지우기",
|
|
278
|
+
"hikari::diff.unchangedLines": "변경되지 않은 {count}줄",
|
|
279
|
+
"hikari::diff.loadFailed": "불러오기 실패, 클릭하여 재시도"
|
|
278
280
|
}
|
|
@@ -274,5 +274,7 @@
|
|
|
274
274
|
"hikari::userPicker.confirm": "Confirmar",
|
|
275
275
|
"hikari::userPicker.selectedCount": "{count} selecionados",
|
|
276
276
|
"hikari::tokenField.editValue": "Editar o valor",
|
|
277
|
-
"hikari::tokenField.clear": "Limpar"
|
|
277
|
+
"hikari::tokenField.clear": "Limpar",
|
|
278
|
+
"hikari::diff.unchangedLines": "{count} linhas inalteradas",
|
|
279
|
+
"hikari::diff.loadFailed": "Falha ao carregar, clique para tentar novamente"
|
|
278
280
|
}
|
|
@@ -274,5 +274,7 @@
|
|
|
274
274
|
"hikari::userPicker.confirm": "Подтвердить",
|
|
275
275
|
"hikari::userPicker.selectedCount": "Выбрано: {count}",
|
|
276
276
|
"hikari::tokenField.editValue": "Изменить значение",
|
|
277
|
-
"hikari::tokenField.clear": "Очистить"
|
|
277
|
+
"hikari::tokenField.clear": "Очистить",
|
|
278
|
+
"hikari::diff.unchangedLines": "{count} строк без изменений",
|
|
279
|
+
"hikari::diff.loadFailed": "Не удалось загрузить, нажмите для повтора"
|
|
278
280
|
}
|
|
@@ -278,5 +278,7 @@
|
|
|
278
278
|
"hikari::userPicker.confirm": "确认",
|
|
279
279
|
"hikari::userPicker.selectedCount": "已选 {count} 人",
|
|
280
280
|
"hikari::tokenField.editValue": "编辑数值",
|
|
281
|
-
"hikari::tokenField.clear": "清除"
|
|
281
|
+
"hikari::tokenField.clear": "清除",
|
|
282
|
+
"hikari::diff.unchangedLines": "{count} 行未修改",
|
|
283
|
+
"hikari::diff.loadFailed": "加载失败,点击重试"
|
|
282
284
|
}
|
|
@@ -278,5 +278,7 @@
|
|
|
278
278
|
"hikari::userPicker.confirm": "確認",
|
|
279
279
|
"hikari::userPicker.selectedCount": "已選 {count} 人",
|
|
280
280
|
"hikari::tokenField.editValue": "編輯數值",
|
|
281
|
-
"hikari::tokenField.clear": "清除"
|
|
281
|
+
"hikari::tokenField.clear": "清除",
|
|
282
|
+
"hikari::diff.unchangedLines": "{count} 行未修改",
|
|
283
|
+
"hikari::diff.loadFailed": "載入失敗,點擊重試"
|
|
282
284
|
}
|
package/src/index.ts
CHANGED
|
@@ -694,6 +694,9 @@ export { default as HkCopyBadge } from "./components/HkCopyBadge";
|
|
|
694
694
|
export { default as HkConfirmDialog } from "./components/HkConfirmDialog";
|
|
695
695
|
export { default as HkCrossfade } from "./components/HkCrossfade";
|
|
696
696
|
export { default as HkDivider } from "./components/HkDivider";
|
|
697
|
+
export { default as HkDiffViewer } from "./components/HkDiffViewer";
|
|
698
|
+
export { parseUnifiedDiff, computeGaps } from "./components/diffParse";
|
|
699
|
+
export type { DiffLine, DiffHunk, ParsedDiff, UnchangedGap } from "./components/diffParse";
|
|
697
700
|
export { default as HkDockBar } from "./components/HkDockBar";
|
|
698
701
|
export { default as HkDrawer } from "./components/HkDrawer";
|
|
699
702
|
export { default as HkEmptyState } from "./components/HkEmptyState";
|