xtralab 0.14.0 → 0.15.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 (52) hide show
  1. package/lib/customPanel/index.d.ts +7 -0
  2. package/lib/customPanel/index.js +74 -0
  3. package/lib/fileBrowser/commands.js +13 -1
  4. package/lib/fileBrowser/contents.d.ts +11 -0
  5. package/lib/fileBrowser/contents.js +28 -0
  6. package/lib/fileBrowser/dragAndDrop.d.ts +66 -0
  7. package/lib/fileBrowser/dragAndDrop.js +115 -0
  8. package/lib/fileBrowser/fileBrowser.d.ts +2 -0
  9. package/lib/fileBrowser/fileBrowser.js +151 -197
  10. package/lib/fileBrowser/index.js +39 -6
  11. package/lib/fileBrowser/widget.d.ts +44 -5
  12. package/lib/fileBrowser/widget.js +153 -14
  13. package/lib/index.js +4 -0
  14. package/lib/launcher/agents.d.ts +1 -1
  15. package/lib/launcher/agents.js +15 -2
  16. package/lib/launcher/icons.js +15 -4
  17. package/lib/launcher/schemaDefaults.d.ts +2 -9
  18. package/lib/launcher/schemaDefaults.js +37 -13
  19. package/lib/menuBar/icons.d.ts +6 -0
  20. package/lib/menuBar/icons.js +13 -0
  21. package/lib/menuBar/index.d.ts +15 -0
  22. package/lib/menuBar/index.js +164 -0
  23. package/lib/sidebar/index.d.ts +7 -13
  24. package/lib/sidebar/index.js +80 -46
  25. package/lib/terminals/index.d.ts +5 -3
  26. package/lib/terminals/index.js +17 -6
  27. package/lib/terminals/widget.d.ts +29 -25
  28. package/lib/terminals/widget.js +150 -65
  29. package/package.json +1 -1
  30. package/schema/launcher.json +2 -2
  31. package/schema/menu-bar.json +37 -0
  32. package/schema/sidebar.json +47 -7
  33. package/src/customPanel/index.ts +104 -0
  34. package/src/fileBrowser/commands.ts +14 -1
  35. package/src/fileBrowser/contents.ts +30 -0
  36. package/src/fileBrowser/dragAndDrop.ts +178 -0
  37. package/src/fileBrowser/fileBrowser.tsx +183 -225
  38. package/src/fileBrowser/index.ts +41 -6
  39. package/src/fileBrowser/widget.tsx +195 -14
  40. package/src/index.ts +4 -0
  41. package/src/launcher/agents.ts +16 -3
  42. package/src/launcher/icons.ts +16 -4
  43. package/src/launcher/schemaDefaults.ts +43 -13
  44. package/src/menuBar/icons.ts +14 -0
  45. package/src/menuBar/index.ts +195 -0
  46. package/src/sidebar/index.ts +95 -52
  47. package/src/terminals/index.ts +24 -6
  48. package/src/terminals/widget.tsx +186 -87
  49. package/style/base.css +22 -34
  50. package/style/customPanel.css +9 -0
  51. package/style/index.js +1 -0
  52. package/style/terminals.css +19 -77
@@ -0,0 +1,37 @@
1
+ {
2
+ "title": "xtralab menu bar",
3
+ "description": "Visibility of the main menu bar.",
4
+ "jupyter.lab.shortcuts": [],
5
+ "jupyter.lab.menus": {
6
+ "main": [
7
+ {
8
+ "id": "jp-mainmenu-view",
9
+ "items": [
10
+ {
11
+ "type": "submenu",
12
+ "rank": 1,
13
+ "submenu": {
14
+ "id": "jp-mainmenu-view-appearance",
15
+ "items": [
16
+ {
17
+ "command": "xtralab:toggle-menu-bar",
18
+ "rank": 14
19
+ }
20
+ ]
21
+ }
22
+ }
23
+ ]
24
+ }
25
+ ]
26
+ },
27
+ "type": "object",
28
+ "properties": {
29
+ "visible": {
30
+ "type": "boolean",
31
+ "title": "Show the menu bar",
32
+ "description": "When false, hides the main menu bar. Its menus stay available from the menu button at the leading edge of the top bar and the bar can be restored from View → Appearance → Show Menu Bar.",
33
+ "default": false
34
+ }
35
+ },
36
+ "additionalProperties": false
37
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "title": "xtralab sidebar",
3
- "description": "Visibility of selected JupyterLab sidebar tabs.",
3
+ "description": "Visibility of individual sidebar tabs.",
4
4
  "jupyter.lab.shortcuts": [],
5
5
  "jupyter.lab.menus": {
6
6
  "main": [
@@ -17,16 +17,32 @@
17
17
  "type": "submenu",
18
18
  "rank": 12.5,
19
19
  "submenu": {
20
- "id": "jp-mainmenu-view-appearance-left-sidebar",
21
- "label": "Left Sidebar",
20
+ "id": "jp-mainmenu-view-appearance-sidebars",
21
+ "label": "Sidebars",
22
22
  "items": [
23
23
  {
24
- "command": "xtralab:toggle-default-filebrowser",
24
+ "command": "xtralab:toggle-terminals",
25
25
  "rank": 1
26
26
  },
27
27
  {
28
- "command": "xtralab:toggle-running-sessions",
28
+ "command": "xtralab:toggle-file-browser",
29
29
  "rank": 2
30
+ },
31
+ {
32
+ "command": "xtralab:toggle-git-panel",
33
+ "rank": 3
34
+ },
35
+ {
36
+ "command": "xtralab:toggle-default-filebrowser",
37
+ "rank": 4
38
+ },
39
+ {
40
+ "command": "xtralab:toggle-running-sessions",
41
+ "rank": 5
42
+ },
43
+ {
44
+ "command": "xtralab:toggle-search-replace",
45
+ "rank": 6
30
46
  }
31
47
  ]
32
48
  }
@@ -40,16 +56,40 @@
40
56
  },
41
57
  "type": "object",
42
58
  "properties": {
59
+ "showTerminals": {
60
+ "type": "boolean",
61
+ "title": "Show the terminals panel",
62
+ "description": "When false, hides the xtralab Terminals (`xtralab-running-terminals`) tab from the sidebar.",
63
+ "default": true
64
+ },
65
+ "showFileBrowser": {
66
+ "type": "boolean",
67
+ "title": "Show the xtralab file browser",
68
+ "description": "When false, hides the xtralab file browser (`xtralab:file-browser`) tab from the sidebar. The default JupyterLab file browser is unaffected.",
69
+ "default": true
70
+ },
71
+ "showGitPanel": {
72
+ "type": "boolean",
73
+ "title": "Show the Git panel",
74
+ "description": "When false, hides the `jp-git-sessions` (Git) tab from the sidebar.",
75
+ "default": true
76
+ },
43
77
  "showDefaultFileBrowser": {
44
78
  "type": "boolean",
45
79
  "title": "Show the default JupyterLab file browser",
46
- "description": "When false, hides the default `filebrowser` tab from the left sidebar. The xtralab file browser is unaffected.",
80
+ "description": "When false, hides the default `filebrowser` tab from the sidebar. The xtralab file browser is unaffected.",
47
81
  "default": true
48
82
  },
49
83
  "showRunningSessions": {
50
84
  "type": "boolean",
51
85
  "title": "Show running terminals and kernels",
52
- "description": "When false, hides the `jp-running-sessions` (Running Terminals and Kernels) tab from the left sidebar.",
86
+ "description": "When false, hides the `jp-running-sessions` (Running Terminals and Kernels) tab from the sidebar.",
87
+ "default": true
88
+ },
89
+ "showSearchReplace": {
90
+ "type": "boolean",
91
+ "title": "Show search and replace",
92
+ "description": "When false, hides the `jp-search-replace` (Search and Replace) tab from the sidebar.",
53
93
  "default": true
54
94
  }
55
95
  },
@@ -0,0 +1,104 @@
1
+ import {
2
+ JupyterFrontEnd,
3
+ JupyterFrontEndPlugin
4
+ } from '@jupyterlab/application';
5
+ import {
6
+ IMovableSectionDestination,
7
+ IMovableSectionRegistry
8
+ } from '@jupyterlab/apputils';
9
+ import {
10
+ ITranslator,
11
+ nullTranslator,
12
+ type TranslationBundle
13
+ } from '@jupyterlab/translation';
14
+ import { SidePanel, tableRowsIcon } from '@jupyterlab/ui-components';
15
+ import { ISignal, Signal } from '@lumino/signaling';
16
+ import { AccordionPanel, Widget } from '@lumino/widgets';
17
+
18
+ const PLUGIN_ID = 'xtralab:custom-panel';
19
+
20
+ const CUSTOM_PANEL_ID = 'xtralab-custom-panel';
21
+
22
+ /**
23
+ * A sidebar panel holding only the sections moved in from other panels.
24
+ */
25
+ class CustomPanel extends SidePanel implements IMovableSectionDestination {
26
+ constructor(options: { trans: TranslationBundle }) {
27
+ super();
28
+ this.id = CUSTOM_PANEL_ID;
29
+ this.title.icon = tableRowsIcon;
30
+ this.title.caption = options.trans.__('Custom Panel');
31
+ this.addClass('jp-xtralab-CustomPanel');
32
+ }
33
+
34
+ get accordionPanel(): AccordionPanel {
35
+ return this.content as AccordionPanel;
36
+ }
37
+
38
+ get sections(): ReadonlyArray<Widget> {
39
+ return this.content.widgets;
40
+ }
41
+
42
+ get sectionCountChanged(): ISignal<this, number> {
43
+ return this._sectionCountChanged;
44
+ }
45
+
46
+ addSection(widget: Widget): void {
47
+ this.addWidget(widget);
48
+ this._sectionCountChanged.emit(this.content.widgets.length);
49
+ }
50
+
51
+ removeSectionWidget(widget: Widget): void {
52
+ if (widget.parent !== this.content) {
53
+ return;
54
+ }
55
+ widget.parent = null;
56
+ this._sectionCountChanged.emit(this.content.widgets.length);
57
+ }
58
+
59
+ private _sectionCountChanged = new Signal<this, number>(this);
60
+ }
61
+
62
+ /**
63
+ * Registers "Custom Panel", which takes a sidebar tab only while it hosts a
64
+ * section.
65
+ */
66
+ const plugin: JupyterFrontEndPlugin<void> = {
67
+ id: PLUGIN_ID,
68
+ description:
69
+ 'A user-assembled sidebar panel hosting sections moved in from other panels; hidden while empty.',
70
+ autoStart: true,
71
+ optional: [IMovableSectionRegistry, ITranslator],
72
+ activate: (
73
+ app: JupyterFrontEnd,
74
+ registry: IMovableSectionRegistry | null,
75
+ translator: ITranslator | null
76
+ ): void => {
77
+ if (!registry) {
78
+ return;
79
+ }
80
+ const trans = (translator ?? nullTranslator).load('jupyterlab');
81
+ const panel = new CustomPanel({ trans });
82
+
83
+ // Only a user move should switch the sidebar, not the startup restoration.
84
+ let restored = false;
85
+ void app.restored.then(() => {
86
+ restored = true;
87
+ });
88
+
89
+ panel.sectionCountChanged.connect((_, count) => {
90
+ if (count > 0 && !panel.parent) {
91
+ app.shell.add(panel, 'left', { rank: 3 });
92
+ if (restored) {
93
+ app.shell.activateById(panel.id);
94
+ }
95
+ } else if (count === 0 && panel.parent) {
96
+ panel.parent = null;
97
+ }
98
+ });
99
+
100
+ registry.registerTarget(PLUGIN_ID, trans.__('Custom Panel'), panel);
101
+ }
102
+ };
103
+
104
+ export default plugin;
@@ -512,11 +512,24 @@ export function registerCommands(opts: IRegisterCommandsOptions): () => void {
512
512
  // point of view. An empty path is the "workspace root" gesture —
513
513
  // there is no tree row for the root itself, so the browser is asked
514
514
  // to scroll back to the top and clear its selection instead.
515
+ // The tree is movable, so surface whichever sidebar widget holds it.
516
+ const activateTreeHost = (): void => {
517
+ for (const area of ['left', 'right']) {
518
+ for (const widget of app.shell.widgets(area)) {
519
+ if (widget.node.contains(browser.sectionNode)) {
520
+ app.shell.activateById(widget.id);
521
+ return;
522
+ }
523
+ }
524
+ }
525
+ app.shell.activateById(FILE_BROWSER_ID);
526
+ };
527
+
515
528
  commands.addCommand(CommandIDs.revealPath, {
516
529
  label: trans.__('Reveal in File Browser'),
517
530
  execute: (args: ReadonlyPartialJSONObject) => {
518
531
  const path = (args.path as string | undefined) ?? '';
519
- app.shell.activateById(FILE_BROWSER_ID);
532
+ activateTreeHost();
520
533
  if (path.length === 0) {
521
534
  browser.scrollToRoot();
522
535
  } else {
@@ -17,6 +17,36 @@ export function toCanonicalPath(child: Contents.IModel): string {
17
17
  return child.type === 'directory' ? `${child.path}${DIR_SUFFIX}` : child.path;
18
18
  }
19
19
 
20
+ /**
21
+ * Compute the canonical parent path for a canonical path. Returns
22
+ * {@link ROOT_LOAD_KEY} for top-level entries.
23
+ */
24
+ export function parentOf(canonicalPath: string): string {
25
+ const trimmed = canonicalPath.endsWith(DIR_SUFFIX)
26
+ ? canonicalPath.slice(0, -DIR_SUFFIX.length)
27
+ : canonicalPath;
28
+ const idx = trimmed.lastIndexOf(DIR_SUFFIX);
29
+ if (idx < 0) {
30
+ return ROOT_LOAD_KEY;
31
+ }
32
+ return `${trimmed.slice(0, idx)}${DIR_SUFFIX}`;
33
+ }
34
+
35
+ /**
36
+ * The last segment of a canonical path. Directories keep their trailing
37
+ * slash, so appending the result to a canonical directory path yields a
38
+ * canonical path again.
39
+ */
40
+ export function canonicalBasename(canonicalPath: string): string {
41
+ const isDir = canonicalPath.endsWith(DIR_SUFFIX);
42
+ const trimmed = isDir
43
+ ? canonicalPath.slice(0, -DIR_SUFFIX.length)
44
+ : canonicalPath;
45
+ const idx = trimmed.lastIndexOf(DIR_SUFFIX);
46
+ const base = idx < 0 ? trimmed : trimmed.slice(idx + DIR_SUFFIX.length);
47
+ return isDir ? `${base}${DIR_SUFFIX}` : base;
48
+ }
49
+
20
50
  /**
21
51
  * Strip the trailing slash that `@pierre/trees` uses for canonical directory
22
52
  * paths so the value can be sent to the Jupyter contents API.
@@ -0,0 +1,178 @@
1
+ import * as React from 'react';
2
+
3
+ import type {
4
+ FileTree,
5
+ FileTreeDragAndDropConfig,
6
+ FileTreeDropContext,
7
+ FileTreeDropResult,
8
+ FileTreeDropTarget
9
+ } from '@pierre/trees';
10
+
11
+ import { ROOT_LOAD_KEY, canonicalBasename, parentOf } from './contents';
12
+
13
+ /**
14
+ * One tree-model move a drop performs, as canonical paths.
15
+ */
16
+ export interface IDropMove {
17
+ from: string;
18
+ to: string;
19
+ }
20
+
21
+ /**
22
+ * Receives drop outcomes from the `@pierre/trees` drag-and-drop
23
+ * machinery. Implemented inside the file browser's contents-sync
24
+ * effect, which owns the load-state and contents-API bookkeeping.
25
+ */
26
+ export interface ITreeDropHandler {
27
+ /**
28
+ * The tree applied the drop to its model: persist the move and
29
+ * re-key the load-state mirrors.
30
+ */
31
+ completeDrop(result: FileTreeDropResult): void;
32
+
33
+ /**
34
+ * The tree refused the drop (typically a name collision) and left
35
+ * its model untouched.
36
+ */
37
+ failedDrop(error: string, context: FileTreeDropContext): void;
38
+
39
+ /**
40
+ * A drop on the empty space below the last row: move the dragged
41
+ * paths to the workspace root.
42
+ */
43
+ dropOnRoot(draggedPaths: readonly string[]): void;
44
+ }
45
+
46
+ /**
47
+ * The from→to moves a drop performs: each dragged path keeps its
48
+ * basename under the target directory. Paths already directly inside
49
+ * the target are skipped.
50
+ */
51
+ export function computeDropMoves(context: FileTreeDropContext): IDropMove[] {
52
+ const dir = context.target.directoryPath ?? ROOT_LOAD_KEY;
53
+ return context.draggedPaths
54
+ .filter(path => parentOf(path) !== dir)
55
+ .map(path => ({ from: path, to: `${dir}${canonicalBasename(path)}` }));
56
+ }
57
+
58
+ /**
59
+ * The drop target the tree reports for a move to the workspace root.
60
+ */
61
+ const ROOT_DROP_TARGET: FileTreeDropTarget = {
62
+ directoryPath: null,
63
+ flattenedSegmentPath: null,
64
+ hoveredPath: null,
65
+ kind: 'root'
66
+ };
67
+
68
+ /**
69
+ * The moves for a drop of `paths` onto the workspace root.
70
+ */
71
+ export function computeRootDropMoves(paths: readonly string[]): IDropMove[] {
72
+ return computeDropMoves({ draggedPaths: paths, target: ROOT_DROP_TARGET });
73
+ }
74
+
75
+ /**
76
+ * Build the `dragAndDrop` option for `useFileTree`. The tree captures
77
+ * its options once, so the callbacks delegate through `handlerRef`,
78
+ * filled by the contents-sync effect. `canDrop` hides the drop
79
+ * highlight for all-no-op drops, which the library would accept.
80
+ */
81
+ export function createTreeDragAndDropConfig(
82
+ handlerRef: React.RefObject<ITreeDropHandler | null>
83
+ ): FileTreeDragAndDropConfig {
84
+ return {
85
+ canDrop: context => computeDropMoves(context).length > 0,
86
+ onDropComplete: result => {
87
+ handlerRef.current?.completeDrop(result);
88
+ },
89
+ onDropError: (error, context) => {
90
+ handlerRef.current?.failedDrop(error, context);
91
+ }
92
+ };
93
+ }
94
+
95
+ interface IRootDropZoneOptions {
96
+ model: FileTree;
97
+ handlerRef: React.RefObject<ITreeDropHandler | null>;
98
+ /**
99
+ * The light-DOM wrapper around the tree host; composed drag events
100
+ * bubble out of the shadow root to it.
101
+ */
102
+ wrapperRef: React.RefObject<HTMLDivElement | null>;
103
+ }
104
+
105
+ /**
106
+ * Accept drops on the empty space below the last row as moves to the
107
+ * workspace root — the tree resolves a drop target only while the
108
+ * cursor is over a row and silently discards such drops. dragstart
109
+ * records the dragged paths; a drop whose composed path contains no
110
+ * row hands them over as a root move.
111
+ */
112
+ export function useRootDropZone(options: IRootDropZoneOptions): void {
113
+ const { model, handlerRef, wrapperRef } = options;
114
+
115
+ React.useEffect(() => {
116
+ const wrapper = wrapperRef.current;
117
+ if (wrapper === null) {
118
+ return;
119
+ }
120
+
121
+ let draggedPaths: readonly string[] | null = null;
122
+
123
+ const findRow = (event: DragEvent): HTMLElement | null => {
124
+ for (const target of event.composedPath()) {
125
+ if (target instanceof HTMLElement && target.dataset.type === 'item') {
126
+ return target;
127
+ }
128
+ }
129
+ return null;
130
+ };
131
+
132
+ const handleDragStart = (event: DragEvent): void => {
133
+ draggedPaths = null;
134
+ // The tree cancels dragstart when it refuses a session, e.g.
135
+ // while the filter is open.
136
+ if (event.defaultPrevented) {
137
+ return;
138
+ }
139
+ const path = findRow(event)?.dataset.itemPath;
140
+ if (path === undefined || path.length === 0) {
141
+ return;
142
+ }
143
+ // The selection is the dragged set (the tree selects the pressed
144
+ // row); entries nested under a dragged folder travel with it.
145
+ const selection = model.getSelectedPaths();
146
+ const candidates = selection.includes(path) ? selection : [path];
147
+ const folders = candidates.filter(c => c.endsWith('/'));
148
+ draggedPaths = candidates.filter(
149
+ c => !folders.some(folder => folder !== c && c.startsWith(folder))
150
+ );
151
+ };
152
+
153
+ const handleDrop = (event: DragEvent): void => {
154
+ const paths = draggedPaths;
155
+ draggedPaths = null;
156
+ // Drops on a row were already resolved by the tree itself.
157
+ if (paths === null || findRow(event) !== null) {
158
+ return;
159
+ }
160
+ event.preventDefault();
161
+ handlerRef.current?.dropOnRoot(paths);
162
+ };
163
+
164
+ const handleDragEnd = (): void => {
165
+ draggedPaths = null;
166
+ };
167
+
168
+ wrapper.addEventListener('dragstart', handleDragStart);
169
+ wrapper.addEventListener('drop', handleDrop);
170
+ window.addEventListener('dragend', handleDragEnd);
171
+ return () => {
172
+ wrapper.removeEventListener('dragstart', handleDragStart);
173
+ wrapper.removeEventListener('drop', handleDrop);
174
+ window.removeEventListener('dragend', handleDragEnd);
175
+ draggedPaths = null;
176
+ };
177
+ }, [model, handlerRef, wrapperRef]);
178
+ }