@astryxdesign/lab 0.7.6-canary.fda92ac → 0.7.7-canary.0d1ddcd
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/DiffViewer/DiffViewer.d.ts.map +1 -1
- package/dist/DiffViewer/DiffViewer.js +94 -49
- package/dist/DiffViewer/createDiffFile.d.ts +0 -5
- package/dist/DiffViewer/createDiffFile.d.ts.map +1 -1
- package/dist/DiffViewer/createDiffFile.js +215 -18
- package/package.json +5 -5
- package/src/DiffViewer/DiffViewer.doc.mjs +1 -1
- package/src/DiffViewer/DiffViewer.test.tsx +74 -0
- package/src/DiffViewer/DiffViewer.tsx +135 -58
- package/src/DiffViewer/createDiffFile.test.ts +136 -0
- package/src/DiffViewer/createDiffFile.ts +264 -18
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DiffViewer.d.ts","sourceRoot":"","sources":["../../src/DiffViewer/DiffViewer.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"DiffViewer.d.ts","sourceRoot":"","sources":["../../src/DiffViewer/DiffViewer.tsx"],"names":[],"mappings":"AAwCA,OAAO,EAQL,KAAK,UAAU,EACf,KAAK,SAAS,EACd,KAAK,GAAG,EAET,MAAM,OAAO,CAAC;AAGf,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAGL,KAAK,YAAY,EAGlB,MAAM,8BAA8B,CAAC;AAQtC,OAAO,EAEL,KAAK,qBAAqB,EAC3B,MAAM,iCAAiC,CAAC;AAsBzC,OAAO,KAAK,EACV,cAAc,EAGd,iBAAiB,EAElB,MAAM,aAAa,CAAC;AAGrB,uEAAuE;AACvE,MAAM,MAAM,kBAAkB,GAAG,SAAS,GAAG,OAAO,CAAC;AAErD;;;;GAIG;AACH,MAAM,MAAM,yBAAyB,GAAG,SAAS,GAAG,MAAM,GAAG,MAAM,CAAC;AAEpE;;;GAGG;AACH,MAAM,MAAM,yBAAyB,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAEjE,MAAM,WAAW,eAAgB,SAAQ,SAAS,CAAC,cAAc,CAAC;IAChE,yCAAyC;IACzC,GAAG,CAAC,EAAE,GAAG,CAAC,cAAc,CAAC,CAAC;IAC1B;;;OAGG;IACH,IAAI,EAAE,cAAc,CAAC;IACrB;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE,kBAAkB,CAAC;IAC9B;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,MAAM,GAAG,YAAY,CAAC;IACjC;;;;OAIG;IACH,aAAa,CAAC,EAAE,aAAa,CAAC,YAAY,CAAC,CAAC;IAC5C;;;OAGG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;;;;;;OAQG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;OAGG;IACH,eAAe,CAAC,EAAE,yBAAyB,CAAC;IAC5C;;;;;OAKG;IACH,eAAe,CAAC,EAAE,yBAAyB,CAAC;IAC5C;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,iEAAiE;IACjE,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;OAGG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,uEAAuE;IACvE,iBAAiB,CAAC,EAAE,CAAC,WAAW,EAAE,OAAO,KAAK,IAAI,CAAC;IACnD;;;OAGG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,CAAC,GAAG,EAAE,iBAAiB,KAAK,SAAS,CAAC;IAC7D;;;;;;OAMG;IACH,iBAAiB,CAAC,EAAE,CAClB,GAAG,EAAE,iBAAiB,EACtB,KAAK,EAAE,UAAU,CAAC,iBAAiB,CAAC,KACjC,IAAI,CAAC;IACV,yDAAyD;IACzD,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAC5B;;;OAGG;IACH,WAAW,CAAC,EAAE,qBAAqB,CAAC;CACrC;AAw/BD;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CAAC,EACzB,GAAG,EACH,IAAI,EACJ,QAAoB,EACpB,QAAQ,EAAE,YAAY,EACtB,aAAa,EACb,cAAqB,EACrB,SAAiB,EACjB,eAA2B,EAC3B,eAAwB,EACxB,mBAA0B,EAC1B,aAAoB,EACpB,WAAW,EAAE,eAAe,EAC5B,kBAA0B,EAC1B,iBAAiB,EACjB,SAAS,EACT,oBAAoB,EACpB,iBAAiB,EACjB,SAAS,EACT,WAAW,EACX,MAAM,EACN,SAAS,EACT,KAAK,EACL,GAAG,KAAK,EACT,EAAE,eAAe,+BA6yBjB;yBAr0Be,UAAU"}
|
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Renders one DiffViewerFile: a file header (path, status, +/- counts, a
|
|
14
14
|
* caller slot, optional disclosure) over its hunks in unified or split view.
|
|
15
|
-
* A hunk marked `isReplacedBlock
|
|
16
|
-
* changed lines
|
|
15
|
+
* A hunk marked `isReplacedBlock`, and a file with more than
|
|
16
|
+
* MAX_UNFOLDED_CHANGED_LINES other changed lines, show a labeled fold until
|
|
17
|
+
* the reader opens it.
|
|
17
18
|
* Unified view is one table. Split view is two side-by-side panes (each an
|
|
18
19
|
* ARIA table) whose rows share one grid's row tracks through `subgrid`, so a
|
|
19
20
|
* row is as tall as its taller side while each pane scrolls sideways on its
|
|
@@ -148,30 +149,6 @@ function lineAnnotation(renderLineAnnotation, ref) {
|
|
|
148
149
|
const note = renderLineAnnotation(ref);
|
|
149
150
|
return isRenderable(note) ? note : null;
|
|
150
151
|
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Replaced blocks with an annotation on one of their changed lines. Such a
|
|
154
|
-
* block starts open, so a review comment is never hidden behind the fold.
|
|
155
|
-
*/
|
|
156
|
-
function findAnnotatedBlocks(file, renderLineAnnotation) {
|
|
157
|
-
const blocks = new Set();
|
|
158
|
-
if (renderLineAnnotation == null) {
|
|
159
|
-
return blocks;
|
|
160
|
-
}
|
|
161
|
-
file.hunks.forEach((hunk, hunkIndex) => {
|
|
162
|
-
if (hunk.isReplacedBlock !== true) {
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
const isAnnotated = hunk.lines.some(line => {
|
|
166
|
-
const ref = line.kind === 'context' ? null : lineRef(line, hunkIndex);
|
|
167
|
-
return ref != null && lineAnnotation(renderLineAnnotation, ref) != null;
|
|
168
|
-
});
|
|
169
|
-
if (isAnnotated) {
|
|
170
|
-
blocks.add(hunkIndex);
|
|
171
|
-
}
|
|
172
|
-
});
|
|
173
|
-
return blocks;
|
|
174
|
-
}
|
|
175
152
|
function lineRef(line, hunkIndex) {
|
|
176
153
|
if (line.kind === 'removed') {
|
|
177
154
|
return line.oldLineNumber == null ? null : {
|
|
@@ -218,6 +195,39 @@ function addInlineEmphasis(cells, granularity) {
|
|
|
218
195
|
}
|
|
219
196
|
}
|
|
220
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Most changed lines a file shows before the reader asks for them. Rows are
|
|
200
|
+
* not virtualized, and each row costs render time. A file with more changed
|
|
201
|
+
* lines starts folded. Lines in replaced blocks do not count, because those
|
|
202
|
+
* blocks fold on their own.
|
|
203
|
+
*/
|
|
204
|
+
const MAX_UNFOLDED_CHANGED_LINES = 2000;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Hunks with an annotation on one of their lines. A fold that would hide such
|
|
208
|
+
* a line starts open, so a review comment is never hidden.
|
|
209
|
+
*/
|
|
210
|
+
function findAnnotatedHunks(file, renderLineAnnotation) {
|
|
211
|
+
const hunks = new Set();
|
|
212
|
+
if (renderLineAnnotation == null) {
|
|
213
|
+
return hunks;
|
|
214
|
+
}
|
|
215
|
+
file.hunks.forEach((hunk, hunkIndex) => {
|
|
216
|
+
const isAnnotated = hunk.lines.some(line => {
|
|
217
|
+
// A replaced block's fold keeps its context lines in view.
|
|
218
|
+
if (hunk.isReplacedBlock === true && line.kind === 'context') {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
const ref = lineRef(line, hunkIndex);
|
|
222
|
+
return ref != null && lineAnnotation(renderLineAnnotation, ref) != null;
|
|
223
|
+
});
|
|
224
|
+
if (isAnnotated) {
|
|
225
|
+
hunks.add(hunkIndex);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
return hunks;
|
|
229
|
+
}
|
|
230
|
+
|
|
221
231
|
/**
|
|
222
232
|
* Tokenize a run of hunk lines with its old and new side each as continuous
|
|
223
233
|
* text, so constructs that span lines (block comments, template strings) keep
|
|
@@ -890,13 +900,36 @@ export function DiffViewer({
|
|
|
890
900
|
}
|
|
891
901
|
onCollapsedChange?.(next);
|
|
892
902
|
};
|
|
903
|
+
let unfoldedChangedLines = 0;
|
|
904
|
+
let hasReplacedBlock = false;
|
|
905
|
+
for (const hunk of file.hunks) {
|
|
906
|
+
if (hunk.isReplacedBlock === true) {
|
|
907
|
+
hasReplacedBlock = true;
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
for (const line of hunk.lines) {
|
|
911
|
+
if (line.kind !== 'context') {
|
|
912
|
+
unfoldedChangedLines++;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
const isLargeFile = unfoldedChangedLines > MAX_UNFOLDED_CHANGED_LINES;
|
|
917
|
+
const annotatedHunks = isLargeFile || hasReplacedBlock ? findAnnotatedHunks(file, renderLineAnnotation) : null;
|
|
893
918
|
|
|
894
|
-
// A replaced block
|
|
895
|
-
// reader's toggles are kept per file; a new file
|
|
919
|
+
// A large file and a replaced block start folded unless a line they would
|
|
920
|
+
// hide is annotated. The reader's toggles are kept per file; a new file
|
|
921
|
+
// starts over.
|
|
922
|
+
const [fileToggle, setFileToggle] = useState(null);
|
|
923
|
+
const isFileFolded = isLargeFile && !(fileToggle?.file === file ? fileToggle.isExpanded : (annotatedHunks?.size ?? 0) > 0);
|
|
924
|
+
const toggleFile = () => {
|
|
925
|
+
setFileToggle({
|
|
926
|
+
file,
|
|
927
|
+
isExpanded: isFileFolded
|
|
928
|
+
});
|
|
929
|
+
};
|
|
896
930
|
const [blockToggles, setBlockToggles] = useState(null);
|
|
897
931
|
const toggles = blockToggles?.file === file ? blockToggles.isExpanded : null;
|
|
898
|
-
const
|
|
899
|
-
const isBlockExpanded = hunkIndex => toggles?.get(hunkIndex) ?? annotatedBlocks.has(hunkIndex);
|
|
932
|
+
const isBlockExpanded = hunkIndex => toggles?.get(hunkIndex) ?? annotatedHunks?.has(hunkIndex) ?? false;
|
|
900
933
|
const toggleBlock = hunkIndex => {
|
|
901
934
|
const next = new Map(toggles);
|
|
902
935
|
next.set(hunkIndex, !isBlockExpanded(hunkIndex));
|
|
@@ -909,9 +942,11 @@ export function DiffViewer({
|
|
|
909
942
|
// cells are built again only when a block opens or closes.
|
|
910
943
|
const expandedKey = file.hunks.flatMap((hunk, hunkIndex) => hunk.isReplacedBlock === true && isBlockExpanded(hunkIndex) ? [hunkIndex] : []).join(',');
|
|
911
944
|
const expandedBlocks = useMemo(() => new Set(expandedKey === '' ? [] : expandedKey.split(',').map(Number)), [expandedKey]);
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
const
|
|
945
|
+
|
|
946
|
+
// A folded file builds no cells, so its lines are not tokenized.
|
|
947
|
+
const cells = useMemo(() => isFileFolded ? [] : buildCells(file, language, inlineHighlight, expandedBlocks), [isFileFolded, file, language, inlineHighlight, expandedBlocks]);
|
|
948
|
+
const unifiedRows = useMemo(() => viewMode === 'unified' && !isFileFolded ? buildUnifiedRows(file, cells) : [], [viewMode, isFileFolded, file, cells]);
|
|
949
|
+
const splitRows = useMemo(() => viewMode === 'split' && !isFileFolded ? buildSplitRows(file, cells) : [], [viewMode, isFileFolded, file, cells]);
|
|
915
950
|
let addedCount = 0;
|
|
916
951
|
let removedCount = 0;
|
|
917
952
|
let maxLineNumber = 1;
|
|
@@ -1020,7 +1055,7 @@ export function DiffViewer({
|
|
|
1020
1055
|
children: cell != null && buildSpans(cell.line.content, cell.tokens, cell.emphasis, [styles.change, kind === 'added' ? styles.changeAdded : styles.changeRemoved])
|
|
1021
1056
|
}, key);
|
|
1022
1057
|
};
|
|
1023
|
-
const
|
|
1058
|
+
const renderFoldControl = (changedLines, isExpanded, onToggle, isSticky) => /*#__PURE__*/_jsxs("span", {
|
|
1024
1059
|
...{
|
|
1025
1060
|
0: {
|
|
1026
1061
|
className: "x3nfvp2 x6s0dn4 x1a02dak xjcht0a"
|
|
@@ -1028,19 +1063,20 @@ export function DiffViewer({
|
|
|
1028
1063
|
1: {
|
|
1029
1064
|
className: "x3nfvp2 x6s0dn4 x1a02dak xjcht0a x7wzq59 x1ybfrjj"
|
|
1030
1065
|
}
|
|
1031
|
-
}[!!
|
|
1066
|
+
}[!!isSticky << 0],
|
|
1032
1067
|
children: [/*#__PURE__*/_jsx("span", {
|
|
1033
1068
|
children: t('@astryx.diffViewer.largeChange', {
|
|
1034
|
-
count:
|
|
1069
|
+
count: changedLines
|
|
1035
1070
|
})
|
|
1036
1071
|
}), /*#__PURE__*/_jsx(Button, {
|
|
1037
1072
|
size: "sm",
|
|
1038
1073
|
variant: "secondary",
|
|
1039
|
-
label:
|
|
1040
|
-
"aria-expanded":
|
|
1041
|
-
onClick:
|
|
1074
|
+
label: isExpanded ? t('@astryx.diffViewer.hideFullDiff') : t('@astryx.diffViewer.showFullDiff'),
|
|
1075
|
+
"aria-expanded": isExpanded,
|
|
1076
|
+
onClick: onToggle
|
|
1042
1077
|
})]
|
|
1043
1078
|
});
|
|
1079
|
+
const renderFold = fold => renderFoldControl(fold.changedLines, fold.isExpanded, () => toggleBlock(fold.hunkIndex), isPinned);
|
|
1044
1080
|
const renderHunkText = header => /*#__PURE__*/_jsx("span", {
|
|
1045
1081
|
...{
|
|
1046
1082
|
0: {
|
|
@@ -1352,7 +1388,6 @@ export function DiffViewer({
|
|
|
1352
1388
|
children: [renderSide('old'), renderSide('new')]
|
|
1353
1389
|
});
|
|
1354
1390
|
};
|
|
1355
|
-
const table = viewMode === 'split' ? renderSplit() : renderUnified();
|
|
1356
1391
|
let body;
|
|
1357
1392
|
if (file.isBinary) {
|
|
1358
1393
|
body = /*#__PURE__*/_jsx("div", {
|
|
@@ -1369,17 +1404,27 @@ export function DiffViewer({
|
|
|
1369
1404
|
children: t('@astryx.diffViewer.noChanges')
|
|
1370
1405
|
});
|
|
1371
1406
|
} else {
|
|
1372
|
-
|
|
1373
|
-
|
|
1407
|
+
// The fold row stays first whether the file is folded or not, so its
|
|
1408
|
+
// button keeps focus when it toggles.
|
|
1409
|
+
body = /*#__PURE__*/_jsxs(_Fragment, {
|
|
1410
|
+
children: [isLargeFile && /*#__PURE__*/_jsx("div", {
|
|
1411
|
+
"data-diff-fold": "",
|
|
1374
1412
|
...{
|
|
1375
|
-
className: "
|
|
1413
|
+
className: "xce4md1 xrrkdod x1yc453h x11ds9ch xv1l7n4 x9ynric x141an7d x1ltkj2j xszpfcc xykt701 xgtxpae"
|
|
1376
1414
|
},
|
|
1377
|
-
|
|
1378
|
-
})
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1415
|
+
children: renderFoldControl(unfoldedChangedLines, !isFileFolded, toggleFile, false)
|
|
1416
|
+
}), !isFileFolded && /*#__PURE__*/_jsx("div", {
|
|
1417
|
+
...withoutInlineOverscroll(getViewportProps({
|
|
1418
|
+
...{
|
|
1419
|
+
className: "xeuugli xzt5al7"
|
|
1420
|
+
},
|
|
1421
|
+
style: scrollStyle
|
|
1422
|
+
})),
|
|
1423
|
+
children: /*#__PURE__*/_jsx("div", {
|
|
1424
|
+
...getContentProps({}),
|
|
1425
|
+
children: viewMode === 'split' ? renderSplit() : renderUnified()
|
|
1426
|
+
})
|
|
1427
|
+
})]
|
|
1383
1428
|
});
|
|
1384
1429
|
}
|
|
1385
1430
|
|
|
@@ -6,11 +6,6 @@
|
|
|
6
6
|
* @position Lab utility; computes a line diff (Myers) when the caller has two
|
|
7
7
|
* texts rather than a patch
|
|
8
8
|
*
|
|
9
|
-
* Shared leading and trailing lines are matched without a search. The minimal
|
|
10
|
-
* search over the rest stops after MAX_EDIT_DISTANCE changed lines, so memory
|
|
11
|
-
* stays bounded for any input; a middle that differs more than that becomes
|
|
12
|
-
* one replaced block, marked with `isReplacedBlock` on its hunk.
|
|
13
|
-
*
|
|
14
9
|
* SYNC: When modified, update:
|
|
15
10
|
* - /packages/lab/src/DiffViewer/createDiffFile.test.ts
|
|
16
11
|
* - /packages/lab/src/DiffViewer/DiffViewer.doc.mjs (utilities section)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"createDiffFile.d.ts","sourceRoot":"","sources":["../../src/DiffViewer/createDiffFile.ts"],"names":[],"mappings":"AAEA
|
|
1
|
+
{"version":3,"file":"createDiffFile.d.ts","sourceRoot":"","sources":["../../src/DiffViewer/createDiffFile.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EACV,cAAc,EAIf,MAAM,aAAa,CAAC;AAErB,MAAM,WAAW,qBAAqB;IACpC,yBAAyB;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,wBAAwB;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,qEAAqE;IACrE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,sEAAsE;IACtE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAqZD;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAAC,EAC7B,QAAQ,EACR,QAAQ,EACR,IAAI,EACJ,OAAO,EAAE,aAAa,EACtB,OAAO,EAAE,aAAa,EACtB,YAAgB,GACjB,EAAE,qBAAqB,GAAG,cAAc,CA4GxC"}
|
|
@@ -8,11 +8,6 @@
|
|
|
8
8
|
* @position Lab utility; computes a line diff (Myers) when the caller has two
|
|
9
9
|
* texts rather than a patch
|
|
10
10
|
*
|
|
11
|
-
* Shared leading and trailing lines are matched without a search. The minimal
|
|
12
|
-
* search over the rest stops after MAX_EDIT_DISTANCE changed lines, so memory
|
|
13
|
-
* stays bounded for any input; a middle that differs more than that becomes
|
|
14
|
-
* one replaced block, marked with `isReplacedBlock` on its hunk.
|
|
15
|
-
*
|
|
16
11
|
* SYNC: When modified, update:
|
|
17
12
|
* - /packages/lab/src/DiffViewer/createDiffFile.test.ts
|
|
18
13
|
* - /packages/lab/src/DiffViewer/DiffViewer.doc.mjs (utilities section)
|
|
@@ -30,16 +25,33 @@ function splitLines(value) {
|
|
|
30
25
|
}
|
|
31
26
|
|
|
32
27
|
/**
|
|
33
|
-
* Most changed lines the
|
|
34
|
-
*
|
|
35
|
-
*
|
|
28
|
+
* Most changed lines the search compares line by line; past it the changed
|
|
29
|
+
* middle is shown as one replaced block. The search keeps a few arrays the
|
|
30
|
+
* size of the input, so its memory does not grow with this limit.
|
|
31
|
+
*/
|
|
32
|
+
const MAX_EDIT_DISTANCE = 10_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Most search work, counted as changed-middle lines times edits. The search
|
|
36
|
+
* time grows with both, so a long file gets a lower edit limit. A file of up
|
|
37
|
+
* to 50,000 lines keeps the full MAX_EDIT_DISTANCE.
|
|
36
38
|
*/
|
|
37
|
-
const
|
|
39
|
+
const MAX_SEARCH_WORK = 1_000_000_000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Most changed lines the traced search tries first. Most changes are small,
|
|
43
|
+
* and for them the traced search is faster than the linear-space one. Its
|
|
44
|
+
* trace grows with the square of the edits, so at this limit it stays near
|
|
45
|
+
* 4 MB.
|
|
46
|
+
*/
|
|
47
|
+
const TRACED_SEARCH_LIMIT = 1000;
|
|
38
48
|
/**
|
|
39
49
|
* Line edit script from `a` to `b`. Shared leading and trailing lines are
|
|
40
|
-
* context without a search
|
|
41
|
-
*
|
|
42
|
-
*
|
|
50
|
+
* context without a search. The middle uses a shortest edit script, or, when
|
|
51
|
+
* it needs more edits than the limit, removes the old middle and adds the new
|
|
52
|
+
* one. The limit is MAX_EDIT_DISTANCE, lowered for a long middle so the work
|
|
53
|
+
* stays under MAX_SEARCH_WORK. The traced search runs first; the linear-space
|
|
54
|
+
* search takes over when the change is larger than TRACED_SEARCH_LIMIT.
|
|
43
55
|
*/
|
|
44
56
|
function diffLines(a, b) {
|
|
45
57
|
let head = 0;
|
|
@@ -63,7 +75,8 @@ function diffLines(a, b) {
|
|
|
63
75
|
// When one side of the middle is empty, adding or removing the other side
|
|
64
76
|
// is already the minimal script.
|
|
65
77
|
const isOneSided = tailA === head || tailB === head;
|
|
66
|
-
const
|
|
78
|
+
const editLimit = Math.min(MAX_EDIT_DISTANCE, Math.floor(MAX_SEARCH_WORK / (tailA - head + (tailB - head))));
|
|
79
|
+
const middle = isOneSided ? null : tracedEditScript(a, b, head, tailA, tailB, Math.min(TRACED_SEARCH_LIMIT, editLimit)) ?? shortestEditScript(a, b, head, tailA, tailB, editLimit);
|
|
67
80
|
if (middle != null) {
|
|
68
81
|
// A loop, not a spread: one call argument per op overflows the stack on
|
|
69
82
|
// long inputs.
|
|
@@ -98,11 +111,195 @@ function diffLines(a, b) {
|
|
|
98
111
|
}
|
|
99
112
|
|
|
100
113
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
114
|
+
* Shortest edit script over `a[start, endA)` and `b[start, endB)`, with
|
|
115
|
+
* absolute indexes in the ops; null when it needs more than `maxEdits` edits.
|
|
116
|
+
*
|
|
117
|
+
* This is Myers' O(ND) search in linear space. Each step searches forward
|
|
118
|
+
* from the start and backward from the end at the same time until the two
|
|
119
|
+
* searches meet. The meeting point is on a shortest path, so the step then
|
|
120
|
+
* solves the part before it and the part after it. Lines are compared as
|
|
121
|
+
* integer ids.
|
|
122
|
+
*/
|
|
123
|
+
function shortestEditScript(a, b, start, endA, endB, maxEdits) {
|
|
124
|
+
const ids = new Map();
|
|
125
|
+
const toIds = (lines, end) => {
|
|
126
|
+
const out = new Int32Array(end - start);
|
|
127
|
+
for (let i = start; i < end; i++) {
|
|
128
|
+
let id = ids.get(lines[i]);
|
|
129
|
+
if (id === undefined) {
|
|
130
|
+
id = ids.size;
|
|
131
|
+
ids.set(lines[i], id);
|
|
132
|
+
}
|
|
133
|
+
out[i - start] = id;
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
};
|
|
137
|
+
const oldIds = toIds(a, endA);
|
|
138
|
+
const newIds = toIds(b, endB);
|
|
139
|
+
|
|
140
|
+
// Each copy of a line that one side has more often than the other is an
|
|
141
|
+
// edit, so this count is a lower bound on the edits. When it is past the
|
|
142
|
+
// limit (a rewrite, for example), skip the search.
|
|
143
|
+
const surplus = new Int32Array(ids.size);
|
|
144
|
+
for (const id of oldIds) {
|
|
145
|
+
surplus[id]++;
|
|
146
|
+
}
|
|
147
|
+
for (const id of newIds) {
|
|
148
|
+
surplus[id]--;
|
|
149
|
+
}
|
|
150
|
+
let minEdits = 0;
|
|
151
|
+
for (const count of surplus) {
|
|
152
|
+
minEdits += Math.abs(count);
|
|
153
|
+
}
|
|
154
|
+
if (minEdits > maxEdits) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
// The furthest x each search reached on diagonal k (x - y), stored at
|
|
158
|
+
// index k + offset. The backward search counts x and y from the end.
|
|
159
|
+
const offset = oldIds.length + newIds.length + 1;
|
|
160
|
+
const forward = new Int32Array(2 * offset + 1);
|
|
161
|
+
const backward = new Int32Array(2 * offset + 1);
|
|
162
|
+
const ops = [];
|
|
163
|
+
|
|
164
|
+
// A point on a shortest path through the box, or null when the path needs
|
|
165
|
+
// more than `limit` edits.
|
|
166
|
+
const meetingPoint = (aLo, aHi, bLo, bHi, limit) => {
|
|
167
|
+
const n = aHi - aLo;
|
|
168
|
+
const m = bHi - bLo;
|
|
169
|
+
const delta = n - m;
|
|
170
|
+
const isOdd = (delta & 1) !== 0;
|
|
171
|
+
forward[offset + 1] = 0;
|
|
172
|
+
backward[offset + 1] = 0;
|
|
173
|
+
for (let d = 0; d <= Math.ceil((n + m) / 2); d++) {
|
|
174
|
+
// Forward step d can find a path of 2d - 1 edits, backward step d one
|
|
175
|
+
// of 2d edits.
|
|
176
|
+
if (2 * d - 1 > limit) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
for (let k = -d; k <= d; k += 2) {
|
|
180
|
+
let x = k === -d || k !== d && forward[offset + k - 1] < forward[offset + k + 1] ? forward[offset + k + 1] : forward[offset + k - 1] + 1;
|
|
181
|
+
let y = x - k;
|
|
182
|
+
while (x < n && y < m && oldIds[aLo + x] === newIds[bLo + y]) {
|
|
183
|
+
x++;
|
|
184
|
+
y++;
|
|
185
|
+
}
|
|
186
|
+
forward[offset + k] = x;
|
|
187
|
+
// The backward search has done d - 1 steps, so it holds diagonals
|
|
188
|
+
// -(d - 1)..d - 1, in its own coordinates.
|
|
189
|
+
const back = delta - k;
|
|
190
|
+
if (isOdd && x <= n && y <= m && back >= 1 - d && back <= d - 1 && x + backward[offset + back] >= n) {
|
|
191
|
+
return [aLo + x, bLo + y];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (2 * d > limit) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
for (let k = -d; k <= d; k += 2) {
|
|
198
|
+
let x = k === -d || k !== d && backward[offset + k - 1] < backward[offset + k + 1] ? backward[offset + k + 1] : backward[offset + k - 1] + 1;
|
|
199
|
+
let y = x - k;
|
|
200
|
+
while (x < n && y < m && oldIds[aHi - 1 - x] === newIds[bHi - 1 - y]) {
|
|
201
|
+
x++;
|
|
202
|
+
y++;
|
|
203
|
+
}
|
|
204
|
+
backward[offset + k] = x;
|
|
205
|
+
const front = delta - k;
|
|
206
|
+
if (!isOdd && x <= n && y <= m && front >= -d && front <= d && x + forward[offset + front] >= n) {
|
|
207
|
+
const meetX = forward[offset + front];
|
|
208
|
+
return [aLo + meetX, bLo + meetX - front];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// Appends the script for the box to `ops`; false when the box needs more
|
|
216
|
+
// than `limit` edits. Only the outermost box sets a limit.
|
|
217
|
+
const solve = (aLo, aHi, bLo, bHi, limit) => {
|
|
218
|
+
while (aLo < aHi && bLo < bHi && oldIds[aLo] === newIds[bLo]) {
|
|
219
|
+
ops.push({
|
|
220
|
+
kind: 'context',
|
|
221
|
+
a: start + aLo,
|
|
222
|
+
b: start + bLo
|
|
223
|
+
});
|
|
224
|
+
aLo++;
|
|
225
|
+
bLo++;
|
|
226
|
+
}
|
|
227
|
+
let tail = 0;
|
|
228
|
+
while (aHi - tail > aLo && bHi - tail > bLo && oldIds[aHi - 1 - tail] === newIds[bHi - 1 - tail]) {
|
|
229
|
+
tail++;
|
|
230
|
+
}
|
|
231
|
+
const aEnd = aHi - tail;
|
|
232
|
+
const bEnd = bHi - tail;
|
|
233
|
+
if (aLo === aEnd || bLo === bEnd) {
|
|
234
|
+
if (aEnd - aLo + (bEnd - bLo) > limit) {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
for (let i = aLo; i < aEnd; i++) {
|
|
238
|
+
ops.push({
|
|
239
|
+
kind: 'removed',
|
|
240
|
+
a: start + i
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
for (let j = bLo; j < bEnd; j++) {
|
|
244
|
+
ops.push({
|
|
245
|
+
kind: 'added',
|
|
246
|
+
b: start + j
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
const point = meetingPoint(aLo, aEnd, bLo, bEnd, limit);
|
|
251
|
+
if (point == null || !solve(aLo, point[0], bLo, point[1], Infinity) || !solve(point[0], aEnd, point[1], bEnd, Infinity)) {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
for (let t = tail; t > 0; t--) {
|
|
256
|
+
ops.push({
|
|
257
|
+
kind: 'context',
|
|
258
|
+
a: start + aHi - t,
|
|
259
|
+
b: start + bHi - t
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return true;
|
|
263
|
+
};
|
|
264
|
+
if (!solve(0, oldIds.length, 0, newIds.length, maxEdits)) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
return removalsFirst(ops);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Put the removals of each run of changes before its additions, so split view
|
|
272
|
+
* pairs each removed line with the added line that replaced it.
|
|
273
|
+
*/
|
|
274
|
+
function removalsFirst(ops) {
|
|
275
|
+
const sorted = [];
|
|
276
|
+
const added = [];
|
|
277
|
+
for (const op of ops) {
|
|
278
|
+
if (op.kind === 'added') {
|
|
279
|
+
added.push(op);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (op.kind === 'context') {
|
|
283
|
+
for (const addedOp of added) {
|
|
284
|
+
sorted.push(addedOp);
|
|
285
|
+
}
|
|
286
|
+
added.length = 0;
|
|
287
|
+
}
|
|
288
|
+
sorted.push(op);
|
|
289
|
+
}
|
|
290
|
+
for (const addedOp of added) {
|
|
291
|
+
sorted.push(addedOp);
|
|
292
|
+
}
|
|
293
|
+
return sorted;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Myers' O(ND) shortest edit script over `a[start, endA)` and
|
|
298
|
+
* `b[start, endB)`, with absolute indexes in the ops; null when it needs more
|
|
299
|
+
* than `maxEdits` edits. It keeps every step's diagonals to trace the path
|
|
300
|
+
* back, so its memory grows with the square of the edits.
|
|
104
301
|
*/
|
|
105
|
-
function
|
|
302
|
+
function tracedEditScript(a, b, start, endA, endB, maxEdits) {
|
|
106
303
|
const n = endA - start;
|
|
107
304
|
const m = endB - start;
|
|
108
305
|
const max = n + m;
|
|
@@ -111,7 +308,7 @@ function myers(a, b, start, endA, endB) {
|
|
|
111
308
|
// trace[d] holds diagonals -d..d of v as it was before step d.
|
|
112
309
|
const trace = [];
|
|
113
310
|
outer: for (let d = 0; d <= max; d++) {
|
|
114
|
-
if (d >
|
|
311
|
+
if (d > maxEdits) {
|
|
115
312
|
return null;
|
|
116
313
|
}
|
|
117
314
|
trace.push(v.slice(offset - d, offset + d + 1));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/lab",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.7-canary.0d1ddcd",
|
|
4
4
|
"description": "Experimental Astryx components — published to npm only under the @canary dist-tag for early testing; never released as a stable (latest) version.",
|
|
5
5
|
"author": "Meta Open Source",
|
|
6
6
|
"license": "MIT",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"@stylexjs/stylex": ">=0.10.0",
|
|
52
|
-
"@astryxdesign/core": "0.7.
|
|
52
|
+
"@astryxdesign/core": "0.7.7-canary.0d1ddcd",
|
|
53
53
|
"react": ">=19.0.0",
|
|
54
54
|
"react-dom": ">=19.0.0"
|
|
55
55
|
},
|
|
@@ -59,9 +59,9 @@
|
|
|
59
59
|
"d3-shape": "^3.2.0"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@astryxdesign/a11y-spec": "0.7.
|
|
63
|
-
"@astryxdesign/cli": "0.7.
|
|
64
|
-
"@astryxdesign/interaction-harness": "0.7.
|
|
62
|
+
"@astryxdesign/a11y-spec": "0.7.7-canary.0d1ddcd",
|
|
63
|
+
"@astryxdesign/cli": "0.7.7-canary.0d1ddcd",
|
|
64
|
+
"@astryxdesign/interaction-harness": "0.7.7-canary.0d1ddcd",
|
|
65
65
|
"@babel/cli": "^7.29.7",
|
|
66
66
|
"@babel/core": "^7.29.7",
|
|
67
67
|
"@babel/preset-react": "^7.29.7",
|
|
@@ -30,7 +30,7 @@ export const docs = {
|
|
|
30
30
|
|
|
31
31
|
usage: {
|
|
32
32
|
description:
|
|
33
|
-
'Read-only, syntax-highlighted diff of one file for code review surfaces. Renders a file header (path, Added/Deleted/Renamed token, +/− line counts, a headerEnd slot, and an optional disclosure) over the file\'s hunks in unified or split (side-by-side) view. Rows use the same syntax-theme diff slots as CodeBlock with isDiff, so one syntax theme styles both. renderLineAnnotation adds a full-width row under any line (for review comments) and onLineNumberClick turns line numbers into buttons. Build the file model with parseUnifiedDiff(patch), which returns one file per file in a git or diff -u patch, or with createDiffFile({oldValue, newValue}) when you hold two versions of a text. Display options cover how a change is marked (classic +/− glyphs, bars, or none), whether changed rows are tinted, and word- or character-level highlighting of what changed inside each removed/added line pair. Each changed line carries a visually hidden "Added"/"Removed" label, so the change reads without colour in every mode. Unwrapped, line numbers and change indicators stay pinned while the code scrolls; in split view each side is its own scroller, so the split stays fixed at half and half, the sides scroll sideways in lockstep, and a row is as tall as its taller side. While code overflows sideways, sideways overscroll is off, so trackpads neither bounce past a line nor swipe the browser back. In right-to-left pages the hunk area stays left-to-right, like a code editor: annotation content reads in the page direction, and annotation rows scroll with the code. When a change is too large to compare line by line, createDiffFile marks its hunk with isReplacedBlock: it lists every old line as removed and every new line as added. DiffViewer folds those lines behind a "Large change: N lines" row, where N counts the block\'s removed plus added lines, with a Show full diff button. A
|
|
33
|
+
'Read-only, syntax-highlighted diff of one file for code review surfaces. Renders a file header (path, Added/Deleted/Renamed token, +/− line counts, a headerEnd slot, and an optional disclosure) over the file\'s hunks in unified or split (side-by-side) view. Rows use the same syntax-theme diff slots as CodeBlock with isDiff, so one syntax theme styles both. renderLineAnnotation adds a full-width row under any line (for review comments) and onLineNumberClick turns line numbers into buttons. Build the file model with parseUnifiedDiff(patch), which returns one file per file in a git or diff -u patch, or with createDiffFile({oldValue, newValue}) when you hold two versions of a text. Display options cover how a change is marked (classic +/− glyphs, bars, or none), whether changed rows are tinted, and word- or character-level highlighting of what changed inside each removed/added line pair. Each changed line carries a visually hidden "Added"/"Removed" label, so the change reads without colour in every mode. Unwrapped, line numbers and change indicators stay pinned while the code scrolls; in split view each side is its own scroller, so the split stays fixed at half and half, the sides scroll sideways in lockstep, and a row is as tall as its taller side. While code overflows sideways, sideways overscroll is off, so trackpads neither bounce past a line nor swipe the browser back. In right-to-left pages the hunk area stays left-to-right, like a code editor: annotation content reads in the page direction, and annotation rows scroll with the code. When a change is too large to compare line by line, createDiffFile marks its hunk with isReplacedBlock: it lists every old line as removed and every new line as added. DiffViewer folds those lines behind a "Large change: N lines" row, where N counts the block\'s removed plus added lines, with a Show full diff button. A file with more than 2,000 other changed lines starts folded the same way, because every row costs render time. Its line diff is still there when the reader opens it. A fold opens by itself when a line it would hide has an annotation, so no review comment is hidden. Rows are plain DOM with no virtualization: collapse or window very large files.',
|
|
34
34
|
bestPractices: [
|
|
35
35
|
{
|
|
36
36
|
guidance: true,
|
|
@@ -763,6 +763,80 @@ describe('DiffViewer', () => {
|
|
|
763
763
|
});
|
|
764
764
|
});
|
|
765
765
|
|
|
766
|
+
describe('large files', () => {
|
|
767
|
+
/** One hunk that adds `count` lines to an empty file. */
|
|
768
|
+
function addedFile(count: number): DiffViewerFile {
|
|
769
|
+
return {
|
|
770
|
+
oldPath: null,
|
|
771
|
+
newPath: 'generated.txt',
|
|
772
|
+
status: 'added',
|
|
773
|
+
isBinary: false,
|
|
774
|
+
hunks: [
|
|
775
|
+
{
|
|
776
|
+
header: `@@ -0,0 +1,${count} @@`,
|
|
777
|
+
oldStart: 0,
|
|
778
|
+
oldLines: 0,
|
|
779
|
+
newStart: 1,
|
|
780
|
+
newLines: count,
|
|
781
|
+
lines: Array.from({length: count}, (_, i) => ({
|
|
782
|
+
kind: 'added' as const,
|
|
783
|
+
content: `line ${i}`,
|
|
784
|
+
oldLineNumber: null,
|
|
785
|
+
newLineNumber: i + 1,
|
|
786
|
+
})),
|
|
787
|
+
},
|
|
788
|
+
],
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
it('folds a file with more than 2,000 changed lines until the reader opens it', async () => {
|
|
793
|
+
const user = userEvent.setup();
|
|
794
|
+
const {container} = render(<DiffViewer file={addedFile(2001)} />);
|
|
795
|
+
expect(screen.getByText('Large change: 2,001 lines')).toBeTruthy();
|
|
796
|
+
expect(container.querySelector('table')).toBeNull();
|
|
797
|
+
// The header still counts every line.
|
|
798
|
+
expect(
|
|
799
|
+
screen.getByText('2,001 lines added, 0 lines removed'),
|
|
800
|
+
).toBeTruthy();
|
|
801
|
+
const button = screen.getByRole('button', {name: 'Show full diff'});
|
|
802
|
+
button.focus();
|
|
803
|
+
await user.keyboard('{Enter}');
|
|
804
|
+
const hide = screen.getByRole('button', {name: 'Hide full diff'});
|
|
805
|
+
expect(hide.getAttribute('aria-expanded')).toBe('true');
|
|
806
|
+
expect(hide).toBe(document.activeElement);
|
|
807
|
+
expect(container.querySelectorAll('[data-diff="added"]')).toHaveLength(
|
|
808
|
+
2001,
|
|
809
|
+
);
|
|
810
|
+
await user.click(hide);
|
|
811
|
+
expect(container.querySelector('table')).toBeNull();
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it('shows a file with 2,000 changed lines without a fold', () => {
|
|
815
|
+
const {container} = render(<DiffViewer file={addedFile(2000)} />);
|
|
816
|
+
expect(screen.queryByRole('button', {name: 'Show full diff'})).toBeNull();
|
|
817
|
+
expect(container.querySelectorAll('[data-diff="added"]')).toHaveLength(
|
|
818
|
+
2000,
|
|
819
|
+
);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it('opens a large file that has an annotated line', () => {
|
|
823
|
+
render(
|
|
824
|
+
<DiffViewer
|
|
825
|
+
file={addedFile(2001)}
|
|
826
|
+
renderLineAnnotation={ref =>
|
|
827
|
+
ref.lineNumber === 1500 ? <span>Generated?</span> : null
|
|
828
|
+
}
|
|
829
|
+
/>,
|
|
830
|
+
);
|
|
831
|
+
expect(screen.getByText('Generated?')).toBeTruthy();
|
|
832
|
+
expect(
|
|
833
|
+
screen
|
|
834
|
+
.getByRole('button', {name: 'Hide full diff'})
|
|
835
|
+
.getAttribute('aria-expanded'),
|
|
836
|
+
).toBe('true');
|
|
837
|
+
});
|
|
838
|
+
});
|
|
839
|
+
|
|
766
840
|
it('shows a message for binary files', () => {
|
|
767
841
|
const [binary] = parseUnifiedDiff(
|
|
768
842
|
'diff --git a/x.png b/x.png\nBinary files a/x.png and b/x.png differ\n',
|