jupyterlab_commands_extension 1.0.18 → 1.1.3

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 CHANGED
@@ -55,16 +55,26 @@ JUPYTERLAB_COMMANDS_TOKEN=... jupyterlab-commands --url https://remote-host:8888
55
55
  jupyterlab-commands activity
56
56
  ```
57
57
 
58
+ `file:download` starts a browser download of a file the contents API serves. The `path` argument names the file relative to the server root; the file must exist, and folders are refused. The download starts in the browser tab that runs the relay, so the file lands on the user computer.
59
+
60
+ ```bash
61
+ jupyterlab-commands execute file:download --args '{"path": "data/report.csv"}'
62
+ ```
63
+
58
64
  Exit codes: 0 success, 2 discovery or usage error, 3 no browser client, 4 command not allowed, 5 execution error.
59
65
 
60
66
  The browser tab is the executor: the command runs in the lab, not in the terminal.
61
67
 
68
+ Every execute request reaches the JupyterLab server log: the outcome, the command ID, the arguments as compact JSON truncated to 500 characters, the elapsed milliseconds and the caller's remote IP, at INFO on success and WARNING on refusal or failure.
69
+
62
70
  ## Allowlist
63
71
 
64
- The `allowedCommands` setting holds the command IDs the CLI may run. The defaults are `docmanager:open`, `help:open`, `apputils:notify`, `notebook:create-new`, `console:create`, `terminal:create-new`, `launcher:create` and `jupyterlab-commands-reference:open`.
72
+ The `allowedCommands` setting holds the command IDs the CLI may run. The defaults are `docmanager:open`, `help:open`, `apputils:notify`, `notebook:create-new`, `console:create`, `terminal:create-new`, `launcher:create`, `jupyterlab-commands-reference:open` and `file:download`.
65
73
 
66
74
  The **Add to command executor** button in the Command Executor column of the reference tab appends that command ID to the setting, and the button then shows **Added**. The setting can also be edited directly in **Settings > Settings Editor > Commands Executor**, whose plugin ID is `jupyterlab_commands_extension:plugin`.
67
75
 
76
+ A user with a saved `allowedCommands` list keeps it; the extension does not rewrite the list, so add `file:download` with the **Add to command executor** button.
77
+
68
78
  The reference tab opens from the command palette as **Show Commands Reference** under Help, and carries the text editor icon.
69
79
 
70
80
  ## Agent skill
package/lib/index.js CHANGED
@@ -9,6 +9,7 @@ import { CommandsReferenceWidget } from './widget';
9
9
  var CommandIDs;
10
10
  (function (CommandIDs) {
11
11
  CommandIDs.open = 'jupyterlab-commands-reference:open';
12
+ CommandIDs.download = 'file:download';
12
13
  })(CommandIDs || (CommandIDs = {}));
13
14
  /**
14
15
  * The id of this plugin. It is also the id of its settings schema.
@@ -70,6 +71,43 @@ const plugin = {
70
71
  command: CommandIDs.open,
71
72
  category: 'Help'
72
73
  });
74
+ // Download a file the contents API serves to the computer that runs
75
+ // the browser tab.
76
+ app.commands.addCommand(CommandIDs.download, {
77
+ label: 'Download File',
78
+ caption: 'Download a file from the server to the local computer',
79
+ describedBy: () => ({
80
+ args: {
81
+ type: 'object',
82
+ properties: {
83
+ path: {
84
+ type: 'string',
85
+ description: 'the file path relative to the server root'
86
+ }
87
+ },
88
+ required: ['path']
89
+ }
90
+ }),
91
+ execute: async (args) => {
92
+ const path = typeof args.path === 'string' ? args.path : '';
93
+ if (!path) {
94
+ throw new Error('file:download requires a non-empty path argument');
95
+ }
96
+ const model = await app.serviceManager.contents.get(path, {
97
+ content: false
98
+ });
99
+ if (model.type === 'directory') {
100
+ throw new Error(`file:download refuses a directory: ${path}`);
101
+ }
102
+ const url = await app.serviceManager.contents.getDownloadUrl(model.path);
103
+ const element = document.createElement('a');
104
+ element.href = url;
105
+ element.download = '';
106
+ document.body.appendChild(element);
107
+ element.click();
108
+ document.body.removeChild(element);
109
+ }
110
+ });
73
111
  if (settings) {
74
112
  relay = new RelayClient({
75
113
  app,
package/lib/widget.js CHANGED
@@ -47,7 +47,8 @@ export class CommandsReferenceWidget extends Widget {
47
47
  this._searchInput = document.createElement('input');
48
48
  this._searchInput.type = 'text';
49
49
  this._searchInput.placeholder = 'Filter commands...';
50
- this._searchInput.className = 'jp-CommandsReferenceWidget-search';
50
+ this._searchInput.className =
51
+ 'jp-CommandsReferenceWidget-search jp-mod-styled';
51
52
  this._searchInput.addEventListener('input', () => {
52
53
  this._filterCommands();
53
54
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_commands_extension",
3
- "version": "1.0.18",
3
+ "version": "1.1.3",
4
4
  "description": "Display all available JupyterLab commands with their full reference IDs and arguments in a dedicated tab",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -21,7 +21,8 @@
21
21
  "console:create",
22
22
  "terminal:create-new",
23
23
  "launcher:create",
24
- "jupyterlab-commands-reference:open"
24
+ "jupyterlab-commands-reference:open",
25
+ "file:download"
25
26
  ]
26
27
  }
27
28
  },
@@ -0,0 +1,121 @@
1
+ import type { JupyterFrontEnd } from '@jupyterlab/application';
2
+ import type { ICommandPalette } from '@jupyterlab/apputils';
3
+ import type { ISettingRegistry } from '@jupyterlab/settingregistry';
4
+ import { CommandRegistry } from '@lumino/commands';
5
+
6
+ // The tests never run the reference-tab command, so mock the UI modules it
7
+ // reaches for: the real ones pull an untranspiled ESM chain.
8
+ jest.mock('@jupyterlab/apputils', () => ({
9
+ MainAreaWidget: jest.fn()
10
+ }));
11
+ jest.mock('@jupyterlab/ui-components', () => ({
12
+ textEditorIcon: null
13
+ }));
14
+
15
+ import plugin from '../index';
16
+
17
+ const COMMAND_ID = 'file:download';
18
+
19
+ interface IFakeApp {
20
+ app: JupyterFrontEnd;
21
+ get: jest.Mock;
22
+ getDownloadUrl: jest.Mock;
23
+ }
24
+
25
+ /**
26
+ * Activate the plugin with a registry whose settings load fails, so the relay
27
+ * never starts, and return the contents mocks the command executes against.
28
+ */
29
+ async function activatePlugin(): Promise<IFakeApp> {
30
+ const commands = new CommandRegistry();
31
+ const get = jest.fn();
32
+ const getDownloadUrl = jest.fn();
33
+ const app = {
34
+ commands,
35
+ serviceManager: { contents: { get, getDownloadUrl } }
36
+ } as unknown as JupyterFrontEnd;
37
+ const palette = { addItem: jest.fn() } as unknown as ICommandPalette;
38
+ const registry = {
39
+ load: jest.fn().mockRejectedValue(new Error('no settings in this test'))
40
+ } as unknown as ISettingRegistry;
41
+ await plugin.activate(app, palette, registry);
42
+ return { app, get, getDownloadUrl };
43
+ }
44
+
45
+ function spyOnAnchorClick(): jest.Mock {
46
+ const clicked = jest.fn();
47
+ jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
48
+ this: HTMLAnchorElement
49
+ ) {
50
+ clicked({ element: this, parent: this.parentNode });
51
+ });
52
+ return clicked;
53
+ }
54
+
55
+ describe('file:download', () => {
56
+ it('registers the command with the describedBy path argument', async () => {
57
+ const { app } = await activatePlugin();
58
+
59
+ expect(app.commands.hasCommand(COMMAND_ID)).toBe(true);
60
+ expect(app.commands.label(COMMAND_ID)).toBe('Download File');
61
+ const description = await app.commands.describedBy(COMMAND_ID);
62
+ const args = description.args as Record<string, any>;
63
+ expect(args.type).toBe('object');
64
+ expect(args.properties.path.type).toBe('string');
65
+ expect(args.properties.path.description).toBe(
66
+ 'the file path relative to the server root'
67
+ );
68
+ expect(args.required).toEqual(['path']);
69
+ });
70
+
71
+ it('rejects when path is missing or not a non-empty string', async () => {
72
+ const { app, get } = await activatePlugin();
73
+
74
+ await expect(app.commands.execute(COMMAND_ID, {})).rejects.toThrow(
75
+ 'non-empty path'
76
+ );
77
+ await expect(
78
+ app.commands.execute(COMMAND_ID, { path: '' })
79
+ ).rejects.toThrow('non-empty path');
80
+ await expect(app.commands.execute(COMMAND_ID, { path: 3 })).rejects.toThrow(
81
+ 'non-empty path'
82
+ );
83
+ expect(get).not.toHaveBeenCalled();
84
+ });
85
+
86
+ it('rejects a directory and clicks nothing', async () => {
87
+ const { app, get, getDownloadUrl } = await activatePlugin();
88
+ get.mockResolvedValue({ type: 'directory', path: 'data' });
89
+ const clicked = spyOnAnchorClick();
90
+
91
+ await expect(
92
+ app.commands.execute(COMMAND_ID, { path: 'data' })
93
+ ).rejects.toThrow(/directory: data/);
94
+ expect(get).toHaveBeenCalledWith('data', { content: false });
95
+ expect(getDownloadUrl).not.toHaveBeenCalled();
96
+ expect(clicked).not.toHaveBeenCalled();
97
+ });
98
+
99
+ it('downloads a file through a temporary anchor', async () => {
100
+ const { app, get, getDownloadUrl } = await activatePlugin();
101
+ get.mockResolvedValue({ type: 'file', path: 'data/report.csv' });
102
+ const url = 'http://host/files/data/report.csv';
103
+ getDownloadUrl.mockResolvedValue(url);
104
+ const clicked = spyOnAnchorClick();
105
+
106
+ await expect(
107
+ app.commands.execute(COMMAND_ID, { path: 'data/report.csv' })
108
+ ).resolves.toBeUndefined();
109
+ expect(get).toHaveBeenCalledWith('data/report.csv', { content: false });
110
+ expect(getDownloadUrl).toHaveBeenCalledWith('data/report.csv');
111
+ expect(clicked).toHaveBeenCalledTimes(1);
112
+ const { element: anchor, parent } = clicked.mock.calls[0][0] as {
113
+ element: HTMLAnchorElement;
114
+ parent: Node | null;
115
+ };
116
+ expect(anchor.href).toBe(url);
117
+ expect(anchor.download).toBe('');
118
+ expect(parent).toBe(document.body);
119
+ expect(document.querySelector('a')).toBeNull();
120
+ });
121
+ });
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ import { CommandsReferenceWidget } from './widget';
17
17
  */
18
18
  namespace CommandIDs {
19
19
  export const open = 'jupyterlab-commands-reference:open';
20
+ export const download = 'file:download';
20
21
  }
21
22
 
22
23
  /**
@@ -103,6 +104,46 @@ const plugin: JupyterFrontEndPlugin<void> = {
103
104
  category: 'Help'
104
105
  });
105
106
 
107
+ // Download a file the contents API serves to the computer that runs
108
+ // the browser tab.
109
+ app.commands.addCommand(CommandIDs.download, {
110
+ label: 'Download File',
111
+ caption: 'Download a file from the server to the local computer',
112
+ describedBy: () => ({
113
+ args: {
114
+ type: 'object',
115
+ properties: {
116
+ path: {
117
+ type: 'string',
118
+ description: 'the file path relative to the server root'
119
+ }
120
+ },
121
+ required: ['path']
122
+ }
123
+ }),
124
+ execute: async args => {
125
+ const path = typeof args.path === 'string' ? args.path : '';
126
+ if (!path) {
127
+ throw new Error('file:download requires a non-empty path argument');
128
+ }
129
+ const model = await app.serviceManager.contents.get(path, {
130
+ content: false
131
+ });
132
+ if (model.type === 'directory') {
133
+ throw new Error(`file:download refuses a directory: ${path}`);
134
+ }
135
+ const url = await app.serviceManager.contents.getDownloadUrl(
136
+ model.path
137
+ );
138
+ const element = document.createElement('a');
139
+ element.href = url;
140
+ element.download = '';
141
+ document.body.appendChild(element);
142
+ element.click();
143
+ document.body.removeChild(element);
144
+ }
145
+ });
146
+
106
147
  if (settings) {
107
148
  relay = new RelayClient({
108
149
  app,
package/src/widget.ts CHANGED
@@ -69,7 +69,8 @@ export class CommandsReferenceWidget extends Widget {
69
69
  this._searchInput = document.createElement('input');
70
70
  this._searchInput.type = 'text';
71
71
  this._searchInput.placeholder = 'Filter commands...';
72
- this._searchInput.className = 'jp-CommandsReferenceWidget-search';
72
+ this._searchInput.className =
73
+ 'jp-CommandsReferenceWidget-search jp-mod-styled';
73
74
  this._searchInput.addEventListener('input', () => {
74
75
  this._filterCommands();
75
76
  });
package/style/base.css CHANGED
@@ -10,6 +10,7 @@
10
10
  flex-direction: column;
11
11
  height: 100%;
12
12
  overflow: hidden;
13
+ container-type: inline-size;
13
14
  }
14
15
 
15
16
  /* Header with search and count */
@@ -23,23 +24,28 @@
23
24
  flex-shrink: 0;
24
25
  }
25
26
 
26
- /* Search input */
27
- .jp-CommandsReferenceWidget-search {
27
+ /*
28
+ * Search input. jp-mod-styled supplies the same properties at a higher
29
+ * specificity, so each rule repeats the class after it.
30
+ */
31
+ input.jp-mod-styled.jp-CommandsReferenceWidget-search {
28
32
  flex: 1;
29
- padding: 6px 10px;
33
+ height: 24px;
34
+ padding: 0 9px;
30
35
  border: 1px solid var(--jp-border-color1);
31
- border-radius: 4px;
36
+ border-radius: 2px;
32
37
  background: var(--jp-layout-color0);
33
38
  color: var(--jp-ui-font-color1);
34
39
  font-size: var(--jp-ui-font-size1);
35
40
  outline: none;
36
41
  }
37
42
 
38
- .jp-CommandsReferenceWidget-search:focus {
43
+ input.jp-mod-styled.jp-CommandsReferenceWidget-search:focus {
39
44
  border-color: var(--jp-brand-color1);
45
+ box-shadow: none;
40
46
  }
41
47
 
42
- .jp-CommandsReferenceWidget-search::placeholder {
48
+ input.jp-mod-styled.jp-CommandsReferenceWidget-search::placeholder {
43
49
  color: var(--jp-ui-font-color3);
44
50
  }
45
51
 
@@ -60,6 +66,7 @@
60
66
  /* Commands table */
61
67
  .jp-CommandsReferenceWidget-table {
62
68
  width: 100%;
69
+ table-layout: fixed;
63
70
  border-collapse: collapse;
64
71
  font-size: var(--jp-ui-font-size1);
65
72
  }
@@ -73,21 +80,29 @@
73
80
  }
74
81
 
75
82
  .jp-CommandsReferenceWidget-table th {
76
- padding: 8px 12px;
83
+ box-sizing: border-box;
84
+ padding: 6px 12px;
85
+ overflow: hidden;
77
86
  text-align: left;
78
- font-weight: 600;
79
- color: var(--jp-ui-font-color1);
80
- border-bottom: 2px solid var(--jp-border-color1);
87
+ text-overflow: ellipsis;
81
88
  white-space: nowrap;
89
+ font-size: 10px;
90
+ font-weight: 600;
91
+ line-height: 1;
92
+ letter-spacing: 0.08em;
93
+ text-transform: uppercase;
94
+ color: var(--jp-ui-font-color3);
95
+ border-bottom: 1px solid var(--jp-border-color2);
82
96
  }
83
97
 
84
98
  /* Table cells */
85
99
  .jp-CommandsReferenceWidget-table td {
100
+ box-sizing: border-box;
86
101
  padding: 6px 12px;
87
102
  border-bottom: 1px solid var(--jp-border-color2);
88
103
  color: var(--jp-ui-font-color1);
89
104
  vertical-align: top;
90
- max-width: 300px;
105
+ white-space: nowrap;
91
106
  overflow: hidden;
92
107
  text-overflow: ellipsis;
93
108
  }
@@ -101,6 +116,43 @@
101
116
  white-space: nowrap;
102
117
  }
103
118
 
119
+ /* Command executor cell - the row action sits at the right edge */
120
+ .jp-CommandsReferenceWidget-executor {
121
+ text-align: right;
122
+ }
123
+
124
+ /* In-row action button */
125
+ .jp-CommandsReferenceWidget-add {
126
+ display: inline-block;
127
+ box-sizing: border-box;
128
+ max-width: 100%;
129
+ height: 20px;
130
+ padding: 0 8px;
131
+ overflow: hidden;
132
+ border: 1px solid transparent;
133
+ border-radius: 2px;
134
+ background: transparent;
135
+ color: var(--jp-ui-font-color2);
136
+ font-family: inherit;
137
+ font-size: var(--jp-ui-font-size0);
138
+ line-height: 18px;
139
+ text-overflow: ellipsis;
140
+ white-space: nowrap;
141
+ vertical-align: top;
142
+ cursor: pointer;
143
+ }
144
+
145
+ /* Added state, and no settings to add to */
146
+ .jp-CommandsReferenceWidget-add:disabled {
147
+ opacity: 0.45;
148
+ cursor: default;
149
+ }
150
+
151
+ .jp-CommandsReferenceWidget-add:enabled:hover {
152
+ background: var(--jp-layout-color2);
153
+ color: var(--jp-ui-font-color1);
154
+ }
155
+
104
156
  /* Alternating row colors */
105
157
  .jp-CommandsReferenceWidget-table tbody tr:nth-child(even) {
106
158
  background: var(--jp-layout-color1);
@@ -115,27 +167,84 @@
115
167
  background: var(--jp-layout-color2);
116
168
  }
117
169
 
118
- /* Column widths */
170
+ /* Column widths - five shares summing to 100% under the fixed layout */
119
171
  .jp-CommandsReferenceWidget-table th:nth-child(1),
120
172
  .jp-CommandsReferenceWidget-table td:nth-child(1) {
121
- width: 30%;
122
- min-width: 200px;
173
+ width: 25%;
123
174
  }
124
175
 
125
176
  .jp-CommandsReferenceWidget-table th:nth-child(2),
126
177
  .jp-CommandsReferenceWidget-table td:nth-child(2) {
127
- width: 20%;
178
+ width: 17%;
128
179
  }
129
180
 
130
181
  .jp-CommandsReferenceWidget-table th:nth-child(3),
131
182
  .jp-CommandsReferenceWidget-table td:nth-child(3) {
132
- width: 30%;
183
+ width: 26%;
184
+ }
185
+
186
+ .jp-CommandsReferenceWidget-table th:nth-child(4) {
187
+ width: 17%;
133
188
  }
134
189
 
135
- .jp-CommandsReferenceWidget-table th:nth-child(4),
136
190
  .jp-CommandsReferenceWidget-table td:nth-child(4) {
137
- width: 20%;
191
+ width: 17%;
138
192
  font-family: var(--jp-code-font-family);
139
193
  font-size: var(--jp-code-font-size);
140
194
  color: var(--jp-ui-font-color2);
141
195
  }
196
+
197
+ .jp-CommandsReferenceWidget-table th:nth-child(5),
198
+ .jp-CommandsReferenceWidget-table td:nth-child(5) {
199
+ width: 15%;
200
+ }
201
+
202
+ /* Adaptive columns - keyed to the panel width, not the viewport. Narrower blocks come later so they win. */
203
+ @container (max-width: 1099px) {
204
+ .jp-CommandsReferenceWidget-table th:nth-child(3),
205
+ .jp-CommandsReferenceWidget-table td:nth-child(3) {
206
+ display: none;
207
+ }
208
+
209
+ .jp-CommandsReferenceWidget-table th:nth-child(1),
210
+ .jp-CommandsReferenceWidget-table td:nth-child(1) {
211
+ width: 29%;
212
+ }
213
+
214
+ .jp-CommandsReferenceWidget-table th:nth-child(2),
215
+ .jp-CommandsReferenceWidget-table td:nth-child(2) {
216
+ width: 21%;
217
+ }
218
+
219
+ .jp-CommandsReferenceWidget-table th:nth-child(4),
220
+ .jp-CommandsReferenceWidget-table td:nth-child(4) {
221
+ width: 32%;
222
+ }
223
+
224
+ .jp-CommandsReferenceWidget-table th:nth-child(5),
225
+ .jp-CommandsReferenceWidget-table td:nth-child(5) {
226
+ width: 18%;
227
+ }
228
+ }
229
+
230
+ @container (max-width: 899px) {
231
+ .jp-CommandsReferenceWidget-table th:nth-child(4),
232
+ .jp-CommandsReferenceWidget-table td:nth-child(4) {
233
+ display: none;
234
+ }
235
+
236
+ .jp-CommandsReferenceWidget-table th:nth-child(1),
237
+ .jp-CommandsReferenceWidget-table td:nth-child(1) {
238
+ width: 47%;
239
+ }
240
+
241
+ .jp-CommandsReferenceWidget-table th:nth-child(2),
242
+ .jp-CommandsReferenceWidget-table td:nth-child(2) {
243
+ width: 32%;
244
+ }
245
+
246
+ .jp-CommandsReferenceWidget-table th:nth-child(5),
247
+ .jp-CommandsReferenceWidget-table td:nth-child(5) {
248
+ width: 21%;
249
+ }
250
+ }