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.
Files changed (56) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +69 -0
  3. package/lib/fileBrowser/commands.d.ts +62 -0
  4. package/lib/fileBrowser/commands.js +418 -0
  5. package/lib/fileBrowser/contents.d.ts +28 -0
  6. package/lib/fileBrowser/contents.js +48 -0
  7. package/lib/fileBrowser/fileBrowser.d.ts +24 -0
  8. package/lib/fileBrowser/fileBrowser.js +535 -0
  9. package/lib/fileBrowser/icons.d.ts +37 -0
  10. package/lib/fileBrowser/icons.js +153 -0
  11. package/lib/fileBrowser/index.d.ts +7 -0
  12. package/lib/fileBrowser/index.js +38 -0
  13. package/lib/fileBrowser/widget.d.ts +97 -0
  14. package/lib/fileBrowser/widget.js +95 -0
  15. package/lib/git/api.d.ts +39 -0
  16. package/lib/git/api.js +164 -0
  17. package/lib/git/commands.d.ts +69 -0
  18. package/lib/git/commands.js +67 -0
  19. package/lib/git/diffWidget.d.ts +108 -0
  20. package/lib/git/diffWidget.js +624 -0
  21. package/lib/git/icons.d.ts +14 -0
  22. package/lib/git/icons.js +19 -0
  23. package/lib/git/index.d.ts +11 -0
  24. package/lib/git/index.js +74 -0
  25. package/lib/git/notebookDiff.d.ts +176 -0
  26. package/lib/git/notebookDiff.js +805 -0
  27. package/lib/git/panel.d.ts +29 -0
  28. package/lib/git/panel.js +42 -0
  29. package/lib/git/panelComponent.d.ts +26 -0
  30. package/lib/git/panelComponent.js +300 -0
  31. package/lib/git/tokens.d.ts +78 -0
  32. package/lib/git/tokens.js +6 -0
  33. package/lib/index.d.ts +10 -0
  34. package/lib/index.js +14 -0
  35. package/package.json +216 -0
  36. package/schema/plugin.json +67 -0
  37. package/src/fileBrowser/commands.ts +507 -0
  38. package/src/fileBrowser/contents.ts +66 -0
  39. package/src/fileBrowser/fileBrowser.tsx +635 -0
  40. package/src/fileBrowser/icons.ts +172 -0
  41. package/src/fileBrowser/index.ts +56 -0
  42. package/src/fileBrowser/widget.tsx +202 -0
  43. package/src/git/api.ts +207 -0
  44. package/src/git/commands.ts +143 -0
  45. package/src/git/diffWidget.tsx +903 -0
  46. package/src/git/icons.ts +20 -0
  47. package/src/git/index.ts +109 -0
  48. package/src/git/notebookDiff.tsx +1294 -0
  49. package/src/git/panel.tsx +56 -0
  50. package/src/git/panelComponent.tsx +558 -0
  51. package/src/git/tokens.ts +91 -0
  52. package/src/index.ts +18 -0
  53. package/style/base.css +175 -0
  54. package/style/git.css +770 -0
  55. package/style/index.css +2 -0
  56. 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
+ }