xtralab 0.1.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/LICENSE +28 -0
- package/README.md +69 -0
- package/lib/fileBrowser/commands.d.ts +62 -0
- package/lib/fileBrowser/commands.js +418 -0
- package/lib/fileBrowser/contents.d.ts +28 -0
- package/lib/fileBrowser/contents.js +48 -0
- package/lib/fileBrowser/fileBrowser.d.ts +24 -0
- package/lib/fileBrowser/fileBrowser.js +535 -0
- package/lib/fileBrowser/icons.d.ts +37 -0
- package/lib/fileBrowser/icons.js +153 -0
- package/lib/fileBrowser/index.d.ts +7 -0
- package/lib/fileBrowser/index.js +38 -0
- package/lib/fileBrowser/widget.d.ts +97 -0
- package/lib/fileBrowser/widget.js +95 -0
- package/lib/git/api.d.ts +39 -0
- package/lib/git/api.js +164 -0
- package/lib/git/commands.d.ts +69 -0
- package/lib/git/commands.js +67 -0
- package/lib/git/diffWidget.d.ts +108 -0
- package/lib/git/diffWidget.js +624 -0
- package/lib/git/icons.d.ts +14 -0
- package/lib/git/icons.js +19 -0
- package/lib/git/index.d.ts +11 -0
- package/lib/git/index.js +74 -0
- package/lib/git/notebookDiff.d.ts +176 -0
- package/lib/git/notebookDiff.js +805 -0
- package/lib/git/panel.d.ts +29 -0
- package/lib/git/panel.js +42 -0
- package/lib/git/panelComponent.d.ts +26 -0
- package/lib/git/panelComponent.js +300 -0
- package/lib/git/tokens.d.ts +78 -0
- package/lib/git/tokens.js +6 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.js +14 -0
- package/package.json +216 -0
- package/schema/plugin.json +67 -0
- package/src/fileBrowser/commands.ts +507 -0
- package/src/fileBrowser/contents.ts +66 -0
- package/src/fileBrowser/fileBrowser.tsx +635 -0
- package/src/fileBrowser/icons.ts +172 -0
- package/src/fileBrowser/index.ts +56 -0
- package/src/fileBrowser/widget.tsx +202 -0
- package/src/git/api.ts +207 -0
- package/src/git/commands.ts +143 -0
- package/src/git/diffWidget.tsx +903 -0
- package/src/git/icons.ts +20 -0
- package/src/git/index.ts +109 -0
- package/src/git/notebookDiff.tsx +1294 -0
- package/src/git/panel.tsx +56 -0
- package/src/git/panelComponent.tsx +558 -0
- package/src/git/tokens.ts +91 -0
- package/src/index.ts +18 -0
- package/style/base.css +175 -0
- package/style/git.css +770 -0
- package/style/index.css +2 -0
- package/style/index.js +2 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { LabIcon, fileIcon } from '@jupyterlab/ui-components';
|
|
2
|
+
import {
|
|
3
|
+
createFileTreeIconResolver,
|
|
4
|
+
getBuiltInFileIconColor,
|
|
5
|
+
getBuiltInSpriteSheet
|
|
6
|
+
} from '@pierre/trees';
|
|
7
|
+
import type { FileTreeIconConfig } from '@pierre/trees';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The glyph used as the Xtralab file browser's sidebar tab icon. Defined as a
|
|
11
|
+
* `LabIcon` (rather than the default `folderIcon` from
|
|
12
|
+
* `@jupyterlab/ui-components`) so the Xtralab browser is visually distinguishable
|
|
13
|
+
* from JupyterLab's built-in file browser, which also lives in the left
|
|
14
|
+
* sidebar.
|
|
15
|
+
*
|
|
16
|
+
* Source: `file-tree` from SVG Repo (https://www.svgrepo.com/svg/371275/file-tree).
|
|
17
|
+
* The original `fill="#444"` is replaced with `fill="currentColor"` and the
|
|
18
|
+
* root element is given the `jp-icon3` class so the icon picks up JupyterLab's
|
|
19
|
+
* theme color and the sidebar's inverse-color painting automatically.
|
|
20
|
+
*/
|
|
21
|
+
export const xtralabFileBrowserIcon = new LabIcon({
|
|
22
|
+
name: 'xtralab:file-browser',
|
|
23
|
+
svgstr: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" class="jp-icon3" fill="currentColor">
|
|
24
|
+
<path d="M16 10v-4h-11v1h-2v-3h9v-4h-12v4h2v10h3v2h11v-4h-11v1h-2v-5h2v2z"/>
|
|
25
|
+
</svg>`
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Sprite sheet injected into the `@pierre/trees` shadow DOM. Contains a single
|
|
30
|
+
* `<symbol>` for the Jupyter notebook icon, drawn from the JupyterLab core
|
|
31
|
+
* notebook icon (`@jupyterlab/ui-components/style/icons/filetype/notebook.svg`).
|
|
32
|
+
*
|
|
33
|
+
* `@pierre/trees` does not ship a Jupyter token, so `.ipynb` files would
|
|
34
|
+
* otherwise fall through to the generic file icon. The symbol id below is
|
|
35
|
+
* referenced from `byFileExtension` in {@link FILE_BROWSER_ICONS}.
|
|
36
|
+
*
|
|
37
|
+
* The orange `#EF6C00` fill is set as a presentation attribute on the inner
|
|
38
|
+
* `<g>` so it survives the `fill: currentColor` rule the tree applies on the
|
|
39
|
+
* icon container — the inherited color does not override a presentation
|
|
40
|
+
* attribute set directly on a child.
|
|
41
|
+
*/
|
|
42
|
+
const JUPYTER_NOTEBOOK_SPRITE_SHEET = `<svg data-icon-sprite aria-hidden="true" width="0" height="0">
|
|
43
|
+
<symbol id="jupyter-notebook" viewBox="0 0 22 22">
|
|
44
|
+
<g fill="#EF6C00">
|
|
45
|
+
<path d="M18.7 3.3v15.4H3.3V3.3zm1.5-1.5H1.8v18.3h18.3z"/>
|
|
46
|
+
<path d="m16.5 16.5-5.4-4.3-5.6 4.3v-11h11z"/>
|
|
47
|
+
</g>
|
|
48
|
+
</symbol>
|
|
49
|
+
</svg>`;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Icon configuration for the Xtralab file browser. Keeps the default `complete`
|
|
53
|
+
* built-in icon set (the colored language icons) and layers a Jupyter notebook
|
|
54
|
+
* symbol on top so `.ipynb` files render with the JupyterLab notebook glyph.
|
|
55
|
+
*
|
|
56
|
+
* `set` must be set explicitly: when a config object includes any custom
|
|
57
|
+
* overrides without `set`, `@pierre/trees` defaults `set` to `'none'`, which
|
|
58
|
+
* disables the built-in icons entirely.
|
|
59
|
+
*/
|
|
60
|
+
export const FILE_BROWSER_ICONS: FileTreeIconConfig = {
|
|
61
|
+
set: 'complete',
|
|
62
|
+
colored: true,
|
|
63
|
+
spriteSheet: JUPYTER_NOTEBOOK_SPRITE_SHEET,
|
|
64
|
+
byFileExtension: {
|
|
65
|
+
ipynb: 'jupyter-notebook'
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Concatenated sprite-sheet text. Combines the built-in sheet for the active
|
|
71
|
+
* icon set with our custom Jupyter notebook sheet so {@link extractSymbol}
|
|
72
|
+
* can find the body of any symbol the tree itself can render.
|
|
73
|
+
*/
|
|
74
|
+
const COMBINED_SPRITE_SHEETS = [
|
|
75
|
+
getBuiltInSpriteSheet(FILE_BROWSER_ICONS.set ?? 'complete'),
|
|
76
|
+
JUPYTER_NOTEBOOK_SPRITE_SHEET
|
|
77
|
+
].join('\n');
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolver instance reused across calls to {@link getTreeIcon}. The resolver
|
|
81
|
+
* is pure relative to the icon config, so building it once at module load
|
|
82
|
+
* avoids re-walking the icon rules on every diff that opens.
|
|
83
|
+
*/
|
|
84
|
+
const treeIconResolver = createFileTreeIconResolver(FILE_BROWSER_ICONS);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Cache of LabIcons keyed by the resolved sprite symbol id (e.g.
|
|
88
|
+
* `file-tree-builtin-python`). All Python files share the same icon, so
|
|
89
|
+
* caching by symbol id prevents repeated LabIcon registration warnings and
|
|
90
|
+
* keeps the JupyterLab icon registry small.
|
|
91
|
+
*/
|
|
92
|
+
const TREE_ICON_CACHE = new Map<string, LabIcon>();
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Extract the inner SVG markup and viewBox of the `<symbol>` with the given
|
|
96
|
+
* id from {@link COMBINED_SPRITE_SHEETS}. Returns `null` if no matching
|
|
97
|
+
* symbol exists, in which case the caller falls back to JupyterLab's default
|
|
98
|
+
* file icon.
|
|
99
|
+
*/
|
|
100
|
+
function extractSymbol(
|
|
101
|
+
symbolId: string
|
|
102
|
+
): { viewBox: string; inner: string } | null {
|
|
103
|
+
// Match a `<symbol>` whose `id` attribute equals `symbolId` (allowing other
|
|
104
|
+
// attributes in any order). The `[\s\S]*?` body is non-greedy so we stop
|
|
105
|
+
// at the first matching `</symbol>` even when multiple symbols sit
|
|
106
|
+
// back-to-back in the sheet.
|
|
107
|
+
const escaped = symbolId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
108
|
+
const symbolRegex = new RegExp(
|
|
109
|
+
`<symbol\\b[^>]*\\bid="${escaped}"[^>]*>([\\s\\S]*?)</symbol>`,
|
|
110
|
+
'i'
|
|
111
|
+
);
|
|
112
|
+
const match = symbolRegex.exec(COMBINED_SPRITE_SHEETS);
|
|
113
|
+
if (match === null) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const viewBoxMatch = /\bviewBox="([^"]*)"/i.exec(match[0]);
|
|
117
|
+
return {
|
|
118
|
+
viewBox: viewBoxMatch?.[1] ?? '0 0 16 16',
|
|
119
|
+
inner: match[1]
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
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.
|
|
128
|
+
*
|
|
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.
|
|
133
|
+
*/
|
|
134
|
+
export function getTreeIcon(filePath: string): LabIcon {
|
|
135
|
+
const resolved = treeIconResolver.resolveIcon(
|
|
136
|
+
'file-tree-icon-file',
|
|
137
|
+
filePath
|
|
138
|
+
);
|
|
139
|
+
const cached = TREE_ICON_CACHE.get(resolved.name);
|
|
140
|
+
if (cached !== undefined) {
|
|
141
|
+
return cached;
|
|
142
|
+
}
|
|
143
|
+
const symbol = extractSymbol(resolved.name);
|
|
144
|
+
if (symbol === null) {
|
|
145
|
+
return fileIcon;
|
|
146
|
+
}
|
|
147
|
+
// Built-in language icons paint with `fill="currentColor"` so the colored
|
|
148
|
+
// tier of the tree can recolor them by setting `color` on the host
|
|
149
|
+
// element. Reproduce that here by setting the same color value as an
|
|
150
|
+
// inline `style="color: …"` on the outer `<svg>`. `getBuiltInFileIconColor`
|
|
151
|
+
// returns a `var(--trees-…)` chain that bottoms out in `light-dark(…)`,
|
|
152
|
+
// which respects the page's `color-scheme` declaration so dark themes pick
|
|
153
|
+
// up the brighter variants automatically.
|
|
154
|
+
const colored = FILE_BROWSER_ICONS.colored === true;
|
|
155
|
+
const color =
|
|
156
|
+
colored && resolved.token !== undefined
|
|
157
|
+
? getBuiltInFileIconColor(resolved.token)
|
|
158
|
+
: undefined;
|
|
159
|
+
const styleAttr = color !== undefined ? ` style="color: ${color};"` : '';
|
|
160
|
+
// Prefer a viewBox the resolver supplies (custom sprite overrides may set
|
|
161
|
+
// one), then fall back to the symbol's own viewBox so 22x22 sprites like
|
|
162
|
+
// the Jupyter notebook glyph render at the right scale rather than being
|
|
163
|
+
// cropped to a 16x16 box.
|
|
164
|
+
const viewBox = resolved.viewBox ?? symbol.viewBox;
|
|
165
|
+
const svgstr = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}"${styleAttr}>${symbol.inner}</svg>`;
|
|
166
|
+
const icon = new LabIcon({
|
|
167
|
+
name: `xtralab:tree-icon-${resolved.name}`,
|
|
168
|
+
svgstr
|
|
169
|
+
});
|
|
170
|
+
TREE_ICON_CACHE.set(resolved.name, icon);
|
|
171
|
+
return icon;
|
|
172
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ILayoutRestorer,
|
|
3
|
+
JupyterFrontEnd,
|
|
4
|
+
JupyterFrontEndPlugin
|
|
5
|
+
} from '@jupyterlab/application';
|
|
6
|
+
|
|
7
|
+
import { IDocumentManager } from '@jupyterlab/docmanager';
|
|
8
|
+
|
|
9
|
+
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
|
10
|
+
|
|
11
|
+
import { populateToolbar, registerCommands } from './commands';
|
|
12
|
+
import { XtralabFileBrowser } from './widget';
|
|
13
|
+
|
|
14
|
+
const PLUGIN_ID = 'xtralab:plugin';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The file browser plugin. Adds a `@pierre/trees`-powered file browser to the
|
|
18
|
+
* JupyterLab left sidebar.
|
|
19
|
+
*/
|
|
20
|
+
const plugin: JupyterFrontEndPlugin<void> = {
|
|
21
|
+
id: PLUGIN_ID,
|
|
22
|
+
description:
|
|
23
|
+
'A path-first file browser for JupyterLab built on @pierre/trees.',
|
|
24
|
+
autoStart: true,
|
|
25
|
+
requires: [IDocumentManager],
|
|
26
|
+
optional: [ILayoutRestorer, ISettingRegistry],
|
|
27
|
+
activate: (
|
|
28
|
+
app: JupyterFrontEnd,
|
|
29
|
+
docManager: IDocumentManager,
|
|
30
|
+
restorer: ILayoutRestorer | null,
|
|
31
|
+
settingRegistry: ISettingRegistry | null
|
|
32
|
+
): void => {
|
|
33
|
+
const browser = new XtralabFileBrowser({
|
|
34
|
+
contentsManager: app.serviceManager.contents,
|
|
35
|
+
docManager,
|
|
36
|
+
onOpenFile: (serverPath: string) => {
|
|
37
|
+
void app.commands.execute('docmanager:open', { path: serverPath });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
app.shell.add(browser, 'left', { rank: 700 });
|
|
41
|
+
if (restorer) {
|
|
42
|
+
restorer.add(browser, browser.id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
registerCommands({ app, browser });
|
|
46
|
+
populateToolbar({ app, browser });
|
|
47
|
+
|
|
48
|
+
if (settingRegistry) {
|
|
49
|
+
settingRegistry.load(PLUGIN_ID).catch(reason => {
|
|
50
|
+
console.error('Failed to load settings for xtralab.', reason);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export default plugin;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import { IDocumentManager } from '@jupyterlab/docmanager';
|
|
4
|
+
import { Contents } from '@jupyterlab/services';
|
|
5
|
+
import { ReactWidget, Toolbar } from '@jupyterlab/ui-components';
|
|
6
|
+
import { Signal, ISignal } from '@lumino/signaling';
|
|
7
|
+
import { PanelLayout, Widget } from '@lumino/widgets';
|
|
8
|
+
|
|
9
|
+
import { FileBrowserComponent } from './fileBrowser';
|
|
10
|
+
import { xtralabFileBrowserIcon } from './icons';
|
|
11
|
+
|
|
12
|
+
export const FILE_BROWSER_ID = 'xtralab:file-browser';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The CSS class added to the Xtralab file browser widget. The selectors that
|
|
16
|
+
* bind application context menu items hang off this class, so it must remain
|
|
17
|
+
* specific enough not to clash with other browsers (such as the default
|
|
18
|
+
* `jp-FileBrowser`).
|
|
19
|
+
*/
|
|
20
|
+
export const FILE_BROWSER_CSS_CLASS = 'jp-xtralab-FileBrowser';
|
|
21
|
+
|
|
22
|
+
const TOOLBAR_CSS_CLASS = 'jp-xtralab-FileBrowser-toolbar';
|
|
23
|
+
const CONTENT_CSS_CLASS = 'jp-xtralab-FileBrowser-content';
|
|
24
|
+
|
|
25
|
+
export interface IXtralabFileBrowserOptions {
|
|
26
|
+
contentsManager: Contents.IManager;
|
|
27
|
+
docManager: IDocumentManager;
|
|
28
|
+
onOpenFile?: (serverPath: string) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Public API of the Xtralab file browser used by command handlers and other
|
|
33
|
+
* collaborators. Kept narrow on purpose so the React component stays free to
|
|
34
|
+
* evolve the underlying tree integration.
|
|
35
|
+
*/
|
|
36
|
+
export interface IXtralabFileBrowser {
|
|
37
|
+
/**
|
|
38
|
+
* The contents manager backing the tree. Exposed so command handlers can
|
|
39
|
+
* issue contents operations (rename, delete, copy, …) against the same
|
|
40
|
+
* drive the tree was loaded from.
|
|
41
|
+
*/
|
|
42
|
+
readonly contentsManager: Contents.IManager;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The toolbar shown above the tree. Plugin code populates it with command
|
|
46
|
+
* buttons after the widget is constructed.
|
|
47
|
+
*/
|
|
48
|
+
readonly toolbar: Toolbar;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Canonical paths of the items currently selected in the tree. Folder
|
|
52
|
+
* paths carry a trailing slash; file paths do not.
|
|
53
|
+
*/
|
|
54
|
+
readonly selectedPaths: readonly string[];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Emits when the tree's selection changes. Commands that have an
|
|
58
|
+
* `isVisible` / `isEnabled` predicate should listen so they re-evaluate.
|
|
59
|
+
*/
|
|
60
|
+
readonly selectionChanged: ISignal<IXtralabFileBrowser, readonly string[]>;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Emits when {@link refresh} is called. The React component listens and
|
|
64
|
+
* re-fetches the directories that were previously loaded so the tree
|
|
65
|
+
* matches the contents on disk again.
|
|
66
|
+
*/
|
|
67
|
+
readonly refreshRequested: ISignal<IXtralabFileBrowser, void>;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Emits when the widget is asked to surface a newly-created path
|
|
71
|
+
* (for example, after the toolbar's "new folder" command). The React
|
|
72
|
+
* component listens and inserts the path into the model so the user
|
|
73
|
+
* sees the new item without a full refresh.
|
|
74
|
+
*/
|
|
75
|
+
readonly pathAdded: ISignal<IXtralabFileBrowser, string>;
|
|
76
|
+
|
|
77
|
+
/** Trigger a refresh of every loaded directory in the tree. */
|
|
78
|
+
refresh(): void;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Notify the React component that a new path was created and should
|
|
82
|
+
* appear in the tree. `canonicalPath` follows the `@pierre/trees`
|
|
83
|
+
* convention: directories carry a trailing slash, files do not.
|
|
84
|
+
*/
|
|
85
|
+
notifyPathAdded(canonicalPath: string): void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Lumino widget that hosts the React-based `@pierre/trees` file browser.
|
|
90
|
+
* The widget is laid out with a JupyterLab `Toolbar` on top and the tree
|
|
91
|
+
* filling the remaining space.
|
|
92
|
+
*/
|
|
93
|
+
export class XtralabFileBrowser extends Widget implements IXtralabFileBrowser {
|
|
94
|
+
constructor(options: IXtralabFileBrowserOptions) {
|
|
95
|
+
super();
|
|
96
|
+
const layout = (this.layout = new PanelLayout());
|
|
97
|
+
|
|
98
|
+
this._contentsManager = options.contentsManager;
|
|
99
|
+
this._docManager = options.docManager;
|
|
100
|
+
this._onOpenFile = options.onOpenFile;
|
|
101
|
+
this.id = FILE_BROWSER_ID;
|
|
102
|
+
this.title.icon = xtralabFileBrowserIcon;
|
|
103
|
+
this.title.caption = 'Xtralab File Browser';
|
|
104
|
+
this.addClass(FILE_BROWSER_CSS_CLASS);
|
|
105
|
+
|
|
106
|
+
this._toolbar = new Toolbar();
|
|
107
|
+
this._toolbar.addClass(TOOLBAR_CSS_CLASS);
|
|
108
|
+
this._toolbar.node.setAttribute('aria-label', 'file browser');
|
|
109
|
+
layout.addWidget(this._toolbar);
|
|
110
|
+
|
|
111
|
+
this._content = new XtralabFileTreeContent({
|
|
112
|
+
contentsManager: this._contentsManager,
|
|
113
|
+
docManager: this._docManager,
|
|
114
|
+
onOpenFile: this._onOpenFile,
|
|
115
|
+
browser: this
|
|
116
|
+
});
|
|
117
|
+
this._content.addClass(CONTENT_CSS_CLASS);
|
|
118
|
+
layout.addWidget(this._content);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
get contentsManager(): Contents.IManager {
|
|
122
|
+
return this._contentsManager;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
get toolbar(): Toolbar {
|
|
126
|
+
return this._toolbar;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
get selectedPaths(): readonly string[] {
|
|
130
|
+
return this._selectedPaths;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
get selectionChanged(): ISignal<this, readonly string[]> {
|
|
134
|
+
return this._selectionChanged;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
get refreshRequested(): ISignal<this, void> {
|
|
138
|
+
return this._refreshRequested;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
get pathAdded(): ISignal<this, string> {
|
|
142
|
+
return this._pathAdded;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Update the cached selection. Called from the React tree when the
|
|
147
|
+
* underlying `@pierre/trees` model emits a selection change.
|
|
148
|
+
*/
|
|
149
|
+
updateSelection(paths: readonly string[]): void {
|
|
150
|
+
this._selectedPaths = paths;
|
|
151
|
+
this._selectionChanged.emit(paths);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
refresh(): void {
|
|
155
|
+
this._refreshRequested.emit();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
notifyPathAdded(canonicalPath: string): void {
|
|
159
|
+
this._pathAdded.emit(canonicalPath);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private _contentsManager: Contents.IManager;
|
|
163
|
+
private _docManager: IDocumentManager;
|
|
164
|
+
private _onOpenFile: ((serverPath: string) => void) | undefined;
|
|
165
|
+
private _selectedPaths: readonly string[] = [];
|
|
166
|
+
private _selectionChanged = new Signal<this, readonly string[]>(this);
|
|
167
|
+
private _refreshRequested = new Signal<this, void>(this);
|
|
168
|
+
private _pathAdded = new Signal<this, string>(this);
|
|
169
|
+
private _toolbar: Toolbar;
|
|
170
|
+
private _content: XtralabFileTreeContent;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
interface IFileTreeContentOptions {
|
|
174
|
+
contentsManager: Contents.IManager;
|
|
175
|
+
docManager: IDocumentManager;
|
|
176
|
+
onOpenFile?: (serverPath: string) => void;
|
|
177
|
+
browser: XtralabFileBrowser;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The React-rendered region of the file browser. Lives inside the host
|
|
182
|
+
* widget below the toolbar and renders the actual tree.
|
|
183
|
+
*/
|
|
184
|
+
class XtralabFileTreeContent extends ReactWidget {
|
|
185
|
+
constructor(options: IFileTreeContentOptions) {
|
|
186
|
+
super();
|
|
187
|
+
this._options = options;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
protected render(): React.ReactElement {
|
|
191
|
+
return (
|
|
192
|
+
<FileBrowserComponent
|
|
193
|
+
contentsManager={this._options.contentsManager}
|
|
194
|
+
docManager={this._options.docManager}
|
|
195
|
+
onOpenFile={this._options.onOpenFile}
|
|
196
|
+
widget={this._options.browser}
|
|
197
|
+
/>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private _options: IFileTreeContentOptions;
|
|
202
|
+
}
|
package/src/git/api.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { URLExt } from '@jupyterlab/coreutils';
|
|
2
|
+
import { ServerConnection } from '@jupyterlab/services';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
GitReference,
|
|
6
|
+
IFileChange,
|
|
7
|
+
IGitContentResult,
|
|
8
|
+
IGitStatusFile,
|
|
9
|
+
IGitStatusResult
|
|
10
|
+
} from './tokens';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Root URL segment used by the `jupyterlab_git` server extension. Every
|
|
14
|
+
* endpoint hangs off this prefix; the `<repoPath>` between it and the
|
|
15
|
+
* endpoint suffix is the JupyterLab server-relative path of the git
|
|
16
|
+
* repository (URL-encoded). For an empty `repoPath` the URL collapses to
|
|
17
|
+
* `/git/<endpoint>` — a shape git's URL routing also accepts.
|
|
18
|
+
*/
|
|
19
|
+
const GIT_NAMESPACE = 'git';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Issue a `POST` against a `jupyterlab_git` endpoint that takes a `<path>`
|
|
23
|
+
* prefix (e.g. `/git/<path>/status`). The response body is decoded as JSON;
|
|
24
|
+
* non-2xx responses are surfaced via the JupyterLab `ServerConnection`
|
|
25
|
+
* machinery so callers can show their `message` field as-is.
|
|
26
|
+
*/
|
|
27
|
+
async function postWithPath<T>(
|
|
28
|
+
endpoint: string,
|
|
29
|
+
repoPath: string,
|
|
30
|
+
body: unknown
|
|
31
|
+
): Promise<T> {
|
|
32
|
+
const settings = ServerConnection.makeSettings();
|
|
33
|
+
// `URLExt.encodeParts` encodes each path segment but preserves slashes
|
|
34
|
+
// between them — the server expects the path to look like a posix path.
|
|
35
|
+
const encodedPath = repoPath.length > 0 ? URLExt.encodeParts(repoPath) : '';
|
|
36
|
+
const url = URLExt.join(
|
|
37
|
+
settings.baseUrl,
|
|
38
|
+
GIT_NAMESPACE,
|
|
39
|
+
encodedPath,
|
|
40
|
+
endpoint
|
|
41
|
+
);
|
|
42
|
+
const init: RequestInit = {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
body: JSON.stringify(body ?? {})
|
|
45
|
+
};
|
|
46
|
+
const response = await ServerConnection.makeRequest(url, init, settings);
|
|
47
|
+
const text = await response.text();
|
|
48
|
+
let data: T | undefined;
|
|
49
|
+
if (text.length > 0) {
|
|
50
|
+
try {
|
|
51
|
+
data = JSON.parse(text) as T;
|
|
52
|
+
} catch (_err) {
|
|
53
|
+
// Fall through into the error handling below.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
const message =
|
|
58
|
+
(data as { message?: string } | undefined)?.message ?? text ?? '';
|
|
59
|
+
throw new ServerConnection.ResponseError(response, message);
|
|
60
|
+
}
|
|
61
|
+
return (data ?? ({} as T)) as T;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Fetch the absolute filesystem path of the git top-level for the given
|
|
66
|
+
* server-relative path. Returns `null` when the path is not in a git
|
|
67
|
+
* repository — the server returns a non-zero `code` (or `path: null`) in
|
|
68
|
+
* that case, which is expected and not a fatal error.
|
|
69
|
+
*/
|
|
70
|
+
export async function showTopLevel(repoPath: string): Promise<string | null> {
|
|
71
|
+
interface IShowTopLevelResult {
|
|
72
|
+
code: number;
|
|
73
|
+
path?: string | null;
|
|
74
|
+
message?: string;
|
|
75
|
+
}
|
|
76
|
+
let result: IShowTopLevelResult;
|
|
77
|
+
try {
|
|
78
|
+
result = await postWithPath<IShowTopLevelResult>(
|
|
79
|
+
'show_top_level',
|
|
80
|
+
repoPath,
|
|
81
|
+
{}
|
|
82
|
+
);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (result.code !== 0 || !result.path) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return result.path;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Fetch `git status --porcelain` for the repository the server resolves at
|
|
94
|
+
* `repoPath`. When `repoPath` is the empty string, git resolves the repo
|
|
95
|
+
* from the JupyterLab server's root directory.
|
|
96
|
+
*/
|
|
97
|
+
export async function status(repoPath: string): Promise<IGitStatusResult> {
|
|
98
|
+
return postWithPath<IGitStatusResult>('status', repoPath, {});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Fetch a file's contents at a particular git reference. `filename` is the
|
|
103
|
+
* file's path relative to the git repository root (matching the `to` field
|
|
104
|
+
* of {@link status}'s response).
|
|
105
|
+
*/
|
|
106
|
+
export async function content(
|
|
107
|
+
repoPath: string,
|
|
108
|
+
filename: string,
|
|
109
|
+
reference: GitReference
|
|
110
|
+
): Promise<IGitContentResult> {
|
|
111
|
+
return postWithPath<IGitContentResult>('content', repoPath, {
|
|
112
|
+
filename,
|
|
113
|
+
reference
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Stage a single file. */
|
|
118
|
+
export async function add(repoPath: string, filename: string): Promise<void> {
|
|
119
|
+
await postWithPath('add', repoPath, { add_all: false, filename });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Stage every changed file (including untracked). */
|
|
123
|
+
export async function addAll(repoPath: string): Promise<void> {
|
|
124
|
+
await postWithPath('add', repoPath, { add_all: true });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Unstage a single file. */
|
|
128
|
+
export async function reset(repoPath: string, filename: string): Promise<void> {
|
|
129
|
+
await postWithPath('reset', repoPath, { reset_all: false, filename });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Unstage every file. */
|
|
133
|
+
export async function resetAll(repoPath: string): Promise<void> {
|
|
134
|
+
await postWithPath('reset', repoPath, { reset_all: true });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Restore a single file to the version in the index (i.e. discard worktree changes). */
|
|
138
|
+
export async function checkout(
|
|
139
|
+
repoPath: string,
|
|
140
|
+
filename: string
|
|
141
|
+
): Promise<void> {
|
|
142
|
+
await postWithPath('checkout', repoPath, {
|
|
143
|
+
checkout_branch: false,
|
|
144
|
+
filename
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Translate one entry in the porcelain `files` array into the panel's
|
|
150
|
+
* preferred shape. A single porcelain entry may represent up to two changes
|
|
151
|
+
* (one in the index, one in the worktree); we emit one {@link IFileChange}
|
|
152
|
+
* per non-empty side, so a "modified-staged-and-then-modified-again" file
|
|
153
|
+
* shows up twice in the panel — once under "Staged Changes" and once under
|
|
154
|
+
* "Changes" — matching the layout the user sees in VS Code.
|
|
155
|
+
*/
|
|
156
|
+
export function porcelainToFileChanges(file: IGitStatusFile): IFileChange[] {
|
|
157
|
+
const result: IFileChange[] = [];
|
|
158
|
+
// Untracked files are reported with `??` and only appear under "Changes".
|
|
159
|
+
if (file.x === '?' && file.y === '?') {
|
|
160
|
+
return [
|
|
161
|
+
{
|
|
162
|
+
path: file.to,
|
|
163
|
+
group: 'unstaged',
|
|
164
|
+
status: 'untracked',
|
|
165
|
+
isBinary: file.is_binary
|
|
166
|
+
}
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
if (file.x !== ' ' && file.x !== '?') {
|
|
170
|
+
result.push({
|
|
171
|
+
path: file.to,
|
|
172
|
+
from: file.from === file.to ? undefined : file.from,
|
|
173
|
+
group: 'staged',
|
|
174
|
+
status: porcelainCodeToStatus(file.x),
|
|
175
|
+
isBinary: file.is_binary
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
if (file.y !== ' ' && file.y !== '?') {
|
|
179
|
+
result.push({
|
|
180
|
+
path: file.to,
|
|
181
|
+
from: file.from === file.to ? undefined : file.from,
|
|
182
|
+
group: 'unstaged',
|
|
183
|
+
status: porcelainCodeToStatus(file.y),
|
|
184
|
+
isBinary: file.is_binary
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function porcelainCodeToStatus(code: string): IFileChange['status'] {
|
|
191
|
+
switch (code) {
|
|
192
|
+
case 'M':
|
|
193
|
+
return 'modified';
|
|
194
|
+
case 'A':
|
|
195
|
+
return 'added';
|
|
196
|
+
case 'D':
|
|
197
|
+
return 'deleted';
|
|
198
|
+
case 'R':
|
|
199
|
+
return 'renamed';
|
|
200
|
+
case 'U':
|
|
201
|
+
return 'unmerged';
|
|
202
|
+
case 'T':
|
|
203
|
+
return 'typechange';
|
|
204
|
+
default:
|
|
205
|
+
return 'unknown';
|
|
206
|
+
}
|
|
207
|
+
}
|