xtralab 0.15.0 → 0.15.2

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.
Files changed (237) hide show
  1. package/README.md +65 -30
  2. package/lib/about/icons.d.ts +0 -5
  3. package/lib/about/icons.js +0 -5
  4. package/lib/about/index.d.ts +4 -8
  5. package/lib/about/index.js +6 -17
  6. package/lib/agentSessions.d.ts +9 -29
  7. package/lib/agentSessions.js +2 -7
  8. package/lib/askAgent/editorSelection.d.ts +20 -26
  9. package/lib/askAgent/editorSelection.js +11 -26
  10. package/lib/askAgent/icons.d.ts +2 -3
  11. package/lib/askAgent/icons.js +2 -3
  12. package/lib/askAgent/index.d.ts +4 -26
  13. package/lib/askAgent/index.js +54 -158
  14. package/lib/askAgent/popup.d.ts +12 -16
  15. package/lib/askAgent/popup.js +18 -37
  16. package/lib/askAgent/prompt.d.ts +3 -5
  17. package/lib/askAgent/prompt.js +6 -19
  18. package/lib/askAgent/queue.d.ts +11 -21
  19. package/lib/askAgent/queue.js +9 -19
  20. package/lib/askAgent/queuePanel.d.ts +13 -18
  21. package/lib/askAgent/queuePanel.js +15 -37
  22. package/lib/askAgent/targetPicker.d.ts +2 -9
  23. package/lib/askAgent/targetPicker.js +6 -11
  24. package/lib/askAgent/tokens.d.ts +32 -46
  25. package/lib/askAgent/tokens.js +4 -6
  26. package/lib/commandBar/index.d.ts +4 -13
  27. package/lib/commandBar/index.js +21 -39
  28. package/lib/customPanel/index.js +15 -0
  29. package/lib/editorBreadcrumbs/index.d.ts +3 -6
  30. package/lib/editorBreadcrumbs/index.js +10 -6
  31. package/lib/editorBreadcrumbs/widget.d.ts +17 -4
  32. package/lib/editorBreadcrumbs/widget.js +11 -17
  33. package/lib/editorIndent/index.d.ts +4 -12
  34. package/lib/editorIndent/index.js +8 -23
  35. package/lib/fileBrowser/commands.d.ts +29 -19
  36. package/lib/fileBrowser/commands.js +83 -97
  37. package/lib/fileBrowser/contents.d.ts +3 -0
  38. package/lib/fileBrowser/dragAndDrop.d.ts +17 -4
  39. package/lib/fileBrowser/dragAndDrop.js +2 -7
  40. package/lib/fileBrowser/fileBrowser.d.ts +19 -9
  41. package/lib/fileBrowser/fileBrowser.js +70 -222
  42. package/lib/fileBrowser/gitStatus.d.ts +16 -10
  43. package/lib/fileBrowser/gitStatus.js +31 -30
  44. package/lib/fileBrowser/gitignore.d.ts +4 -17
  45. package/lib/fileBrowser/gitignore.js +4 -25
  46. package/lib/fileBrowser/icons.d.ts +7 -17
  47. package/lib/fileBrowser/icons.js +25 -80
  48. package/lib/fileBrowser/index.js +2 -4
  49. package/lib/fileBrowser/widget.d.ts +141 -48
  50. package/lib/fileBrowser/widget.js +102 -4
  51. package/lib/fileTypeIcons/index.js +0 -10
  52. package/lib/git/api.d.ts +5 -13
  53. package/lib/git/api.js +10 -36
  54. package/lib/git/askRequest.d.ts +4 -8
  55. package/lib/git/askRequest.js +6 -20
  56. package/lib/git/commands.d.ts +45 -5
  57. package/lib/git/commands.js +20 -13
  58. package/lib/git/diffModel.js +4 -0
  59. package/lib/git/diffPreferences.d.ts +90 -0
  60. package/lib/git/diffPreferences.js +107 -0
  61. package/lib/git/diffProvider.js +10 -2
  62. package/lib/git/diffSurface.d.ts +64 -63
  63. package/lib/git/diffSurface.js +72 -213
  64. package/lib/git/diffTheme.d.ts +7 -0
  65. package/lib/git/diffTheme.js +23 -0
  66. package/lib/git/diffWidget.d.ts +70 -14
  67. package/lib/git/diffWidget.js +100 -94
  68. package/lib/git/imageDiff.d.ts +14 -6
  69. package/lib/git/imageDiff.js +19 -75
  70. package/lib/git/index.d.ts +0 -3
  71. package/lib/git/index.js +44 -10
  72. package/lib/git/notebookDiff.d.ts +110 -33
  73. package/lib/git/notebookDiff.js +55 -197
  74. package/lib/git/tokens.d.ts +73 -31
  75. package/lib/git/tokens.js +2 -3
  76. package/lib/highlight/index.d.ts +4 -10
  77. package/lib/highlight/index.js +10 -41
  78. package/lib/index.d.ts +0 -9
  79. package/lib/index.js +0 -9
  80. package/lib/launcher/agents.d.ts +55 -51
  81. package/lib/launcher/agents.js +15 -57
  82. package/lib/launcher/availability.d.ts +3 -9
  83. package/lib/launcher/availability.js +3 -9
  84. package/lib/launcher/commands.d.ts +7 -32
  85. package/lib/launcher/commands.js +14 -45
  86. package/lib/launcher/dashboard.d.ts +19 -24
  87. package/lib/launcher/dashboard.js +21 -67
  88. package/lib/launcher/editorRegistry.d.ts +10 -22
  89. package/lib/launcher/editorRegistry.js +14 -18
  90. package/lib/launcher/editors.d.ts +39 -45
  91. package/lib/launcher/editors.js +8 -34
  92. package/lib/launcher/icons.d.ts +0 -12
  93. package/lib/launcher/icons.js +5 -39
  94. package/lib/launcher/index.js +12 -55
  95. package/lib/launcher/invocation.d.ts +4 -10
  96. package/lib/launcher/invocation.js +7 -22
  97. package/lib/launcher/registry.d.ts +9 -7
  98. package/lib/launcher/registry.js +9 -7
  99. package/lib/launcher/tokens.d.ts +10 -22
  100. package/lib/launcher/tokens.js +5 -8
  101. package/lib/menuBar/index.d.ts +3 -10
  102. package/lib/menuBar/index.js +11 -28
  103. package/lib/menus/index.d.ts +3 -9
  104. package/lib/menus/index.js +14 -30
  105. package/lib/omnibox/files.d.ts +2 -4
  106. package/lib/omnibox/files.js +5 -11
  107. package/lib/omnibox/index.d.ts +4 -14
  108. package/lib/omnibox/index.js +9 -27
  109. package/lib/omnibox/model.d.ts +38 -10
  110. package/lib/omnibox/model.js +15 -33
  111. package/lib/omnibox/recents.d.ts +15 -17
  112. package/lib/omnibox/recents.js +12 -17
  113. package/lib/omnibox/tokens.d.ts +5 -10
  114. package/lib/omnibox/tokens.js +2 -6
  115. package/lib/omnibox/widget.d.ts +11 -7
  116. package/lib/omnibox/widget.js +8 -16
  117. package/lib/searchReplace/index.d.ts +4 -20
  118. package/lib/searchReplace/index.js +7 -27
  119. package/lib/showOutput/index.d.ts +3 -12
  120. package/lib/showOutput/index.js +6 -22
  121. package/lib/sidebar/index.d.ts +4 -7
  122. package/lib/sidebar/index.js +5 -12
  123. package/lib/terminalNotifications/index.d.ts +4 -10
  124. package/lib/terminalNotifications/index.js +6 -20
  125. package/lib/terminals/agentTerminals.d.ts +12 -5
  126. package/lib/terminals/agentTerminals.js +25 -44
  127. package/lib/terminals/detection.d.ts +4 -8
  128. package/lib/terminals/detection.js +4 -8
  129. package/lib/terminals/index.d.ts +0 -35
  130. package/lib/terminals/index.js +12 -101
  131. package/lib/terminals/model.d.ts +64 -139
  132. package/lib/terminals/model.js +80 -220
  133. package/lib/terminals/tokens.d.ts +11 -22
  134. package/lib/terminals/tokens.js +2 -2
  135. package/lib/terminals/widget.d.ts +43 -13
  136. package/lib/terminals/widget.js +33 -18
  137. package/lib/topBar/icons.d.ts +3 -8
  138. package/lib/topBar/icons.js +3 -8
  139. package/lib/topBar/index.d.ts +4 -11
  140. package/lib/topBar/index.js +10 -38
  141. package/lib/walkthrough/index.d.ts +4 -9
  142. package/lib/walkthrough/index.js +6 -20
  143. package/lib/walkthrough/panel.d.ts +40 -9
  144. package/lib/walkthrough/panel.js +4 -10
  145. package/package.json +5 -3
  146. package/schema/git-diff-preferences.json +42 -0
  147. package/src/about/icons.ts +0 -5
  148. package/src/about/index.tsx +6 -17
  149. package/src/agentSessions.ts +9 -29
  150. package/src/askAgent/editorSelection.ts +22 -29
  151. package/src/askAgent/icons.ts +2 -3
  152. package/src/askAgent/index.ts +54 -158
  153. package/src/askAgent/popup.tsx +23 -45
  154. package/src/askAgent/prompt.ts +6 -19
  155. package/src/askAgent/queue.ts +13 -24
  156. package/src/askAgent/queuePanel.tsx +21 -46
  157. package/src/askAgent/targetPicker.tsx +5 -15
  158. package/src/askAgent/tokens.ts +32 -46
  159. package/src/commandBar/index.ts +24 -39
  160. package/src/customPanel/index.ts +15 -0
  161. package/src/editorBreadcrumbs/index.ts +10 -6
  162. package/src/editorBreadcrumbs/widget.ts +23 -17
  163. package/src/editorIndent/index.ts +17 -23
  164. package/src/fileBrowser/commands.ts +119 -96
  165. package/src/fileBrowser/contents.ts +3 -0
  166. package/src/fileBrowser/dragAndDrop.ts +17 -7
  167. package/src/fileBrowser/fileBrowser.tsx +86 -230
  168. package/src/fileBrowser/gitStatus.ts +36 -33
  169. package/src/fileBrowser/gitignore.ts +4 -25
  170. package/src/fileBrowser/icons.ts +25 -80
  171. package/src/fileBrowser/index.ts +2 -4
  172. package/src/fileBrowser/widget.tsx +171 -50
  173. package/src/fileTypeIcons/index.ts +0 -10
  174. package/src/git/api.ts +10 -36
  175. package/src/git/askRequest.ts +6 -20
  176. package/src/git/commands.ts +83 -11
  177. package/src/git/diffModel.ts +25 -0
  178. package/src/git/diffPreferences.ts +192 -0
  179. package/src/git/diffProvider.tsx +11 -2
  180. package/src/git/diffSurface.tsx +165 -286
  181. package/src/git/diffTheme.ts +24 -0
  182. package/src/git/diffWidget.tsx +194 -134
  183. package/src/git/imageDiff.tsx +56 -84
  184. package/src/git/index.ts +54 -10
  185. package/src/git/notebookDiff.tsx +214 -240
  186. package/src/git/tokens.ts +73 -31
  187. package/src/highlight/index.ts +10 -41
  188. package/src/index.ts +0 -9
  189. package/src/launcher/agents.ts +64 -95
  190. package/src/launcher/availability.ts +3 -9
  191. package/src/launcher/commands.ts +14 -51
  192. package/src/launcher/dashboard.tsx +49 -88
  193. package/src/launcher/editorRegistry.ts +21 -29
  194. package/src/launcher/editors.ts +39 -58
  195. package/src/launcher/icons.ts +5 -39
  196. package/src/launcher/index.ts +12 -55
  197. package/src/launcher/invocation.ts +7 -22
  198. package/src/launcher/registry.ts +9 -7
  199. package/src/launcher/tokens.ts +10 -22
  200. package/src/menuBar/index.ts +11 -28
  201. package/src/menus/index.ts +14 -30
  202. package/src/omnibox/files.ts +10 -14
  203. package/src/omnibox/index.ts +9 -27
  204. package/src/omnibox/model.ts +50 -38
  205. package/src/omnibox/recents.ts +17 -22
  206. package/src/omnibox/tokens.ts +5 -10
  207. package/src/omnibox/widget.tsx +14 -19
  208. package/src/searchReplace/index.ts +7 -27
  209. package/src/showOutput/index.ts +6 -22
  210. package/src/sidebar/index.ts +20 -12
  211. package/src/terminalNotifications/index.ts +42 -25
  212. package/src/terminals/agentTerminals.ts +25 -49
  213. package/src/terminals/detection.ts +4 -8
  214. package/src/terminals/index.ts +12 -101
  215. package/src/terminals/model.ts +130 -246
  216. package/src/terminals/tokens.ts +11 -22
  217. package/src/terminals/widget.tsx +48 -31
  218. package/src/topBar/icons.ts +3 -8
  219. package/src/topBar/index.ts +21 -44
  220. package/src/walkthrough/index.ts +6 -20
  221. package/src/walkthrough/panel.ts +40 -10
  222. package/style/about.css +2 -7
  223. package/style/askAgent.css +8 -40
  224. package/style/base.css +9 -45
  225. package/style/chrome.css +108 -308
  226. package/style/commandBar.css +4 -20
  227. package/style/customPanel.css +1 -4
  228. package/style/git.css +26 -186
  229. package/style/highlight.css +1 -3
  230. package/style/launcher.css +6 -70
  231. package/style/omnibox.css +2 -14
  232. package/style/showOutput.css +0 -6
  233. package/style/sidebar.css +29 -9
  234. package/style/tabs.css +5 -13
  235. package/style/terminals.css +6 -53
  236. package/style/topBar.css +2 -22
  237. package/style/walkthrough.css +0 -8
@@ -10,59 +10,114 @@ import { FileDiff } from '@pierre/diffs/react';
10
10
  import {
11
11
  parseDiffFromFile,
12
12
  type DiffsThemeNames,
13
- type FileContents,
14
13
  type FileDiffMetadata
15
14
  } from '@pierre/diffs';
16
15
 
17
- import { resolveDiffTheme } from './diffTheme';
16
+ import { DIFF_SCROLLBAR_CSS, resolveDiffTheme } from './diffTheme';
18
17
 
19
- /**
20
- * The CSS class added to the root of the notebook diff. Selectors that
21
- * style cell sections, headers, and the per-cell diff blocks hang off this
22
- * class.
23
- */
24
18
  const NOTEBOOK_DIFF_CSS_CLASS = 'jp-xtralab-NotebookDiff';
25
19
 
26
20
  /**
27
- * Minimal slice of nbformat 4.x that the diff inspects. Fields we don't
28
- * read (attachments, e.g.) are intentionally not narrowed — we still pass
29
- * them around as part of the cell, but we never project them into the diff.
21
+ * Minimal slice of nbformat 4.x that the diff inspects.
30
22
  */
31
23
  interface INotebookCell {
24
+ /**
25
+ * The cell type ('code', 'markdown', or 'raw').
26
+ */
32
27
  cell_type: string;
28
+ /**
29
+ * Stable cell id (nbformat >= 4.5); absent in older notebooks.
30
+ */
33
31
  id?: string;
32
+ /**
33
+ * The cell source, as a single string or an array of lines.
34
+ */
34
35
  source: string | string[];
36
+ /**
37
+ * Execution outputs; present on code cells only.
38
+ */
35
39
  outputs?: INotebookOutput[];
40
+ /**
41
+ * The cell-level metadata.
42
+ */
36
43
  metadata?: Record<string, unknown>;
44
+ /**
45
+ * Execution count of a code cell; `null` when the cell has not run.
46
+ */
37
47
  execution_count?: number | null;
48
+ /**
49
+ * Media attachments keyed by name (markdown and raw cells).
50
+ */
38
51
  attachments?: Record<string, unknown>;
39
52
  }
40
53
 
54
+ /**
55
+ * Minimal slice of an nbformat 4.x cell output that the diff inspects.
56
+ */
41
57
  interface INotebookOutput {
58
+ /**
59
+ * The output type ('stream', 'execute_result', 'display_data', or 'error').
60
+ */
42
61
  output_type: string;
62
+ /**
63
+ * The mime bundle of an `execute_result` or `display_data` output.
64
+ */
43
65
  data?: Record<string, unknown>;
66
+ /**
67
+ * The text of a `stream` output.
68
+ */
44
69
  text?: string | string[];
70
+ /**
71
+ * The stream name ('stdout' or 'stderr') of a `stream` output.
72
+ */
45
73
  name?: string;
74
+ /**
75
+ * The exception name of an `error` output.
76
+ */
46
77
  ename?: string;
78
+ /**
79
+ * The exception message of an `error` output.
80
+ */
47
81
  evalue?: string;
82
+ /**
83
+ * The traceback lines of an `error` output; may contain ANSI escapes.
84
+ */
48
85
  traceback?: string[];
86
+ /**
87
+ * The execution count of an `execute_result` output.
88
+ */
49
89
  execution_count?: number | null;
90
+ /**
91
+ * The output-level metadata.
92
+ */
50
93
  metadata?: Record<string, unknown>;
51
94
  }
52
95
 
96
+ /**
97
+ * Minimal slice of an nbformat 4.x notebook that the diff inspects.
98
+ */
53
99
  interface INotebook {
100
+ /**
101
+ * The notebook cells.
102
+ */
54
103
  cells: INotebookCell[];
104
+ /**
105
+ * The notebook-level metadata.
106
+ */
55
107
  metadata: Record<string, unknown>;
108
+ /**
109
+ * The nbformat major version.
110
+ */
56
111
  nbformat: number;
112
+ /**
113
+ * The nbformat minor version.
114
+ */
57
115
  nbformat_minor: number;
58
116
  }
59
117
 
60
118
  /**
61
- * One entry in the per-cell diff. `unchanged` cells are rendered as a
62
- * collapsed summary; the other variants render their source / outputs /
63
- * metadata via {@link FileDiff}. The `modified` and `unchanged` branches
64
- * carry the same payload but live on separate union members so `Extract`
65
- * narrowing works on `kind` literals downstream.
119
+ * One entry in the per-cell diff. `modified` and `unchanged` carry the same
120
+ * payload on separate union members so `Extract` narrowing on `kind` works.
66
121
  */
67
122
  type NotebookCellDiff =
68
123
  | {
@@ -91,31 +146,34 @@ type NotebookCellDiff =
91
146
  };
92
147
 
93
148
  /**
94
- * Notebook-level diff result returned by {@link buildNotebookDiff}. Returns
95
- * `null` from the builder when either side fails to parse — the caller then
96
- * falls back to a raw JSON file diff.
149
+ * Notebook-level diff result returned by {@link buildNotebookDiff}.
97
150
  */
98
151
  export interface INotebookDiffResult {
152
+ /**
153
+ * The parsed old revision of the notebook.
154
+ */
99
155
  oldNotebook: INotebook;
156
+ /**
157
+ * The parsed new revision of the notebook.
158
+ */
100
159
  newNotebook: INotebook;
160
+ /**
161
+ * Per-cell diff entries, in new-notebook order with removed cells appended.
162
+ */
101
163
  cells: NotebookCellDiff[];
102
164
  /**
103
- * Kernel language pulled from `metadata.language_info.name`, used to pick
104
- * a syntax-highlighting filename for code cells.
165
+ * Kernel language from `metadata.language_info.name`; picks the highlighting filename.
105
166
  */
106
167
  language: string | undefined;
107
168
  /**
108
- * Pre-computed diff for notebook-level metadata (kernelspec, language_info,
109
- * nbformat) when it differs between revisions; `null` otherwise so the
110
- * section can be hidden without further checks.
169
+ * Diff of notebook-level metadata when it differs between revisions; `null` otherwise.
111
170
  */
112
171
  notebookMetadataDiff: FileDiffMetadata | null;
113
172
  }
114
173
 
115
174
  /**
116
- * Parse notebook JSON. Returns `null` if the text doesn't parse or doesn't
117
- * look like a notebook (missing `cells` array). The caller falls back to a
118
- * raw text diff so a malformed file is still inspectable.
175
+ * Parse notebook JSON. Returns `null` when the text is not a notebook so
176
+ * the caller can fall back to a raw text diff.
119
177
  */
120
178
  function parseNotebook(text: string): INotebook | null {
121
179
  if (text.length === 0) {
@@ -150,12 +208,6 @@ function emptyNotebook(): INotebook {
150
208
  };
151
209
  }
152
210
 
153
- /**
154
- * nbformat allows multiline string fields (`source`, stream `text`,
155
- * `text/plain` outputs, …) to be either a single string or an array of
156
- * strings. Normalize to one string so equality checks and diffs see the
157
- * same shape regardless of how the writer chose to serialize it.
158
- */
159
211
  function joinMultiline(value: string | string[] | undefined): string {
160
212
  if (value === undefined) {
161
213
  return '';
@@ -168,11 +220,9 @@ function cellSource(cell: INotebookCell): string {
168
220
  }
169
221
 
170
222
  /**
171
- * Canonical text representation of a cell's outputs, used both for equality
172
- * checks and for the per-cell output diff. Stream and `text/plain` data
173
- * round-trip verbatim; richer mime types (image/png, text/html, …) collapse
174
- * to a `<mime-type>` placeholder so diffing image bytes doesn't drown the
175
- * panel in base64 noise.
223
+ * Canonical text form of a cell's outputs, used for equality checks and
224
+ * diffs. Rich mime types collapse to a `<mime-type>` placeholder so base64
225
+ * payloads don't drown the diff.
176
226
  */
177
227
  function canonicalOutputs(cell: INotebookCell): string {
178
228
  const outputs = cell.outputs ?? [];
@@ -213,10 +263,6 @@ function formatOutput(output: INotebookOutput): string {
213
263
  case 'error': {
214
264
  const ename = output.ename ?? 'Error';
215
265
  const evalue = output.evalue ?? '';
216
- // Tracebacks contain ANSI escape codes by default. Strip them so the
217
- // diff stays readable — coloring information has no analog in a plain
218
- // text diff, and the escapes themselves would inflate every traceback
219
- // line into a fake change.
220
266
  const traceback = (output.traceback ?? []).map(stripAnsi).join('\n');
221
267
  return `[error]\n${ename}: ${evalue}\n${traceback}`;
222
268
  }
@@ -225,10 +271,7 @@ function formatOutput(output: INotebookOutput): string {
225
271
  }
226
272
  }
227
273
 
228
- // Matching ANSI escape codes inherently requires a control character in
229
- // the pattern, which is what no-control-regex flags. Disable the rule on
230
- // this single literal — silently emitting un-stripped escapes would just
231
- // reintroduce the very noise stripAnsi exists to remove.
274
+ // Matching ANSI escapes requires a control character in the pattern.
232
275
  // eslint-disable-next-line no-control-regex
233
276
  const ANSI_ESCAPE_RE = /\x1B\[[0-9;]*[A-Za-z]/g;
234
277
 
@@ -237,10 +280,8 @@ function stripAnsi(value: string): string {
237
280
  }
238
281
 
239
282
  /**
240
- * Stable JSON of the cell's `metadata` object. Keys are sorted recursively
241
- * so a writer that re-orders the metadata object doesn't surface as a diff.
242
- * An empty metadata object returns the empty string so freshly-added cells
243
- * that carry the default `{}` don't produce a `+{}` placeholder section.
283
+ * Stable sorted JSON of the cell's `metadata`; empty metadata returns ''
284
+ * so cells carrying the default `{}` don't produce a `+{}` diff section.
244
285
  */
245
286
  function canonicalMetadata(cell: INotebookCell): string {
246
287
  const md = cell.metadata ?? {};
@@ -250,11 +291,6 @@ function canonicalMetadata(cell: INotebookCell): string {
250
291
  return stableStringify(md);
251
292
  }
252
293
 
253
- /**
254
- * Stable JSON of the notebook-level metadata bundle (kernelspec, language
255
- * info, nbformat). We diff the bundle as a single block so the user sees
256
- * all environment-level changes in one place.
257
- */
258
294
  function canonicalNotebookMetadata(notebook: INotebook): string {
259
295
  return stableStringify({
260
296
  metadata: notebook.metadata,
@@ -279,11 +315,8 @@ function sortKeysReplacer(key: string, value: unknown): unknown {
279
315
  }
280
316
 
281
317
  /**
282
- * Equality check used to classify matched cells as `unchanged` vs
283
- * `modified`. Compares the three things we actually render — type, source,
284
- * outputs, metadata — using the same canonical representations the diff
285
- * itself sees, so an "equal" verdict here implies all per-cell diff blocks
286
- * would be empty.
318
+ * Classify matched cells as `unchanged` vs `modified` using the same
319
+ * canonical forms the diff renders, so "equal" implies empty per-cell diffs.
287
320
  */
288
321
  function cellsAreEqual(a: INotebookCell, b: INotebookCell): boolean {
289
322
  return (
@@ -295,16 +328,9 @@ function cellsAreEqual(a: INotebookCell, b: INotebookCell): boolean {
295
328
  }
296
329
 
297
330
  /**
298
- * Align cells between two notebook revisions. Cells that carry a stable id
299
- * (nbformat ≥ 4.5) match on their id; remaining cells fall back to
300
- * positional matching against the next un-consumed unidentified old cell so
301
- * older notebooks still produce a sensible diff.
302
- *
303
- * Output ordering: entries follow new-file order, with cells that exist
304
- * only in the old revision appended at the end as `removed`. We don't try
305
- * to interleave removals at their original position — for typical notebook
306
- * edits this is more readable than guessing alignment between unrelated
307
- * insertions and deletions.
331
+ * Align cells between two revisions: stable ids (nbformat ≥ 4.5) match by
332
+ * id, the rest positionally. Entries follow new-file order with old-only
333
+ * cells appended as `removed`.
308
334
  */
309
335
  function alignNotebookCells(
310
336
  oldCells: INotebookCell[],
@@ -320,8 +346,6 @@ function alignNotebookCells(
320
346
  const consumed = new Set<number>();
321
347
  const entries: NotebookCellDiff[] = [];
322
348
 
323
- // Cursor used for positional matching of unidentified cells. Only advances
324
- // forward so we don't double-match a single old cell.
325
349
  let positionalCursor = 0;
326
350
 
327
351
  for (let newIndex = 0; newIndex < newCells.length; newIndex++) {
@@ -374,19 +398,25 @@ function alignNotebookCells(
374
398
  return entries;
375
399
  }
376
400
 
401
+ /**
402
+ * Options for {@link buildNotebookDiff}.
403
+ */
377
404
  interface IBuildNotebookDiffOptions {
405
+ /**
406
+ * The raw text of the old notebook revision.
407
+ */
378
408
  oldText: string;
409
+ /**
410
+ * The raw text of the new notebook revision.
411
+ */
379
412
  newText: string;
380
413
  }
381
414
 
382
415
  /**
383
- * Build a complete notebook diff from the textual contents of two
384
- * revisions. Returns `null` if either side fails to parse as a notebook;
385
- * the caller is expected to fall back to a raw text diff in that case.
386
- *
387
- * An empty string on either side is treated as an empty notebook (no
388
- * cells, default metadata) so a freshly-added or deleted notebook still
389
- * produces a sensible cell-by-cell diff.
416
+ * Build a notebook diff from the text of two revisions. Returns `null` when
417
+ * either side fails to parse (caller falls back to a raw text diff); an
418
+ * empty string counts as an empty notebook so added/deleted notebooks still
419
+ * diff cell by cell.
390
420
  */
391
421
  export function buildNotebookDiff(
392
422
  options: IBuildNotebookDiffOptions
@@ -425,10 +455,8 @@ function detectLanguage(notebook: INotebook): string | undefined {
425
455
  }
426
456
 
427
457
  /**
428
- * Pick a filename whose extension drives `@pierre/diffs`'s syntax
429
- * highlighter. The library never reads the file from disk — `name` only
430
- * influences token-level rendering, so any plausible extension that maps
431
- * to the right language is fine.
458
+ * Pick a filename whose extension drives the `@pierre/diffs` highlighter;
459
+ * the library never reads the file, only the extension matters.
432
460
  */
433
461
  function cellFilename(
434
462
  cell: INotebookCell,
@@ -463,49 +491,23 @@ function cellFilename(
463
491
  }
464
492
 
465
493
  /**
466
- * Build the FileContents pair the diff library wants. `kind` controls the
467
- * filename so each per-cell sub-diff (source, metadata) gets the right
468
- * extension and therefore the right highlighter.
494
+ * One rendered sub-diff (source or metadata) of a cell entry.
469
495
  */
470
- function pierreFiles(
471
- kind: 'source' | 'metadata',
472
- oldText: string,
473
- newText: string,
474
- oldCell: INotebookCell | null,
475
- newCell: INotebookCell | null,
476
- language: string | undefined
477
- ): { oldFile: FileContents; newFile: FileContents } {
478
- let name: string;
479
- if (kind === 'metadata') {
480
- name = 'cell-metadata.json';
481
- } else {
482
- // Use the new cell's type/language when available; fall back to the old
483
- // side so a deleted cell still gets the right highlighter.
484
- const reference = newCell ?? oldCell;
485
- name = reference !== null ? cellFilename(reference, language) : 'cell.txt';
486
- }
487
- return {
488
- oldFile: { name, contents: oldText },
489
- newFile: { name, contents: newText }
490
- };
491
- }
492
-
493
496
  interface ICellSubDiff {
497
+ /**
498
+ * The cell section the diff covers.
499
+ */
494
500
  kind: 'source' | 'metadata';
501
+ /**
502
+ * The parsed `@pierre/diffs` diff for the section.
503
+ */
495
504
  metadata: FileDiffMetadata;
496
505
  }
497
506
 
498
507
  /**
499
- * Compute the source / metadata sub-diffs for a single cell entry.
500
- * Returns the empty list for `unchanged` entries so the caller can collapse
501
- * them; for `added` / `removed` entries the populated side is paired with
502
- * an empty counterpart so the library treats every line as an addition or
503
- * deletion respectively.
504
- *
505
- * Outputs are deliberately *not* included here — they're rendered through
506
- * rendermime side-by-side in {@link OutputsSection} so images render as
507
- * images, HTML as HTML, etc. The canonical text form survives for equality
508
- * (it drives the `unchanged` classification) but not for display.
508
+ * Source / metadata sub-diffs for one cell entry; empty for `unchanged`.
509
+ * Outputs are excluded on purpose — they render through rendermime in
510
+ * {@link OutputsSection}; their canonical text form only drives equality.
509
511
  */
510
512
  function buildCellSubDiffs(
511
513
  entry: NotebookCellDiff,
@@ -516,6 +518,10 @@ function buildCellSubDiffs(
516
518
  }
517
519
  const oldCell = entry.kind === 'added' ? null : entry.oldCell;
518
520
  const newCell = entry.kind === 'removed' ? null : entry.newCell;
521
+ const sourceName = cellFilename(
522
+ entry.kind === 'removed' ? entry.oldCell : entry.newCell,
523
+ language
524
+ );
519
525
 
520
526
  const oldSource = oldCell !== null ? cellSource(oldCell) : '';
521
527
  const newSource = newCell !== null ? cellSource(newCell) : '';
@@ -535,45 +541,33 @@ function buildCellSubDiffs(
535
541
  if (section.oldText === section.newText) {
536
542
  continue;
537
543
  }
538
- const { oldFile, newFile } = pierreFiles(
539
- section.kind,
540
- section.oldText,
541
- section.newText,
542
- oldCell,
543
- newCell,
544
- language
545
- );
544
+ const name =
545
+ section.kind === 'metadata' ? 'cell-metadata.json' : sourceName;
546
546
  subdiffs.push({
547
547
  kind: section.kind,
548
- metadata: parseDiffFromFile(oldFile, newFile)
548
+ metadata: parseDiffFromFile(
549
+ oldCell === null ? null : { name, contents: section.oldText },
550
+ newCell === null ? null : { name, contents: section.newText }
551
+ )
549
552
  });
550
553
  }
551
554
 
552
555
  return subdiffs;
553
556
  }
554
557
 
555
- /**
556
- * Common diff library options shared across every per-cell sub-diff and
557
- * the notebook-level metadata diff. Split mode matches the file-diff path
558
- * and gives the user the same left=old / right=new mental model inside
559
- * each cell — cells stack vertically but each cell internally reads
560
- * side-by-side, which matches how nbdime renders.
561
- */
562
558
  function diffLibraryOptions(theme: DiffsThemeNames, dark: boolean) {
563
559
  return {
564
560
  diffStyle: 'split' as const,
565
561
  disableFileHeader: true,
566
562
  theme,
567
- themeType: dark ? ('dark' as const) : ('light' as const)
563
+ themeType: dark ? ('dark' as const) : ('light' as const),
564
+ unsafeCSS: DIFF_SCROLLBAR_CSS
568
565
  };
569
566
  }
570
567
 
571
568
  /**
572
- * Mount a Lumino {@link Widget} inside a React tree. The widget is the
573
- * source of truth for its DOM — React just owns the host element and
574
- * Lumino owns the content the widget paints into it. Detach + dispose
575
- * happens on unmount or when a new widget arrives, so the parent doesn't
576
- * need to manage the widget's lifecycle separately.
569
+ * Mount a Lumino {@link Widget} inside a React tree; detach + dispose
570
+ * happens on unmount or when a new widget arrives.
577
571
  */
578
572
  function LuminoWidget(props: {
579
573
  widget: Widget;
@@ -589,31 +583,14 @@ function LuminoWidget(props: {
589
583
  try {
590
584
  Widget.attach(widget, host);
591
585
  } catch (err) {
592
- // Lumino refuses to attach a widget whose host isn't connected to
593
- // the document, or that's already attached elsewhere. Either way
594
- // there's nothing useful to do here — log and bail so the parent
595
- // tree still mounts.
586
+ // Lumino refuses to attach when the host is not in the document or
587
+ // the widget is attached elsewhere; log and let the tree mount.
596
588
  console.warn('xtralab: Widget.attach failed', err);
597
589
  return;
598
590
  }
599
591
  return () => {
600
- // Lumino's `Widget.detach` throws "Widget is not attached" when
601
- // either the `IsAttached` flag is false *or* the node is no longer
602
- // connected to the document. During a parent React unmount the
603
- // host element gets removed from the DOM before this cleanup
604
- // runs, so `node.isConnected` is already `false` while the
605
- // `IsAttached` flag is still set from our earlier `Widget.attach`
606
- // call. Calling `Widget.detach` in that state would throw, and —
607
- // worse — `widget.dispose()` re-enters the same detach branch
608
- // internally (its `else if (this.isAttached)` guard does not
609
- // check the DOM connection), so leaving the flag stale produces
610
- // the noisy "Widget is not attached" warning out of `dispose`.
611
- //
612
- // Resolve the race by driving Lumino's detach lifecycle by hand
613
- // when the host has already been torn down: send `BeforeDetach`
614
- // and `AfterDetach` so the layout cleanup hooks run and the flag
615
- // clears, then dispose. When the host is still connected the
616
- // ordinary `Widget.detach` path handles both steps for us.
592
+ // On parent unmount React removes the host first; detach (and dispose,
593
+ // which re-enters it) throws on a disconnected node, so message by hand.
617
594
  if (widget.isAttached) {
618
595
  if (widget.node.isConnected) {
619
596
  try {
@@ -643,14 +620,8 @@ function LuminoWidget(props: {
643
620
  }
644
621
 
645
622
  /**
646
- * Render a sequence of nbformat outputs through JupyterLab's rendermime,
647
- * exactly the way a live notebook would. Wrapped here so the side-by-side
648
- * cell layout can drop a fully-rendered output column on either side
649
- * without re-implementing rich mime rendering.
650
- *
651
- * The output area is created `trusted` because the source git ref is on
652
- * the user's machine (working tree / index / HEAD). A diff viewer that
653
- * sandboxed its own user's outputs would just be inconvenient.
623
+ * Render nbformat outputs through rendermime like a live notebook would.
624
+ * Trusted: the source git ref already lives on the user's machine.
654
625
  */
655
626
  function OutputsPreview(props: {
656
627
  outputs: INotebookOutput[];
@@ -669,11 +640,8 @@ function OutputsPreview(props: {
669
640
  }
670
641
 
671
642
  /**
672
- * Render markdown source through rendermime so the cell preview matches
673
- * what JupyterLab would render in a live notebook (LaTeX, syntax-highlighted
674
- * code fences, sanitized HTML, …). Used for both the rendered preview that
675
- * sits next to a markdown cell's source diff, and for unchanged markdown
676
- * cells when the user expands them.
643
+ * Render markdown source through rendermime so the preview matches a live
644
+ * notebook (LaTeX, code fences, sanitized HTML).
677
645
  */
678
646
  function MarkdownPreview(props: {
679
647
  source: string;
@@ -698,11 +666,8 @@ function MarkdownPreview(props: {
698
666
  }
699
667
 
700
668
  /**
701
- * Two-column "old | new" layout used by both {@link OutputsSection} and
702
- * {@link MarkdownPreviewSection}. The visible side(s) depend on the cell
703
- * kind: modified shows both, added shows only new, removed shows only old,
704
- * unchanged collapses to a single full-width pane (since both sides
705
- * render the same content).
669
+ * Two-column "old | new" layout; collapses to a single full-width pane
670
+ * when only one side has content.
706
671
  */
707
672
  function SideBySidePanes(props: {
708
673
  label: string;
@@ -745,11 +710,8 @@ function SideBySidePanes(props: {
745
710
  }
746
711
 
747
712
  /**
748
- * Rendered outputs section for a cell. Renders old / new outputs through
749
- * rendermime in two columns; for added or removed cells only the populated
750
- * side is shown. Returns `null` (so the section is hidden entirely) when
751
- * neither side has any outputs, or when the same set of outputs appears
752
- * on both sides and the host doesn't want the duplicate render.
713
+ * Rendered outputs for a cell, old / new through rendermime; `null` when
714
+ * neither side has outputs.
753
715
  */
754
716
  function OutputsSection(props: {
755
717
  entry: NotebookCellDiff;
@@ -767,11 +729,6 @@ function OutputsSection(props: {
767
729
  if (!hasOld && !hasNew) {
768
730
  return null;
769
731
  }
770
- // For added / removed cells the parent already lives in a single outer
771
- // column — rendering the populated side full width within that column
772
- // is the right move. SideBySidePanes itself collapses to a single grid
773
- // column when only one side has content, so we can use it for both
774
- // cases by feeding it only the side(s) we want.
775
732
  const showOld = placement !== 'right' && hasOld;
776
733
  const showNew = placement !== 'left' && hasNew;
777
734
  if (rendermime === null) {
@@ -805,9 +762,7 @@ function OutputsSection(props: {
805
762
  }
806
763
 
807
764
  /**
808
- * Rendered markdown preview section. Sits next to (and below) the markdown
809
- * source diff so the user sees both the source-level changes and how the
810
- * rendered cell looks. Skipped for non-markdown cells.
765
+ * Rendered markdown preview beside the source diff; skipped for non-markdown cells.
811
766
  */
812
767
  function MarkdownPreviewSection(props: {
813
768
  entry: NotebookCellDiff;
@@ -838,14 +793,25 @@ function MarkdownPreviewSection(props: {
838
793
  );
839
794
  }
840
795
 
796
+ /**
797
+ * Props for {@link NotebookDiffView}.
798
+ */
841
799
  interface INotebookDiffViewProps {
800
+ /**
801
+ * The notebook diff to render.
802
+ */
842
803
  diff: INotebookDiffResult;
804
+ /**
805
+ * Whether the active JupyterLab theme is dark.
806
+ */
843
807
  dark: boolean;
844
808
  /**
845
- * Whether a Pierre JupyterLab theme is active; selects Pierre's rich
846
- * highlighting vs the CSS-variable theme (see {@link resolveDiffTheme}).
809
+ * Whether a Pierre JupyterLab theme is active; selects Pierre highlighting vs the CSS-variable theme.
847
810
  */
848
811
  pierreTheme: boolean;
812
+ /**
813
+ * Registry rendering markdown and output previews; `null` falls back to plain text.
814
+ */
849
815
  rendermime: IRenderMimeRegistry | null;
850
816
  /**
851
817
  * Translation bundle for user-facing strings.
@@ -854,14 +820,9 @@ interface INotebookDiffViewProps {
854
820
  }
855
821
 
856
822
  /**
857
- * The notebook-level diff view. Lays out cells in a 2-column grid where
858
- * the left column tracks the *old* notebook and the right column tracks
859
- * the *new* one — modified and unchanged cells span both columns (their
860
- * internal split-mode FileDiff aligns with the outer columns), added
861
- * cells live in the right column with an empty placeholder on the left,
862
- * and removed cells live in the left column with an empty placeholder on
863
- * the right. The diff library's red/green tint within each cell makes
864
- * the orientation clear without a separate column header.
823
+ * Notebook-level diff view: a 2-column grid where the left column tracks
824
+ * the old notebook and the right the new one — modified/unchanged cells
825
+ * span both, added cells sit right, removed cells sit left.
865
826
  */
866
827
  export function NotebookDiffView(
867
828
  props: INotebookDiffViewProps
@@ -898,12 +859,8 @@ export function NotebookDiffView(
898
859
  }
899
860
 
900
861
  /**
901
- * Place a single cell entry into the outer 2-column grid. Modified /
902
- * unchanged entries span the full row; added entries take the right
903
- * column with an empty placeholder on the left; removed entries take the
904
- * left column with an empty placeholder on the right. The placeholders
905
- * are visible (subtle dashed outline) so the user can see *where* a
906
- * cell was inserted or removed relative to the other side.
862
+ * Place one cell entry into the 2-column grid, with a visible placeholder
863
+ * marking where a cell was inserted or removed on the other side.
907
864
  */
908
865
  function CellEntryRow(props: ICellDiffBlockProps): React.ReactElement {
909
866
  const { entry, trans } = props;
@@ -951,10 +908,25 @@ function EmptyCellPlaceholder(props: {
951
908
  );
952
909
  }
953
910
 
911
+ /**
912
+ * Cell counts per diff kind, shown in the header.
913
+ */
954
914
  interface INotebookDiffSummary {
915
+ /**
916
+ * The number of added cells.
917
+ */
955
918
  added: number;
919
+ /**
920
+ * The number of removed cells.
921
+ */
956
922
  removed: number;
923
+ /**
924
+ * The number of modified cells.
925
+ */
957
926
  modified: number;
927
+ /**
928
+ * The number of unchanged cells.
929
+ */
958
930
  unchanged: number;
959
931
  }
960
932
 
@@ -1023,38 +995,50 @@ function cellEntryKey(entry: NotebookCellDiff): string {
1023
995
  return `${entry.kind}:${entry.newIndex}:${entry.newCell.id ?? entry.oldCell.id ?? ''}`;
1024
996
  }
1025
997
 
998
+ /**
999
+ * Props shared by {@link CellEntryRow} and {@link CellDiffBlock}.
1000
+ */
1026
1001
  interface ICellDiffBlockProps {
1002
+ /**
1003
+ * The cell diff entry to render.
1004
+ */
1027
1005
  entry: NotebookCellDiff;
1006
+ /**
1007
+ * The kernel language driving code-cell highlighting.
1008
+ */
1028
1009
  language: string | undefined;
1010
+ /**
1011
+ * The `@pierre/diffs` theme name.
1012
+ */
1029
1013
  theme: DiffsThemeNames;
1014
+ /**
1015
+ * Whether the active JupyterLab theme is dark.
1016
+ */
1030
1017
  dark: boolean;
1018
+ /**
1019
+ * Registry rendering markdown and output previews; `null` falls back to plain text.
1020
+ */
1031
1021
  rendermime: IRenderMimeRegistry | null;
1022
+ /**
1023
+ * The translation bundle for user-facing strings.
1024
+ */
1032
1025
  trans: TranslationBundle;
1033
1026
  }
1034
1027
 
1035
- /**
1036
- * How the cell card should sit inside the outer 2-column grid:
1037
- * - `full` → spans both columns (modified / unchanged cells)
1038
- * - `left` → left column only (removed cells)
1039
- * - `right` → right column only (added cells)
1040
- *
1041
- * Drives both grid placement and the inner layout choices: a single-column
1042
- * placement uses unified-mode FileDiff for the source (since one side is
1043
- * empty) and renders only the populated side of outputs / markdown
1044
- * previews, while a `full` placement keeps split mode and side-by-side
1045
- * panes.
1046
- */
1047
1028
  type CellPlacement = 'full' | 'left' | 'right';
1048
1029
 
1030
+ /**
1031
+ * Cell diff block props with a resolved grid placement.
1032
+ */
1049
1033
  interface IPlacedCellDiffBlockProps extends ICellDiffBlockProps {
1034
+ /**
1035
+ * The grid column the block occupies: both ('full'), old ('left'), or new ('right').
1036
+ */
1050
1037
  placement: CellPlacement;
1051
1038
  }
1052
1039
 
1053
1040
  function CellDiffBlock(props: IPlacedCellDiffBlockProps): React.ReactElement {
1054
1041
  const { entry, language, theme, dark, rendermime, placement, trans } = props;
1055
- // Unchanged cells start collapsed — the user opted out of seeing those
1056
- // sections by virtue of them being unchanged. A toggle lets them peek if
1057
- // they want.
1058
1042
  const [collapsed, setCollapsed] = React.useState<boolean>(
1059
1043
  entry.kind === 'unchanged'
1060
1044
  );
@@ -1067,8 +1051,7 @@ function CellDiffBlock(props: IPlacedCellDiffBlockProps): React.ReactElement {
1067
1051
  const cellType = referenceCellType(entry);
1068
1052
  const indexLabel = cellIndexLabel(entry);
1069
1053
  const isCodeCell = cellType === 'code';
1070
- // Output rendering is only meaningful for code cells. Markdown / raw cells
1071
- // never carry outputs in nbformat.
1054
+ // Only code cells carry outputs in nbformat.
1072
1055
  const showOutputs = isCodeCell && entry.kind !== 'unchanged';
1073
1056
 
1074
1057
  return (
@@ -1171,11 +1154,8 @@ function CellSubDiff(props: {
1171
1154
  trans: TranslationBundle;
1172
1155
  }): React.ReactElement {
1173
1156
  const { kind, metadata, theme, dark, placement, trans } = props;
1174
- // For modified / unchanged cells the diff spans both outer columns, and
1175
- // split mode lets it visually align with the outer old | new lanes. For
1176
- // added / removed cells the diff sits inside a single outer column, so
1177
- // unified mode keeps the content readable instead of leaving half the
1178
- // column empty.
1157
+ // Full-width cells align split mode with the outer old|new lanes;
1158
+ // single-column cells read better unified.
1179
1159
  const diffStyle: 'split' | 'unified' =
1180
1160
  placement === 'full' ? 'split' : 'unified';
1181
1161
  return (
@@ -1185,10 +1165,8 @@ function CellSubDiff(props: {
1185
1165
  </div>
1186
1166
  <FileDiff
1187
1167
  fileDiff={metadata}
1188
- // See diffWidget.tsx — the worker bootstrap can't resolve through
1189
- // JupyterLab's federation pipeline, so every diff in this extension
1190
- // runs on the main thread. Cell diffs are small enough that this is
1191
- // not a performance concern.
1168
+ // The worker bootstrap can't resolve through JupyterLab's federation
1169
+ // pipeline (see diffSurface.tsx); run on the main thread.
1192
1170
  disableWorkerPool={true}
1193
1171
  options={{ ...diffLibraryOptions(theme, dark), diffStyle }}
1194
1172
  />
@@ -1239,12 +1217,8 @@ function NotebookMetadataBlock(props: {
1239
1217
  }
1240
1218
 
1241
1219
  /**
1242
- * Body for an unchanged cell when the user has expanded it. The cell's
1243
- * content matches on both sides, so we render once full-width: markdown
1244
- * cells go through rendermime (if available) so they look like the live
1245
- * notebook would render them, code cells fall through to a verbatim
1246
- * pre-formatted block, and any code outputs render through rendermime
1247
- * below the source.
1220
+ * Expanded body of an unchanged cell, rendered once full-width: markdown
1221
+ * through rendermime, code verbatim with outputs below.
1248
1222
  */
1249
1223
  function UnchangedCellBody(props: {
1250
1224
  entry: Extract<NotebookCellDiff, { kind: 'unchanged' }>;