@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.148",
3
+ "version": "0.55.149",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -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";