@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.
@@ -1 +1 @@
1
- {"version":3,"file":"DiffViewer.d.ts","sourceRoot":"","sources":["../../src/DiffViewer/DiffViewer.tsx"],"names":[],"mappings":"AAuCA,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;AA++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,+BA0uBjB;yBAlwBe,UAAU"}
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` shows a labeled fold in place of its
16
- * changed lines until the reader opens it.
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 starts folded unless one of its lines is annotated. The
895
- // reader's toggles are kept per file; a new file starts over.
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 annotatedBlocks = findAnnotatedBlocks(file, renderLineAnnotation);
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
- const cells = useMemo(() => buildCells(file, language, inlineHighlight, expandedBlocks), [file, language, inlineHighlight, expandedBlocks]);
913
- const unifiedRows = useMemo(() => viewMode === 'unified' ? buildUnifiedRows(file, cells) : [], [viewMode, file, cells]);
914
- const splitRows = useMemo(() => viewMode === 'split' ? buildSplitRows(file, cells) : [], [viewMode, file, cells]);
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 renderFold = fold => /*#__PURE__*/_jsxs("span", {
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
- }[!!isPinned << 0],
1066
+ }[!!isSticky << 0],
1032
1067
  children: [/*#__PURE__*/_jsx("span", {
1033
1068
  children: t('@astryx.diffViewer.largeChange', {
1034
- count: fold.changedLines
1069
+ count: changedLines
1035
1070
  })
1036
1071
  }), /*#__PURE__*/_jsx(Button, {
1037
1072
  size: "sm",
1038
1073
  variant: "secondary",
1039
- label: fold.isExpanded ? t('@astryx.diffViewer.hideFullDiff') : t('@astryx.diffViewer.showFullDiff'),
1040
- "aria-expanded": fold.isExpanded,
1041
- onClick: () => toggleBlock(fold.hunkIndex)
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
- body = /*#__PURE__*/_jsx("div", {
1373
- ...withoutInlineOverscroll(getViewportProps({
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: "xeuugli xzt5al7"
1413
+ className: "xce4md1 xrrkdod x1yc453h x11ds9ch xv1l7n4 x9ynric x141an7d x1ltkj2j xszpfcc xykt701 xgtxpae"
1376
1414
  },
1377
- style: scrollStyle
1378
- })),
1379
- children: /*#__PURE__*/_jsx("div", {
1380
- ...getContentProps({}),
1381
- children: table
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;;;;;;;;;;;;;;;;GAgBG;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;AA0JD;;;;;;;;;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"}
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 minimal search explores before giving up. The trace
34
- * it keeps grows with the square of this, so the cap bounds memory to a few
35
- * megabytes; past it the changed middle is shown as one replaced block.
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 MAX_EDIT_DISTANCE = 2000;
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; the middle uses Myers' O(ND) shortest edit script,
41
- * or, when it needs more than MAX_EDIT_DISTANCE edits, removes the old middle
42
- * and adds the new one.
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 middle = isOneSided ? null : myers(a, b, head, tailA, tailB);
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
- * Myers' shortest edit script over `a[start, endA)` and `b[start, endB)`,
102
- * with absolute indexes in the ops; null when it needs more than
103
- * MAX_EDIT_DISTANCE edits.
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 myers(a, b, start, endA, endB) {
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 > MAX_EDIT_DISTANCE) {
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.6-canary.fda92ac",
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.6-canary.fda92ac",
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.6-canary.fda92ac",
63
- "@astryxdesign/cli": "0.7.6-canary.fda92ac",
64
- "@astryxdesign/interaction-harness": "0.7.6-canary.fda92ac",
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 block opens by itself when one of its lines has an annotation, so no review comment is hidden. Rows are plain DOM with no virtualization: collapse or window very large files.',
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',