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.
- package/README.md +129 -46
- package/lib/fileBrowser/commands.d.ts +2 -2
- package/lib/fileBrowser/commands.js +2 -2
- package/lib/fileBrowser/fileBrowser.js +72 -2
- package/lib/fileBrowser/gitignore.d.ts +29 -0
- package/lib/fileBrowser/gitignore.js +74 -0
- package/lib/fileBrowser/icons.d.ts +4 -4
- package/lib/fileBrowser/icons.js +4 -4
- package/lib/fileBrowser/widget.d.ts +2 -2
- package/lib/fileBrowser/widget.js +2 -2
- package/lib/git/api.d.ts +11 -0
- package/lib/git/api.js +19 -0
- package/lib/git/commands.d.ts +3 -1
- package/lib/git/commands.js +10 -4
- package/lib/git/diffWidget.d.ts +11 -0
- package/lib/git/diffWidget.js +90 -9
- package/lib/git/icons.d.ts +1 -1
- package/lib/git/icons.js +1 -1
- package/lib/git/index.js +22 -6
- package/lib/git/panelComponent.d.ts +3 -1
- package/lib/git/panelComponent.js +6 -3
- package/lib/index.js +9 -1
- package/lib/launcher/agents.d.ts +78 -0
- package/lib/launcher/agents.js +168 -0
- package/lib/launcher/availability.d.ts +12 -0
- package/lib/launcher/availability.js +53 -0
- package/lib/launcher/commands.d.ts +22 -0
- package/lib/launcher/commands.js +89 -0
- package/lib/launcher/dashboard.d.ts +46 -0
- package/lib/launcher/dashboard.js +214 -0
- package/lib/launcher/icons.d.ts +33 -0
- package/lib/launcher/icons.js +113 -0
- package/lib/launcher/index.d.ts +23 -0
- package/lib/launcher/index.js +191 -0
- package/lib/launcher/invocation.d.ts +29 -0
- package/lib/launcher/invocation.js +42 -0
- package/lib/menus/index.d.ts +14 -0
- package/lib/menus/index.js +102 -0
- package/lib/sidebar/index.d.ts +18 -0
- package/lib/sidebar/index.js +155 -0
- package/lib/statusBar/index.d.ts +14 -0
- package/lib/statusBar/index.js +89 -0
- package/lib/statusBar/widget.d.ts +121 -0
- package/lib/statusBar/widget.js +237 -0
- package/package.json +25 -3
- package/schema/launcher.json +70 -0
- package/schema/sidebar.json +57 -0
- package/src/fileBrowser/commands.ts +2 -2
- package/src/fileBrowser/fileBrowser.tsx +76 -2
- package/src/fileBrowser/gitignore.ts +86 -0
- package/src/fileBrowser/icons.ts +4 -4
- package/src/fileBrowser/widget.tsx +3 -3
- package/src/git/api.ts +20 -0
- package/src/git/commands.ts +19 -5
- package/src/git/diffWidget.tsx +109 -13
- package/src/git/icons.ts +1 -1
- package/src/git/index.ts +31 -5
- package/src/git/panelComponent.tsx +17 -5
- package/src/index.ts +9 -1
- package/src/launcher/agents.ts +248 -0
- package/src/launcher/availability.ts +73 -0
- package/src/launcher/commands.ts +103 -0
- package/src/launcher/dashboard.tsx +462 -0
- package/src/launcher/icons.ts +123 -0
- package/src/launcher/index.ts +228 -0
- package/src/launcher/invocation.ts +45 -0
- package/src/menus/index.ts +124 -0
- package/src/sidebar/index.ts +195 -0
- package/src/statusBar/index.ts +115 -0
- package/src/statusBar/widget.tsx +318 -0
- package/style/git.css +4 -8
- package/style/index.css +2 -0
- package/style/index.js +2 -0
- package/style/launcher.css +323 -0
- 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
|
|
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
|
+
}
|
package/src/fileBrowser/icons.ts
CHANGED
|
@@ -7,9 +7,9 @@ import {
|
|
|
7
7
|
import type { FileTreeIconConfig } from '@pierre/trees';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* The glyph used as the
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 = '
|
|
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
|
package/src/git/commands.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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' });
|
package/src/git/diffWidget.tsx
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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,
|
|
230
|
-
//
|
|
231
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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 {
|
|
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 =
|
|
68
|
-
|
|
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
|
}
|