@aparte/plugin-diff 0.0.0-stage → 1.7.0

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/dist/index.js ADDED
@@ -0,0 +1,1082 @@
1
+ import { pluralize, subscribeConfigChange, resolveConfig } from "@aparte/core";
2
+ import { p as parseUnifiedDiff, d as diffTexts, a as splitLines } from "./setup-CQYdbRuR.js";
3
+ import { f, s } from "./setup-CQYdbRuR.js";
4
+ const BY_EXTENSION = {
5
+ ts: "typescript",
6
+ mts: "typescript",
7
+ cts: "typescript",
8
+ tsx: "tsx",
9
+ js: "javascript",
10
+ mjs: "javascript",
11
+ cjs: "javascript",
12
+ jsx: "jsx",
13
+ md: "markdown",
14
+ markdown: "markdown",
15
+ mdx: "mdx",
16
+ py: "python",
17
+ rb: "ruby",
18
+ rs: "rust",
19
+ kt: "kotlin",
20
+ kts: "kotlin",
21
+ h: "c",
22
+ hpp: "cpp",
23
+ cc: "cpp",
24
+ cxx: "cpp",
25
+ cs: "csharp",
26
+ sh: "shellscript",
27
+ bash: "shellscript",
28
+ zsh: "shellscript",
29
+ ps1: "powershell",
30
+ yml: "yaml",
31
+ htm: "html",
32
+ svg: "xml",
33
+ patch: "diff"
34
+ };
35
+ const BY_NAME = { dockerfile: "dockerfile", makefile: "make" };
36
+ function languageOfPath(path) {
37
+ const name = path.slice(path.lastIndexOf("/") + 1).toLowerCase();
38
+ if (BY_NAME[name]) return BY_NAME[name];
39
+ const dot = name.lastIndexOf(".");
40
+ if (dot <= 0) return "";
41
+ const extension = name.slice(dot + 1);
42
+ return BY_EXTENSION[extension] ?? extension;
43
+ }
44
+ async function highlightLines(config, lines, language) {
45
+ if (!lines.length || !config.hasHighlightProvider() || typeof document === "undefined") return null;
46
+ let html;
47
+ try {
48
+ html = await config.highlightCode(lines.join("\n"), language || "text", { fallback: false });
49
+ } catch {
50
+ return null;
51
+ }
52
+ const box = document.createElement("div");
53
+ box.innerHTML = html;
54
+ const cut = box.querySelectorAll(".line");
55
+ if (cut.length !== lines.length) return null;
56
+ return { lines: [...cut].map((line) => line.innerHTML), className: box.querySelector("pre")?.className.trim() ?? "" };
57
+ }
58
+ const styles = "/*\n * @aparte/plugin-diff — a change, line by line.\n *\n * Only what is the plugin's own: the file's frame and header, the grid of lines, the folds,\n * the comments. The buttons are core's `aparte-btn`, the field core's `aparte-field`, the\n * tags core's `aparte-badge`; this sheet places them and draws none of them.\n *\n * The lines are one grid per file — old number, new number, marker, code — so the numbers\n * line up whatever their width, and each row is a subgrid that paints its own tint. The grid\n * carries `dir=\"ltr\"`: code reads left to right in a right-to-left page; everything around\n * it uses logical properties and follows the page.\n *\n * The tints are the plugin's tokens, each falling back to core's: added on the success\n * surface, removed on the error surface, so a theme that moves those moves the diff.\n */\naparte-diff {\n display: block;\n min-inline-size: 0;\n color: var(--aparte-text);\n font-family: var(--aparte-font-family);\n font-size: var(--aparte-font-size-sm);\n}\n\naparte-diff[hidden] {\n display: none;\n}\n\n.aparte-diff-file {\n overflow: hidden;\n border: var(--aparte-border-width) solid var(--aparte-code-border);\n border-radius: var(--aparte-radius-md);\n background: var(--aparte-code-bg);\n}\n\n.aparte-diff-file + .aparte-diff-file {\n margin-block-start: var(--aparte-space-3);\n}\n\n/* ── The header: path, what happened, counts ─────────────────────────────── */\n\n.aparte-diff-file__head {\n display: flex;\n align-items: center;\n gap: var(--aparte-space-2);\n padding: var(--aparte-space-1) var(--aparte-space-2);\n background: var(--aparte-code-header-bg);\n color: var(--aparte-code-header-text);\n}\n\n/* The path takes the room, and its button reads from the leading edge. */\n.aparte-diff-file__toggle {\n flex: 1 1 auto;\n justify-content: flex-start;\n min-inline-size: 0;\n color: inherit;\n}\n\n.aparte-diff-file__chevron {\n display: inline-flex;\n flex: none;\n transition: transform var(--aparte-duration-fast);\n}\n\n.aparte-diff-file__toggle[aria-expanded='false'] > .aparte-diff-file__chevron {\n transform: rotate(-90deg);\n}\n\n.aparte-diff-file__toggle[aria-expanded='false']:dir(rtl) > .aparte-diff-file__chevron {\n transform: rotate(90deg);\n}\n\n.aparte-diff-file__path {\n overflow: hidden;\n font-family: var(--aparte-code-font-family);\n font-weight: var(--aparte-font-weight-semibold);\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.aparte-diff-file__status {\n flex: none;\n}\n\n.aparte-diff-file__stat {\n display: inline-flex;\n flex: none;\n gap: var(--aparte-space-2);\n padding-inline-end: var(--aparte-space-1);\n font-family: var(--aparte-code-font-family);\n font-variant-numeric: tabular-nums;\n}\n\n.aparte-diff-file__added {\n color: var(--aparte-diff-added-ink, var(--aparte-success-ink));\n}\n\n.aparte-diff-file__removed {\n color: var(--aparte-diff-removed-ink, var(--aparte-error-ink));\n}\n\n/* ── The lines ───────────────────────────────────────────────────────────── */\n\n/* The rule under the header belongs to the lines: a folded file has none. */\n.aparte-diff-file__lines {\n display: grid;\n grid-template-columns: max-content max-content max-content minmax(0, 1fr);\n border-block-start: var(--aparte-border-width) solid var(--aparte-code-border);\n font-family: var(--aparte-code-font-family);\n font-size: var(--aparte-code-block-font-size);\n line-height: var(--aparte-code-block-line-height);\n}\n\n.aparte-diff-file__lines[hidden] {\n display: none;\n}\n\n.aparte-diff-line {\n display: grid;\n grid-column: 1 / -1;\n grid-template-columns: subgrid;\n}\n\n.aparte-diff-line[data-kind='add'] {\n background: var(--aparte-diff-added-bg, var(--aparte-success-surface));\n}\n\n.aparte-diff-line[data-kind='del'] {\n background: var(--aparte-diff-removed-bg, var(--aparte-error-surface));\n}\n\n/* Picked for a comment: its own tint, and a bar on the leading edge of the code. */\n.aparte-diff-line[data-selected] {\n background: var(--aparte-diff-selected-bg, var(--aparte-surface-hover));\n box-shadow: inset var(--aparte-space-1) 0 0 var(--aparte-primary);\n}\n\n.aparte-diff-line:focus {\n outline: none;\n}\n\n/* The two numbers. A `<span>`, or with `commentable` a button that draws as a number: no chrome. */\n.aparte-diff-line__gutter {\n display: grid;\n grid-column: span 2;\n grid-template-columns: subgrid;\n margin: 0;\n padding: 0;\n border: 0;\n background: none;\n color: var(--aparte-text-muted);\n font: inherit;\n user-select: none;\n}\n\nbutton.aparte-diff-line__gutter {\n cursor: pointer;\n}\n\nbutton.aparte-diff-line__gutter:hover,\n.aparte-diff-line[data-selected] > .aparte-diff-line__gutter {\n color: var(--aparte-text);\n}\n\nbutton.aparte-diff-line__gutter:focus-visible {\n outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);\n outline-offset: calc(-1 * var(--aparte-focus-outline-width));\n}\n\n.aparte-diff-line__num {\n min-inline-size: 3ch;\n padding-inline: var(--aparte-space-2);\n font-variant-numeric: tabular-nums;\n text-align: end;\n}\n\n.aparte-diff-line__marker {\n padding-inline: var(--aparte-space-1);\n color: var(--aparte-text-muted);\n user-select: none;\n}\n\n.aparte-diff-line[data-kind='add'] > .aparte-diff-line__marker {\n color: var(--aparte-diff-added-ink, var(--aparte-success-ink));\n}\n\n.aparte-diff-line[data-kind='del'] > .aparte-diff-line__marker {\n color: var(--aparte-diff-removed-ink, var(--aparte-error-ink));\n}\n\n/* Wraps rather than scrolls: no line hides past the edge, and the view needs no tab stop to scroll. */\n.aparte-diff-line__code {\n padding-block: 0;\n padding-inline: 0 var(--aparte-space-3);\n border: 0;\n background: none;\n color: inherit;\n font: inherit;\n overflow-wrap: anywhere;\n tab-size: 4;\n white-space: pre-wrap;\n}\n\n/* ── Between the lines ───────────────────────────────────────────────────── */\n\n.aparte-diff-fold,\n.aparte-diff-eof,\n.aparte-diff-file__lines > .aparte-diff-empty,\n.aparte-diff-file__lines > .aparte-diff-binary {\n grid-column: 1 / -1;\n}\n\n.aparte-diff-fold {\n display: flex;\n align-items: center;\n gap: var(--aparte-space-2);\n min-inline-size: 0;\n padding: var(--aparte-space-1) var(--aparte-space-2);\n background: var(--aparte-surface-2);\n color: var(--aparte-text-muted);\n font-family: var(--aparte-font-family);\n font-size: var(--aparte-font-size-sm);\n}\n\n.aparte-diff-fold__show {\n flex: none;\n}\n\n.aparte-diff-fold__count {\n flex: none;\n padding-inline: var(--aparte-space-2);\n}\n\n.aparte-diff-fold__section {\n min-inline-size: 0;\n overflow: hidden;\n font-family: var(--aparte-code-font-family);\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.aparte-diff-eof {\n padding-inline-start: var(--aparte-space-3);\n color: var(--aparte-text-muted);\n font-family: var(--aparte-font-family);\n font-size: var(--aparte-font-size-xs);\n}\n\n.aparte-diff-empty,\n.aparte-diff-binary {\n margin: 0;\n padding: var(--aparte-space-3);\n color: var(--aparte-text-muted);\n font-family: var(--aparte-font-family);\n}\n\n/* ── Comments ────────────────────────────────────────────────────────────── */\n\n.aparte-diff-comment,\n.aparte-diff-form {\n grid-column: 1 / -1;\n margin-block: var(--aparte-space-2);\n margin-inline: var(--aparte-space-3);\n font-family: var(--aparte-font-family);\n font-size: var(--aparte-font-size-sm);\n line-height: var(--aparte-line-height-normal);\n}\n\n.aparte-diff-comment {\n padding: var(--aparte-space-2) var(--aparte-space-3);\n border: var(--aparte-border-width) solid var(--aparte-border);\n border-inline-start: var(--aparte-space-1) solid var(--aparte-primary);\n border-radius: var(--aparte-radius-md);\n background: var(--aparte-surface-1);\n}\n\n.aparte-diff-comment__head {\n display: flex;\n align-items: center;\n gap: var(--aparte-space-2);\n color: var(--aparte-text-muted);\n font-size: var(--aparte-font-size-xs);\n}\n\n.aparte-diff-comment__text {\n margin-block: var(--aparte-space-1) 0;\n margin-inline: 0;\n color: var(--aparte-text);\n overflow-wrap: anywhere;\n white-space: pre-wrap;\n}\n\n.aparte-diff-form {\n display: grid;\n gap: var(--aparte-space-2);\n}\n\n.aparte-diff-form__label {\n color: var(--aparte-text-muted);\n font-size: var(--aparte-font-size-xs);\n}\n\n.aparte-diff-form__actions {\n display: flex;\n justify-content: flex-end;\n gap: var(--aparte-space-2);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .aparte-diff-file__chevron {\n transition: none;\n }\n}\n";
59
+ const STYLE_ID = "aparte-diff-styles";
60
+ function ensureStyles(doc = document) {
61
+ if (doc.getElementById(STYLE_ID)) return;
62
+ const style = doc.createElement("style");
63
+ style.id = STYLE_ID;
64
+ style.textContent = styles;
65
+ doc.head.appendChild(style);
66
+ }
67
+ const WORDS = {
68
+ /** A file's added lines, said to a screen reader beside the `+3` it shows. */
69
+ diffAdded: {
70
+ en: "one:{n} line added|other:{n} lines added",
71
+ fr: "one:{n} ligne ajoutée|other:{n} lignes ajoutées",
72
+ de: "one:{n} Zeile hinzugefügt|other:{n} Zeilen hinzugefügt",
73
+ es: "one:{n} línea añadida|other:{n} líneas añadidas",
74
+ it: "one:{n} riga aggiunta|other:{n} righe aggiunte",
75
+ ja: "other:{n} 行追加",
76
+ ko: "other:{n}줄 추가됨",
77
+ pt: "one:{n} linha adicionada|other:{n} linhas adicionadas",
78
+ ru: "one:{n} строка добавлена|few:{n} строки добавлены|many:{n} строк добавлено|other:{n} строки добавлено",
79
+ zh: "other:新增 {n} 行"
80
+ },
81
+ /** A file's removed lines, said to a screen reader beside the `−1` it shows. */
82
+ diffRemoved: {
83
+ en: "one:{n} line removed|other:{n} lines removed",
84
+ fr: "one:{n} ligne supprimée|other:{n} lignes supprimées",
85
+ de: "one:{n} Zeile entfernt|other:{n} Zeilen entfernt",
86
+ es: "one:{n} línea eliminada|other:{n} líneas eliminadas",
87
+ it: "one:{n} riga rimossa|other:{n} righe rimosse",
88
+ ja: "other:{n} 行削除",
89
+ ko: "other:{n}줄 삭제됨",
90
+ pt: "one:{n} linha removida|other:{n} linhas removidas",
91
+ ru: "one:{n} строка удалена|few:{n} строки удалены|many:{n} строк удалено|other:{n} строки удалено",
92
+ zh: "other:删除 {n} 行"
93
+ },
94
+ /** What the `+` of an added line says to a screen reader. */
95
+ diffMarkerAdded: {
96
+ en: "added",
97
+ fr: "ajoutée",
98
+ de: "hinzugefügt",
99
+ es: "añadida",
100
+ it: "aggiunta",
101
+ ja: "追加",
102
+ ko: "추가됨",
103
+ pt: "adicionada",
104
+ ru: "добавлена",
105
+ zh: "新增"
106
+ },
107
+ /** What the `−` of a removed line says to a screen reader. */
108
+ diffMarkerRemoved: {
109
+ en: "removed",
110
+ fr: "supprimée",
111
+ de: "entfernt",
112
+ es: "eliminada",
113
+ it: "rimossa",
114
+ ja: "削除",
115
+ ko: "삭제됨",
116
+ pt: "removida",
117
+ ru: "удалена",
118
+ zh: "删除"
119
+ },
120
+ /** The button over a run of unchanged lines the element can show. */
121
+ diffShowUnchanged: {
122
+ en: "one:Show {n} unchanged line|other:Show {n} unchanged lines",
123
+ fr: "one:Afficher {n} ligne inchangée|other:Afficher {n} lignes inchangées",
124
+ de: "one:{n} unveränderte Zeile anzeigen|other:{n} unveränderte Zeilen anzeigen",
125
+ es: "one:Mostrar {n} línea sin cambios|other:Mostrar {n} líneas sin cambios",
126
+ it: "one:Mostra {n} riga invariata|other:Mostra {n} righe invariate",
127
+ ja: "other:変更のない {n} 行を表示",
128
+ ko: "other:변경되지 않은 {n}줄 표시",
129
+ pt: "one:Mostrar {n} linha inalterada|other:Mostrar {n} linhas inalteradas",
130
+ ru: "one:Показать {n} неизменённую строку|few:Показать {n} неизменённые строки|many:Показать {n} неизменённых строк|other:Показать {n} неизменённой строки",
131
+ zh: "other:显示 {n} 行未更改的内容"
132
+ },
133
+ /** A run of unchanged lines the diff does not hold (a unified diff's text), between two hunks. */
134
+ diffUnchanged: {
135
+ en: "one:{n} unchanged line|other:{n} unchanged lines",
136
+ fr: "one:{n} ligne inchangée|other:{n} lignes inchangées",
137
+ de: "one:{n} unveränderte Zeile|other:{n} unveränderte Zeilen",
138
+ es: "one:{n} línea sin cambios|other:{n} líneas sin cambios",
139
+ it: "one:{n} riga invariata|other:{n} righe invariate",
140
+ ja: "other:変更のない {n} 行",
141
+ ko: "other:변경되지 않은 {n}줄",
142
+ pt: "one:{n} linha inalterada|other:{n} linhas inalteradas",
143
+ ru: "one:{n} неизменённая строка|few:{n} неизменённые строки|many:{n} неизменённых строк|other:{n} неизменённой строки",
144
+ zh: "other:{n} 行未更改"
145
+ },
146
+ /** A new file's tag, beside its path. */
147
+ diffStatusAdded: {
148
+ en: "Added",
149
+ fr: "Ajouté",
150
+ de: "Neu",
151
+ es: "Añadido",
152
+ it: "Aggiunto",
153
+ ja: "追加",
154
+ ko: "추가됨",
155
+ pt: "Adicionado",
156
+ ru: "Добавлен",
157
+ zh: "新增"
158
+ },
159
+ /** A deleted file's tag, beside its path. */
160
+ diffStatusDeleted: {
161
+ en: "Deleted",
162
+ fr: "Supprimé",
163
+ de: "Gelöscht",
164
+ es: "Eliminado",
165
+ it: "Eliminato",
166
+ ja: "削除",
167
+ ko: "삭제됨",
168
+ pt: "Excluído",
169
+ ru: "Удалён",
170
+ zh: "已删除"
171
+ },
172
+ /** A renamed file's tag, beside its paths. */
173
+ diffStatusRenamed: {
174
+ en: "Renamed",
175
+ fr: "Renommé",
176
+ de: "Umbenannt",
177
+ es: "Renombrado",
178
+ it: "Rinominato",
179
+ ja: "名前変更",
180
+ ko: "이름 변경됨",
181
+ pt: "Renomeado",
182
+ ru: "Переименован",
183
+ zh: "已重命名"
184
+ },
185
+ /** Where a binary file's lines would be. */
186
+ diffBinary: {
187
+ en: "Binary file, no lines to show",
188
+ fr: "Fichier binaire, aucune ligne à afficher",
189
+ de: "Binärdatei, keine Zeilen anzuzeigen",
190
+ es: "Archivo binario, no hay líneas que mostrar",
191
+ it: "File binario, nessuna riga da mostrare",
192
+ ja: "バイナリファイルのため表示する行はありません",
193
+ ko: "바이너리 파일이라 표시할 줄이 없습니다",
194
+ pt: "Arquivo binário, nenhuma linha para mostrar",
195
+ ru: "Двоичный файл, строк для показа нет",
196
+ zh: "二进制文件,没有可显示的行"
197
+ },
198
+ /** Under the last line of a file that ends without a newline. */
199
+ diffNoNewline: {
200
+ en: "No newline at end of file",
201
+ fr: "Pas de saut de ligne à la fin du fichier",
202
+ de: "Kein Zeilenumbruch am Dateiende",
203
+ es: "Sin salto de línea al final del archivo",
204
+ it: "Nessun a capo alla fine del file",
205
+ ja: "ファイル末尾に改行がありません",
206
+ ko: "파일 끝에 줄바꿈 없음",
207
+ pt: "Sem quebra de linha no fim do arquivo",
208
+ ru: "Нет перевода строки в конце файла",
209
+ zh: "文件末尾没有换行符"
210
+ },
211
+ /** An unchanged line's number, as its button and a comment name it. */
212
+ diffLine: {
213
+ en: "Line {n}",
214
+ fr: "Ligne {n}",
215
+ de: "Zeile {n}",
216
+ es: "Línea {n}",
217
+ it: "Riga {n}",
218
+ ja: "{n} 行目",
219
+ ko: "{n}번째 줄",
220
+ pt: "Linha {n}",
221
+ ru: "Строка {n}",
222
+ zh: "第 {n} 行"
223
+ },
224
+ /** An added line's number, as its button names it. */
225
+ diffLineAdded: {
226
+ en: "Added line {n}",
227
+ fr: "Ligne ajoutée {n}",
228
+ de: "Hinzugefügte Zeile {n}",
229
+ es: "Línea añadida {n}",
230
+ it: "Riga aggiunta {n}",
231
+ ja: "追加された {n} 行目",
232
+ ko: "추가된 {n}번째 줄",
233
+ pt: "Linha adicionada {n}",
234
+ ru: "Добавленная строка {n}",
235
+ zh: "新增的第 {n} 行"
236
+ },
237
+ /** A removed line's number, as its button and a comment name it. */
238
+ diffLineRemoved: {
239
+ en: "Removed line {n}",
240
+ fr: "Ligne supprimée {n}",
241
+ de: "Entfernte Zeile {n}",
242
+ es: "Línea eliminada {n}",
243
+ it: "Riga rimossa {n}",
244
+ ja: "削除された {n} 行目",
245
+ ko: "삭제된 {n}번째 줄",
246
+ pt: "Linha removida {n}",
247
+ ru: "Удалённая строка {n}",
248
+ zh: "删除的第 {n} 行"
249
+ },
250
+ /** A range a comment is on. */
251
+ diffLines: {
252
+ en: "Lines {start}–{end}",
253
+ fr: "Lignes {start} à {end}",
254
+ de: "Zeilen {start}–{end}",
255
+ es: "Líneas {start}–{end}",
256
+ it: "Righe {start}–{end}",
257
+ ja: "{start}〜{end} 行目",
258
+ ko: "{start}~{end}번째 줄",
259
+ pt: "Linhas {start}–{end}",
260
+ ru: "Строки {start}–{end}",
261
+ zh: "第 {start}–{end} 行"
262
+ },
263
+ /** A range of removed lines a comment is on. */
264
+ diffLinesRemoved: {
265
+ en: "Removed lines {start}–{end}",
266
+ fr: "Lignes supprimées {start} à {end}",
267
+ de: "Entfernte Zeilen {start}–{end}",
268
+ es: "Líneas eliminadas {start}–{end}",
269
+ it: "Righe rimosse {start}–{end}",
270
+ ja: "削除された {start}〜{end} 行目",
271
+ ko: "삭제된 {start}~{end}번째 줄",
272
+ pt: "Linhas removidas {start}–{end}",
273
+ ru: "Удалённые строки {start}–{end}",
274
+ zh: "删除的第 {start}–{end} 行"
275
+ },
276
+ /** The comment field's name, on one line. */
277
+ diffCommentLine: {
278
+ en: "Comment on line {n}",
279
+ fr: "Commenter la ligne {n}",
280
+ de: "Zeile {n} kommentieren",
281
+ es: "Comentar la línea {n}",
282
+ it: "Commenta la riga {n}",
283
+ ja: "{n} 行目にコメント",
284
+ ko: "{n}번째 줄에 댓글",
285
+ pt: "Comentar a linha {n}",
286
+ ru: "Комментарий к строке {n}",
287
+ zh: "评论第 {n} 行"
288
+ },
289
+ /** The comment field's name, on a range. */
290
+ diffCommentLines: {
291
+ en: "Comment on lines {start}–{end}",
292
+ fr: "Commenter les lignes {start} à {end}",
293
+ de: "Zeilen {start}–{end} kommentieren",
294
+ es: "Comentar las líneas {start}–{end}",
295
+ it: "Commenta le righe {start}–{end}",
296
+ ja: "{start}〜{end} 行目にコメント",
297
+ ko: "{start}~{end}번째 줄에 댓글",
298
+ pt: "Comentar as linhas {start}–{end}",
299
+ ru: "Комментарий к строкам {start}–{end}",
300
+ zh: "评论第 {start}–{end} 行"
301
+ },
302
+ /** What the empty comment field shows. */
303
+ diffCommentPlaceholder: {
304
+ en: "Leave a comment",
305
+ fr: "Laisser un commentaire",
306
+ de: "Kommentar schreiben",
307
+ es: "Deja un comentario",
308
+ it: "Lascia un commento",
309
+ ja: "コメントを入力",
310
+ ko: "댓글 남기기",
311
+ pt: "Deixe um comentário",
312
+ ru: "Оставьте комментарий",
313
+ zh: "留下评论"
314
+ },
315
+ /** The button that sends the comment. */
316
+ diffCommentSubmit: {
317
+ en: "Comment",
318
+ fr: "Commenter",
319
+ de: "Kommentieren",
320
+ es: "Comentar",
321
+ it: "Commenta",
322
+ ja: "コメント",
323
+ ko: "댓글 달기",
324
+ pt: "Comentar",
325
+ ru: "Комментировать",
326
+ zh: "评论"
327
+ },
328
+ /** The button that closes the comment field. */
329
+ diffCommentCancel: {
330
+ en: "Cancel",
331
+ fr: "Annuler",
332
+ de: "Abbrechen",
333
+ es: "Cancelar",
334
+ it: "Annulla",
335
+ ja: "キャンセル",
336
+ ko: "취소",
337
+ pt: "Cancelar",
338
+ ru: "Отмена",
339
+ zh: "取消"
340
+ },
341
+ /** A comment kept to be sent. */
342
+ diffCommentPending: {
343
+ en: "Pending",
344
+ fr: "En attente",
345
+ de: "Ausstehend",
346
+ es: "Pendiente",
347
+ it: "In attesa",
348
+ ja: "保留中",
349
+ ko: "대기 중",
350
+ pt: "Pendente",
351
+ ru: "Ожидает",
352
+ zh: "待发送"
353
+ },
354
+ /** A comment that was sent. */
355
+ diffCommentSent: {
356
+ en: "Sent",
357
+ fr: "Envoyé",
358
+ de: "Gesendet",
359
+ es: "Enviado",
360
+ it: "Inviato",
361
+ ja: "送信済み",
362
+ ko: "보냄",
363
+ pt: "Enviado",
364
+ ru: "Отправлен",
365
+ zh: "已发送"
366
+ },
367
+ /** A diff with no change in it. */
368
+ diffEmpty: {
369
+ en: "No changes",
370
+ fr: "Aucun changement",
371
+ de: "Keine Änderungen",
372
+ es: "Sin cambios",
373
+ it: "Nessuna modifica",
374
+ ja: "変更はありません",
375
+ ko: "변경 사항 없음",
376
+ pt: "Nenhuma alteração",
377
+ ru: "Изменений нет",
378
+ zh: "没有更改"
379
+ }
380
+ };
381
+ function languageOf(config) {
382
+ const tag = config.getLocale().tag;
383
+ return typeof tag === "string" && tag ? tag.split("-")[0].toLowerCase() : "en";
384
+ }
385
+ function word(config, key, vars = {}) {
386
+ const fromApp = config.getLocale()[key];
387
+ const table = WORDS[key];
388
+ let text = typeof fromApp === "string" && fromApp || table[languageOf(config)] || table.en;
389
+ if (typeof vars["n"] === "number" && /^(zero|one|two|few|many|other):/.test(text)) {
390
+ const tag = config.getLocale().tag;
391
+ text = pluralize(text, vars["n"], typeof tag === "string" && tag ? tag : void 0);
392
+ }
393
+ return text.replace(/\{(\w+)\}/g, (slot, name) => name in vars ? String(vars[name]) : slot);
394
+ }
395
+ const ElementBase = typeof HTMLElement === "undefined" ? class {
396
+ } : HTMLElement;
397
+ const STATUS_BADGES = {
398
+ added: ["diffStatusAdded", "success"],
399
+ deleted: ["diffStatusDeleted", "danger"],
400
+ renamed: ["diffStatusRenamed", "info"]
401
+ };
402
+ let instances = 0;
403
+ const isSide = (side) => typeof side === "string" || side === null;
404
+ function isSource(value) {
405
+ if (typeof value !== "object" || value === null) return false;
406
+ const { before, after } = value;
407
+ return isSide(before) && isSide(after) && (before !== null || after !== null);
408
+ }
409
+ function isFile(value) {
410
+ return typeof value === "object" && value !== null && Array.isArray(value.hunks);
411
+ }
412
+ function filesOf(input) {
413
+ const shown = (file, source) => {
414
+ const old = source ? splitLines(source.before ?? "") : null;
415
+ const lines = file.hunks.flatMap((h) => h.lines);
416
+ return {
417
+ file: {
418
+ ...file,
419
+ added: typeof file.added === "number" ? file.added : lines.filter((l) => l.kind === "add").length,
420
+ removed: typeof file.removed === "number" ? file.removed : lines.filter((l) => l.kind === "del").length
421
+ },
422
+ oldLines: old ? old.lines : null,
423
+ newLines: source ? splitLines(source.after ?? "").lines : null,
424
+ oldEndsWithNewline: old ? old.endsWithNewline : true,
425
+ collapsed: false,
426
+ expanded: /* @__PURE__ */ new Set(),
427
+ html: null,
428
+ attempt: 0,
429
+ roving: null
430
+ };
431
+ };
432
+ const one = (item) => {
433
+ if (isSource(item)) return [shown(diffTexts(item.before, item.after, { path: item.path, oldPath: item.oldPath }), item)];
434
+ if (isFile(item)) return [shown(item)];
435
+ return [];
436
+ };
437
+ if (input === null || input === void 0) return [];
438
+ if (typeof input === "string") return parseUnifiedDiff(input).map((file) => shown(file));
439
+ if (Array.isArray(input)) return input.flatMap(one);
440
+ return one(input);
441
+ }
442
+ const firstOld = (h) => h.oldCount > 0 ? h.oldStart : h.oldStart + 1;
443
+ const firstNew = (h) => h.newCount > 0 ? h.newStart : h.newStart + 1;
444
+ function keyOf(line) {
445
+ return line.kind === "del" ? `o${line.oldLine}` : `n${line.newLine}`;
446
+ }
447
+ function sideOf(line) {
448
+ return line.kind === "del" ? "old" : "new";
449
+ }
450
+ function numberOn(line, side) {
451
+ return side === "old" ? line.oldLine : line.newLine;
452
+ }
453
+ function rowsOf(shown) {
454
+ const { file, oldLines } = shown;
455
+ const rows = [];
456
+ const push = (gap) => {
457
+ const count = gap.to - gap.from + 1;
458
+ if (count <= 0 && !gap.section) return;
459
+ if (oldLines && count > 0 && shown.expanded.has(gap.index)) {
460
+ for (let k = gap.from; k <= gap.to; k++) {
461
+ const line = { kind: "context", text: oldLines[k - 1] ?? "", oldLine: k, newLine: k + gap.delta };
462
+ if (k === oldLines.length && !shown.oldEndsWithNewline) line.noNewline = true;
463
+ rows.push({ type: "line", line });
464
+ }
465
+ return;
466
+ }
467
+ rows.push({ type: "gap", gap });
468
+ };
469
+ let nextOld = 1;
470
+ let nextNew = 1;
471
+ file.hunks.forEach((hunk, index) => {
472
+ push({ index, from: nextOld, to: firstOld(hunk) - 1, delta: firstNew(hunk) - firstOld(hunk), section: hunk.section });
473
+ for (const line of hunk.lines) rows.push({ type: "line", line });
474
+ nextOld = firstOld(hunk) + hunk.oldCount;
475
+ nextNew = firstNew(hunk) + hunk.newCount;
476
+ });
477
+ if (oldLines && file.hunks.length) push({ index: file.hunks.length, from: nextOld, to: oldLines.length, delta: nextNew - nextOld });
478
+ return rows;
479
+ }
480
+ function el(doc, tag, className, text) {
481
+ const node = doc.createElement(tag);
482
+ if (className) node.className = className;
483
+ if (text !== void 0) node.textContent = text;
484
+ return node;
485
+ }
486
+ class AparteDiff extends ElementBase {
487
+ static get observedAttributes() {
488
+ return ["commentable"];
489
+ }
490
+ _input = null;
491
+ _shown = [];
492
+ /** Each file's element, as last drawn. */
493
+ _files = [];
494
+ _comments = [];
495
+ _id = `aparte-diff-${++instances}`;
496
+ _generation = 0;
497
+ _stopConfig = null;
498
+ _selection = null;
499
+ _form = null;
500
+ /** Whether the reader opened the field (it then follows the selection through a redraw). */
501
+ _writing = false;
502
+ /** Each line row's line. */
503
+ _lineOf = /* @__PURE__ */ new WeakMap();
504
+ // ── What the app sets ──────────────────────────────────────────────────
505
+ /** The change to show: a unified diff's text, `{ path, before, after }`, or a list of files (`AparteDiffFile` or texts). */
506
+ get diff() {
507
+ return this._input;
508
+ }
509
+ set diff(value) {
510
+ this._input = value ?? null;
511
+ this._shown = filesOf(this._input);
512
+ this._generation++;
513
+ this._selection = null;
514
+ this._writing = false;
515
+ this._form?.node.remove();
516
+ if (this.isConnected) {
517
+ this._render();
518
+ void this._highlight();
519
+ }
520
+ }
521
+ /** The comments to show under their lines, with their state. The element only shows them. */
522
+ get comments() {
523
+ return this._comments;
524
+ }
525
+ set comments(value) {
526
+ this._comments = Array.isArray(value) ? [...value] : [];
527
+ if (this.isConnected) this._render();
528
+ }
529
+ /** Whether a reader can comment on a line. Reflects the `commentable` attribute. */
530
+ get commentable() {
531
+ return this.hasAttribute("commentable");
532
+ }
533
+ set commentable(value) {
534
+ this.toggleAttribute("commentable", Boolean(value));
535
+ }
536
+ // ── Lifecycle ──────────────────────────────────────────────────────────
537
+ connectedCallback() {
538
+ ensureStyles(this.ownerDocument);
539
+ this.addEventListener("click", this._onClick);
540
+ this.addEventListener("keydown", this._onKeydown);
541
+ this.addEventListener("keyup", this._onKeyup);
542
+ this._stopConfig?.();
543
+ this._stopConfig = subscribeConfigChange(this, () => this._onConfigChange());
544
+ this._render();
545
+ void this._highlight();
546
+ }
547
+ disconnectedCallback() {
548
+ this.removeEventListener("click", this._onClick);
549
+ this.removeEventListener("keydown", this._onKeydown);
550
+ this.removeEventListener("keyup", this._onKeyup);
551
+ this._stopConfig?.();
552
+ this._stopConfig = null;
553
+ }
554
+ attributeChangedCallback(name, previous, value) {
555
+ if (name !== "commentable" || previous === null === (value === null)) return;
556
+ if (value === null) {
557
+ this._selection = null;
558
+ this._writing = false;
559
+ this._form?.node.remove();
560
+ }
561
+ if (this.isConnected) this._render();
562
+ }
563
+ // ── Drawing ────────────────────────────────────────────────────────────
564
+ get _config() {
565
+ return resolveConfig(this);
566
+ }
567
+ _word(key, vars) {
568
+ return word(this._config, key, vars);
569
+ }
570
+ _onConfigChange() {
571
+ if (!this._config.hasHighlightProvider()) for (const shown of this._shown) shown.html = null;
572
+ this._render();
573
+ void this._highlight(true);
574
+ }
575
+ /** Draw every file again from the state, the reader's selection, field and focus kept. */
576
+ _render() {
577
+ const doc = this.ownerDocument;
578
+ const active = doc.activeElement;
579
+ const focus = active && this.contains(active) ? this._focusKey(active) : null;
580
+ this._files = this._shown.map((_, i) => this._renderFile(i));
581
+ if (this._input === null) {
582
+ this.replaceChildren();
583
+ return;
584
+ }
585
+ if (!this._shown.length) {
586
+ this.replaceChildren(this._message("aparte-diff-empty", "diffEmpty"));
587
+ return;
588
+ }
589
+ this.replaceChildren(...this._files);
590
+ this._paintSelection();
591
+ if (this._writing) this._placeForm(false);
592
+ if (focus) this._restoreFocus(focus);
593
+ }
594
+ _renderFile(index) {
595
+ const doc = this.ownerDocument;
596
+ const shown = this._shown[index];
597
+ const { file } = shown;
598
+ const status = file.status ?? (file.oldPath && file.oldPath !== file.path ? "renamed" : "modified");
599
+ const root = el(doc, "div", "aparte-diff-file");
600
+ root.dataset["path"] = file.path;
601
+ root.dataset["file"] = String(index);
602
+ const head = el(doc, "div", "aparte-diff-file__head");
603
+ const toggle = el(doc, "button", "aparte-btn aparte-btn--ghost aparte-btn--sm aparte-diff-file__toggle");
604
+ toggle.type = "button";
605
+ toggle.setAttribute("aria-expanded", String(!shown.collapsed));
606
+ toggle.setAttribute("aria-controls", `${this._id}-f${index}`);
607
+ const chevron = el(doc, "span", "aparte-diff-file__chevron");
608
+ chevron.setAttribute("aria-hidden", "true");
609
+ chevron.innerHTML = this._config.getIcon("expand");
610
+ const path = el(doc, "span", "aparte-diff-file__path", status === "renamed" && file.oldPath ? `${file.oldPath} → ${file.path}` : file.path);
611
+ toggle.append(chevron, path);
612
+ head.append(toggle);
613
+ const badge = STATUS_BADGES[status];
614
+ if (badge) head.append(el(doc, "span", `aparte-badge aparte-badge--sm aparte-badge--${badge[1]} aparte-diff-file__status`, this._word(badge[0])));
615
+ if (!file.binary) {
616
+ const stat = el(doc, "span", "aparte-diff-file__stat");
617
+ const added = el(doc, "span", "aparte-diff-file__added", `+${file.added}`);
618
+ const removed = el(doc, "span", "aparte-diff-file__removed", `−${file.removed}`);
619
+ added.setAttribute("aria-hidden", "true");
620
+ removed.setAttribute("aria-hidden", "true");
621
+ const said = el(doc, "span", "aparte-sr-only", `${this._word("diffAdded", { n: file.added })}, ${this._word("diffRemoved", { n: file.removed })}`);
622
+ stat.append(added, removed, said);
623
+ head.append(stat);
624
+ }
625
+ const lines = el(doc, "div", "aparte-diff-file__lines");
626
+ lines.id = `${this._id}-f${index}`;
627
+ lines.setAttribute("dir", "ltr");
628
+ lines.hidden = shown.collapsed;
629
+ if (file.binary) lines.append(this._message("aparte-diff-binary", "diffBinary"));
630
+ else if (!file.hunks.length) {
631
+ if (status !== "renamed") lines.append(this._message("aparte-diff-empty", "diffEmpty"));
632
+ } else this._fillLines(lines, index);
633
+ root.append(head, lines);
634
+ return root;
635
+ }
636
+ _message(className, key) {
637
+ const p = el(this.ownerDocument, "p", className, this._word(key));
638
+ p.setAttribute("dir", "auto");
639
+ return p;
640
+ }
641
+ /** The rows of file `index`, its comments under their lines. */
642
+ _fillLines(lines, index) {
643
+ const shown = this._shown[index];
644
+ const commentable = this.commentable;
645
+ const rows = rowsOf(shown);
646
+ const lineRows = [];
647
+ for (const row of rows) {
648
+ if (row.type === "gap") {
649
+ lines.append(this._gapRow(row.gap, index, Boolean(shown.oldLines)));
650
+ continue;
651
+ }
652
+ const node = this._lineRow(row.line, index, commentable);
653
+ lineRows.push(node);
654
+ lines.append(node);
655
+ if (row.line.noNewline) {
656
+ const eof = el(this.ownerDocument, "div", "aparte-diff-eof", this._word("diffNoNewline"));
657
+ eof.setAttribute("dir", "auto");
658
+ lines.append(eof);
659
+ }
660
+ }
661
+ if (commentable && lineRows.length) {
662
+ const roving = lineRows.find((r) => r.dataset["key"] === shown.roving) ?? lineRows[0];
663
+ shown.roving = roving.dataset["key"];
664
+ roving.querySelector(".aparte-diff-line__gutter").tabIndex = 0;
665
+ }
666
+ this._placeComments(lines, shown, lineRows);
667
+ }
668
+ _gapRow(gap, file, expandable) {
669
+ const doc = this.ownerDocument;
670
+ const row = el(doc, "div", "aparte-diff-fold");
671
+ row.setAttribute("dir", "auto");
672
+ const count = gap.to - gap.from + 1;
673
+ if (count > 0) {
674
+ if (expandable) {
675
+ const show = el(doc, "button", "aparte-btn aparte-btn--ghost aparte-btn--sm aparte-diff-fold__show", this._word("diffShowUnchanged", { n: count }));
676
+ show.type = "button";
677
+ show.dataset["file"] = String(file);
678
+ show.dataset["gap"] = String(gap.index);
679
+ row.append(show);
680
+ } else row.append(el(doc, "span", "aparte-diff-fold__count", this._word("diffUnchanged", { n: count })));
681
+ }
682
+ if (gap.section) row.append(el(doc, "span", "aparte-diff-fold__section", gap.section));
683
+ return row;
684
+ }
685
+ _lineRow(line, file, commentable) {
686
+ const doc = this.ownerDocument;
687
+ const shown = this._shown[file];
688
+ const row = el(doc, "div", "aparte-diff-line");
689
+ row.dataset["kind"] = line.kind;
690
+ row.dataset["key"] = keyOf(line);
691
+ this._lineOf.set(row, line);
692
+ const gutter = commentable ? el(doc, "button", "aparte-diff-line__gutter") : el(doc, "span", "aparte-diff-line__gutter");
693
+ gutter.append(
694
+ this._number("old", line.oldLine),
695
+ this._number("new", line.newLine)
696
+ );
697
+ const code = el(doc, "code", "aparte-diff-line__code");
698
+ if (commentable) {
699
+ const button = gutter;
700
+ button.type = "button";
701
+ button.tabIndex = -1;
702
+ const n = numberOn(line, sideOf(line));
703
+ button.setAttribute("aria-label", this._word(line.kind === "add" ? "diffLineAdded" : line.kind === "del" ? "diffLineRemoved" : "diffLine", { n }));
704
+ button.setAttribute("aria-pressed", "false");
705
+ code.id = `${this._id}-${file}-${keyOf(line)}`;
706
+ button.setAttribute("aria-describedby", code.id);
707
+ }
708
+ const marker = el(doc, "span", "aparte-diff-line__marker", line.kind === "add" ? "+" : line.kind === "del" ? "−" : " ");
709
+ marker.setAttribute("aria-hidden", "true");
710
+ row.append(gutter, marker);
711
+ if (line.kind !== "context") row.append(el(doc, "span", "aparte-sr-only", this._word(line.kind === "add" ? "diffMarkerAdded" : "diffMarkerRemoved")));
712
+ if (!this._colour(code, shown, line)) code.textContent = line.text;
713
+ row.append(code);
714
+ return row;
715
+ }
716
+ /** Put the highlighter's colours in a line's code, with its block's classes; `false` when it has none for it. */
717
+ _colour(code, shown, line) {
718
+ const html = this._htmlOf(shown, line);
719
+ if (html === void 0) return false;
720
+ code.innerHTML = html;
721
+ code.className = shown.html.className ? `aparte-diff-line__code ${shown.html.className}` : "aparte-diff-line__code";
722
+ return true;
723
+ }
724
+ _number(side, value) {
725
+ const num = el(this.ownerDocument, "span", "aparte-diff-line__num", value === void 0 ? "" : String(value));
726
+ num.dataset["side"] = side;
727
+ return num;
728
+ }
729
+ _htmlOf(shown, line) {
730
+ if (!shown.html) return void 0;
731
+ if (line.kind === "del") return shown.html.old.get(line.oldLine);
732
+ if (line.kind === "add") return shown.html.new.get(line.newLine);
733
+ return shown.html.new.get(line.newLine) ?? shown.html.old.get(line.oldLine);
734
+ }
735
+ /** The comments the app handed back, each under the line it ends on, or at the end of its file when that line is folded. */
736
+ _placeComments(lines, shown, rows) {
737
+ const { file } = shown;
738
+ const after = /* @__PURE__ */ new Map();
739
+ for (const comment of this._comments) {
740
+ const ours = comment.path === file.path || comment.side === "old" && comment.path === file.oldPath;
741
+ if (!ours) continue;
742
+ const row = rows.find((r) => {
743
+ const line = this._lineOf.get(r);
744
+ if (comment.side === "old" ? line.kind === "add" : line.kind === "del") return false;
745
+ return numberOn(line, comment.side) === comment.endLine;
746
+ });
747
+ const node = this._commentNode(comment);
748
+ if (!row) {
749
+ lines.append(node);
750
+ continue;
751
+ }
752
+ (after.get(row) ?? row).after(node);
753
+ after.set(row, node);
754
+ }
755
+ }
756
+ _commentNode(comment) {
757
+ const doc = this.ownerDocument;
758
+ const node = el(doc, "div", "aparte-diff-comment");
759
+ node.setAttribute("dir", "auto");
760
+ if (comment.state) node.dataset["state"] = comment.state;
761
+ const head = el(doc, "div", "aparte-diff-comment__head");
762
+ head.append(el(doc, "span", "aparte-diff-comment__lines", this._rangeWords(comment.side, comment.startLine, comment.endLine)));
763
+ if (comment.state === "pending" || comment.state === "sent") {
764
+ const pending = comment.state === "pending";
765
+ head.append(el(doc, "span", `aparte-badge aparte-badge--sm aparte-badge--${pending ? "warning" : "success"} aparte-diff-comment__state`, this._word(pending ? "diffCommentPending" : "diffCommentSent")));
766
+ }
767
+ node.append(head, el(doc, "p", "aparte-diff-comment__text", comment.text));
768
+ return node;
769
+ }
770
+ /** "Line 12", "Lines 12–14", "Removed line 5". */
771
+ _rangeWords(side, start, end) {
772
+ if (start === end) return this._word(side === "old" ? "diffLineRemoved" : "diffLine", { n: start });
773
+ return this._word(side === "old" ? "diffLinesRemoved" : "diffLines", { start, end });
774
+ }
775
+ // ── Colours ────────────────────────────────────────────────────────────
776
+ /**
777
+ * Colour each file not coloured yet — or, `again`, every file — each side as one text, and
778
+ * put the colours in the lines on screen. A file is coloured only once its highlighter
779
+ * answered: one that failed (still loading its grammars) leaves it plain, to be tried again.
780
+ */
781
+ async _highlight(again = false) {
782
+ const config = this._config;
783
+ if (!config.hasHighlightProvider()) return;
784
+ const generation = this._generation;
785
+ await Promise.all(this._shown.map(async (shown, index) => {
786
+ if (shown.html && !again) return;
787
+ const attempt = ++shown.attempt;
788
+ const language = languageOfPath(shown.file.path);
789
+ const side = (which) => {
790
+ const full = which === "old" ? shown.oldLines : shown.newLines;
791
+ if (full) return { lines: full, numbers: full.map((_, i) => i + 1) };
792
+ const own = shown.file.hunks.flatMap((h) => h.lines).filter((l) => l.kind === "context" || l.kind === (which === "old" ? "del" : "add"));
793
+ return { lines: own.map((l) => l.text), numbers: own.map((l) => numberOn(l, which)) };
794
+ };
795
+ const old = side("old");
796
+ const next = side("new");
797
+ const [oldHtml, newHtml] = await Promise.all([highlightLines(config, old.lines, language), highlightLines(config, next.lines, language)]);
798
+ if (generation !== this._generation || attempt !== shown.attempt || !oldHtml && !newHtml) return;
799
+ shown.html = {
800
+ old: new Map((oldHtml?.lines ?? []).map((html, i) => [old.numbers[i], html])),
801
+ new: new Map((newHtml?.lines ?? []).map((html, i) => [next.numbers[i], html])),
802
+ className: newHtml?.className ?? oldHtml?.className ?? ""
803
+ };
804
+ this._paintColours(index);
805
+ }));
806
+ }
807
+ _paintColours(index) {
808
+ const shown = this._shown[index];
809
+ const lines = this._linesOf(index);
810
+ if (!lines) return;
811
+ for (const row of lines.querySelectorAll(".aparte-diff-line")) {
812
+ const line = this._lineOf.get(row);
813
+ if (line) this._colour(row.querySelector(".aparte-diff-line__code"), shown, line);
814
+ }
815
+ }
816
+ _linesOf(index) {
817
+ return this._files[index]?.querySelector(":scope > .aparte-diff-file__lines") ?? null;
818
+ }
819
+ /** The line row of file `index` with the key `key`, if it is drawn. */
820
+ _rowOf(index, key) {
821
+ const lines = this._linesOf(index);
822
+ return lines ? [...lines.querySelectorAll(":scope > .aparte-diff-line")].find((row) => row.dataset["key"] === key) : void 0;
823
+ }
824
+ // ── What the reader does ───────────────────────────────────────────────
825
+ _onClick = (event) => {
826
+ const target = event.target;
827
+ if (!target || !this.contains(target)) return;
828
+ const toggle = target.closest(".aparte-diff-file__toggle");
829
+ if (toggle) {
830
+ const index = Number(toggle.closest(".aparte-diff-file").dataset["file"]);
831
+ const shown = this._shown[index];
832
+ shown.collapsed = !shown.collapsed;
833
+ toggle.setAttribute("aria-expanded", String(!shown.collapsed));
834
+ this._linesOf(index).hidden = shown.collapsed;
835
+ return;
836
+ }
837
+ const show = target.closest(".aparte-diff-fold__show");
838
+ if (show) {
839
+ this._expand(Number(show.dataset["file"]), Number(show.dataset["gap"]));
840
+ return;
841
+ }
842
+ const gutter = target.closest("button.aparte-diff-line__gutter");
843
+ if (gutter && this.commentable) this._pick(gutter.closest(".aparte-diff-line"), event.shiftKey);
844
+ };
845
+ _onKeydown = (event) => {
846
+ const target = event.target;
847
+ if (!target || !this.commentable) return;
848
+ if (this._form?.node.contains(target)) {
849
+ if (event.key === "Escape") {
850
+ event.preventDefault();
851
+ event.stopPropagation();
852
+ this._letGo(true);
853
+ } else if (event.key === "Enter" && (event.ctrlKey || event.metaKey) && target === this._form.input) {
854
+ event.preventDefault();
855
+ this._send();
856
+ }
857
+ return;
858
+ }
859
+ const gutter = target.closest("button.aparte-diff-line__gutter");
860
+ if (!gutter || !this.contains(gutter)) return;
861
+ const row = gutter.closest(".aparte-diff-line");
862
+ const rows = this._rowsAround(row);
863
+ const at = rows.indexOf(row);
864
+ const moves = { ArrowDown: at + 1, ArrowUp: at - 1, Home: 0, End: rows.length - 1 };
865
+ const move = moves[event.key];
866
+ if (move !== void 0) {
867
+ event.preventDefault();
868
+ const next = rows[Math.max(0, Math.min(rows.length - 1, move))];
869
+ if (event.shiftKey && (event.key === "ArrowDown" || event.key === "ArrowUp")) this._stretch(row, next);
870
+ this._focusRow(next);
871
+ return;
872
+ }
873
+ if (event.key === "Enter" || event.key === " ") {
874
+ event.preventDefault();
875
+ this._pick(row, event.shiftKey, true);
876
+ return;
877
+ }
878
+ if (event.key === "Escape" && this._selection) {
879
+ event.preventDefault();
880
+ this._letGo(true);
881
+ }
882
+ };
883
+ /** A button acts on Space's keyup: the keydown already did. */
884
+ _onKeyup = (event) => {
885
+ if (event.key === " " && event.target?.closest?.("button.aparte-diff-line__gutter")) event.preventDefault();
886
+ };
887
+ /** The line rows of the file `row` is in. */
888
+ _rowsAround(row) {
889
+ return [...row.closest(".aparte-diff-file__lines").querySelectorAll(":scope > .aparte-diff-line")];
890
+ }
891
+ _fileOf(row) {
892
+ return Number(row.closest(".aparte-diff-file").dataset["file"]);
893
+ }
894
+ /** Move the file's tab stop to `row`'s number, and the focus with it. */
895
+ _focusRow(row) {
896
+ const shown = this._shown[this._fileOf(row)];
897
+ for (const other of this._rowsAround(row)) other.querySelector(".aparte-diff-line__gutter").tabIndex = -1;
898
+ const gutter = row.querySelector(".aparte-diff-line__gutter");
899
+ gutter.tabIndex = 0;
900
+ shown.roving = row.dataset["key"];
901
+ gutter.focus();
902
+ }
903
+ /** Shift+arrow: the selection runs from where it started (or from `from`) to `to`. */
904
+ _stretch(from, to) {
905
+ const file = this._fileOf(from);
906
+ const selection = this._selection;
907
+ if (!selection || selection.file !== file) this._selection = { file, anchor: from.dataset["key"], focus: to.dataset["key"] };
908
+ else selection.focus = to.dataset["key"];
909
+ this._paintSelection();
910
+ if (this._writing) this._placeForm(false);
911
+ }
912
+ /** A click (or Enter, Space) on a line number: that line, or the selection stretched to it; then the field. */
913
+ _pick(row, stretch, keyboard = false) {
914
+ const file = this._fileOf(row);
915
+ const key = row.dataset["key"];
916
+ const selection = this._selection;
917
+ if (stretch && selection && selection.file === file) selection.focus = key;
918
+ else if (!(keyboard && selection && selection.file === file && this._selectedRows().includes(row))) {
919
+ this._selection = { file, anchor: key, focus: key };
920
+ }
921
+ const shown = this._shown[file];
922
+ shown.roving = key;
923
+ for (const other of this._rowsAround(row)) other.querySelector(".aparte-diff-line__gutter").tabIndex = other === row ? 0 : -1;
924
+ this._paintSelection();
925
+ this._writing = true;
926
+ this._placeForm(true);
927
+ }
928
+ _selectedRows() {
929
+ const selection = this._selection;
930
+ const lines = selection && this._linesOf(selection.file);
931
+ if (!selection || !lines) return [];
932
+ const rows = [...lines.querySelectorAll(":scope > .aparte-diff-line")];
933
+ const a = rows.findIndex((r) => r.dataset["key"] === selection.anchor);
934
+ const b = rows.findIndex((r) => r.dataset["key"] === selection.focus);
935
+ if (a < 0 || b < 0) return [];
936
+ return rows.slice(Math.min(a, b), Math.max(a, b) + 1);
937
+ }
938
+ _paintSelection() {
939
+ const selected = new Set(this._selectedRows());
940
+ for (const row of this.querySelectorAll(".aparte-diff-line")) {
941
+ const on = selected.has(row);
942
+ row.toggleAttribute("data-selected", on);
943
+ const gutter = row.querySelector("button.aparte-diff-line__gutter");
944
+ gutter?.setAttribute("aria-pressed", String(on));
945
+ }
946
+ }
947
+ /** What the selection covers: its side, its first and last line there, and those lines' text. */
948
+ _range() {
949
+ const rows = this._selectedRows();
950
+ const selection = this._selection;
951
+ if (!rows.length || !selection) return null;
952
+ const anchor = rows.find((r) => r.dataset["key"] === selection.anchor);
953
+ const side = sideOf(this._lineOf.get(anchor));
954
+ const lines = rows.map((r) => this._lineOf.get(r)).filter((l) => numberOn(l, side) !== void 0 && (side === "old" ? l.kind !== "add" : l.kind !== "del"));
955
+ const numbers = lines.map((l) => numberOn(l, side));
956
+ return {
957
+ path: side === "old" ? this._shown[selection.file].file.oldPath ?? this._shown[selection.file].file.path : this._shown[selection.file].file.path,
958
+ side,
959
+ startLine: Math.min(...numbers),
960
+ endLine: Math.max(...numbers),
961
+ code: lines.map((l) => l.text).join("\n")
962
+ };
963
+ }
964
+ /** The field, under the selection's last line, named for what it covers. */
965
+ _placeForm(focus) {
966
+ const rows = this._selectedRows();
967
+ const range = this._range();
968
+ if (!rows.length || !range) return;
969
+ const form = this._form ?? this._buildForm();
970
+ form.label.textContent = range.startLine === range.endLine ? this._word("diffCommentLine", { n: range.startLine }) : this._word("diffCommentLines", { start: range.startLine, end: range.endLine });
971
+ form.input.placeholder = this._word("diffCommentPlaceholder");
972
+ form.submit.textContent = this._word("diffCommentSubmit");
973
+ form.cancel.textContent = this._word("diffCommentCancel");
974
+ form.submit.disabled = !form.input.value.trim();
975
+ rows[rows.length - 1].after(form.node);
976
+ if (focus) form.input.focus();
977
+ }
978
+ _buildForm() {
979
+ const doc = this.ownerDocument;
980
+ const node = el(doc, "form", "aparte-diff-form");
981
+ node.setAttribute("dir", "auto");
982
+ const label = el(doc, "label", "aparte-diff-form__label");
983
+ const input = el(doc, "textarea", "aparte-field aparte-field--sm aparte-field--textarea aparte-diff-form__input");
984
+ input.id = `${this._id}-comment`;
985
+ input.rows = 3;
986
+ input.setAttribute("dir", "auto");
987
+ label.htmlFor = input.id;
988
+ const actions = el(doc, "div", "aparte-diff-form__actions");
989
+ const cancel = el(doc, "button", "aparte-btn aparte-btn--ghost aparte-btn--sm aparte-diff-form__cancel");
990
+ cancel.type = "button";
991
+ const submit = el(doc, "button", "aparte-btn aparte-btn--primary aparte-btn--solid aparte-btn--sm aparte-diff-form__submit");
992
+ submit.type = "submit";
993
+ actions.append(cancel, submit);
994
+ node.append(label, input, actions);
995
+ input.addEventListener("input", () => {
996
+ submit.disabled = !input.value.trim();
997
+ });
998
+ node.addEventListener("submit", (event) => {
999
+ event.preventDefault();
1000
+ this._send();
1001
+ });
1002
+ cancel.addEventListener("click", () => this._letGo(true));
1003
+ this._form = { node, input, label, submit, cancel };
1004
+ return this._form;
1005
+ }
1006
+ _send() {
1007
+ const form = this._form;
1008
+ const range = this._range();
1009
+ const text = form?.input.value.trim() ?? "";
1010
+ if (!form || !range || !text) return;
1011
+ const detail = { ...range, text };
1012
+ const kept = this.dispatchEvent(new CustomEvent("aparte-diff-comment", {
1013
+ detail,
1014
+ bubbles: true,
1015
+ composed: true,
1016
+ cancelable: true
1017
+ }));
1018
+ if (!kept) return;
1019
+ form.input.value = "";
1020
+ this._letGo(true);
1021
+ }
1022
+ /** Close the field and drop the selection; the focus goes back to the line it ended on. */
1023
+ _letGo(returnFocus) {
1024
+ const last = this._selectedRows().at(-1);
1025
+ this._selection = null;
1026
+ this._writing = false;
1027
+ this._form?.node.remove();
1028
+ this._paintSelection();
1029
+ if (returnFocus && last) this._focusRow(last);
1030
+ }
1031
+ /** Show the unchanged run `gap` of file `index`, and carry on from its first line. */
1032
+ _expand(index, gap) {
1033
+ const shown = this._shown[index];
1034
+ const before = rowsOf(shown).find((r) => r.type === "gap" && r.gap.index === gap);
1035
+ shown.expanded.add(gap);
1036
+ const lines = this._linesOf(index);
1037
+ if (!lines) return;
1038
+ lines.replaceChildren();
1039
+ this._fillLines(lines, index);
1040
+ this._paintSelection();
1041
+ if (this._writing && this._selection?.file === index) this._placeForm(false);
1042
+ if (!before) return;
1043
+ const first = this._rowOf(index, `n${before.gap.from + before.gap.delta}`);
1044
+ if (!first) return;
1045
+ if (this.commentable) this._focusRow(first);
1046
+ else {
1047
+ first.tabIndex = -1;
1048
+ first.focus();
1049
+ }
1050
+ }
1051
+ // ── Focus across a redraw ──────────────────────────────────────────────
1052
+ _focusKey(active) {
1053
+ if (this._form && active === this._form.input) return "form";
1054
+ const file = active.closest(".aparte-diff-file")?.dataset["file"];
1055
+ if (file === void 0) return null;
1056
+ if (active.closest(".aparte-diff-file__toggle")) return `toggle:${file}`;
1057
+ const show = active.closest(".aparte-diff-fold__show");
1058
+ if (show) return `gap:${file}:${show.dataset["gap"]}`;
1059
+ const row = active.closest(".aparte-diff-line");
1060
+ return row ? `line:${file}:${row.dataset["key"]}` : null;
1061
+ }
1062
+ _restoreFocus(key) {
1063
+ if (key === "form") {
1064
+ if (this._form?.node.isConnected) this._form.input.focus();
1065
+ return;
1066
+ }
1067
+ const [kind, file, rest] = key.split(":");
1068
+ const index = Number(file);
1069
+ const scope = this._files[index];
1070
+ const target = kind === "toggle" ? scope?.querySelector(".aparte-diff-file__toggle") : kind === "gap" ? [...scope?.querySelectorAll(".aparte-diff-fold__show") ?? []].find((b) => b.dataset["gap"] === rest) : this._rowOf(index, rest ?? "")?.querySelector(".aparte-diff-line__gutter");
1071
+ if (target instanceof HTMLButtonElement) target.focus();
1072
+ }
1073
+ }
1074
+ if (typeof customElements !== "undefined" && !customElements.get("aparte-diff")) customElements.define("aparte-diff", AparteDiff);
1075
+ export {
1076
+ AparteDiff,
1077
+ diffTexts,
1078
+ f as formatUnifiedDiff,
1079
+ parseUnifiedDiff,
1080
+ s as setupDiff
1081
+ };
1082
+ //# sourceMappingURL=index.js.map