xtralab 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +30 -6
  2. package/lib/fileBrowser/commands.d.ts +7 -0
  3. package/lib/fileBrowser/commands.js +74 -1
  4. package/lib/fileBrowser/fileBrowser.js +4 -1
  5. package/lib/fileBrowser/icons.d.ts +5 -9
  6. package/lib/fileBrowser/icons.js +97 -13
  7. package/lib/fileBrowser/index.js +5 -3
  8. package/lib/fileTypeIcons/index.d.ts +6 -0
  9. package/lib/fileTypeIcons/index.js +144 -0
  10. package/lib/git/commands.d.ts +34 -33
  11. package/lib/git/commands.js +132 -10
  12. package/lib/git/diffModel.d.ts +14 -0
  13. package/lib/git/diffModel.js +75 -0
  14. package/lib/git/diffProvider.d.ts +0 -5
  15. package/lib/git/diffProvider.js +6 -376
  16. package/lib/git/diffSurface.d.ts +36 -26
  17. package/lib/git/diffSurface.js +13 -20
  18. package/lib/git/diffTheme.d.ts +9 -0
  19. package/lib/git/diffTheme.js +31 -0
  20. package/lib/git/diffWidget.d.ts +54 -91
  21. package/lib/git/diffWidget.js +184 -361
  22. package/lib/git/index.d.ts +1 -6
  23. package/lib/git/index.js +7 -23
  24. package/lib/git/notebookDiff.d.ts +6 -1
  25. package/lib/git/notebookDiff.js +4 -3
  26. package/lib/index.js +4 -0
  27. package/lib/terminalNotifications/index.d.ts +15 -0
  28. package/lib/terminalNotifications/index.js +213 -0
  29. package/lib/terminals/model.d.ts +1 -1
  30. package/lib/terminals/model.js +1 -1
  31. package/lib/terminals/widget.d.ts +1 -1
  32. package/lib/terminals/widget.js +1 -1
  33. package/package.json +3 -3
  34. package/schema/plugin.json +6 -0
  35. package/schema/terminal-notifications.json +21 -0
  36. package/src/fileBrowser/commands.ts +91 -2
  37. package/src/fileBrowser/fileBrowser.tsx +4 -1
  38. package/src/fileBrowser/icons.ts +103 -13
  39. package/src/fileBrowser/index.ts +9 -3
  40. package/src/fileTypeIcons/index.ts +174 -0
  41. package/src/git/commands.ts +177 -43
  42. package/src/git/diffModel.ts +102 -0
  43. package/src/git/diffProvider.tsx +8 -540
  44. package/src/git/diffSurface.tsx +44 -28
  45. package/src/git/diffTheme.ts +40 -0
  46. package/src/git/diffWidget.tsx +256 -448
  47. package/src/git/index.ts +14 -28
  48. package/src/git/notebookDiff.tsx +10 -3
  49. package/src/index.ts +4 -0
  50. package/src/terminalNotifications/index.ts +295 -0
  51. package/src/terminals/model.ts +1 -1
  52. package/src/terminals/widget.tsx +1 -1
  53. package/style/git.css +12 -9
  54. package/style/index.css +1 -0
  55. package/style/index.js +1 -0
  56. package/style/launcher.css +46 -18
  57. package/style/sidebar.css +14 -0
@@ -1,7 +1,6 @@
1
1
  import { LabIcon, fileIcon } from '@jupyterlab/ui-components';
2
2
  import {
3
3
  createFileTreeIconResolver,
4
- getBuiltInFileIconColor,
5
4
  getBuiltInSpriteSheet
6
5
  } from '@pierre/trees';
7
6
  import type { FileTreeIconConfig } from '@pierre/trees';
@@ -121,28 +120,104 @@ function extractSymbol(
121
120
  }
122
121
 
123
122
  /**
124
- * Return a JupyterLab `LabIcon` matching the icon the xtralab file tree would
125
- * render for the given file path. Used by other plugins (e.g. the git diff
126
- * viewer) that want their main-area widget tabs to display the same per-file
127
- * glyph the tree shows in the sidebar.
123
+ * Fallback colors for the built-in file icons, keyed by the icon token
124
+ * `@pierre/trees` resolves for a path (e.g. `python`, `typescript`).
128
125
  *
129
- * Resolves the symbol via the same `@pierre/trees` resolver the tree itself
130
- * uses, then inlines the symbol's body into a self-contained SVG so the icon
131
- * works outside the tree's shadow DOM. Falls back to JupyterLab's default
132
- * `fileIcon` when the sprite cannot be located in either sheet.
126
+ * The tree only exposes these as `--trees-file-icon-color-<token>` CSS
127
+ * variables scoped to its shadow host, so they resolve to nothing for the
128
+ * standalone {@link LabIcon}s {@link getTreeIcon} builds for main-area tabs.
129
+ * Mirroring them here lets {@link getBuiltInFileIconColor} bottom out in a
130
+ * real color. Each is a `light-dark(…)` pair for dark themes.
133
131
  */
134
- export function getTreeIcon(filePath: string): LabIcon {
132
+ const BUILT_IN_FILE_ICON_COLOR_FALLBACKS: Record<string, string> = {
133
+ astro: 'light-dark(#a631be, #d568ea)',
134
+ babel: 'light-dark(#d5a910, #ffd452)',
135
+ bash: 'light-dark(#199f43, #5ecc71)',
136
+ biome: 'light-dark(#1a85d4, #69b1ff)',
137
+ bootstrap: 'light-dark(#693acf, #9d6afb)',
138
+ browserslist: 'light-dark(#d5a910, #ffd452)',
139
+ bun: 'light-dark(#594c5b, #79697b)',
140
+ c: 'light-dark(#1a85d4, #69b1ff)',
141
+ claude: 'light-dark(#d47628, #ffa359)',
142
+ cpp: 'light-dark(#1a85d4, #69b1ff)',
143
+ css: 'light-dark(#693acf, #9d6afb)',
144
+ database: 'light-dark(#a631be, #d568ea)',
145
+ default: 'light-dark(#84848a, #adadb1)',
146
+ docker: 'light-dark(#1a85d4, #69b1ff)',
147
+ eslint: 'light-dark(#693acf, #9d6afb)',
148
+ git: 'light-dark(#ff8c5b, #d5512f)',
149
+ go: 'light-dark(#1ca1c7, #68cdf2)',
150
+ graphql: 'light-dark(#d32a61, #ff678d)',
151
+ html: 'light-dark(#d47628, #ffa359)',
152
+ image: 'light-dark(#d32a61, #ff678d)',
153
+ javascript: 'light-dark(#d5a910, #ffd452)',
154
+ json: 'light-dark(#d47628, #ffa359)',
155
+ markdown: 'light-dark(#199f43, #5ecc71)',
156
+ mcp: 'light-dark(#17a5af, #64d1db)',
157
+ npm: 'light-dark(#d52c36, #ff6762)',
158
+ oxc: 'light-dark(#1ca1c7, #68cdf2)',
159
+ postcss: 'light-dark(#d52c36, #ff6762)',
160
+ prettier: 'light-dark(#17a5af, #64d1db)',
161
+ python: 'light-dark(#1a85d4, #69b1ff)',
162
+ react: 'light-dark(#1ca1c7, #68cdf2)',
163
+ ruby: 'light-dark(#d52c36, #ff6762)',
164
+ rust: 'light-dark(#d47628, #ffa359)',
165
+ sass: 'light-dark(#d32a61, #ff678d)',
166
+ svelte: 'light-dark(#d52c36, #ff6762)',
167
+ svg: 'light-dark(#d47628, #ffa359)',
168
+ svgo: 'light-dark(#199f43, #5ecc71)',
169
+ swift: 'light-dark(#d47628, #ffa359)',
170
+ table: 'light-dark(#17a5af, #64d1db)',
171
+ tailwind: 'light-dark(#1ca1c7, #68cdf2)',
172
+ terraform: 'light-dark(#693acf, #9d6afb)',
173
+ text: 'light-dark(#84848a, #adadb1)',
174
+ typescript: 'light-dark(#1a85d4, #69b1ff)',
175
+ vite: 'light-dark(#a631be, #d568ea)',
176
+ vscode: 'light-dark(#1a85d4, #69b1ff)',
177
+ vue: 'light-dark(#199f43, #5ecc71)',
178
+ wasm: 'light-dark(#693acf, #9d6afb)',
179
+ webpack: 'light-dark(#1a85d4, #69b1ff)',
180
+ yml: 'light-dark(#d52c36, #ff6762)',
181
+ zig: 'light-dark(#d47628, #ffa359)',
182
+ zip: 'light-dark(#d47628, #ffa359)'
183
+ };
184
+
185
+ /**
186
+ * Color the file tree would paint for the built-in icon `token`, as a
187
+ * self-contained CSS value usable outside the tree's shadow DOM: the tree's
188
+ * `--trees-file-icon-color-*` properties when in scope, else the literal from
189
+ * {@link BUILT_IN_FILE_ICON_COLOR_FALLBACKS}. Returns `undefined` for unknown
190
+ * tokens.
191
+ */
192
+ function getBuiltInFileIconColor(token: string): string | undefined {
193
+ const fallback = BUILT_IN_FILE_ICON_COLOR_FALLBACKS[token];
194
+ if (fallback === undefined) {
195
+ return undefined;
196
+ }
197
+ return `var(--trees-file-icon-color-${token}, var(--trees-file-icon-color, ${fallback}))`;
198
+ }
199
+
200
+ /**
201
+ * Resolve a tree glyph into a standalone `LabIcon`.
202
+ */
203
+ function resolveTreeIcon(filePath: string): {
204
+ icon: LabIcon | null;
205
+ specific: boolean;
206
+ } {
135
207
  const resolved = treeIconResolver.resolveIcon(
136
208
  'file-tree-icon-file',
137
209
  filePath
138
210
  );
211
+ // Anything other than the catch-all glyph is specific to this file.
212
+ const specific =
213
+ resolved.token !== 'default' && resolved.name !== 'file-tree-icon-file';
139
214
  const cached = TREE_ICON_CACHE.get(resolved.name);
140
215
  if (cached !== undefined) {
141
- return cached;
216
+ return { icon: cached, specific };
142
217
  }
143
218
  const symbol = extractSymbol(resolved.name);
144
219
  if (symbol === null) {
145
- return fileIcon;
220
+ return { icon: null, specific };
146
221
  }
147
222
  // Built-in language icons paint with `fill="currentColor"` so the colored
148
223
  // tier of the tree can recolor them by setting `color` on the host
@@ -168,5 +243,20 @@ export function getTreeIcon(filePath: string): LabIcon {
168
243
  svgstr
169
244
  });
170
245
  TREE_ICON_CACHE.set(resolved.name, icon);
171
- return icon;
246
+ return { icon, specific };
247
+ }
248
+
249
+ /**
250
+ * Return the icon the xtralab file tree would render for `filePath`.
251
+ */
252
+ export function getTreeIcon(filePath: string): LabIcon {
253
+ return resolveTreeIcon(filePath).icon ?? fileIcon;
254
+ }
255
+
256
+ /**
257
+ * Like {@link getTreeIcon}, but returns `null` for the generic file glyph.
258
+ */
259
+ export function getSpecificTreeIcon(filePath: string): LabIcon | null {
260
+ const { icon, specific } = resolveTreeIcon(filePath);
261
+ return specific ? icon : null;
172
262
  }
@@ -4,10 +4,14 @@ import {
4
4
  JupyterFrontEndPlugin
5
5
  } from '@jupyterlab/application';
6
6
 
7
+ import { ICommandPalette } from '@jupyterlab/apputils';
8
+
7
9
  import { IDocumentManager } from '@jupyterlab/docmanager';
8
10
 
9
11
  import { ISettingRegistry } from '@jupyterlab/settingregistry';
10
12
 
13
+ import { ITranslator } from '@jupyterlab/translation';
14
+
11
15
  import { populateToolbar, registerCommands } from './commands';
12
16
  import { XtralabFileBrowser } from './widget';
13
17
 
@@ -23,12 +27,14 @@ const plugin: JupyterFrontEndPlugin<void> = {
23
27
  'A path-first file browser for JupyterLab built on @pierre/trees.',
24
28
  autoStart: true,
25
29
  requires: [IDocumentManager],
26
- optional: [ILayoutRestorer, ISettingRegistry],
30
+ optional: [ILayoutRestorer, ISettingRegistry, ICommandPalette, ITranslator],
27
31
  activate: (
28
32
  app: JupyterFrontEnd,
29
33
  docManager: IDocumentManager,
30
34
  restorer: ILayoutRestorer | null,
31
- settingRegistry: ISettingRegistry | null
35
+ settingRegistry: ISettingRegistry | null,
36
+ palette: ICommandPalette | null,
37
+ translator: ITranslator | null
32
38
  ): void => {
33
39
  const browser = new XtralabFileBrowser({
34
40
  contentsManager: app.serviceManager.contents,
@@ -46,7 +52,7 @@ const plugin: JupyterFrontEndPlugin<void> = {
46
52
  restorer.add(browser, browser.id);
47
53
  }
48
54
 
49
- registerCommands({ app, browser });
55
+ registerCommands({ app, browser, docManager, palette, translator });
50
56
  populateToolbar({ app, browser });
51
57
 
52
58
  if (settingRegistry) {
@@ -0,0 +1,174 @@
1
+ import {
2
+ JupyterFrontEnd,
3
+ JupyterFrontEndPlugin
4
+ } from '@jupyterlab/application';
5
+
6
+ import { IDocumentWidgetOpener } from '@jupyterlab/docmanager';
7
+
8
+ import { DocumentRegistry } from '@jupyterlab/docregistry';
9
+
10
+ import { IGitExtension } from '@jupyterlab/git';
11
+
12
+ import { LabIcon } from '@jupyterlab/ui-components';
13
+
14
+ import { getSpecificTreeIcon } from '../fileBrowser/icons';
15
+
16
+ const PLUGIN_ID = 'xtralab:file-type-icons';
17
+
18
+ /**
19
+ * Synthetic path used to resolve a file type's tree glyph.
20
+ */
21
+ function representativePath(
22
+ fileType: DocumentRegistry.IFileType
23
+ ): string | null {
24
+ const extension = fileType.extensions?.[0];
25
+ if (extension === undefined || extension === '') {
26
+ return null;
27
+ }
28
+ // Extensions are stored with a leading dot (e.g. `.py`, `.tar.gz`).
29
+ return `file${extension.startsWith('.') ? extension : `.${extension}`}`;
30
+ }
31
+
32
+ /**
33
+ * Point a file type's icon at the tree glyph for its extension.
34
+ */
35
+ function applyTreeIcon(fileType: DocumentRegistry.IFileType): void {
36
+ const path = representativePath(fileType);
37
+ if (path === null) {
38
+ return;
39
+ }
40
+ const icon = getSpecificTreeIcon(path);
41
+ if (icon === null) {
42
+ return;
43
+ }
44
+ // The registry owns these objects; `readonly` only blocks the type shape.
45
+ (fileType as { icon?: LabIcon }).icon = icon;
46
+ }
47
+
48
+ /**
49
+ * Source extensions with tree glyphs but no default JupyterLab file type.
50
+ */
51
+ const EXTRA_FILE_TYPE_EXTENSIONS = [
52
+ '.ts',
53
+ '.tsx',
54
+ '.mts',
55
+ '.cts',
56
+ '.js',
57
+ '.jsx',
58
+ '.mjs',
59
+ '.cjs',
60
+ '.go',
61
+ '.rs',
62
+ '.rb',
63
+ '.c',
64
+ '.h',
65
+ '.cpp',
66
+ '.cc',
67
+ '.hpp',
68
+ '.swift',
69
+ '.zig',
70
+ '.vue',
71
+ '.svelte',
72
+ '.astro',
73
+ '.scss',
74
+ '.sass',
75
+ '.sh',
76
+ '.bash',
77
+ '.zsh',
78
+ '.graphql',
79
+ '.wasm'
80
+ ];
81
+
82
+ /**
83
+ * Register icon-only file types for unclaimed source extensions.
84
+ */
85
+ function registerExtraFileTypes(docRegistry: DocumentRegistry): void {
86
+ for (const extension of EXTRA_FILE_TYPE_EXTENSIONS) {
87
+ const icon = getSpecificTreeIcon(`file${extension}`);
88
+ if (icon === null) {
89
+ continue;
90
+ }
91
+ const claimed = docRegistry
92
+ .getFileTypesForPath(`file${extension}`)
93
+ .some(fileType =>
94
+ fileType.extensions?.some(e => e.toLowerCase() === extension)
95
+ );
96
+ if (claimed) {
97
+ continue;
98
+ }
99
+ docRegistry.addFileType({
100
+ name: `xtralab:file-type:${extension.slice(1)}`,
101
+ extensions: [extension],
102
+ icon,
103
+ contentType: 'file',
104
+ fileFormat: 'text'
105
+ });
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Share the file browser's tree glyphs with tabs and git file lists.
111
+ */
112
+ const plugin: JupyterFrontEndPlugin<void> = {
113
+ id: PLUGIN_ID,
114
+ description:
115
+ "Gives the file browser's @pierre/trees glyphs to document tabs, the default file browser, and the git status panel.",
116
+ autoStart: true,
117
+ optional: [IDocumentWidgetOpener, IGitExtension],
118
+ activate: (
119
+ app: JupyterFrontEnd,
120
+ opener: IDocumentWidgetOpener | null,
121
+ gitExtension: IGitExtension | null
122
+ ): void => {
123
+ const { docRegistry } = app;
124
+ for (const fileType of docRegistry.fileTypes()) {
125
+ applyTreeIcon(fileType);
126
+ }
127
+ registerExtraFileTypes(docRegistry);
128
+ // Pick up file types registered after this plugin activates.
129
+ docRegistry.changed.connect((_, args) => {
130
+ if (
131
+ args.type === 'fileType' &&
132
+ args.change === 'added' &&
133
+ args.name !== undefined
134
+ ) {
135
+ const fileType = docRegistry.getFileType(args.name);
136
+ if (fileType !== undefined) {
137
+ applyTreeIcon(fileType);
138
+ }
139
+ }
140
+ });
141
+
142
+ if (opener !== null) {
143
+ opener.opened.connect((_, widget) => {
144
+ const setIcon = (): void => {
145
+ const icon = getSpecificTreeIcon(widget.context.path);
146
+ if (icon !== null) {
147
+ widget.title.icon = icon;
148
+ }
149
+ };
150
+ setIcon();
151
+ // Keep the tab icon right after a rename.
152
+ widget.context.pathChanged.connect(setIcon);
153
+ });
154
+ }
155
+
156
+ if (gitExtension !== null) {
157
+ gitExtension.statusChanged.connect((_, status) => {
158
+ for (const file of status.files) {
159
+ if (file.type === undefined) {
160
+ continue;
161
+ }
162
+ const icon = getSpecificTreeIcon(file.to);
163
+ if (icon === null) {
164
+ continue;
165
+ }
166
+ // Cross jupyterlab-git's nested `LabIcon` package boundary.
167
+ file.type = { ...file.type, icon } as unknown as typeof file.type;
168
+ }
169
+ });
170
+ }
171
+ }
172
+ };
173
+
174
+ export default plugin;
@@ -1,31 +1,29 @@
1
1
  import { JupyterFrontEnd } from '@jupyterlab/application';
2
- import { IThemeManager } from '@jupyterlab/apputils';
2
+ import { IThemeManager, MainAreaWidget } from '@jupyterlab/apputils';
3
3
  import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
4
4
  import { Contents } from '@jupyterlab/services';
5
+ import { ToolbarButton, launchIcon } from '@jupyterlab/ui-components';
5
6
  import { ReadonlyPartialJSONObject } from '@lumino/coreutils';
7
+ import { ISignal, Signal } from '@lumino/signaling';
6
8
 
9
+ import { getTreeIcon } from '../fileBrowser/icons';
10
+ import { fileChangeToDiffModel } from './diffModel';
7
11
  import {
8
- createGitDiffWidget,
9
- diffWidgetId,
10
- updateGitDiffWidget
12
+ DIFF_WIDGET_CSS_CLASS,
13
+ XtralabDiffWidget,
14
+ addDiffToolbarItems
11
15
  } from './diffWidget';
12
16
  import { IFileChange } from './tokens';
13
17
 
14
18
  /**
15
- * Command IDs exposed by the launcher's git diff path. The launcher
16
- * dashboard's "Changes" section drives {@link CommandIDs.openDiff} to open
17
- * its own diff tab, independent of `jupyterlab-git` (whose panel reaches
18
- * the same rendering via the providers in `diffProvider.tsx`).
19
+ * Command IDs exposed by the launcher's git diff path.
19
20
  */
20
21
  export namespace CommandIDs {
21
22
  export const openDiff = 'xtralab:git:open-diff';
22
23
  }
23
24
 
24
25
  /**
25
- * Argument shapes used by {@link CommandIDs.openDiff}. Cast to this type
26
- * inside the execute callback for type safety; the registry itself takes
27
- * `ReadonlyPartialJSONObject` because Lumino does not know about our
28
- * extension-specific shapes.
26
+ * Argument shapes used by {@link CommandIDs.openDiff}.
29
27
  */
30
28
  export namespace CommandArguments {
31
29
  export interface IOpenDiff {
@@ -35,44 +33,184 @@ export namespace CommandArguments {
35
33
  }
36
34
  }
37
35
 
36
+ /**
37
+ * Widget id of the single, reused preview diff tab.
38
+ */
39
+ export const PREVIEW_DIFF_WIDGET_ID = 'xtralab:diff:preview';
40
+
41
+ /**
42
+ * Deterministic widget id for a pinned file/group pair.
43
+ */
44
+ export function pinnedDiffWidgetId(change: IFileChange): string {
45
+ return `xtralab:diff:pinned:${change.group}:${change.path}`;
46
+ }
47
+
48
+ function formatTitle(change: IFileChange): string {
49
+ const name = change.path.split('/').pop() ?? change.path;
50
+ const groupLabel = change.group === 'staged' ? 'Staged' : 'Working';
51
+ return `${name} (${groupLabel})`;
52
+ }
53
+
54
+ /**
55
+ * Launcher-owned host for a shared diff widget.
56
+ */
57
+ export class DiffMainAreaWidget extends MainAreaWidget<XtralabDiffWidget> {
58
+ constructor(
59
+ options: MainAreaWidget.IOptions<XtralabDiffWidget>,
60
+ change: IFileChange,
61
+ pinned: boolean
62
+ ) {
63
+ super(options);
64
+ this._change = change;
65
+ this._pinned = pinned;
66
+ }
67
+
68
+ get change(): IFileChange {
69
+ return this._change;
70
+ }
71
+
72
+ get pinned(): boolean {
73
+ return this._pinned;
74
+ }
75
+
76
+ pin(): void {
77
+ if (this._pinned) {
78
+ return;
79
+ }
80
+ this._pinned = true;
81
+ this._pinnedChanged.emit(true);
82
+ }
83
+
84
+ get pinnedChanged(): ISignal<this, boolean> {
85
+ return this._pinnedChanged;
86
+ }
87
+
88
+ setChange(repoPath: string, change: IFileChange): void {
89
+ this._change = change;
90
+ this.content.setModel(fileChangeToDiffModel(repoPath, change));
91
+ this.title.label = formatTitle(change);
92
+ this.title.caption = change.path;
93
+ this.title.icon = getTreeIcon(change.path);
94
+ }
95
+
96
+ private _change: IFileChange;
97
+ private _pinned: boolean;
98
+ private _pinnedChanged = new Signal<this, boolean>(this);
99
+ }
100
+
101
+ interface ICreateDiffWidgetOptions {
102
+ repoPath: string;
103
+ change: IFileChange;
104
+ themeManager: IThemeManager | null;
105
+ contentsManager: Contents.IManager;
106
+ rendermime: IRenderMimeRegistry | null;
107
+ pinned?: boolean;
108
+ onPinned?: (widget: DiffMainAreaWidget) => void;
109
+ }
110
+
111
+ /**
112
+ * Build the launcher's diff tab around the shared diff widget.
113
+ */
114
+ function createDiffWidget(
115
+ options: ICreateDiffWidgetOptions
116
+ ): DiffMainAreaWidget {
117
+ const { repoPath, change, themeManager, contentsManager, rendermime } =
118
+ options;
119
+ const pinned = options.pinned === true;
120
+ const content = new XtralabDiffWidget(
121
+ fileChangeToDiffModel(repoPath, change),
122
+ { contentsManager, rendermime, themeManager }
123
+ );
124
+ const widget = new DiffMainAreaWidget({ content }, change, pinned);
125
+ widget.id = pinned ? pinnedDiffWidgetId(change) : PREVIEW_DIFF_WIDGET_ID;
126
+ widget.title.label = formatTitle(change);
127
+ widget.title.caption = change.path;
128
+ widget.title.closable = true;
129
+ widget.title.icon = getTreeIcon(change.path);
130
+ widget.title.className = pinned ? '' : 'jp-mod-preview';
131
+ widget.addClass(DIFF_WIDGET_CSS_CLASS);
132
+
133
+ const onPinned = options.onPinned ?? ((): void => undefined);
134
+
135
+ const pinButton = new ToolbarButton({
136
+ icon: launchIcon,
137
+ tooltip: 'Pin tab',
138
+ onClick: () => widget.pin()
139
+ });
140
+ if (widget.pinned) {
141
+ pinButton.hide();
142
+ }
143
+ widget.toolbar.addItem('pin', pinButton);
144
+ addDiffToolbarItems(widget.toolbar, content);
145
+
146
+ // Keep chrome visible only while there is a relevant toolbar control.
147
+ const syncToolbarVisibility = (): void => {
148
+ if (widget.isDisposed) {
149
+ return;
150
+ }
151
+ if (!widget.pinned || content.hasNotebookView || content.fileDiffActive) {
152
+ widget.toolbar.show();
153
+ } else {
154
+ widget.toolbar.hide();
155
+ }
156
+ };
157
+ const onPinnedChanged = (
158
+ _sender: DiffMainAreaWidget,
159
+ value: boolean
160
+ ): void => {
161
+ if (widget.isDisposed) {
162
+ return;
163
+ }
164
+ if (value) {
165
+ pinButton.hide();
166
+ onPinned(widget);
167
+ if (widget.isDisposed) {
168
+ return;
169
+ }
170
+ widget.title.className = '';
171
+ }
172
+ syncToolbarVisibility();
173
+ };
174
+ // Auto-close once a discard leaves nothing to show.
175
+ const onEmptied = (): void => {
176
+ if (!widget.isDisposed) {
177
+ widget.close();
178
+ }
179
+ };
180
+ content.hasNotebookViewChanged.connect(syncToolbarVisibility);
181
+ content.fileDiffActiveChanged.connect(syncToolbarVisibility);
182
+ content.emptied.connect(onEmptied);
183
+ widget.pinnedChanged.connect(onPinnedChanged);
184
+ syncToolbarVisibility();
185
+ widget.disposed.connect(() => {
186
+ content.hasNotebookViewChanged.disconnect(syncToolbarVisibility);
187
+ content.fileDiffActiveChanged.disconnect(syncToolbarVisibility);
188
+ content.emptied.disconnect(onEmptied);
189
+ widget.pinnedChanged.disconnect(onPinnedChanged);
190
+ });
191
+ return widget;
192
+ }
193
+
38
194
  export interface IRegisterGitCommandsOptions {
39
195
  app: JupyterFrontEnd;
40
196
  themeManager: IThemeManager | null;
41
197
  /**
42
- * Contents manager used by the diff widget to write hunk-discard
43
- * results back to the working tree.
198
+ * Contents manager used to write hunk-discard results.
44
199
  */
45
200
  contentsManager: Contents.IManager;
46
201
  /**
47
- * Rendermime registry used by the notebook diff to render outputs and
48
- * markdown cells with their actual mime-type renderers (images as
49
- * images, HTML as HTML, etc.). May be `null` in stripped-down hosts —
50
- * the notebook diff falls back to a text representation in that case.
202
+ * Rendermime registry used by notebook diffs; may be `null`.
51
203
  */
52
204
  rendermime: IRenderMimeRegistry | null;
53
205
  /**
54
- * Called whenever the diff widget mutates the working tree (e.g. after
55
- * a hunk discard). The launcher dashboard polls git status on its own
56
- * cadence and `jupyterlab-git`'s panel auto-refreshes on the contents
57
- * `fileChanged` signal the save emits, so this is a hook for any extra
58
- * bookkeeping rather than a required refresh.
59
- */
60
- onChanged: () => void;
61
- /**
62
- * Called for every diff widget created via the {@link CommandIDs.openDiff}
63
- * command, before the widget is added to the shell. Lets the plugin track
64
- * the widget for layout restoration and reveal-on-reuse.
206
+ * Track newly created diff widgets before they are added to the shell.
65
207
  */
66
- trackDiff(widget: ReturnType<typeof createGitDiffWidget>): Promise<void>;
208
+ trackDiff(widget: DiffMainAreaWidget): Promise<void>;
67
209
  /**
68
- * Look up an already-open diff widget for a given file change. Returns
69
- * `undefined` when none is open.
210
+ * Look up an already-open diff widget for a file change.
70
211
  */
71
- findDiff(
72
- change: IFileChange,
73
- pin?: boolean
74
- ): ReturnType<typeof createGitDiffWidget> | undefined;
75
- onPinned(widget: ReturnType<typeof createGitDiffWidget>): void;
212
+ findDiff(change: IFileChange, pin?: boolean): DiffMainAreaWidget | undefined;
213
+ onPinned(widget: DiffMainAreaWidget): void;
76
214
  }
77
215
 
78
216
  /**
@@ -87,7 +225,6 @@ export function registerGitCommands(
87
225
  themeManager,
88
226
  contentsManager,
89
227
  rendermime,
90
- onChanged,
91
228
  trackDiff,
92
229
  findDiff,
93
230
  onPinned
@@ -106,18 +243,17 @@ export function registerGitCommands(
106
243
  const existing = findDiff(typed.change, pin);
107
244
  if (existing !== undefined && !existing.isDisposed) {
108
245
  if (!pin) {
109
- updateGitDiffWidget(existing, typed.change);
246
+ existing.setChange(typed.repoPath, typed.change);
110
247
  }
111
248
  app.shell.activateById(existing.id);
112
249
  return;
113
250
  }
114
- const widget = createGitDiffWidget({
251
+ const widget = createDiffWidget({
115
252
  repoPath: typed.repoPath,
116
253
  change: typed.change,
117
254
  themeManager,
118
255
  contentsManager,
119
256
  rendermime,
120
- onChanged,
121
257
  pinned: pin,
122
258
  onPinned
123
259
  });
@@ -127,5 +263,3 @@ export function registerGitCommands(
127
263
  }
128
264
  });
129
265
  }
130
-
131
- export { diffWidgetId };