xtralab 0.2.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.
Files changed (39) hide show
  1. package/README.md +126 -125
  2. package/lib/editorBreadcrumbs/index.d.ts +11 -0
  3. package/lib/editorBreadcrumbs/index.js +41 -0
  4. package/lib/editorBreadcrumbs/widget.d.ts +34 -0
  5. package/lib/editorBreadcrumbs/widget.js +131 -0
  6. package/lib/editorIndent/index.d.ts +17 -0
  7. package/lib/editorIndent/index.js +142 -0
  8. package/lib/fileBrowser/commands.d.ts +5 -2
  9. package/lib/fileBrowser/commands.js +42 -3
  10. package/lib/fileBrowser/fileBrowser.js +394 -11
  11. package/lib/fileBrowser/gitStatus.d.ts +13 -0
  12. package/lib/fileBrowser/gitStatus.js +60 -0
  13. package/lib/fileBrowser/icons.d.ts +4 -4
  14. package/lib/fileBrowser/icons.js +4 -4
  15. package/lib/fileBrowser/widget.d.ts +50 -2
  16. package/lib/fileBrowser/widget.js +26 -2
  17. package/lib/git/icons.d.ts +1 -1
  18. package/lib/git/icons.js +1 -1
  19. package/lib/git/panelComponent.js +28 -11
  20. package/lib/index.js +4 -0
  21. package/lib/launcher/dashboard.js +78 -16
  22. package/package.json +22 -2
  23. package/src/editorBreadcrumbs/index.ts +70 -0
  24. package/src/editorBreadcrumbs/widget.ts +158 -0
  25. package/src/editorIndent/index.ts +177 -0
  26. package/src/fileBrowser/commands.ts +45 -3
  27. package/src/fileBrowser/fileBrowser.tsx +428 -12
  28. package/src/fileBrowser/gitStatus.ts +66 -0
  29. package/src/fileBrowser/icons.ts +4 -4
  30. package/src/fileBrowser/widget.tsx +80 -3
  31. package/src/git/icons.ts +1 -1
  32. package/src/git/panelComponent.tsx +29 -15
  33. package/src/index.ts +4 -0
  34. package/src/launcher/dashboard.tsx +135 -16
  35. package/style/editorBreadcrumbs.css +86 -0
  36. package/style/index.css +2 -0
  37. package/style/index.js +1 -0
  38. package/style/launcher.css +0 -5
  39. package/style/tabs.css +66 -0
package/README.md CHANGED
@@ -2,153 +2,154 @@
2
2
 
3
3
  An opinionated JupyterLab meta-package.
4
4
 
5
- `xtralab` bundles a curated set of JupyterLab extensions, ships its own
6
- JupyterLab extension with a path-first file browser and a VS Code-style git
7
- changes panel, and applies a quieter default workspace configuration. The
8
- defaults are shipped under `etc/jupyter/labconfig` and the bundled
9
- labextension is shipped under `share/jupyter/labextensions/xtralab`.
5
+ Bundles a curated set of extensions, a path-first file browser, a VS Code-style
6
+ git changes panel, an agent-focused launcher, and a quieter default workspace.
10
7
 
11
- The goal is to keep the package easy to inspect: most behavior comes from the
12
- upstream Jupyter packages, while `xtralab` defines the default environment we
13
- want out of the box.
8
+ ![xtralab screenshot](./screenshot.png)
14
9
 
15
- ## Approach
10
+ ## Install
16
11
 
17
- The package brings together:
12
+ ```bash
13
+ pip install xtralab
14
+ ```
18
15
 
19
- - The `ajlab` meta-package (agent-ready JupyterLab — collaboration plumbing, MCP support, command tooling, and its own quieter defaults)
20
- - JupyterLab 4.6+
21
- - Git server integration (`jupyterlab-git` — backs the bundled changes panel)
22
- - Quick file opening (`jupyterlab-quickopen`)
23
- - Cursor styling helpers (`jupyterlab-cursor-light`, `jupyterlab-cursor-theme`)
24
- - Day and night themes (`jupyterlab-day`, `jupyterlab-night`)
25
-
26
- These are installed as normal Python dependencies, so deployments can still
27
- override versions and Jupyter configuration in the usual ways.
28
-
29
- ## Bundled labextension
30
-
31
- `xtralab` ships its own prebuilt labextension that contributes:
32
-
33
- - A path-first file browser in the left sidebar, built on `@pierre/trees`.
34
- - A VS Code-style "Source Control" panel in the left sidebar, powered by the
35
- `jupyterlab-git` server REST API and `@pierre/diffs`. The bundled
36
- `@jupyterlab/git` frontend is disabled automatically so the two panels do
37
- not coexist.
38
- - An agent-focused launcher that replaces the stock JupyterLab launcher.
39
- The launcher tab shows an optional initial-prompt textarea, a row of
40
- agent buttons (Claude, Codex, Gemini, Copilot, Goose, OpenCode, Kiro,
41
- and Mistral Vibe), and a collapsible list of changed files (each
42
- clickable into the diff viewer) below them. Clicking an agent opens a
43
- fresh terminal and runs the agent's CLI; if the prompt textarea is
44
- non-empty, the prompt is shell-quoted and spliced into the launch line
45
- for agents that accept one. Agents are filtered by a `which`-based
46
- availability check at activation, so only the ones actually installed
47
- on the machine show up.
48
-
49
- ### Customizing the launcher
50
-
51
- The launcher reads `xtralab:launcher` settings from the JupyterLab settings
52
- registry. Open the Settings Editor (`Settings → Settings Editor → xtralab
53
- launcher`) and edit the `agents` array; xtralab merges your entries with
54
- the defaults by `id`.
16
+ ## Usage
55
17
 
56
- ```jsonc
57
- {
58
- "agents": [
59
- // Hide an agent you never want to see, even if it's installed:
60
- { "id": "kiro", "enabled": false },
18
+ ### As a JupyterLab package
61
19
 
62
- // Override an agent's command — e.g. point Claude at a shell alias.
63
- // `requireAvailable: false` keeps the card visible even though
64
- // `which cl` returns nothing (interactive shells expand the alias).
65
- { "id": "claude", "command": "cl", "requireAvailable": false },
20
+ Run JupyterLab the usual way:
66
21
 
67
- // Add a brand-new agent. `iconSvg` is optional; without it the card
68
- // falls back to a generic terminal icon. `promptArgs: []` means the
69
- // launcher's prompt textarea is appended as a positional argument.
70
- {
71
- "id": "aider",
72
- "label": "Aider",
73
- "command": "aider",
74
- "promptArgs": []
75
- }
76
- ]
77
- }
22
+ ```bash
23
+ jupyter lab
78
24
  ```
79
25
 
80
- Field reference (every field except `id` is optional):
81
-
82
- | Field | Effect |
83
- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
84
- | `id` | Built-in id to override, or a new id to define a new card. |
85
- | `label` | Card title. |
86
- | `caption` | Subtitle shown beneath the title. |
87
- | `command` | Literal text typed into the new terminal. |
88
- | `iconSvg` | Inline SVG for the card icon (only needed for new agents). |
89
- | `rank` | Sort order on the launcher; lower comes first. |
90
- | `enabled` | `false` hides the card from the launcher and palette. |
91
- | `requireAvailable` | `false` skips the `which` check (use for shell aliases that aren't on PATH but expand at runtime). |
92
- | `promptArgs` | How to splice the launcher's optional prompt into the command. `[]` appends it as a positional argument (`<command> 'PROMPT'`); `["-i"]` or `["--prompt"]` puts it after a flag. Set to `null` to opt out — the agent button is dimmed when the prompt textarea is non-empty so a typed prompt is never silently dropped. Defaults vary per agent. |
93
-
94
- ## Default settings
95
-
96
- `xtralab` ships the following JupyterLab defaults:
97
-
98
- - The announcements plugin is disabled, so JupyterLab does not prompt to fetch
99
- news from the Jupyter news feed and does not check for application updates.
100
- - The Table of Contents, Debugger, and Notebook Tools panels are disabled so
101
- they do not show up in the right sidebar by default.
102
- - `dockPanelPadding` is off, so the main dock area is flush with the
103
- surrounding chrome.
104
- - The terminal `fontFamily` defaults to `MesloLGS NF, ui-monospace, monospace`
105
- so Powerline / Nerd Font glyphs (used by Oh My Zsh themes such as
106
- `powerlevel10k`) render correctly when the font is installed; the
107
- `ui-monospace, monospace` fallback keeps things readable when it is not.
108
-
109
- The JupyterLab frontend defaults are shipped as `labconfig/*.d/00-xtralab.json`
110
- fragments so downstream meta-packages can add their own Lab configuration
111
- fragments without replacing `xtralab`'s files.
26
+ ### As a desktop app
112
27
 
113
- ## Install
28
+ A standalone Electron build (DMG on macOS, AppImage on Linux) ships with each
29
+ tagged release — grab the installer for your platform from the
30
+ [Releases page](https://github.com/jtpio/xtralab/releases/latest). Builds from
31
+ the current `main` branch are also produced on every push as workflow
32
+ artifacts under the repository's
33
+ [Actions tab](https://github.com/jtpio/xtralab/actions). See
34
+ [CONTRIBUTING.md](./CONTRIBUTING.md) for the architecture and local build
35
+ instructions.
114
36
 
115
- ```bash
116
- pip install xtralab
117
- ```
37
+ ## What's included
118
38
 
119
- ## Run as a desktop app
39
+ - [`ajlab`](https://github.com/jtpio/ajlab) — agent-ready JupyterLab base
40
+ - JupyterLab 4.6+
41
+ - `jupyterlab-git` — backs the bundled changes panel
42
+ - `jupyterlab-lsp` + `ty` — Python LSP via Astral's `ty` (bundled); also
43
+ detects `typescript-language-server` on `PATH` for JS/TS
44
+ - `jupyterlab-quickopen`
45
+ - `jupyterlab-cursor-light`, `jupyterlab-cursor-dark`
46
+ - `jupyterlab-day`, `jupyterlab-night` themes
47
+
48
+ The bundled labextension adds:
49
+
50
+ - A path-first file browser in the left sidebar.
51
+ - A "Source Control" panel powered by `jupyterlab-git` and `@pierre/diffs`.
52
+ - An agent launcher with a prompt textarea, a row of agent buttons (Claude,
53
+ Codex, Gemini, Copilot, Goose, OpenCode, Kiro, Mistral Vibe), and a
54
+ collapsible list of changed files. Buttons are filtered to agents installed
55
+ on the machine; a typed prompt is shell-quoted and spliced into the launch
56
+ command for agents that accept one.
57
+
58
+ ## Language servers
59
+
60
+ xtralab ships with
61
+ [`jupyterlab-lsp`](https://github.com/jupyter-lsp/jupyterlab-lsp) and
62
+ pre-registers two language servers through
63
+ `jupyter_server_config.d/xtralab-lsp.json`:
64
+
65
+ - **Python — [`ty`](https://github.com/astral-sh/ty)** — installed as a
66
+ Python dependency. Works out of the box.
67
+ - **TypeScript / JavaScript — `typescript-language-server`** — install
68
+ yourself:
69
+
70
+ ```bash
71
+ npm install -g typescript-language-server typescript
72
+ ```
73
+
74
+ Restart JupyterLab (or the desktop app) after installing.
75
+
76
+ Specs use bare command names (`["ty", "server"]`), so binaries are resolved
77
+ from `PATH` at spawn time.
78
+
79
+ ### Where binaries are discovered
80
+
81
+ - **`pip install xtralab`** — anything on the JupyterLab process's `PATH`.
82
+ - **Desktop app** — the supervisor augments `PATH` with common shim
83
+ locations (`~/.volta/bin`, `~/.npm-global/bin`, `~/.bun/bin`,
84
+ `~/.asdf/shims`, `~/.mise/shims`, `/opt/homebrew/bin`, `/usr/local/bin`,
85
+ …). Set `XTRALAB_EXTRA_PATH` (colon-separated) before launching the app
86
+ to add directories the defaults miss.
87
+
88
+ ### Adding more servers
89
+
90
+ To enable another server (bash, yaml, json, dockerfile, pyright, …),
91
+ install the binary then drop a JSON file into a `jupyter_server_config.d/`
92
+ directory:
93
+
94
+ - **`pip install xtralab`** — run `jupyter --paths` and pick a config dir
95
+ (typically `~/.jupyter/jupyter_server_config.d/`).
96
+ - **Desktop app (macOS)** —
97
+ `~/Library/Application Support/xtralab/jupyter/config/jupyter_server_config.d/`
98
+ (or `xtralab dev` for local dev builds).
99
+ - **Desktop app (Linux AppImage)** —
100
+ `~/.config/xtralab/jupyter/config/jupyter_server_config.d/`.
101
+
102
+ Example (`bash-lsp.json`):
103
+
104
+ ```json
105
+ {
106
+ "LanguageServerManager": {
107
+ "language_servers": {
108
+ "bash-language-server": {
109
+ "version": 2,
110
+ "argv": ["bash-language-server", "start"],
111
+ "languages": ["bash", "sh"],
112
+ "mime_types": ["text/x-sh", "application/x-sh"],
113
+ "display_name": "bash-language-server"
114
+ }
115
+ }
116
+ }
117
+ }
118
+ ```
120
119
 
121
- `xtralab` ships a tiny launcher that opens JupyterLab in a dedicated Chrome
122
- window (via Chrome's `--app` mode), with an isolated user-data directory so it
123
- does not share cookies, extensions, or history with your normal browser.
120
+ Pair with `npm install -g bash-language-server`. Reuse a bundled `key` to
121
+ override it. See
122
+ [jupyterlab-lsp's docs](https://jupyterlab-lsp.readthedocs.io/en/latest/Configuring.html)
123
+ for the full spec schema.
124
124
 
125
- Requires Chrome or Chromium installed. macOS only for now; other platforms will be added later.
125
+ ## Customizing the launcher
126
126
 
127
- The simplest way, no install needed:
127
+ Open `Settings → Settings Editor → xtralab launcher` and edit the `agents`
128
+ array. Entries are merged with the defaults by `id`.
128
129
 
129
- ```bash
130
- uvx xtralab
131
- ```
130
+ ```jsonc
131
+ {
132
+ "agents": [
133
+ // Hide an agent
134
+ { "id": "kiro", "enabled": false },
132
135
 
133
- Or, after `pip install xtralab` into an environment:
136
+ // Override an agent's command (e.g. point Claude at a shell alias)
137
+ { "id": "claude", "command": "cl", "requireAvailable": false },
134
138
 
135
- ```bash
136
- xtralab
139
+ // Add a new agent; promptArgs: [] appends the prompt as a positional arg
140
+ { "id": "aider", "label": "Aider", "command": "aider", "promptArgs": [] }
141
+ ]
142
+ }
137
143
  ```
138
144
 
139
- The launcher starts a local Jupyter server on a random port with a fresh
140
- token, waits for it to come up, opens the app window, and shuts the server
141
- down when you close the window. `Ctrl-C` in the launching terminal also
142
- shuts everything down cleanly.
145
+ Fields: `id` (required), `label`, `caption`, `command`, `promptArgs` (how to
146
+ splice the prompt — `[]` for positional, `["--flag"]` for flagged, `null` to
147
+ opt out), `iconSvg`, `rank`, `enabled`, `requireAvailable`.
143
148
 
144
- ## Development
149
+ ## Contributing
145
150
 
146
- ```bash
147
- # Install dependencies and the package in editable mode
148
- uv pip install -e ".[dev]"
149
- jlpm
150
- jlpm build
151
- ```
151
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for the development setup, the
152
+ Electron desktop app architecture, and the build pipeline.
152
153
 
153
154
  ## License
154
155
 
@@ -0,0 +1,11 @@
1
+ import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
+ /**
3
+ * Adds a VS Code-style path breadcrumb to text editor toolbars. Each
4
+ * segment is clickable: clicking dispatches `xtralab:reveal-path` so
5
+ * any plugin that listens (today, the file browser) can surface the
6
+ * underlying folder or file. The breadcrumbs plugin therefore does not
7
+ * import the file browser at all — the JupyterLab command registry is
8
+ * the only seam between them.
9
+ */
10
+ declare const plugin: JupyterFrontEndPlugin<void>;
11
+ export default plugin;
@@ -0,0 +1,41 @@
1
+ import { DisposableDelegate } from '@lumino/disposable';
2
+ import { EditorBreadcrumbs } from './widget';
3
+ const PLUGIN_ID = 'xtralab:editor-breadcrumbs';
4
+ const EDITOR_FACTORY = 'Editor';
5
+ const TOOLBAR_ITEM_NAME = 'xtralab-editor-breadcrumbs';
6
+ class EditorBreadcrumbsExtension {
7
+ constructor(commands) {
8
+ this._commands = commands;
9
+ }
10
+ createNew(widget, context) {
11
+ const breadcrumbs = new EditorBreadcrumbs({
12
+ context,
13
+ commands: this._commands
14
+ });
15
+ const added = widget.toolbar.insertItem(0, TOOLBAR_ITEM_NAME, breadcrumbs);
16
+ if (!added) {
17
+ breadcrumbs.dispose();
18
+ return;
19
+ }
20
+ return new DisposableDelegate(() => {
21
+ breadcrumbs.dispose();
22
+ });
23
+ }
24
+ }
25
+ /**
26
+ * Adds a VS Code-style path breadcrumb to text editor toolbars. Each
27
+ * segment is clickable: clicking dispatches `xtralab:reveal-path` so
28
+ * any plugin that listens (today, the file browser) can surface the
29
+ * underlying folder or file. The breadcrumbs plugin therefore does not
30
+ * import the file browser at all — the JupyterLab command registry is
31
+ * the only seam between them.
32
+ */
33
+ const plugin = {
34
+ id: PLUGIN_ID,
35
+ description: 'Display the active file path in text editor toolbars.',
36
+ autoStart: true,
37
+ activate: (app) => {
38
+ app.docRegistry.addWidgetExtension(EDITOR_FACTORY, new EditorBreadcrumbsExtension(app.commands));
39
+ }
40
+ };
41
+ export default plugin;
@@ -0,0 +1,34 @@
1
+ import type { DocumentRegistry } from '@jupyterlab/docregistry';
2
+ import type { CommandRegistry } from '@lumino/commands';
3
+ import { Widget } from '@lumino/widgets';
4
+ export interface IEditorBreadcrumbsOptions {
5
+ context: DocumentRegistry.IContext<DocumentRegistry.IModel>;
6
+ commands: CommandRegistry;
7
+ }
8
+ /**
9
+ * Read-only breadcrumb path for file editor toolbars.
10
+ *
11
+ * The upstream `@jupyterlab/filebrowser` BreadCrumbs widget is coupled to
12
+ * FileBrowserModel navigation, drag/drop, and path editing. If that code
13
+ * becomes reusable upstream, replace this local display-only renderer with
14
+ * the shared implementation.
15
+ */
16
+ export declare class EditorBreadcrumbs extends Widget {
17
+ constructor(options: IEditorBreadcrumbsOptions);
18
+ dispose(): void;
19
+ private _onPathChanged;
20
+ private _render;
21
+ private _createRootCrumb;
22
+ /**
23
+ * Bind click and keyboard activation on a crumb element to the
24
+ * shared reveal command. Dispatch is best-effort: a failure to
25
+ * resolve or execute the command is logged but never thrown into
26
+ * the editor's toolbar.
27
+ */
28
+ private _attachReveal;
29
+ private _createSeparator;
30
+ private _context;
31
+ private _commands;
32
+ private _container;
33
+ private _content;
34
+ }
@@ -0,0 +1,131 @@
1
+ import { PageConfig } from '@jupyterlab/coreutils';
2
+ import { homeIcon as rootIcon } from '@jupyterlab/ui-components';
3
+ import { Widget } from '@lumino/widgets';
4
+ const EDITOR_BREADCRUMBS_CLASS = 'jp-xtralab-EditorBreadcrumbs';
5
+ const BREADCRUMBS_CONTAINER_CLASS = 'jp-BreadCrumbs-container';
6
+ const BREADCRUMBS_CONTENT_CLASS = 'jp-BreadCrumbs-content';
7
+ const ROOT_CLASS = 'jp-BreadCrumbs-home';
8
+ const ITEM_CLASS = 'jp-BreadCrumbs-item';
9
+ const CURRENT_ITEM_CLASS = 'jp-mod-current';
10
+ const SEPARATOR_CLASS = 'jp-BreadCrumbs-separator';
11
+ /**
12
+ * Command dispatched when the user clicks a breadcrumb segment.
13
+ * Registered by the file browser plugin; the breadcrumbs only know
14
+ * the name so they stay decoupled from the file browser internals.
15
+ */
16
+ const REVEAL_COMMAND = 'xtralab:reveal-path';
17
+ /**
18
+ * Read-only breadcrumb path for file editor toolbars.
19
+ *
20
+ * The upstream `@jupyterlab/filebrowser` BreadCrumbs widget is coupled to
21
+ * FileBrowserModel navigation, drag/drop, and path editing. If that code
22
+ * becomes reusable upstream, replace this local display-only renderer with
23
+ * the shared implementation.
24
+ */
25
+ export class EditorBreadcrumbs extends Widget {
26
+ constructor(options) {
27
+ super({ node: document.createElement('nav') });
28
+ this._context = options.context;
29
+ this._commands = options.commands;
30
+ this.addClass(EDITOR_BREADCRUMBS_CLASS);
31
+ this.node.setAttribute('aria-label', 'Editor file path');
32
+ this._container = document.createElement('span');
33
+ this._container.className = BREADCRUMBS_CONTAINER_CLASS;
34
+ this._content = document.createElement('span');
35
+ this._content.className = BREADCRUMBS_CONTENT_CLASS;
36
+ this._container.appendChild(this._content);
37
+ this.node.appendChild(this._container);
38
+ this._context.pathChanged.connect(this._onPathChanged, this);
39
+ this._render();
40
+ }
41
+ dispose() {
42
+ if (this.isDisposed) {
43
+ return;
44
+ }
45
+ this._context.pathChanged.disconnect(this._onPathChanged, this);
46
+ super.dispose();
47
+ }
48
+ _onPathChanged() {
49
+ this._render();
50
+ }
51
+ _render() {
52
+ const path = this._context.localPath || this._context.path;
53
+ const parts = path.split('/').filter(part => part.length > 0);
54
+ this.node.title = this._context.path;
55
+ this._content.textContent = '';
56
+ this._content.appendChild(this._createRootCrumb());
57
+ if (parts.length > 0) {
58
+ this._content.appendChild(this._createSeparator());
59
+ }
60
+ parts.forEach((part, index) => {
61
+ const isLast = index === parts.length - 1;
62
+ const subPath = parts.slice(0, index + 1).join('/');
63
+ // Canonical `@pierre/trees` paths: directories carry a trailing
64
+ // slash, files do not. Every part except the last names a folder
65
+ // along the way to the open file.
66
+ const canonical = isLast ? subPath : `${subPath}/`;
67
+ const item = document.createElement('span');
68
+ item.className = ITEM_CLASS;
69
+ item.textContent = part;
70
+ item.title = subPath;
71
+ item.tabIndex = 0;
72
+ item.setAttribute('role', 'button');
73
+ if (isLast) {
74
+ item.classList.add(CURRENT_ITEM_CLASS);
75
+ item.setAttribute('aria-current', 'page');
76
+ }
77
+ this._attachReveal(item, canonical);
78
+ this._content.appendChild(item);
79
+ if (!isLast) {
80
+ this._content.appendChild(this._createSeparator());
81
+ }
82
+ });
83
+ }
84
+ _createRootCrumb() {
85
+ const item = rootIcon.element({
86
+ className: ROOT_CLASS,
87
+ tag: 'span',
88
+ title: PageConfig.getOption('serverRoot') || 'Jupyter Server Root',
89
+ stylesheet: 'breadCrumb'
90
+ });
91
+ item.dataset.path = '/';
92
+ item.tabIndex = 0;
93
+ item.setAttribute('role', 'button');
94
+ // Empty canonical path: the reveal command treats this as the
95
+ // workspace-root gesture — surface the file browser, clear its
96
+ // selection, and scroll back to the top. The tree has no row for
97
+ // the workspace root itself.
98
+ this._attachReveal(item, '');
99
+ return item;
100
+ }
101
+ /**
102
+ * Bind click and keyboard activation on a crumb element to the
103
+ * shared reveal command. Dispatch is best-effort: a failure to
104
+ * resolve or execute the command is logged but never thrown into
105
+ * the editor's toolbar.
106
+ */
107
+ _attachReveal(element, canonicalPath) {
108
+ const fire = (event) => {
109
+ event.preventDefault();
110
+ event.stopPropagation();
111
+ void this._commands
112
+ .execute(REVEAL_COMMAND, { path: canonicalPath })
113
+ .catch((err) => {
114
+ console.error(`xtralab: failed to reveal "${canonicalPath}"`, err);
115
+ });
116
+ };
117
+ element.addEventListener('click', fire);
118
+ element.addEventListener('keydown', event => {
119
+ if (event.key === 'Enter' || event.key === ' ') {
120
+ fire(event);
121
+ }
122
+ });
123
+ }
124
+ _createSeparator() {
125
+ const item = document.createElement('span');
126
+ item.className = SEPARATOR_CLASS;
127
+ item.setAttribute('aria-hidden', 'true');
128
+ item.textContent = '/';
129
+ return item;
130
+ }
131
+ }
@@ -0,0 +1,17 @@
1
+ import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
+ /**
3
+ * Sniff each opened document's leading whitespace and configure CodeMirror's
4
+ * `indentUnit` / `tabSize` to match. JupyterLab only exposes a single global
5
+ * indent setting, which means TypeScript files written with 4-space indent and
6
+ * Python files written with 2-space indent both look wrong in the same editor;
7
+ * detecting from the file's own content sidesteps that without forcing every
8
+ * project to share the same convention.
9
+ *
10
+ * Detection runs at editor-construction time and again on the first content
11
+ * change — that second pass exists because JupyterLab's collaborative document
12
+ * loader can settle the file body into the shared model after the editor view
13
+ * already exists, in which case the factory would otherwise have only seen an
14
+ * empty string.
15
+ */
16
+ declare const plugin: JupyterFrontEndPlugin<void>;
17
+ export default plugin;
@@ -0,0 +1,142 @@
1
+ import { EditorExtensionRegistry, IEditorExtensionRegistry } from '@jupyterlab/codemirror';
2
+ import { indentUnit } from '@codemirror/language';
3
+ import { Compartment, EditorState, Prec } from '@codemirror/state';
4
+ import { ViewPlugin } from '@codemirror/view';
5
+ import detectIndent from 'detect-indent';
6
+ const PLUGIN_ID = 'xtralab:editor-indent';
7
+ /**
8
+ * Mime types whose newly-created (or too-short-to-detect) files should default
9
+ * to a 2-space indent. Matches Prettier's defaults for web languages; mime
10
+ * types not listed here inherit whatever the user has set in JupyterLab.
11
+ * Covers .ts/.tsx/.jsx/.js variants registered by `@jupyterlab/codemirror`.
12
+ */
13
+ const FALLBACK_INDENT_BY_MIME = {
14
+ 'application/ecmascript': 2,
15
+ 'application/javascript': 2,
16
+ 'application/json': 2,
17
+ 'application/typescript': 2,
18
+ 'application/x-javascript': 2,
19
+ 'application/x-json': 2,
20
+ 'application/x-yaml': 2,
21
+ 'text/css': 2,
22
+ 'text/ecmascript': 2,
23
+ 'text/html': 2,
24
+ 'text/javascript': 2,
25
+ 'text/jsx': 2,
26
+ 'text/typescript-jsx': 2,
27
+ 'text/x-markdown': 2,
28
+ 'text/x-yaml': 2,
29
+ 'text/yaml': 2
30
+ };
31
+ /**
32
+ * Run `detect-indent` against the file's content and convert its result into
33
+ * something we can hand to CodeMirror.
34
+ */
35
+ function detectFromContent(text) {
36
+ if (text.length === 0) {
37
+ return null;
38
+ }
39
+ const result = detectIndent(text);
40
+ if (!result.type || result.amount <= 0) {
41
+ return null;
42
+ }
43
+ if (result.type === 'tab') {
44
+ // detect-indent returns `\t` for `indent` in tab mode; use a 4-column tab
45
+ // as the visual width, which is what most language servers assume.
46
+ return { unit: '\t', width: 4 };
47
+ }
48
+ return { unit: result.indent, width: result.amount };
49
+ }
50
+ function resolveIndent(text, mimeType) {
51
+ const detected = detectFromContent(text);
52
+ if (detected) {
53
+ return detected;
54
+ }
55
+ const fallback = FALLBACK_INDENT_BY_MIME[mimeType];
56
+ if (fallback === undefined) {
57
+ return null;
58
+ }
59
+ return { unit: ' '.repeat(fallback), width: fallback };
60
+ }
61
+ function buildExtension(resolved) {
62
+ // `Prec.highest` keeps these values first in their respective facets so
63
+ // they beat both `defaultConfig` and per-editor `editorConfig.indentUnit`,
64
+ // both of which JupyterLab registers at default precedence.
65
+ return Prec.highest([
66
+ indentUnit.of(resolved.unit),
67
+ EditorState.tabSize.of(resolved.width)
68
+ ]);
69
+ }
70
+ /**
71
+ * Sniff each opened document's leading whitespace and configure CodeMirror's
72
+ * `indentUnit` / `tabSize` to match. JupyterLab only exposes a single global
73
+ * indent setting, which means TypeScript files written with 4-space indent and
74
+ * Python files written with 2-space indent both look wrong in the same editor;
75
+ * detecting from the file's own content sidesteps that without forcing every
76
+ * project to share the same convention.
77
+ *
78
+ * Detection runs at editor-construction time and again on the first content
79
+ * change — that second pass exists because JupyterLab's collaborative document
80
+ * loader can settle the file body into the shared model after the editor view
81
+ * already exists, in which case the factory would otherwise have only seen an
82
+ * empty string.
83
+ */
84
+ const plugin = {
85
+ id: PLUGIN_ID,
86
+ description: 'Detect each file’s indentation style from its contents and configure CodeMirror to match; new files fall back to a language-based default.',
87
+ autoStart: true,
88
+ requires: [IEditorExtensionRegistry],
89
+ activate: (app, registry) => {
90
+ registry.addExtension({
91
+ name: 'xtralab:per-language-indent',
92
+ factory: options => {
93
+ const { model } = options;
94
+ const slot = new Compartment();
95
+ const initialResolved = resolveIndent(model.sharedModel.getSource(), model.mimeType);
96
+ const initial = initialResolved ? buildExtension(initialResolved) : [];
97
+ let settled = initialResolved !== null;
98
+ const refreshPlugin = ViewPlugin.fromClass(class {
99
+ constructor(view) {
100
+ this.view = view;
101
+ this.disconnect = null;
102
+ if (settled) {
103
+ return;
104
+ }
105
+ const handler = () => {
106
+ if (settled) {
107
+ return;
108
+ }
109
+ const resolved = resolveIndent(model.sharedModel.getSource(), model.mimeType);
110
+ if (!resolved) {
111
+ return;
112
+ }
113
+ settled = true;
114
+ view.dispatch({
115
+ effects: slot.reconfigure(buildExtension(resolved))
116
+ });
117
+ if (this.disconnect) {
118
+ this.disconnect();
119
+ this.disconnect = null;
120
+ }
121
+ };
122
+ model.sharedModel.changed.connect(handler);
123
+ this.disconnect = () => {
124
+ model.sharedModel.changed.disconnect(handler);
125
+ };
126
+ }
127
+ destroy() {
128
+ if (this.disconnect) {
129
+ this.disconnect();
130
+ this.disconnect = null;
131
+ }
132
+ }
133
+ });
134
+ return EditorExtensionRegistry.createImmutableExtension([
135
+ slot.of(initial),
136
+ refreshPlugin
137
+ ]);
138
+ }
139
+ });
140
+ }
141
+ };
142
+ export default plugin;