xtralab 0.4.0 → 0.5.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.
@@ -1,320 +0,0 @@
1
- import * as React from 'react';
2
- import { Poll } from '@lumino/polling';
3
- import { add, addAll, checkout, porcelainToFileChanges, reset, resetAll, showTopLevel, status } from './api';
4
- /**
5
- * The polling interval for the panel. We refresh the status periodically so
6
- * out-of-band changes (e.g. the user runs `git add` from a terminal) become
7
- * visible without requiring a manual refresh.
8
- */
9
- const STATUS_POLL_INTERVAL_MS = 5000;
10
- /**
11
- * Upper bound on the poll's exponential backoff when status requests fail
12
- * repeatedly. Matches the default JupyterLab file browser's `max`: long
13
- * enough that a server outage stops hammering the API, short enough that
14
- * a transient failure heals on its own.
15
- */
16
- const STATUS_POLL_MAX_MS = 300000;
17
- /**
18
- * Top-level React component rendered inside the {@link GitPanel} widget.
19
- * Owns the polling + refresh wiring around the `/git/status` endpoint and
20
- * the small bit of UI state that controls section collapse and the
21
- * in-flight per-file action spinners.
22
- */
23
- export function GitPanelComponent(props) {
24
- const { handlers, refreshSignal } = props;
25
- const [state, setState] = React.useState({
26
- loading: true,
27
- result: null,
28
- error: null
29
- });
30
- // The absolute filesystem path of the resolved git top-level. Used purely
31
- // for display in the panel header.
32
- const [topLevel, setTopLevel] = React.useState(null);
33
- // Per-action spinner gating: keyed by `${group}:${path}`, value is the
34
- // action currently in flight. Lets the row render a disabled state on
35
- // exactly the file that is being mutated, without locking the whole panel.
36
- const [pendingActions, setPendingActions] = React.useState({});
37
- const [stagedCollapsed, setStagedCollapsed] = React.useState(false);
38
- const [changesCollapsed, setChangesCollapsed] = React.useState(false);
39
- // The repo path passed to every API call. We use the JupyterLab server's
40
- // root (the empty string) and let `git` resolve the enclosing repo on the
41
- // server side. This works as long as the server was started inside a git
42
- // repo — the common case.
43
- const repoPath = '';
44
- const refresh = React.useCallback(async () => {
45
- var _a;
46
- try {
47
- const [topLevelResult, statusResult] = await Promise.all([
48
- showTopLevel(repoPath),
49
- status(repoPath)
50
- ]);
51
- setTopLevel(topLevelResult);
52
- if (statusResult.code !== 0) {
53
- setState({
54
- loading: false,
55
- result: null,
56
- error: (_a = statusResult.message) !== null && _a !== void 0 ? _a : 'Failed to load git status'
57
- });
58
- return;
59
- }
60
- setState({ loading: false, result: statusResult, error: null });
61
- }
62
- catch (err) {
63
- setState({
64
- loading: false,
65
- result: null,
66
- error: err instanceof Error ? err.message : String(err)
67
- });
68
- }
69
- }, [repoPath]);
70
- // Initial load + polling. Driven by a Lumino `Poll` so the panel stops
71
- // hitting the git endpoint while the JupyterLab tab is hidden, and so
72
- // repeated failures back off exponentially instead of spinning at 5s.
73
- // External refresh triggers (toolbar button, plugin-level events) call
74
- // `poll.refresh()` to schedule an immediate tick instead of running
75
- // `refresh()` directly — that keeps the panel from issuing two parallel
76
- // status requests when an automatic tick happens to land at the same
77
- // time as an external trigger.
78
- React.useEffect(() => {
79
- const poll = new Poll({
80
- name: '@xtralab/git:panelStatus',
81
- factory: () => refresh(),
82
- frequency: {
83
- interval: STATUS_POLL_INTERVAL_MS,
84
- backoff: true,
85
- max: STATUS_POLL_MAX_MS
86
- },
87
- standby: 'when-hidden'
88
- });
89
- const handler = () => {
90
- void poll.refresh();
91
- };
92
- refreshSignal.connect(handler);
93
- return () => {
94
- refreshSignal.disconnect(handler);
95
- poll.dispose();
96
- };
97
- }, [refresh, refreshSignal]);
98
- const runAction = React.useCallback(async (change, action, run) => {
99
- const key = `${change.group}:${change.path}`;
100
- setPendingActions(prev => ({ ...prev, [key]: action }));
101
- try {
102
- await run();
103
- await refresh();
104
- }
105
- catch (err) {
106
- await handlers.showError('Git operation failed', err instanceof Error ? err : new Error(String(err)));
107
- }
108
- finally {
109
- setPendingActions(prev => {
110
- const next = { ...prev };
111
- delete next[key];
112
- return next;
113
- });
114
- }
115
- }, [handlers, refresh]);
116
- const onStage = React.useCallback((change) => runAction(change, 'stage', () => add(repoPath, change.path)), [repoPath, runAction]);
117
- const onUnstage = React.useCallback((change) => runAction(change, 'unstage', () => reset(repoPath, change.path)), [repoPath, runAction]);
118
- const onDiscard = React.useCallback(async (change) => {
119
- const verb = change.status === 'untracked' ? 'remove' : 'restore';
120
- const accepted = await handlers.confirm(verb === 'remove' ? 'Remove file' : 'Discard changes', verb === 'remove'
121
- ? `Permanently remove ${change.path}? This cannot be undone.`
122
- : `Discard all changes to ${change.path}? This will revert the file to its last staged version and cannot be undone.`, verb === 'remove' ? 'Remove' : 'Discard');
123
- if (!accepted) {
124
- return;
125
- }
126
- // The git extension's `checkout` endpoint without a branch reverts the
127
- // working tree copy of the file to the index. For untracked files we
128
- // can't checkout, so the user is told the action is unsupported and we
129
- // bail out — they can delete the file via the file browser instead.
130
- if (change.status === 'untracked') {
131
- await handlers.showError('Cannot discard untracked file', new Error('Untracked files cannot be discarded from the git panel — delete them from the file browser instead.'));
132
- return;
133
- }
134
- await runAction(change, 'discard', () => checkout(repoPath, change.path));
135
- }, [handlers, repoPath, runAction]);
136
- const onStageAll = React.useCallback(async () => {
137
- try {
138
- await addAll(repoPath);
139
- await refresh();
140
- }
141
- catch (err) {
142
- await handlers.showError('Stage all failed', err instanceof Error ? err : new Error(String(err)));
143
- }
144
- }, [handlers, refresh, repoPath]);
145
- const onUnstageAll = React.useCallback(async () => {
146
- try {
147
- await resetAll(repoPath);
148
- await refresh();
149
- }
150
- catch (err) {
151
- await handlers.showError('Unstage all failed', err instanceof Error ? err : new Error(String(err)));
152
- }
153
- }, [handlers, refresh, repoPath]);
154
- // Categorize the porcelain entries into the two visible groups. Because
155
- // `porcelainToFileChanges` may emit two changes per file (one staged, one
156
- // unstaged), the same file can show up in both lists when it has been
157
- // partially staged.
158
- const { staged, unstaged } = React.useMemo(() => {
159
- const stagedAcc = [];
160
- const unstagedAcc = [];
161
- if (state.result === null) {
162
- return { staged: stagedAcc, unstaged: unstagedAcc };
163
- }
164
- for (const file of state.result.files) {
165
- for (const change of porcelainToFileChanges(file)) {
166
- if (change.group === 'staged') {
167
- stagedAcc.push(change);
168
- }
169
- else {
170
- unstagedAcc.push(change);
171
- }
172
- }
173
- }
174
- stagedAcc.sort((a, b) => a.path.localeCompare(b.path));
175
- unstagedAcc.sort((a, b) => a.path.localeCompare(b.path));
176
- return { staged: stagedAcc, unstaged: unstagedAcc };
177
- }, [state.result]);
178
- return (React.createElement("div", { className: "jp-xtralab-GitPanel-content" },
179
- React.createElement(PanelHeader, { topLevel: topLevel, result: state.result, loading: state.loading, error: state.error, onRefresh: () => void refresh() }),
180
- state.error !== null ? (React.createElement("div", { className: "jp-xtralab-GitPanel-error" }, state.error)) : (React.createElement(React.Fragment, null,
181
- React.createElement(ChangeSection, { label: "Staged Changes", count: staged.length, collapsed: stagedCollapsed, onToggleCollapsed: () => setStagedCollapsed(c => !c), actions: staged.length > 0
182
- ? [
183
- {
184
- label: 'Unstage all changes',
185
- symbol: '−',
186
- onClick: onUnstageAll
187
- }
188
- ]
189
- : undefined }, staged.map(change => (React.createElement(ChangeRow, { key: `${change.group}:${change.path}`, change: change, pendingAction: pendingActions[`${change.group}:${change.path}`], onClick: event => handlers.openDiff(change, {
190
- pin: event.ctrlKey || event.metaKey
191
- }), actions: [
192
- {
193
- label: 'Unstage changes',
194
- symbol: '−',
195
- onClick: () => void onUnstage(change)
196
- }
197
- ] })))),
198
- React.createElement(ChangeSection, { label: "Changes", count: unstaged.length, collapsed: changesCollapsed, onToggleCollapsed: () => setChangesCollapsed(c => !c), actions: unstaged.length > 0
199
- ? [
200
- {
201
- label: 'Stage all changes',
202
- symbol: '+',
203
- onClick: onStageAll
204
- }
205
- ]
206
- : undefined }, unstaged.map(change => (React.createElement(ChangeRow, { key: `${change.group}:${change.path}`, change: change, pendingAction: pendingActions[`${change.group}:${change.path}`], onClick: event => handlers.openDiff(change, {
207
- pin: event.ctrlKey || event.metaKey
208
- }), actions: [
209
- {
210
- label: 'Discard changes',
211
- symbol: '⟲',
212
- onClick: () => void onDiscard(change)
213
- },
214
- {
215
- label: 'Stage changes',
216
- symbol: '+',
217
- onClick: () => void onStage(change)
218
- }
219
- ] })))),
220
- staged.length === 0 && unstaged.length === 0 && !state.loading ? (React.createElement("div", { className: "jp-xtralab-GitPanel-empty" }, "No changes detected.")) : null))));
221
- }
222
- function PanelHeader(props) {
223
- var _a;
224
- const { topLevel, result, loading, error, onRefresh } = props;
225
- const repoName = topLevel !== null
226
- ? ((_a = topLevel
227
- .split('/')
228
- .filter(s => s.length > 0)
229
- .pop()) !== null && _a !== void 0 ? _a : topLevel)
230
- : null;
231
- return (React.createElement("div", { className: "jp-xtralab-GitPanel-header" },
232
- React.createElement("div", { className: "jp-xtralab-GitPanel-headerInfo" },
233
- repoName !== null ? (React.createElement("div", { className: "jp-xtralab-GitPanel-repo", title: topLevel !== null && topLevel !== void 0 ? topLevel : '' }, repoName)) : null,
234
- (result === null || result === void 0 ? void 0 : result.branch) !== undefined && (result === null || result === void 0 ? void 0 : result.branch) !== null ? (React.createElement("div", { className: "jp-xtralab-GitPanel-branch" },
235
- result.branch,
236
- result.ahead > 0 ? React.createElement("span", null,
237
- " \u2191",
238
- result.ahead) : null,
239
- result.behind > 0 ? React.createElement("span", null,
240
- " \u2193",
241
- result.behind) : null)) : null),
242
- React.createElement("button", { type: "button", className: "jp-xtralab-GitPanel-iconButton", title: "Refresh", disabled: loading && error === null, onClick: onRefresh }, "\u27F3")));
243
- }
244
- function ChangeSection(props) {
245
- const { label, count, collapsed, onToggleCollapsed, actions, children } = props;
246
- return (React.createElement("div", { className: "jp-xtralab-GitPanel-section" },
247
- React.createElement("div", { className: "jp-xtralab-GitPanel-sectionHeader", onClick: onToggleCollapsed },
248
- React.createElement("span", { className: "jp-xtralab-GitPanel-sectionChevron" }, collapsed ? '▸' : '▾'),
249
- React.createElement("span", { className: "jp-xtralab-GitPanel-sectionLabel" }, label),
250
- actions !== undefined ? (React.createElement("span", { className: "jp-xtralab-GitPanel-sectionActions" }, actions.map(action => (React.createElement("button", { key: action.label, type: "button", className: "jp-xtralab-GitPanel-iconButton", title: action.label, onClick: event => {
251
- event.stopPropagation();
252
- action.onClick();
253
- } }, action.symbol))))) : null,
254
- React.createElement("span", { className: "jp-xtralab-GitPanel-sectionCount" }, count)),
255
- !collapsed ? (React.createElement("div", { className: "jp-xtralab-GitPanel-sectionBody" }, children)) : null));
256
- }
257
- function ChangeRow(props) {
258
- const { change, pendingAction, onClick, actions } = props;
259
- const filename = basename(change.path);
260
- const dirname = dirnameOrEmpty(change.path);
261
- return (React.createElement("div", { className: "jp-xtralab-GitPanel-row", "data-status": change.status, title: change.from !== undefined
262
- ? `${change.from} → ${change.path}`
263
- : change.path, onClick: onClick },
264
- React.createElement("span", { className: "jp-xtralab-GitPanel-rowLabel" },
265
- React.createElement("span", { className: "jp-xtralab-GitPanel-rowName" }, filename),
266
- dirname !== '' ? (React.createElement("span", { className: "jp-xtralab-GitPanel-rowPath" }, dirname)) : null),
267
- React.createElement("span", { className: "jp-xtralab-GitPanel-rowActions", "data-pending": pendingAction !== undefined }, actions.map(action => (React.createElement("button", { key: action.label, type: "button", className: "jp-xtralab-GitPanel-iconButton", title: action.label, disabled: pendingAction !== undefined, onClick: event => {
268
- event.stopPropagation();
269
- action.onClick();
270
- } }, action.symbol)))),
271
- React.createElement("span", { className: "jp-xtralab-GitPanel-rowBadge", "data-status": change.status, title: statusTooltip(change.status) }, statusBadge(change.status))));
272
- }
273
- function statusBadge(status) {
274
- switch (status) {
275
- case 'modified':
276
- return 'M';
277
- case 'added':
278
- return 'A';
279
- case 'deleted':
280
- return 'D';
281
- case 'renamed':
282
- return 'R';
283
- case 'untracked':
284
- return 'U';
285
- case 'unmerged':
286
- return '!';
287
- case 'typechange':
288
- return 'T';
289
- default:
290
- return '?';
291
- }
292
- }
293
- function statusTooltip(status) {
294
- switch (status) {
295
- case 'modified':
296
- return 'Modified';
297
- case 'added':
298
- return 'Added';
299
- case 'deleted':
300
- return 'Deleted';
301
- case 'renamed':
302
- return 'Renamed';
303
- case 'untracked':
304
- return 'Untracked';
305
- case 'unmerged':
306
- return 'Unmerged (conflict)';
307
- case 'typechange':
308
- return 'Type changed';
309
- default:
310
- return 'Unknown';
311
- }
312
- }
313
- function basename(path) {
314
- const idx = path.lastIndexOf('/');
315
- return idx < 0 ? path : path.slice(idx + 1);
316
- }
317
- function dirnameOrEmpty(path) {
318
- const idx = path.lastIndexOf('/');
319
- return idx < 0 ? '' : path.slice(0, idx);
320
- }
package/src/git/icons.ts DELETED
@@ -1,20 +0,0 @@
1
- import { LabIcon } from '@jupyterlab/ui-components';
2
-
3
- /**
4
- * The branch glyph used as the git panel's sidebar tab icon. Defined as a
5
- * `LabIcon` (rather than a CSS-only mask) so it picks up JupyterLab's icon
6
- * recoloring and inverse-color sidebar painting automatically.
7
- *
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`
10
- * icon, so the two sidebar tab glyphs read as a matched set. The original
11
- * `fill="#000000"` is replaced with `fill="currentColor"` and the root element
12
- * is given the `jp-icon3` class so the icon picks up JupyterLab's theme color
13
- * and the sidebar's inverse-color painting automatically.
14
- */
15
- export const gitIcon = new LabIcon({
16
- name: 'xtralab:git',
17
- svgstr: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="jp-icon3" fill="currentColor">
18
- <path d="M5 2h2v12h3v3h7v-7h-3V2h8v8h-3v9h-9v3H2v-8h3V2zm15 6V4h-4v4h4zM8 19v-3H4v4h4v-1z"/>
19
- </svg>`
20
- });
package/src/git/panel.tsx DELETED
@@ -1,56 +0,0 @@
1
- import * as React from 'react';
2
-
3
- import { ReactWidget } from '@jupyterlab/ui-components';
4
- import { Signal, ISignal } from '@lumino/signaling';
5
-
6
- import { gitIcon } from './icons';
7
- import { GitPanelComponent, IGitPanelHandlers } from './panelComponent';
8
-
9
- /**
10
- * The id and CSS class used for the git panel widget. The id ends up as the
11
- * sidebar tab id (so layout restoration can find the widget) and the CSS
12
- * class lets per-panel styles attach without leaking to other widgets.
13
- */
14
- export const GIT_PANEL_ID = 'xtralab:git-panel';
15
- export const GIT_PANEL_CSS_CLASS = 'jp-xtralab-GitPanel';
16
-
17
- /**
18
- * The lumino host widget for the git changes panel. Owns the `refresh`
19
- * signal that the React component listens to, and exposes a typed callback
20
- * surface (`handlers`) the React component uses to react to user actions.
21
- */
22
- export class GitPanel extends ReactWidget {
23
- constructor(handlers: IGitPanelHandlers) {
24
- super();
25
- this.id = GIT_PANEL_ID;
26
- this.title.icon = gitIcon;
27
- this.title.caption = 'Git Changes';
28
- this.addClass(GIT_PANEL_CSS_CLASS);
29
- this._handlers = handlers;
30
- }
31
-
32
- /**
33
- * Public refresh hook called by the plugin when something happened that
34
- * invalidates the current view (the user clicked the toolbar refresh, the
35
- * file browser refreshed, a focused notebook saved, …).
36
- */
37
- refresh(): void {
38
- this._refreshRequested.emit();
39
- }
40
-
41
- get refreshRequested(): ISignal<this, void> {
42
- return this._refreshRequested;
43
- }
44
-
45
- protected render(): React.ReactElement {
46
- return (
47
- <GitPanelComponent
48
- handlers={this._handlers}
49
- refreshSignal={this._refreshRequested}
50
- />
51
- );
52
- }
53
-
54
- private _handlers: IGitPanelHandlers;
55
- private _refreshRequested = new Signal<this, void>(this);
56
- }