xtralab 0.1.2 → 0.3.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 (75) hide show
  1. package/README.md +129 -46
  2. package/lib/fileBrowser/commands.d.ts +2 -2
  3. package/lib/fileBrowser/commands.js +2 -2
  4. package/lib/fileBrowser/fileBrowser.js +72 -2
  5. package/lib/fileBrowser/gitignore.d.ts +29 -0
  6. package/lib/fileBrowser/gitignore.js +74 -0
  7. package/lib/fileBrowser/icons.d.ts +4 -4
  8. package/lib/fileBrowser/icons.js +4 -4
  9. package/lib/fileBrowser/widget.d.ts +2 -2
  10. package/lib/fileBrowser/widget.js +2 -2
  11. package/lib/git/api.d.ts +11 -0
  12. package/lib/git/api.js +19 -0
  13. package/lib/git/commands.d.ts +3 -1
  14. package/lib/git/commands.js +10 -4
  15. package/lib/git/diffWidget.d.ts +11 -0
  16. package/lib/git/diffWidget.js +90 -9
  17. package/lib/git/icons.d.ts +1 -1
  18. package/lib/git/icons.js +1 -1
  19. package/lib/git/index.js +22 -6
  20. package/lib/git/panelComponent.d.ts +3 -1
  21. package/lib/git/panelComponent.js +6 -3
  22. package/lib/index.js +9 -1
  23. package/lib/launcher/agents.d.ts +78 -0
  24. package/lib/launcher/agents.js +168 -0
  25. package/lib/launcher/availability.d.ts +12 -0
  26. package/lib/launcher/availability.js +53 -0
  27. package/lib/launcher/commands.d.ts +22 -0
  28. package/lib/launcher/commands.js +89 -0
  29. package/lib/launcher/dashboard.d.ts +46 -0
  30. package/lib/launcher/dashboard.js +214 -0
  31. package/lib/launcher/icons.d.ts +33 -0
  32. package/lib/launcher/icons.js +113 -0
  33. package/lib/launcher/index.d.ts +23 -0
  34. package/lib/launcher/index.js +191 -0
  35. package/lib/launcher/invocation.d.ts +29 -0
  36. package/lib/launcher/invocation.js +42 -0
  37. package/lib/menus/index.d.ts +14 -0
  38. package/lib/menus/index.js +102 -0
  39. package/lib/sidebar/index.d.ts +18 -0
  40. package/lib/sidebar/index.js +155 -0
  41. package/lib/statusBar/index.d.ts +14 -0
  42. package/lib/statusBar/index.js +89 -0
  43. package/lib/statusBar/widget.d.ts +121 -0
  44. package/lib/statusBar/widget.js +237 -0
  45. package/package.json +25 -3
  46. package/schema/launcher.json +70 -0
  47. package/schema/sidebar.json +57 -0
  48. package/src/fileBrowser/commands.ts +2 -2
  49. package/src/fileBrowser/fileBrowser.tsx +76 -2
  50. package/src/fileBrowser/gitignore.ts +86 -0
  51. package/src/fileBrowser/icons.ts +4 -4
  52. package/src/fileBrowser/widget.tsx +3 -3
  53. package/src/git/api.ts +20 -0
  54. package/src/git/commands.ts +19 -5
  55. package/src/git/diffWidget.tsx +109 -13
  56. package/src/git/icons.ts +1 -1
  57. package/src/git/index.ts +31 -5
  58. package/src/git/panelComponent.tsx +17 -5
  59. package/src/index.ts +9 -1
  60. package/src/launcher/agents.ts +248 -0
  61. package/src/launcher/availability.ts +73 -0
  62. package/src/launcher/commands.ts +103 -0
  63. package/src/launcher/dashboard.tsx +462 -0
  64. package/src/launcher/icons.ts +123 -0
  65. package/src/launcher/index.ts +228 -0
  66. package/src/launcher/invocation.ts +45 -0
  67. package/src/menus/index.ts +124 -0
  68. package/src/sidebar/index.ts +195 -0
  69. package/src/statusBar/index.ts +115 -0
  70. package/src/statusBar/widget.tsx +318 -0
  71. package/style/git.css +4 -8
  72. package/style/index.css +2 -0
  73. package/style/index.js +2 -0
  74. package/style/launcher.css +323 -0
  75. package/style/statusBar.css +95 -0
@@ -12,7 +12,10 @@ import type {
12
12
  FileTreeDirectoryHandle
13
13
  } from '@pierre/trees';
14
14
 
15
+ import type { Ignore } from 'ignore';
16
+
15
17
  import { ROOT_LOAD_KEY, listDirectory, toServerPath } from './contents';
18
+ import { buildIgnoredEntries, loadGitignoreMatcher } from './gitignore';
16
19
  import { FILE_BROWSER_ICONS } from './icons';
17
20
  import type { XtralabFileBrowser } from './widget';
18
21
 
@@ -29,7 +32,7 @@ type LoadState = 'unloaded' | 'loading' | 'loaded';
29
32
  * synchronous function that returns a Widget. The contents MIME types
30
33
  * mirror what the default file browser sends so other drop targets that
31
34
  * understand them (the file browser itself, custom drop zones) keep
32
- * working when the user drags out of Xtralab.
35
+ * working when the user drags out of xtralab.
33
36
  */
34
37
  const FACTORY_MIME = 'application/vnd.lumino.widget-factory';
35
38
  const CONTENTS_MIME = 'application/x-jupyter-icontents';
@@ -76,13 +79,57 @@ export function FileBrowserComponent(
76
79
  paths: [],
77
80
  initialExpansion: 'closed',
78
81
  search: true,
79
- icons: FILE_BROWSER_ICONS
82
+ icons: FILE_BROWSER_ICONS,
83
+ itemHeight: 24
80
84
  });
81
85
 
82
86
  React.useEffect(() => {
83
87
  const knownDirs = new Map<string, LoadState>();
88
+ // Mirror of the canonical paths currently loaded into the tree. Kept in
89
+ // sync with `model.resetPaths`/`model.batch`/`model.add` so we can
90
+ // re-test every loaded path against the gitignore matcher whenever
91
+ // either side changes — the model itself does not expose a
92
+ // path-iteration API.
93
+ const loadedPaths = new Set<string>();
94
+ let gitignoreMatcher: Ignore | null = null;
84
95
  let cancelled = false;
85
96
 
97
+ /**
98
+ * Recompute the `ignored` git status entries from the current
99
+ * gitignore matcher and the set of loaded paths, and push them into
100
+ * the tree. Safe to call at any time: when the matcher is `null`
101
+ * (no `.gitignore` or it failed to load) we send an empty list, which
102
+ * also clears any statuses that may have been applied previously.
103
+ */
104
+ const syncGitStatus = (): void => {
105
+ if (gitignoreMatcher === null) {
106
+ model.setGitStatus([]);
107
+ return;
108
+ }
109
+ const entries = buildIgnoredEntries(gitignoreMatcher, loadedPaths);
110
+ model.setGitStatus(entries);
111
+ };
112
+
113
+ /**
114
+ * Reload the workspace `.gitignore`, then re-apply the resulting
115
+ * ignored statuses. Called on initial mount and whenever the user
116
+ * triggers a refresh — the file may have been edited or created in
117
+ * between.
118
+ */
119
+ const refreshGitignoreMatcher = async (): Promise<void> => {
120
+ let next: Ignore | null = null;
121
+ try {
122
+ next = await loadGitignoreMatcher(contentsManager);
123
+ } catch (err) {
124
+ console.error('xtralab: failed to load .gitignore', err);
125
+ }
126
+ if (cancelled) {
127
+ return;
128
+ }
129
+ gitignoreMatcher = next;
130
+ syncGitStatus();
131
+ };
132
+
86
133
  const fetchDirectory = async (canonicalPath: string): Promise<void> => {
87
134
  const current = knownDirs.get(canonicalPath);
88
135
  if (current === 'loading' || current === 'loaded') {
@@ -100,6 +147,10 @@ export function FileBrowserComponent(
100
147
  }
101
148
  if (canonicalPath === ROOT_LOAD_KEY) {
102
149
  model.resetPaths(paths);
150
+ loadedPaths.clear();
151
+ for (const path of paths) {
152
+ loadedPaths.add(path);
153
+ }
103
154
  } else {
104
155
  // Filter out paths already in the model. The path-store throws
105
156
  // when an explicit directory is added a second time, so any
@@ -111,6 +162,14 @@ export function FileBrowserComponent(
111
162
  if (operations.length > 0) {
112
163
  model.batch(operations);
113
164
  }
165
+ // Mirror every directory child into `loadedPaths` regardless of
166
+ // whether it was just added or skipped above — paths skipped by
167
+ // the filter are already in the model from a prior add and so
168
+ // belong in the mirror too. `Set.add` is idempotent, so the
169
+ // duplicate adds are a no-op.
170
+ for (const path of paths) {
171
+ loadedPaths.add(path);
172
+ }
114
173
  }
115
174
  for (const subdir of subdirectories) {
116
175
  if (!knownDirs.has(subdir)) {
@@ -118,6 +177,7 @@ export function FileBrowserComponent(
118
177
  }
119
178
  }
120
179
  knownDirs.set(canonicalPath, 'loaded');
180
+ syncGitStatus();
121
181
  } catch (err) {
122
182
  console.error(
123
183
  `xtralab: failed to load directory "${canonicalPath}"`,
@@ -216,6 +276,10 @@ export function FileBrowserComponent(
216
276
  // Reset the tree contents and restore the load-state map so future
217
277
  // expansions know which directories still need fetching.
218
278
  model.resetPaths(allPaths);
279
+ loadedPaths.clear();
280
+ for (const path of allPaths) {
281
+ loadedPaths.add(path);
282
+ }
219
283
  knownDirs.set(ROOT_LOAD_KEY, 'loaded');
220
284
  subdirsByParent.forEach(subdirs => {
221
285
  for (const subdir of subdirs) {
@@ -226,6 +290,13 @@ export function FileBrowserComponent(
226
290
  }
227
291
  });
228
292
 
293
+ // Refresh the `.gitignore` matcher in case the file was edited
294
+ // since the last load, then re-apply the resulting statuses to the
295
+ // newly-loaded paths. The reload runs in parallel with the rest of
296
+ // the refresh — `refreshGitignoreMatcher` calls `syncGitStatus`
297
+ // itself when it completes.
298
+ void refreshGitignoreMatcher();
299
+
229
300
  // Re-expand the directories that were expanded before the refresh.
230
301
  // We have to do this after `resetPaths` because the reset starts
231
302
  // every directory in its initial collapsed state.
@@ -251,12 +322,14 @@ export function FileBrowserComponent(
251
322
  return;
252
323
  }
253
324
  }
325
+ loadedPaths.add(canonicalPath);
254
326
  if (canonicalPath.endsWith('/') && !knownDirs.has(canonicalPath)) {
255
327
  // The new directory has no children yet, so mark it as already
256
328
  // loaded — there's nothing to fetch and we don't want a stale
257
329
  // "unloaded" entry to trigger a fetch on the next expand.
258
330
  knownDirs.set(canonicalPath, 'loaded');
259
331
  }
332
+ syncGitStatus();
260
333
  const parent = parentOf(canonicalPath);
261
334
  if (parent === ROOT_LOAD_KEY) {
262
335
  return;
@@ -273,6 +346,7 @@ export function FileBrowserComponent(
273
346
 
274
347
  knownDirs.set(ROOT_LOAD_KEY, 'unloaded');
275
348
  void fetchDirectory(ROOT_LOAD_KEY);
349
+ void refreshGitignoreMatcher();
276
350
 
277
351
  const unsubscribe = model.subscribe(() => {
278
352
  knownDirs.forEach((state, canonicalPath) => {
@@ -0,0 +1,86 @@
1
+ import ignore from 'ignore';
2
+ import type { Ignore } from 'ignore';
3
+
4
+ import { Contents } from '@jupyterlab/services';
5
+
6
+ import type { GitStatusEntry } from '@pierre/trees';
7
+
8
+ /**
9
+ * The path of the workspace `.gitignore` file. Loaded relative to the
10
+ * Jupyter contents root, which matches the directory the gitignore patterns
11
+ * are evaluated against — patterns and tested paths share the same base.
12
+ */
13
+ const GITIGNORE_PATH = '.gitignore';
14
+
15
+ /**
16
+ * Load the workspace `.gitignore` and return an `ignore` matcher built from
17
+ * its contents. Returns `null` when the file is missing, empty, or fails to
18
+ * load — callers should treat any of those as "no ignored entries".
19
+ */
20
+ export async function loadGitignoreMatcher(
21
+ contentsManager: Contents.IManager
22
+ ): Promise<Ignore | null> {
23
+ let model: Contents.IModel;
24
+ try {
25
+ model = await contentsManager.get(GITIGNORE_PATH, {
26
+ content: true,
27
+ format: 'text',
28
+ type: 'file'
29
+ });
30
+ } catch {
31
+ // The most common failure here is `.gitignore` simply not existing.
32
+ // Other transient errors (permission denied, server unreachable) are
33
+ // also treated as "no matcher" so the file browser still works.
34
+ return null;
35
+ }
36
+ if (model.type !== 'file' || typeof model.content !== 'string') {
37
+ return null;
38
+ }
39
+ if (model.content.length === 0) {
40
+ return null;
41
+ }
42
+ return ignore().add(model.content);
43
+ }
44
+
45
+ /**
46
+ * Build `GitStatusEntry`s for the canonical paths that match the given
47
+ * gitignore matcher.
48
+ *
49
+ * `@pierre/trees` propagates the `ignored` style from a directory entry
50
+ * down to every descendant via its internal `ignoredDirectoryPaths` set, so
51
+ * we only emit one entry per ignored subtree root: emitting descendants
52
+ * would also pollute the library's `directoriesWithChanges` set, causing
53
+ * parent directories of nested ignored items to render a "contains git
54
+ * status items" dot.
55
+ *
56
+ * Paths are sorted lexicographically so a directory always precedes its
57
+ * descendants, allowing a simple ancestor-suppression check via
58
+ * `path.startsWith(dir)`. The trailing slash on canonical directory paths
59
+ * (required by `@pierre/trees`, also recognized by `ignore` as the
60
+ * "directory" indicator) keeps the prefix check from accidentally matching
61
+ * sibling paths that happen to share a name prefix (e.g. `node_modules_old`
62
+ * vs `node_modules/`).
63
+ */
64
+ export function buildIgnoredEntries(
65
+ matcher: Ignore,
66
+ canonicalPaths: Iterable<string>
67
+ ): GitStatusEntry[] {
68
+ const sorted = Array.from(canonicalPaths).sort();
69
+ const emittedIgnoredDirs: string[] = [];
70
+ const entries: GitStatusEntry[] = [];
71
+ for (const path of sorted) {
72
+ if (path.length === 0) {
73
+ continue;
74
+ }
75
+ if (emittedIgnoredDirs.some(dir => path.startsWith(dir))) {
76
+ continue;
77
+ }
78
+ if (matcher.ignores(path)) {
79
+ entries.push({ path, status: 'ignored' });
80
+ if (path.endsWith('/')) {
81
+ emittedIgnoredDirs.push(path);
82
+ }
83
+ }
84
+ }
85
+ return entries;
86
+ }
@@ -7,9 +7,9 @@ import {
7
7
  import type { FileTreeIconConfig } from '@pierre/trees';
8
8
 
9
9
  /**
10
- * The glyph used as the Xtralab file browser's sidebar tab icon. Defined as a
10
+ * The glyph used as the xtralab file browser's sidebar tab icon. Defined as a
11
11
  * `LabIcon` (rather than the default `folderIcon` from
12
- * `@jupyterlab/ui-components`) so the Xtralab browser is visually distinguishable
12
+ * `@jupyterlab/ui-components`) so the xtralab browser is visually distinguishable
13
13
  * from JupyterLab's built-in file browser, which also lives in the left
14
14
  * sidebar.
15
15
  *
@@ -49,7 +49,7 @@ const JUPYTER_NOTEBOOK_SPRITE_SHEET = `<svg data-icon-sprite aria-hidden="true"
49
49
  </svg>`;
50
50
 
51
51
  /**
52
- * Icon configuration for the Xtralab file browser. Keeps the default `complete`
52
+ * Icon configuration for the xtralab file browser. Keeps the default `complete`
53
53
  * built-in icon set (the colored language icons) and layers a Jupyter notebook
54
54
  * symbol on top so `.ipynb` files render with the JupyterLab notebook glyph.
55
55
  *
@@ -121,7 +121,7 @@ function extractSymbol(
121
121
  }
122
122
 
123
123
  /**
124
- * Return a JupyterLab `LabIcon` matching the icon the Xtralab file tree would
124
+ * Return a JupyterLab `LabIcon` matching the icon the xtralab file tree would
125
125
  * render for the given file path. Used by other plugins (e.g. the git diff
126
126
  * viewer) that want their main-area widget tabs to display the same per-file
127
127
  * glyph the tree shows in the sidebar.
@@ -12,7 +12,7 @@ import { xtralabFileBrowserIcon } from './icons';
12
12
  export const FILE_BROWSER_ID = 'xtralab:file-browser';
13
13
 
14
14
  /**
15
- * The CSS class added to the Xtralab file browser widget. The selectors that
15
+ * The CSS class added to the xtralab file browser widget. The selectors that
16
16
  * bind application context menu items hang off this class, so it must remain
17
17
  * specific enough not to clash with other browsers (such as the default
18
18
  * `jp-FileBrowser`).
@@ -29,7 +29,7 @@ export interface IXtralabFileBrowserOptions {
29
29
  }
30
30
 
31
31
  /**
32
- * Public API of the Xtralab file browser used by command handlers and other
32
+ * Public API of the xtralab file browser used by command handlers and other
33
33
  * collaborators. Kept narrow on purpose so the React component stays free to
34
34
  * evolve the underlying tree integration.
35
35
  */
@@ -100,7 +100,7 @@ export class XtralabFileBrowser extends Widget implements IXtralabFileBrowser {
100
100
  this._onOpenFile = options.onOpenFile;
101
101
  this.id = FILE_BROWSER_ID;
102
102
  this.title.icon = xtralabFileBrowserIcon;
103
- this.title.caption = 'Xtralab File Browser';
103
+ this.title.caption = 'xtralab File Browser';
104
104
  this.addClass(FILE_BROWSER_CSS_CLASS);
105
105
 
106
106
  this._toolbar = new Toolbar();
package/src/git/api.ts CHANGED
@@ -145,6 +145,26 @@ export async function checkout(
145
145
  });
146
146
  }
147
147
 
148
+ /**
149
+ * Expand the porcelain `files` array into one entry per logical change.
150
+ * A single porcelain row may map to two `IFileChange` rows (a file that's
151
+ * both staged and modified again in the worktree); callers that just want
152
+ * a flat list of all changes — without any staged-vs-unstaged grouping —
153
+ * can use this helper.
154
+ *
155
+ * Provided as a manual concat rather than `Array.prototype.flatMap` so we
156
+ * don't pin our `tsconfig.lib` to ES2019.
157
+ */
158
+ export function expandStatusFiles(files: IGitStatusFile[]): IFileChange[] {
159
+ const result: IFileChange[] = [];
160
+ for (const file of files) {
161
+ for (const change of porcelainToFileChanges(file)) {
162
+ result.push(change);
163
+ }
164
+ }
165
+ return result;
166
+ }
167
+
148
168
  /**
149
169
  * Translate one entry in the porcelain `files` array into the panel's
150
170
  * preferred shape. A single porcelain entry may represent up to two changes
@@ -5,7 +5,11 @@ import { Contents } from '@jupyterlab/services';
5
5
  import { refreshIcon } from '@jupyterlab/ui-components';
6
6
  import { ReadonlyPartialJSONObject } from '@lumino/coreutils';
7
7
 
8
- import { createGitDiffWidget, diffWidgetId } from './diffWidget';
8
+ import {
9
+ createGitDiffWidget,
10
+ diffWidgetId,
11
+ updateGitDiffWidget
12
+ } from './diffWidget';
9
13
  import { GitPanel } from './panel';
10
14
  import { IFileChange } from './tokens';
11
15
 
@@ -29,6 +33,7 @@ export namespace CommandArguments {
29
33
  export interface IOpenDiff {
30
34
  repoPath: string;
31
35
  change: IFileChange;
36
+ pin?: boolean;
32
37
  }
33
38
  }
34
39
 
@@ -65,8 +70,10 @@ export interface IRegisterGitCommandsOptions {
65
70
  * `undefined` when none is open.
66
71
  */
67
72
  findDiff(
68
- change: IFileChange
73
+ change: IFileChange,
74
+ pin?: boolean
69
75
  ): ReturnType<typeof createGitDiffWidget> | undefined;
76
+ onPinned(widget: ReturnType<typeof createGitDiffWidget>): void;
70
77
  }
71
78
 
72
79
  /**
@@ -86,7 +93,8 @@ export function registerGitCommands(
86
93
  rendermime,
87
94
  onChanged,
88
95
  trackDiff,
89
- findDiff
96
+ findDiff,
97
+ onPinned
90
98
  } = options;
91
99
  const { commands } = app;
92
100
 
@@ -107,8 +115,12 @@ export function registerGitCommands(
107
115
  if (typed?.change === undefined) {
108
116
  return;
109
117
  }
110
- const existing = findDiff(typed.change);
118
+ const pin = typed.pin === true;
119
+ const existing = findDiff(typed.change, pin);
111
120
  if (existing !== undefined && !existing.isDisposed) {
121
+ if (!pin) {
122
+ updateGitDiffWidget(existing, typed.change);
123
+ }
112
124
  app.shell.activateById(existing.id);
113
125
  return;
114
126
  }
@@ -118,7 +130,9 @@ export function registerGitCommands(
118
130
  themeManager,
119
131
  contentsManager,
120
132
  rendermime,
121
- onChanged
133
+ onChanged,
134
+ pinned: pin,
135
+ onPinned
122
136
  });
123
137
  await trackDiff(widget);
124
138
  app.shell.add(widget, 'main', { mode: 'tab-after' });
@@ -4,7 +4,12 @@ import { IThemeManager, MainAreaWidget } from '@jupyterlab/apputils';
4
4
  import { PathExt } from '@jupyterlab/coreutils';
5
5
  import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
6
6
  import { Contents } from '@jupyterlab/services';
7
- import { ReactWidget, undoIcon } from '@jupyterlab/ui-components';
7
+ import {
8
+ ReactWidget,
9
+ ToolbarButton,
10
+ launchIcon,
11
+ undoIcon
12
+ } from '@jupyterlab/ui-components';
8
13
  import { ISignal, Signal } from '@lumino/signaling';
9
14
  import { FileDiff } from '@pierre/diffs/react';
10
15
  import {
@@ -30,6 +35,12 @@ import { GitReference, IFileChange } from './tokens';
30
35
  */
31
36
  export const DIFF_WIDGET_CSS_CLASS = 'jp-xtralab-DiffWidget';
32
37
 
38
+ export const PREVIEW_DIFF_WIDGET_ID = 'xtralab:diff:preview';
39
+
40
+ export function pinnedDiffWidgetId(change: IFileChange): string {
41
+ return `xtralab:diff:pinned:${change.group}:${change.path}`;
42
+ }
43
+
33
44
  /**
34
45
  * `localStorage` key for the user's last-chosen split ratio. Persisted so
35
46
  * the preference survives reloads — the library has no built-in way to
@@ -143,6 +154,10 @@ export function diffWidgetId(change: IFileChange): string {
143
154
  return `xtralab:diff:${change.group}:${change.path}`;
144
155
  }
145
156
 
157
+ function toServerPath(repoPath: string, filePath: string): string {
158
+ return repoPath.length > 0 ? PathExt.join(repoPath, filePath) : filePath;
159
+ }
160
+
146
161
  /**
147
162
  * Resolve the pair of git references whose `content` should appear on the
148
163
  * left and right side of the diff for a given file change. Mirrors VS
@@ -190,6 +205,8 @@ export interface IGitDiffWidgetOptions {
190
205
  * would just be visual noise.
191
206
  */
192
207
  onEmpty?: () => void;
208
+ onPinned?: (widget: MainAreaWidget<DiffContentWidget>) => void;
209
+ pinned?: boolean;
193
210
  }
194
211
 
195
212
  /**
@@ -216,44 +233,88 @@ export function createGitDiffWidget(
216
233
  }
217
234
  });
218
235
  widget = new MainAreaWidget<DiffContentWidget>({ content });
219
- widget.id = diffWidgetId(options.change);
236
+ widget.id =
237
+ options.pinned === true
238
+ ? pinnedDiffWidgetId(options.change)
239
+ : PREVIEW_DIFF_WIDGET_ID;
220
240
  widget.title.label = formatTitle(options.change);
221
241
  widget.title.caption = options.change.path;
222
242
  widget.title.closable = true;
223
243
  widget.title.icon = getTreeIcon(options.change.path);
244
+ widget.title.className = options.pinned === true ? '' : 'jp-mod-preview';
245
+ const onPinned = options.onPinned ?? (() => {});
224
246
  widget.addClass(DIFF_WIDGET_CSS_CLASS);
225
247
  // Wire the notebook view-mode selector into the `MainAreaWidget` toolbar
226
248
  // — the proper home for document chrome controls. The toolbar starts
227
249
  // hidden and becomes visible once the React content reports a parsed
228
250
  // notebook diff via `setHasNotebookView`. Plain text / code diffs never
229
- // flip that flag, so their toolbar bar stays collapsed and the
230
- // resulting layout matches what other read-only main-area documents
231
- // look like in JupyterLab.
251
+ // flip that flag, and pin state can hide the toolbar again, so the host
252
+ // chrome tracks the active mode.
253
+ const pinButton = new ToolbarButton({
254
+ icon: launchIcon,
255
+ tooltip: 'Pin tab',
256
+ onClick: () => content.pin()
257
+ });
258
+ if (content.pinned) {
259
+ pinButton.hide();
260
+ }
261
+ widget.toolbar.addItem('pin', pinButton);
232
262
  widget.toolbar.addItem(
233
263
  'notebook-view-mode',
234
264
  new NotebookViewModeToolbarItem(content)
235
265
  );
236
- widget.toolbar.hide();
237
- const onHasNotebookViewChanged = (
238
- _sender: DiffContentWidget,
239
- value: boolean
240
- ): void => {
266
+ const syncToolbarVisibility = (): void => {
241
267
  if (widget === null || widget.isDisposed) {
242
268
  return;
243
269
  }
244
- if (value) {
270
+ if (!content.pinned || content.hasNotebookView) {
245
271
  widget.toolbar.show();
246
272
  } else {
247
273
  widget.toolbar.hide();
248
274
  }
249
275
  };
276
+ const onHasNotebookViewChanged = (): void => {
277
+ if (widget === null || widget.isDisposed) {
278
+ return;
279
+ }
280
+ syncToolbarVisibility();
281
+ };
282
+ const onPinnedChanged = (sender: DiffContentWidget, value: boolean): void => {
283
+ if (widget === null || widget.isDisposed) {
284
+ return;
285
+ }
286
+ void sender;
287
+ if (value) {
288
+ pinButton.hide();
289
+ // Keep toolbar state and title styling consistent for promoted tabs.
290
+ onPinned(widget);
291
+ if (widget.isDisposed) {
292
+ return;
293
+ }
294
+ widget.title.className = '';
295
+ }
296
+ syncToolbarVisibility();
297
+ };
250
298
  content.hasNotebookViewChanged.connect(onHasNotebookViewChanged);
299
+ content.pinnedChanged.connect(onPinnedChanged);
300
+ syncToolbarVisibility();
251
301
  widget.disposed.connect(() => {
252
302
  content.hasNotebookViewChanged.disconnect(onHasNotebookViewChanged);
303
+ content.pinnedChanged.disconnect(onPinnedChanged);
253
304
  });
254
305
  return widget;
255
306
  }
256
307
 
308
+ export function updateGitDiffWidget(
309
+ widget: MainAreaWidget<DiffContentWidget>,
310
+ change: IFileChange
311
+ ): void {
312
+ widget.content.setChange(change);
313
+ widget.title.label = formatTitle(change);
314
+ widget.title.caption = change.path;
315
+ widget.title.icon = getTreeIcon(change.path);
316
+ }
317
+
257
318
  /**
258
319
  * The actual React-rendered region of the diff viewer. Held in its own
259
320
  * `ReactWidget` so the surrounding `MainAreaWidget` can manage chrome
@@ -270,6 +331,7 @@ export class DiffContentWidget extends ReactWidget {
270
331
  constructor(options: IGitDiffWidgetOptions) {
271
332
  super();
272
333
  this._options = options;
334
+ this._pinned = options.pinned === true;
273
335
  this._notebookViewMode = readStoredNotebookViewMode();
274
336
  }
275
337
 
@@ -320,6 +382,27 @@ export class DiffContentWidget extends ReactWidget {
320
382
  return this._hasNotebookViewChanged;
321
383
  }
322
384
 
385
+ get pinned(): boolean {
386
+ return this._pinned;
387
+ }
388
+
389
+ pin(): void {
390
+ if (this._pinned) {
391
+ return;
392
+ }
393
+ this._pinned = true;
394
+ this._pinnedChanged.emit(true);
395
+ }
396
+
397
+ get pinnedChanged(): ISignal<this, boolean> {
398
+ return this._pinnedChanged;
399
+ }
400
+
401
+ setChange(change: IFileChange): void {
402
+ this._options = { ...this._options, change };
403
+ this.update();
404
+ }
405
+
323
406
  protected render(): React.ReactElement {
324
407
  return <DiffViewer {...this._options} widget={this} />;
325
408
  }
@@ -331,6 +414,8 @@ export class DiffContentWidget extends ReactWidget {
331
414
  );
332
415
  private _hasNotebookView = false;
333
416
  private _hasNotebookViewChanged = new Signal<this, boolean>(this);
417
+ private _pinned: boolean;
418
+ private _pinnedChanged = new Signal<this, boolean>(this);
334
419
  }
335
420
 
336
421
  interface IDiffState {
@@ -505,9 +590,16 @@ function DiffViewer(
505
590
 
506
591
  React.useEffect(() => {
507
592
  let cancelled = false;
593
+ const serverPath = toServerPath(repoPath, change.path);
594
+ setState({
595
+ loading: true,
596
+ metadata: null,
597
+ notebookDiff: null,
598
+ serverPath,
599
+ isBinary: false,
600
+ error: null
601
+ });
508
602
  const { oldRef, newRef } = resolveReferences(change);
509
- const serverPath =
510
- repoPath.length > 0 ? PathExt.join(repoPath, change.path) : change.path;
511
603
  void (async () => {
512
604
  try {
513
605
  if (change.isBinary === true) {
@@ -861,6 +953,10 @@ function NotebookViewModeControl(props: {
861
953
  content.notebookViewModeChanged.disconnect(handler);
862
954
  };
863
955
  }, [content]);
956
+ if (!content.hasNotebookView) {
957
+ return <></>;
958
+ }
959
+
864
960
  return (
865
961
  <div
866
962
  className="jp-xtralab-DiffWidget-segmented"
package/src/git/icons.ts CHANGED
@@ -6,7 +6,7 @@ import { LabIcon } from '@jupyterlab/ui-components';
6
6
  * recoloring and inverse-color sidebar painting automatically.
7
7
  *
8
8
  * Source: `git-branch` from SVG Repo (https://www.svgrepo.com/svg/377352/git-branch),
9
- * part of the same Pixelarticons family as the Xtralab file browser's `file-tree`
9
+ * part of the same Pixelarticons family as the xtralab file browser's `file-tree`
10
10
  * icon, so the two sidebar tab glyphs read as a matched set. The original
11
11
  * `fill="#000000"` is replaced with `fill="currentColor"` and the root element
12
12
  * is given the `jp-icon3` class so the icon picks up JupyterLab's theme color
package/src/git/index.ts CHANGED
@@ -16,7 +16,11 @@ import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
16
16
  import { ReadonlyPartialJSONObject } from '@lumino/coreutils';
17
17
 
18
18
  import { CommandArguments, CommandIDs, registerGitCommands } from './commands';
19
- import { DiffContentWidget, diffWidgetId } from './diffWidget';
19
+ import {
20
+ DiffContentWidget,
21
+ PREVIEW_DIFF_WIDGET_ID,
22
+ pinnedDiffWidgetId
23
+ } from './diffWidget';
20
24
  import type { MainAreaWidget } from '@jupyterlab/apputils';
21
25
  import { GitPanel } from './panel';
22
26
  import { IFileChange } from './tokens';
@@ -62,15 +66,22 @@ const plugin: JupyterFrontEndPlugin<void> = {
62
66
  });
63
67
 
64
68
  const findDiff = (
65
- change: IFileChange
69
+ change: IFileChange,
70
+ pin = false
66
71
  ): MainAreaWidget<DiffContentWidget> | undefined => {
67
- const id = diffWidgetId(change);
68
- return tracker.find(widget => widget.id === id) ?? undefined;
72
+ const id = pin ? pinnedDiffWidgetId(change) : PREVIEW_DIFF_WIDGET_ID;
73
+ const existing = tracker.find(
74
+ widget => !widget.isDisposed && widget.id === id
75
+ );
76
+ return existing ?? undefined;
69
77
  };
70
78
 
71
79
  const panel = new GitPanel({
72
- openDiff: change => {
80
+ openDiff: (change, options) => {
73
81
  const args: CommandArguments.IOpenDiff = { repoPath, change };
82
+ if (options?.pin === true) {
83
+ args.pin = true;
84
+ }
74
85
  void app.commands.execute(
75
86
  CommandIDs.openDiff,
76
87
  args as unknown as ReadonlyPartialJSONObject
@@ -101,6 +112,21 @@ const plugin: JupyterFrontEndPlugin<void> = {
101
112
  rendermime,
102
113
  onChanged: () => panel.refresh(),
103
114
  trackDiff: widget => tracker.add(widget),
115
+ onPinned: current => {
116
+ const existing = findDiff(current.content.change, true);
117
+ if (
118
+ existing !== undefined &&
119
+ existing !== current &&
120
+ !existing.isDisposed
121
+ ) {
122
+ app.shell.activateById(existing.id);
123
+ current.close();
124
+ return;
125
+ }
126
+ current.id = pinnedDiffWidgetId(current.content.change);
127
+ current.title.className = '';
128
+ app.shell.activateById(current.id);
129
+ },
104
130
  findDiff
105
131
  });
106
132
  }