xtralab 0.3.0 → 0.4.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 +2 -0
- package/lib/editorBreadcrumbs/index.d.ts +11 -0
- package/lib/editorBreadcrumbs/index.js +41 -0
- package/lib/editorBreadcrumbs/widget.d.ts +34 -0
- package/lib/editorBreadcrumbs/widget.js +131 -0
- package/lib/editorIndent/index.d.ts +17 -0
- package/lib/editorIndent/index.js +142 -0
- package/lib/fileBrowser/commands.d.ts +3 -0
- package/lib/fileBrowser/commands.js +40 -1
- package/lib/fileBrowser/fileBrowser.js +393 -10
- package/lib/fileBrowser/gitStatus.d.ts +13 -0
- package/lib/fileBrowser/gitStatus.js +60 -0
- package/lib/fileBrowser/widget.d.ts +48 -0
- package/lib/fileBrowser/widget.js +24 -0
- package/lib/git/panelComponent.js +28 -11
- package/lib/index.js +4 -0
- package/lib/launcher/dashboard.js +27 -15
- package/package.json +7 -1
- package/src/editorBreadcrumbs/index.ts +70 -0
- package/src/editorBreadcrumbs/widget.ts +158 -0
- package/src/editorIndent/index.ts +177 -0
- package/src/fileBrowser/commands.ts +43 -1
- package/src/fileBrowser/fileBrowser.tsx +427 -11
- package/src/fileBrowser/gitStatus.ts +66 -0
- package/src/fileBrowser/widget.tsx +77 -0
- package/src/git/panelComponent.tsx +29 -15
- package/src/index.ts +4 -0
- package/src/launcher/dashboard.tsx +28 -15
- package/style/editorBreadcrumbs.css +86 -0
- package/style/index.css +2 -0
- package/style/index.js +1 -0
- package/style/launcher.css +0 -5
- package/style/tabs.css +66 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
+
import { Poll } from '@lumino/polling';
|
|
2
3
|
import { add, addAll, checkout, porcelainToFileChanges, reset, resetAll, showTopLevel, status } from './api';
|
|
3
4
|
/**
|
|
4
5
|
* The polling interval for the panel. We refresh the status periodically so
|
|
@@ -6,6 +7,13 @@ import { add, addAll, checkout, porcelainToFileChanges, reset, resetAll, showTop
|
|
|
6
7
|
* visible without requiring a manual refresh.
|
|
7
8
|
*/
|
|
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;
|
|
9
17
|
/**
|
|
10
18
|
* Top-level React component rendered inside the {@link GitPanel} widget.
|
|
11
19
|
* Owns the polling + refresh wiring around the `/git/status` endpoint and
|
|
@@ -59,23 +67,32 @@ export function GitPanelComponent(props) {
|
|
|
59
67
|
});
|
|
60
68
|
}
|
|
61
69
|
}, [repoPath]);
|
|
62
|
-
// Initial load + polling.
|
|
63
|
-
// the
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}, [refresh]);
|
|
71
|
-
// External refresh triggers (toolbar button, plugin-level events).
|
|
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.
|
|
72
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
|
+
});
|
|
73
89
|
const handler = () => {
|
|
74
|
-
void refresh();
|
|
90
|
+
void poll.refresh();
|
|
75
91
|
};
|
|
76
92
|
refreshSignal.connect(handler);
|
|
77
93
|
return () => {
|
|
78
94
|
refreshSignal.disconnect(handler);
|
|
95
|
+
poll.dispose();
|
|
79
96
|
};
|
|
80
97
|
}, [refresh, refreshSignal]);
|
|
81
98
|
const runAction = React.useCallback(async (change, action, run) => {
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import fileBrowserPlugin from './fileBrowser';
|
|
2
|
+
import editorBreadcrumbsPlugin from './editorBreadcrumbs';
|
|
3
|
+
import editorIndentPlugin from './editorIndent';
|
|
2
4
|
import gitPlugin from './git';
|
|
3
5
|
import launcherPlugin from './launcher';
|
|
4
6
|
import menusPlugin from './menus';
|
|
@@ -12,6 +14,8 @@ import statusBarPlugin from './statusBar';
|
|
|
12
14
|
* available end up running.
|
|
13
15
|
*/
|
|
14
16
|
const plugins = [
|
|
17
|
+
editorBreadcrumbsPlugin,
|
|
18
|
+
editorIndentPlugin,
|
|
15
19
|
fileBrowserPlugin,
|
|
16
20
|
gitPlugin,
|
|
17
21
|
launcherPlugin,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
+
import { Poll } from '@lumino/polling';
|
|
2
3
|
import { LabIcon, ReactWidget, consoleIcon, notebookIcon, refreshIcon, terminalIcon } from '@jupyterlab/ui-components';
|
|
3
4
|
import { expandStatusFiles, status } from '../git/api';
|
|
4
5
|
import { CommandIDs as GitCommandIDs } from '../git/commands';
|
|
@@ -24,6 +25,12 @@ export class LauncherDashboard extends ReactWidget {
|
|
|
24
25
|
* only opened intentionally, and the grid does not need to be live.
|
|
25
26
|
*/
|
|
26
27
|
const CHANGES_POLL_INTERVAL_MS = 8000;
|
|
28
|
+
/**
|
|
29
|
+
* Upper bound on the exponential backoff when the status request fails
|
|
30
|
+
* repeatedly. Matches the rest of the plugin's polls so behavior is
|
|
31
|
+
* consistent across the dashboard, the git panel, and the file tree.
|
|
32
|
+
*/
|
|
33
|
+
const CHANGES_POLL_MAX_MS = 300000;
|
|
27
34
|
function LauncherDashboardComponent(props) {
|
|
28
35
|
const { commands, agents, onAgentLaunch, repoPath, cwd } = props;
|
|
29
36
|
const [prompt, setPrompt] = React.useState('');
|
|
@@ -45,12 +52,23 @@ function LauncherDashboardComponent(props) {
|
|
|
45
52
|
});
|
|
46
53
|
}
|
|
47
54
|
}, [repoPath]);
|
|
55
|
+
// Driven by a Lumino `Poll` so the dashboard stops hitting the git
|
|
56
|
+
// endpoint while the JupyterLab tab is hidden, and so repeated
|
|
57
|
+
// failures back off exponentially instead of pegging the 8s cadence.
|
|
48
58
|
React.useEffect(() => {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
59
|
+
const poll = new Poll({
|
|
60
|
+
name: '@xtralab/launcher:gitChanges',
|
|
61
|
+
factory: () => refresh(),
|
|
62
|
+
frequency: {
|
|
63
|
+
interval: CHANGES_POLL_INTERVAL_MS,
|
|
64
|
+
backoff: true,
|
|
65
|
+
max: CHANGES_POLL_MAX_MS
|
|
66
|
+
},
|
|
67
|
+
standby: 'when-hidden'
|
|
68
|
+
});
|
|
69
|
+
return () => {
|
|
70
|
+
poll.dispose();
|
|
71
|
+
};
|
|
54
72
|
}, [refresh]);
|
|
55
73
|
const launch = React.useCallback(async (agent) => {
|
|
56
74
|
const args = { cwd };
|
|
@@ -154,10 +172,9 @@ function AgentSection(props) {
|
|
|
154
172
|
const { agents, prompt, onPromptChange, onLaunch } = props;
|
|
155
173
|
const trimmed = prompt.trim();
|
|
156
174
|
const promptActive = trimmed.length > 0;
|
|
157
|
-
// The
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
// textarea — picking by the same rule keeps the two behaviors in sync.
|
|
175
|
+
// The first prompt-capable agent is what Cmd/Ctrl+Enter fires; the hint
|
|
176
|
+
// text below the textarea names it so the keyboard shortcut target
|
|
177
|
+
// isn't a mystery.
|
|
161
178
|
const primaryAgent = agents.find(agent => agent.promptArgs !== undefined);
|
|
162
179
|
const hintId = 'jp-xtralab-Launcher-agent-hint';
|
|
163
180
|
return (React.createElement("section", { className: "jp-xtralab-Launcher-section" },
|
|
@@ -177,15 +194,10 @@ function AgentSection(props) {
|
|
|
177
194
|
React.createElement("div", { className: "jp-xtralab-Launcher-agents" }, agents.map(agent => {
|
|
178
195
|
const supportsPrompt = agent.promptArgs !== undefined;
|
|
179
196
|
const disabled = promptActive && !supportsPrompt;
|
|
180
|
-
const isPrimary = agent === primaryAgent;
|
|
181
197
|
const tooltip = disabled
|
|
182
198
|
? `${agent.label} doesn't accept an initial prompt — clear the prompt to launch.`
|
|
183
199
|
: agent.caption;
|
|
184
|
-
|
|
185
|
-
if (isPrimary) {
|
|
186
|
-
classes.push('jp-xtralab-Launcher-agent-primary');
|
|
187
|
-
}
|
|
188
|
-
return (React.createElement("button", { key: agent.id, type: "button", className: classes.join(' '), title: tooltip, "aria-label": agent.label, "aria-describedby": hintId, disabled: disabled, onClick: () => onLaunch(agent) },
|
|
200
|
+
return (React.createElement("button", { key: agent.id, type: "button", className: "jp-xtralab-Launcher-agent", title: tooltip, "aria-label": agent.label, "aria-describedby": hintId, disabled: disabled, onClick: () => onLaunch(agent) },
|
|
189
201
|
React.createElement(LabIcon.resolveReact, { icon: agent.icon, tag: "span", className: "jp-xtralab-Launcher-agent-icon" }),
|
|
190
202
|
React.createElement("span", { className: "jp-xtralab-Launcher-agent-label" }, agent.label)));
|
|
191
203
|
}))));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xtralab",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "An opinionated JupyterLab meta-package that bundles a curated set of extensions, ships a path-first file browser and a VS Code-style git changes panel, and applies a quieter default workspace configuration.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jupyter",
|
|
@@ -57,8 +57,12 @@
|
|
|
57
57
|
"watch:labextension": "jupyter-builder watch ."
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
+
"@codemirror/language": "^6.12.1",
|
|
61
|
+
"@codemirror/state": "^6.5.4",
|
|
62
|
+
"@codemirror/view": "^6.39.14",
|
|
60
63
|
"@jupyterlab/application": "^4.6.0-alpha.5",
|
|
61
64
|
"@jupyterlab/apputils": "^4.7.0-alpha.5",
|
|
65
|
+
"@jupyterlab/codemirror": "^4.6.0-alpha.5",
|
|
62
66
|
"@jupyterlab/coreutils": "^6.6.0-alpha.5",
|
|
63
67
|
"@jupyterlab/docmanager": "^4.6.0-alpha.5",
|
|
64
68
|
"@jupyterlab/docregistry": "^4.6.0-alpha.5",
|
|
@@ -78,10 +82,12 @@
|
|
|
78
82
|
"@lumino/disposable": "^2.0.0",
|
|
79
83
|
"@lumino/dragdrop": "^2.0.0",
|
|
80
84
|
"@lumino/messaging": "^2.0.0",
|
|
85
|
+
"@lumino/polling": "^2.1.5",
|
|
81
86
|
"@lumino/signaling": "^2.0.0",
|
|
82
87
|
"@lumino/widgets": "^2.0.0",
|
|
83
88
|
"@pierre/diffs": "^1.1.20",
|
|
84
89
|
"@pierre/trees": "1.0.0-beta.3",
|
|
90
|
+
"detect-indent": "^7.0.2",
|
|
85
91
|
"ignore": "^5.3.2",
|
|
86
92
|
"react": "^18.2.0",
|
|
87
93
|
"react-dom": "^18.2.0"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import {
|
|
2
|
+
JupyterFrontEnd,
|
|
3
|
+
JupyterFrontEndPlugin
|
|
4
|
+
} from '@jupyterlab/application';
|
|
5
|
+
import type {
|
|
6
|
+
DocumentRegistry,
|
|
7
|
+
IDocumentWidget
|
|
8
|
+
} from '@jupyterlab/docregistry';
|
|
9
|
+
import type { CommandRegistry } from '@lumino/commands';
|
|
10
|
+
import { DisposableDelegate, type IDisposable } from '@lumino/disposable';
|
|
11
|
+
import type { Widget } from '@lumino/widgets';
|
|
12
|
+
|
|
13
|
+
import { EditorBreadcrumbs } from './widget';
|
|
14
|
+
|
|
15
|
+
const PLUGIN_ID = 'xtralab:editor-breadcrumbs';
|
|
16
|
+
const EDITOR_FACTORY = 'Editor';
|
|
17
|
+
const TOOLBAR_ITEM_NAME = 'xtralab-editor-breadcrumbs';
|
|
18
|
+
|
|
19
|
+
class EditorBreadcrumbsExtension implements DocumentRegistry.IWidgetExtension<
|
|
20
|
+
IDocumentWidget<Widget, DocumentRegistry.IModel>,
|
|
21
|
+
DocumentRegistry.IModel
|
|
22
|
+
> {
|
|
23
|
+
constructor(commands: CommandRegistry) {
|
|
24
|
+
this._commands = commands;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
createNew(
|
|
28
|
+
widget: IDocumentWidget<Widget, DocumentRegistry.IModel>,
|
|
29
|
+
context: DocumentRegistry.IContext<DocumentRegistry.IModel>
|
|
30
|
+
): IDisposable | void {
|
|
31
|
+
const breadcrumbs = new EditorBreadcrumbs({
|
|
32
|
+
context,
|
|
33
|
+
commands: this._commands
|
|
34
|
+
});
|
|
35
|
+
const added = widget.toolbar.insertItem(0, TOOLBAR_ITEM_NAME, breadcrumbs);
|
|
36
|
+
|
|
37
|
+
if (!added) {
|
|
38
|
+
breadcrumbs.dispose();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return new DisposableDelegate(() => {
|
|
43
|
+
breadcrumbs.dispose();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private _commands: CommandRegistry;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Adds a VS Code-style path breadcrumb to text editor toolbars. Each
|
|
52
|
+
* segment is clickable: clicking dispatches `xtralab:reveal-path` so
|
|
53
|
+
* any plugin that listens (today, the file browser) can surface the
|
|
54
|
+
* underlying folder or file. The breadcrumbs plugin therefore does not
|
|
55
|
+
* import the file browser at all — the JupyterLab command registry is
|
|
56
|
+
* the only seam between them.
|
|
57
|
+
*/
|
|
58
|
+
const plugin: JupyterFrontEndPlugin<void> = {
|
|
59
|
+
id: PLUGIN_ID,
|
|
60
|
+
description: 'Display the active file path in text editor toolbars.',
|
|
61
|
+
autoStart: true,
|
|
62
|
+
activate: (app: JupyterFrontEnd): void => {
|
|
63
|
+
app.docRegistry.addWidgetExtension(
|
|
64
|
+
EDITOR_FACTORY,
|
|
65
|
+
new EditorBreadcrumbsExtension(app.commands)
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export default plugin;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { PageConfig } from '@jupyterlab/coreutils';
|
|
2
|
+
import type { DocumentRegistry } from '@jupyterlab/docregistry';
|
|
3
|
+
import { homeIcon as rootIcon } from '@jupyterlab/ui-components';
|
|
4
|
+
import type { CommandRegistry } from '@lumino/commands';
|
|
5
|
+
import { Widget } from '@lumino/widgets';
|
|
6
|
+
|
|
7
|
+
const EDITOR_BREADCRUMBS_CLASS = 'jp-xtralab-EditorBreadcrumbs';
|
|
8
|
+
const BREADCRUMBS_CONTAINER_CLASS = 'jp-BreadCrumbs-container';
|
|
9
|
+
const BREADCRUMBS_CONTENT_CLASS = 'jp-BreadCrumbs-content';
|
|
10
|
+
const ROOT_CLASS = 'jp-BreadCrumbs-home';
|
|
11
|
+
const ITEM_CLASS = 'jp-BreadCrumbs-item';
|
|
12
|
+
const CURRENT_ITEM_CLASS = 'jp-mod-current';
|
|
13
|
+
const SEPARATOR_CLASS = 'jp-BreadCrumbs-separator';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Command dispatched when the user clicks a breadcrumb segment.
|
|
17
|
+
* Registered by the file browser plugin; the breadcrumbs only know
|
|
18
|
+
* the name so they stay decoupled from the file browser internals.
|
|
19
|
+
*/
|
|
20
|
+
const REVEAL_COMMAND = 'xtralab:reveal-path';
|
|
21
|
+
|
|
22
|
+
export interface IEditorBreadcrumbsOptions {
|
|
23
|
+
context: DocumentRegistry.IContext<DocumentRegistry.IModel>;
|
|
24
|
+
commands: CommandRegistry;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read-only breadcrumb path for file editor toolbars.
|
|
29
|
+
*
|
|
30
|
+
* The upstream `@jupyterlab/filebrowser` BreadCrumbs widget is coupled to
|
|
31
|
+
* FileBrowserModel navigation, drag/drop, and path editing. If that code
|
|
32
|
+
* becomes reusable upstream, replace this local display-only renderer with
|
|
33
|
+
* the shared implementation.
|
|
34
|
+
*/
|
|
35
|
+
export class EditorBreadcrumbs extends Widget {
|
|
36
|
+
constructor(options: IEditorBreadcrumbsOptions) {
|
|
37
|
+
super({ node: document.createElement('nav') });
|
|
38
|
+
this._context = options.context;
|
|
39
|
+
this._commands = options.commands;
|
|
40
|
+
this.addClass(EDITOR_BREADCRUMBS_CLASS);
|
|
41
|
+
this.node.setAttribute('aria-label', 'Editor file path');
|
|
42
|
+
|
|
43
|
+
this._container = document.createElement('span');
|
|
44
|
+
this._container.className = BREADCRUMBS_CONTAINER_CLASS;
|
|
45
|
+
this._content = document.createElement('span');
|
|
46
|
+
this._content.className = BREADCRUMBS_CONTENT_CLASS;
|
|
47
|
+
this._container.appendChild(this._content);
|
|
48
|
+
this.node.appendChild(this._container);
|
|
49
|
+
|
|
50
|
+
this._context.pathChanged.connect(this._onPathChanged, this);
|
|
51
|
+
this._render();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
dispose(): void {
|
|
55
|
+
if (this.isDisposed) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
this._context.pathChanged.disconnect(this._onPathChanged, this);
|
|
59
|
+
super.dispose();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private _onPathChanged(): void {
|
|
63
|
+
this._render();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private _render(): void {
|
|
67
|
+
const path = this._context.localPath || this._context.path;
|
|
68
|
+
const parts = path.split('/').filter(part => part.length > 0);
|
|
69
|
+
|
|
70
|
+
this.node.title = this._context.path;
|
|
71
|
+
this._content.textContent = '';
|
|
72
|
+
this._content.appendChild(this._createRootCrumb());
|
|
73
|
+
if (parts.length > 0) {
|
|
74
|
+
this._content.appendChild(this._createSeparator());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
parts.forEach((part, index) => {
|
|
78
|
+
const isLast = index === parts.length - 1;
|
|
79
|
+
const subPath = parts.slice(0, index + 1).join('/');
|
|
80
|
+
// Canonical `@pierre/trees` paths: directories carry a trailing
|
|
81
|
+
// slash, files do not. Every part except the last names a folder
|
|
82
|
+
// along the way to the open file.
|
|
83
|
+
const canonical = isLast ? subPath : `${subPath}/`;
|
|
84
|
+
|
|
85
|
+
const item = document.createElement('span');
|
|
86
|
+
item.className = ITEM_CLASS;
|
|
87
|
+
item.textContent = part;
|
|
88
|
+
item.title = subPath;
|
|
89
|
+
item.tabIndex = 0;
|
|
90
|
+
item.setAttribute('role', 'button');
|
|
91
|
+
if (isLast) {
|
|
92
|
+
item.classList.add(CURRENT_ITEM_CLASS);
|
|
93
|
+
item.setAttribute('aria-current', 'page');
|
|
94
|
+
}
|
|
95
|
+
this._attachReveal(item, canonical);
|
|
96
|
+
this._content.appendChild(item);
|
|
97
|
+
|
|
98
|
+
if (!isLast) {
|
|
99
|
+
this._content.appendChild(this._createSeparator());
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private _createRootCrumb(): HTMLElement {
|
|
105
|
+
const item = rootIcon.element({
|
|
106
|
+
className: ROOT_CLASS,
|
|
107
|
+
tag: 'span',
|
|
108
|
+
title: PageConfig.getOption('serverRoot') || 'Jupyter Server Root',
|
|
109
|
+
stylesheet: 'breadCrumb'
|
|
110
|
+
});
|
|
111
|
+
item.dataset.path = '/';
|
|
112
|
+
item.tabIndex = 0;
|
|
113
|
+
item.setAttribute('role', 'button');
|
|
114
|
+
// Empty canonical path: the reveal command treats this as the
|
|
115
|
+
// workspace-root gesture — surface the file browser, clear its
|
|
116
|
+
// selection, and scroll back to the top. The tree has no row for
|
|
117
|
+
// the workspace root itself.
|
|
118
|
+
this._attachReveal(item, '');
|
|
119
|
+
return item;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Bind click and keyboard activation on a crumb element to the
|
|
124
|
+
* shared reveal command. Dispatch is best-effort: a failure to
|
|
125
|
+
* resolve or execute the command is logged but never thrown into
|
|
126
|
+
* the editor's toolbar.
|
|
127
|
+
*/
|
|
128
|
+
private _attachReveal(element: HTMLElement, canonicalPath: string): void {
|
|
129
|
+
const fire = (event: Event): void => {
|
|
130
|
+
event.preventDefault();
|
|
131
|
+
event.stopPropagation();
|
|
132
|
+
void this._commands
|
|
133
|
+
.execute(REVEAL_COMMAND, { path: canonicalPath })
|
|
134
|
+
.catch((err: unknown) => {
|
|
135
|
+
console.error(`xtralab: failed to reveal "${canonicalPath}"`, err);
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
element.addEventListener('click', fire);
|
|
139
|
+
element.addEventListener('keydown', event => {
|
|
140
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
141
|
+
fire(event);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private _createSeparator(): HTMLElement {
|
|
147
|
+
const item = document.createElement('span');
|
|
148
|
+
item.className = SEPARATOR_CLASS;
|
|
149
|
+
item.setAttribute('aria-hidden', 'true');
|
|
150
|
+
item.textContent = '/';
|
|
151
|
+
return item;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private _context: DocumentRegistry.IContext<DocumentRegistry.IModel>;
|
|
155
|
+
private _commands: CommandRegistry;
|
|
156
|
+
private _container: HTMLElement;
|
|
157
|
+
private _content: HTMLElement;
|
|
158
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import {
|
|
2
|
+
JupyterFrontEnd,
|
|
3
|
+
JupyterFrontEndPlugin
|
|
4
|
+
} from '@jupyterlab/application';
|
|
5
|
+
import {
|
|
6
|
+
EditorExtensionRegistry,
|
|
7
|
+
IEditorExtensionRegistry
|
|
8
|
+
} from '@jupyterlab/codemirror';
|
|
9
|
+
import { indentUnit } from '@codemirror/language';
|
|
10
|
+
import { Compartment, EditorState, Extension, Prec } from '@codemirror/state';
|
|
11
|
+
import { EditorView, ViewPlugin } from '@codemirror/view';
|
|
12
|
+
import detectIndent from 'detect-indent';
|
|
13
|
+
|
|
14
|
+
const PLUGIN_ID = 'xtralab:editor-indent';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Mime types whose newly-created (or too-short-to-detect) files should default
|
|
18
|
+
* to a 2-space indent. Matches Prettier's defaults for web languages; mime
|
|
19
|
+
* types not listed here inherit whatever the user has set in JupyterLab.
|
|
20
|
+
* Covers .ts/.tsx/.jsx/.js variants registered by `@jupyterlab/codemirror`.
|
|
21
|
+
*/
|
|
22
|
+
const FALLBACK_INDENT_BY_MIME: Record<string, number> = {
|
|
23
|
+
'application/ecmascript': 2,
|
|
24
|
+
'application/javascript': 2,
|
|
25
|
+
'application/json': 2,
|
|
26
|
+
'application/typescript': 2,
|
|
27
|
+
'application/x-javascript': 2,
|
|
28
|
+
'application/x-json': 2,
|
|
29
|
+
'application/x-yaml': 2,
|
|
30
|
+
'text/css': 2,
|
|
31
|
+
'text/ecmascript': 2,
|
|
32
|
+
'text/html': 2,
|
|
33
|
+
'text/javascript': 2,
|
|
34
|
+
'text/jsx': 2,
|
|
35
|
+
'text/typescript-jsx': 2,
|
|
36
|
+
'text/x-markdown': 2,
|
|
37
|
+
'text/x-yaml': 2,
|
|
38
|
+
'text/yaml': 2
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
interface IResolvedIndent {
|
|
42
|
+
unit: string;
|
|
43
|
+
width: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Run `detect-indent` against the file's content and convert its result into
|
|
48
|
+
* something we can hand to CodeMirror.
|
|
49
|
+
*/
|
|
50
|
+
function detectFromContent(text: string): IResolvedIndent | null {
|
|
51
|
+
if (text.length === 0) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const result = detectIndent(text);
|
|
55
|
+
if (!result.type || result.amount <= 0) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
if (result.type === 'tab') {
|
|
59
|
+
// detect-indent returns `\t` for `indent` in tab mode; use a 4-column tab
|
|
60
|
+
// as the visual width, which is what most language servers assume.
|
|
61
|
+
return { unit: '\t', width: 4 };
|
|
62
|
+
}
|
|
63
|
+
return { unit: result.indent, width: result.amount };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolveIndent(text: string, mimeType: string): IResolvedIndent | null {
|
|
67
|
+
const detected = detectFromContent(text);
|
|
68
|
+
if (detected) {
|
|
69
|
+
return detected;
|
|
70
|
+
}
|
|
71
|
+
const fallback = FALLBACK_INDENT_BY_MIME[mimeType];
|
|
72
|
+
if (fallback === undefined) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
return { unit: ' '.repeat(fallback), width: fallback };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildExtension(resolved: IResolvedIndent): Extension {
|
|
79
|
+
// `Prec.highest` keeps these values first in their respective facets so
|
|
80
|
+
// they beat both `defaultConfig` and per-editor `editorConfig.indentUnit`,
|
|
81
|
+
// both of which JupyterLab registers at default precedence.
|
|
82
|
+
return Prec.highest([
|
|
83
|
+
indentUnit.of(resolved.unit),
|
|
84
|
+
EditorState.tabSize.of(resolved.width)
|
|
85
|
+
]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Sniff each opened document's leading whitespace and configure CodeMirror's
|
|
90
|
+
* `indentUnit` / `tabSize` to match. JupyterLab only exposes a single global
|
|
91
|
+
* indent setting, which means TypeScript files written with 4-space indent and
|
|
92
|
+
* Python files written with 2-space indent both look wrong in the same editor;
|
|
93
|
+
* detecting from the file's own content sidesteps that without forcing every
|
|
94
|
+
* project to share the same convention.
|
|
95
|
+
*
|
|
96
|
+
* Detection runs at editor-construction time and again on the first content
|
|
97
|
+
* change — that second pass exists because JupyterLab's collaborative document
|
|
98
|
+
* loader can settle the file body into the shared model after the editor view
|
|
99
|
+
* already exists, in which case the factory would otherwise have only seen an
|
|
100
|
+
* empty string.
|
|
101
|
+
*/
|
|
102
|
+
const plugin: JupyterFrontEndPlugin<void> = {
|
|
103
|
+
id: PLUGIN_ID,
|
|
104
|
+
description:
|
|
105
|
+
'Detect each file’s indentation style from its contents and configure CodeMirror to match; new files fall back to a language-based default.',
|
|
106
|
+
autoStart: true,
|
|
107
|
+
requires: [IEditorExtensionRegistry],
|
|
108
|
+
activate: (
|
|
109
|
+
app: JupyterFrontEnd,
|
|
110
|
+
registry: IEditorExtensionRegistry
|
|
111
|
+
): void => {
|
|
112
|
+
registry.addExtension({
|
|
113
|
+
name: 'xtralab:per-language-indent',
|
|
114
|
+
factory: options => {
|
|
115
|
+
const { model } = options;
|
|
116
|
+
const slot = new Compartment();
|
|
117
|
+
const initialResolved = resolveIndent(
|
|
118
|
+
model.sharedModel.getSource(),
|
|
119
|
+
model.mimeType
|
|
120
|
+
);
|
|
121
|
+
const initial = initialResolved ? buildExtension(initialResolved) : [];
|
|
122
|
+
|
|
123
|
+
let settled = initialResolved !== null;
|
|
124
|
+
|
|
125
|
+
const refreshPlugin = ViewPlugin.fromClass(
|
|
126
|
+
class {
|
|
127
|
+
disconnect: (() => void) | null = null;
|
|
128
|
+
|
|
129
|
+
constructor(public view: EditorView) {
|
|
130
|
+
if (settled) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const handler = (): void => {
|
|
134
|
+
if (settled) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const resolved = resolveIndent(
|
|
138
|
+
model.sharedModel.getSource(),
|
|
139
|
+
model.mimeType
|
|
140
|
+
);
|
|
141
|
+
if (!resolved) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
settled = true;
|
|
145
|
+
view.dispatch({
|
|
146
|
+
effects: slot.reconfigure(buildExtension(resolved))
|
|
147
|
+
});
|
|
148
|
+
if (this.disconnect) {
|
|
149
|
+
this.disconnect();
|
|
150
|
+
this.disconnect = null;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
model.sharedModel.changed.connect(handler);
|
|
154
|
+
this.disconnect = () => {
|
|
155
|
+
model.sharedModel.changed.disconnect(handler);
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
destroy(): void {
|
|
160
|
+
if (this.disconnect) {
|
|
161
|
+
this.disconnect();
|
|
162
|
+
this.disconnect = null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
return EditorExtensionRegistry.createImmutableExtension([
|
|
169
|
+
slot.of(initial),
|
|
170
|
+
refreshPlugin
|
|
171
|
+
]);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export default plugin;
|
|
@@ -10,6 +10,7 @@ import { PageConfig, PathExt } from '@jupyterlab/coreutils';
|
|
|
10
10
|
import {
|
|
11
11
|
addIcon,
|
|
12
12
|
closeIcon,
|
|
13
|
+
collapseAllIcon,
|
|
13
14
|
CommandToolbarButton,
|
|
14
15
|
copyIcon,
|
|
15
16
|
downloadIcon,
|
|
@@ -24,7 +25,7 @@ import { ReadonlyPartialJSONObject } from '@lumino/coreutils';
|
|
|
24
25
|
import { ContextMenu } from '@lumino/widgets';
|
|
25
26
|
|
|
26
27
|
import { toCanonicalPath, toServerPath } from './contents';
|
|
27
|
-
import { IXtralabFileBrowser } from './widget';
|
|
28
|
+
import { FILE_BROWSER_ID, IXtralabFileBrowser } from './widget';
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Command identifiers exposed by the xtralab browser. We deliberately namespace
|
|
@@ -42,8 +43,10 @@ export namespace CommandIDs {
|
|
|
42
43
|
export const copyPath = 'xtralab:copy-path';
|
|
43
44
|
export const download = 'xtralab:download';
|
|
44
45
|
export const refresh = 'xtralab:refresh';
|
|
46
|
+
export const collapseAll = 'xtralab:collapse-all';
|
|
45
47
|
export const createNewDirectory = 'xtralab:create-new-directory';
|
|
46
48
|
export const newLauncher = 'xtralab:new-launcher';
|
|
49
|
+
export const revealPath = 'xtralab:reveal-path';
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
/**
|
|
@@ -406,6 +409,36 @@ export function registerCommands(opts: IRegisterCommandsOptions): () => void {
|
|
|
406
409
|
}
|
|
407
410
|
});
|
|
408
411
|
|
|
412
|
+
commands.addCommand(CommandIDs.collapseAll, {
|
|
413
|
+
label: 'Collapse All Folders',
|
|
414
|
+
caption: 'Collapse all folders in the file browser',
|
|
415
|
+
icon: collapseAllIcon.bindprops({ stylesheet: 'menuItem' }),
|
|
416
|
+
execute: () => {
|
|
417
|
+
browser.collapseAll();
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// Public reveal seam. Other plugins (editor breadcrumbs, future
|
|
422
|
+
// "reveal in tree" actions) call this command rather than depending
|
|
423
|
+
// on the file browser widget directly. Activating the sidebar is part
|
|
424
|
+
// of the contract: the tree is hidden behind a tab and a reveal that
|
|
425
|
+
// does not surface the panel would silently no-op from the user's
|
|
426
|
+
// point of view. An empty path is the "workspace root" gesture —
|
|
427
|
+
// there is no tree row for the root itself, so the browser is asked
|
|
428
|
+
// to scroll back to the top and clear its selection instead.
|
|
429
|
+
commands.addCommand(CommandIDs.revealPath, {
|
|
430
|
+
label: 'Reveal in File Browser',
|
|
431
|
+
execute: (args: ReadonlyPartialJSONObject) => {
|
|
432
|
+
const path = (args.path as string | undefined) ?? '';
|
|
433
|
+
app.shell.activateById(FILE_BROWSER_ID);
|
|
434
|
+
if (path.length === 0) {
|
|
435
|
+
browser.scrollToRoot();
|
|
436
|
+
} else {
|
|
437
|
+
browser.reveal(path);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
409
442
|
commands.addCommand(CommandIDs.createNewDirectory, {
|
|
410
443
|
label: 'New Folder',
|
|
411
444
|
caption: 'Create a new folder',
|
|
@@ -468,6 +501,7 @@ export namespace ToolbarNames {
|
|
|
468
501
|
export const newLauncher = 'new-launcher';
|
|
469
502
|
export const newDirectory = 'new-directory';
|
|
470
503
|
export const refresh = 'refresh';
|
|
504
|
+
export const collapseAll = 'collapse-all';
|
|
471
505
|
}
|
|
472
506
|
|
|
473
507
|
/**
|
|
@@ -507,4 +541,12 @@ export function populateToolbar(opts: {
|
|
|
507
541
|
label: ''
|
|
508
542
|
})
|
|
509
543
|
);
|
|
544
|
+
browser.toolbar.addItem(
|
|
545
|
+
ToolbarNames.collapseAll,
|
|
546
|
+
new CommandToolbarButton({
|
|
547
|
+
commands,
|
|
548
|
+
id: CommandIDs.collapseAll,
|
|
549
|
+
label: ''
|
|
550
|
+
})
|
|
551
|
+
);
|
|
510
552
|
}
|