xtralab 0.3.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.
Files changed (60) hide show
  1. package/README.md +11 -4
  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 +3 -0
  9. package/lib/fileBrowser/commands.js +40 -1
  10. package/lib/fileBrowser/fileBrowser.js +393 -10
  11. package/lib/fileBrowser/gitStatus.d.ts +13 -0
  12. package/lib/fileBrowser/gitStatus.js +60 -0
  13. package/lib/fileBrowser/widget.d.ts +48 -0
  14. package/lib/fileBrowser/widget.js +24 -0
  15. package/lib/git/commands.d.ts +12 -13
  16. package/lib/git/commands.js +7 -28
  17. package/lib/git/diffProvider.d.ts +8 -0
  18. package/lib/git/diffProvider.js +314 -0
  19. package/lib/git/diffSurface.d.ts +115 -0
  20. package/lib/git/diffSurface.js +342 -0
  21. package/lib/git/diffWidget.d.ts +8 -13
  22. package/lib/git/diffWidget.js +65 -311
  23. package/lib/git/imageDiff.d.ts +23 -0
  24. package/lib/git/imageDiff.js +184 -0
  25. package/lib/git/index.d.ts +10 -8
  26. package/lib/git/index.js +37 -52
  27. package/lib/index.d.ts +3 -2
  28. package/lib/index.js +9 -4
  29. package/lib/launcher/dashboard.js +27 -15
  30. package/package.json +24 -4
  31. package/src/editorBreadcrumbs/index.ts +70 -0
  32. package/src/editorBreadcrumbs/widget.ts +158 -0
  33. package/src/editorIndent/index.ts +177 -0
  34. package/src/fileBrowser/commands.ts +43 -1
  35. package/src/fileBrowser/fileBrowser.tsx +427 -11
  36. package/src/fileBrowser/gitStatus.ts +66 -0
  37. package/src/fileBrowser/widget.tsx +77 -0
  38. package/src/git/commands.ts +12 -38
  39. package/src/git/diffProvider.tsx +462 -0
  40. package/src/git/diffSurface.tsx +588 -0
  41. package/src/git/diffWidget.tsx +105 -465
  42. package/src/git/imageDiff.tsx +368 -0
  43. package/src/git/index.ts +38 -68
  44. package/src/index.ts +9 -4
  45. package/src/launcher/dashboard.tsx +28 -15
  46. package/style/editorBreadcrumbs.css +86 -0
  47. package/style/git.css +158 -266
  48. package/style/index.css +2 -0
  49. package/style/index.js +1 -0
  50. package/style/launcher.css +0 -5
  51. package/style/tabs.css +66 -0
  52. package/lib/git/icons.d.ts +0 -14
  53. package/lib/git/icons.js +0 -19
  54. package/lib/git/panel.d.ts +0 -29
  55. package/lib/git/panel.js +0 -42
  56. package/lib/git/panelComponent.d.ts +0 -28
  57. package/lib/git/panelComponent.js +0 -303
  58. package/src/git/icons.ts +0 -20
  59. package/src/git/panel.tsx +0 -56
  60. package/src/git/panelComponent.tsx +0 -561
@@ -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
  }