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
package/README.md CHANGED
@@ -2,8 +2,11 @@
2
2
 
3
3
  An opinionated JupyterLab meta-package.
4
4
 
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.
5
+ Bundles a curated set of extensions, a path-first file browser, the
6
+ `jupyterlab-git` panel with its text, notebook and image diffs rendered by
7
+ xtralab, an agent-focused launcher, and a quieter default workspace.
8
+
9
+ ![xtralab screenshot](./screenshot.png)
7
10
 
8
11
  ## Install
9
12
 
@@ -36,7 +39,8 @@ instructions.
36
39
 
37
40
  - [`ajlab`](https://github.com/jtpio/ajlab) — agent-ready JupyterLab base
38
41
  - JupyterLab 4.6+
39
- - `jupyterlab-git` — backs the bundled changes panel
42
+ - `jupyterlab-git` — provides the git panel; xtralab swaps its text,
43
+ notebook and image diff rendering for its own
40
44
  - `jupyterlab-lsp` + `ty` — Python LSP via Astral's `ty` (bundled); also
41
45
  detects `typescript-language-server` on `PATH` for JS/TS
42
46
  - `jupyterlab-quickopen`
@@ -46,7 +50,10 @@ instructions.
46
50
  The bundled labextension adds:
47
51
 
48
52
  - A path-first file browser in the left sidebar.
49
- - A "Source Control" panel powered by `jupyterlab-git` and `@pierre/diffs`.
53
+ - xtralab-rendered diffs registered as `jupyterlab-git`'s diff providers
54
+ (its own notebook/plain-text/image diff plugins are disabled) so the
55
+ upstream git panel shows xtralab's diffs: `@pierre/diffs` for text and
56
+ notebooks, and an `<img>`-based 2-up/swipe/onion-skin view for images.
50
57
  - An agent launcher with a prompt textarea, a row of agent buttons (Claude,
51
58
  Codex, Gemini, Copilot, Goose, OpenCode, Kiro, Mistral Vibe), and a
52
59
  collapsible list of changed files. Buttons are filtered to agents installed
@@ -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;
@@ -16,8 +16,10 @@ export declare namespace CommandIDs {
16
16
  const copyPath = "xtralab:copy-path";
17
17
  const download = "xtralab:download";
18
18
  const refresh = "xtralab:refresh";
19
+ const collapseAll = "xtralab:collapse-all";
19
20
  const createNewDirectory = "xtralab:create-new-directory";
20
21
  const newLauncher = "xtralab:new-launcher";
22
+ const revealPath = "xtralab:reveal-path";
21
23
  }
22
24
  /**
23
25
  * Submenu id used to host the dynamically populated "Open With" entries.
@@ -49,6 +51,7 @@ export declare namespace ToolbarNames {
49
51
  const newLauncher = "new-launcher";
50
52
  const newDirectory = "new-directory";
51
53
  const refresh = "refresh";
54
+ const collapseAll = "collapse-all";
52
55
  }
53
56
  /**
54
57
  * Populate the file browser's toolbar with the buttons that mirror the
@@ -1,7 +1,8 @@
1
1
  import { Clipboard, Dialog, InputDialog, showDialog, showErrorMessage } from '@jupyterlab/apputils';
2
2
  import { PageConfig, PathExt } from '@jupyterlab/coreutils';
3
- import { addIcon, closeIcon, CommandToolbarButton, copyIcon, downloadIcon, editIcon, fileIcon, newFolderIcon, refreshIcon } from '@jupyterlab/ui-components';
3
+ import { addIcon, closeIcon, collapseAllIcon, CommandToolbarButton, copyIcon, downloadIcon, editIcon, fileIcon, newFolderIcon, refreshIcon } from '@jupyterlab/ui-components';
4
4
  import { toCanonicalPath, toServerPath } from './contents';
5
+ import { FILE_BROWSER_ID } from './widget';
5
6
  /**
6
7
  * Command identifiers exposed by the xtralab browser. We deliberately namespace
7
8
  * these under `xtralab:` rather than reusing the `filebrowser:` ids
@@ -19,8 +20,10 @@ export var CommandIDs;
19
20
  CommandIDs.copyPath = 'xtralab:copy-path';
20
21
  CommandIDs.download = 'xtralab:download';
21
22
  CommandIDs.refresh = 'xtralab:refresh';
23
+ CommandIDs.collapseAll = 'xtralab:collapse-all';
22
24
  CommandIDs.createNewDirectory = 'xtralab:create-new-directory';
23
25
  CommandIDs.newLauncher = 'xtralab:new-launcher';
26
+ CommandIDs.revealPath = 'xtralab:reveal-path';
24
27
  })(CommandIDs || (CommandIDs = {}));
25
28
  /**
26
29
  * Submenu id used to host the dynamically populated "Open With" entries.
@@ -331,6 +334,36 @@ export function registerCommands(opts) {
331
334
  browser.refresh();
332
335
  }
333
336
  });
337
+ commands.addCommand(CommandIDs.collapseAll, {
338
+ label: 'Collapse All Folders',
339
+ caption: 'Collapse all folders in the file browser',
340
+ icon: collapseAllIcon.bindprops({ stylesheet: 'menuItem' }),
341
+ execute: () => {
342
+ browser.collapseAll();
343
+ }
344
+ });
345
+ // Public reveal seam. Other plugins (editor breadcrumbs, future
346
+ // "reveal in tree" actions) call this command rather than depending
347
+ // on the file browser widget directly. Activating the sidebar is part
348
+ // of the contract: the tree is hidden behind a tab and a reveal that
349
+ // does not surface the panel would silently no-op from the user's
350
+ // point of view. An empty path is the "workspace root" gesture —
351
+ // there is no tree row for the root itself, so the browser is asked
352
+ // to scroll back to the top and clear its selection instead.
353
+ commands.addCommand(CommandIDs.revealPath, {
354
+ label: 'Reveal in File Browser',
355
+ execute: (args) => {
356
+ var _a;
357
+ const path = (_a = args.path) !== null && _a !== void 0 ? _a : '';
358
+ app.shell.activateById(FILE_BROWSER_ID);
359
+ if (path.length === 0) {
360
+ browser.scrollToRoot();
361
+ }
362
+ else {
363
+ browser.reveal(path);
364
+ }
365
+ }
366
+ });
334
367
  commands.addCommand(CommandIDs.createNewDirectory, {
335
368
  label: 'New Folder',
336
369
  caption: 'Create a new folder',
@@ -390,6 +423,7 @@ export var ToolbarNames;
390
423
  ToolbarNames.newLauncher = 'new-launcher';
391
424
  ToolbarNames.newDirectory = 'new-directory';
392
425
  ToolbarNames.refresh = 'refresh';
426
+ ToolbarNames.collapseAll = 'collapse-all';
393
427
  })(ToolbarNames || (ToolbarNames = {}));
394
428
  /**
395
429
  * Populate the file browser's toolbar with the buttons that mirror the
@@ -415,4 +449,9 @@ export function populateToolbar(opts) {
415
449
  id: CommandIDs.refresh,
416
450
  label: ''
417
451
  }));
452
+ browser.toolbar.addItem(ToolbarNames.collapseAll, new CommandToolbarButton({
453
+ commands,
454
+ id: CommandIDs.collapseAll,
455
+ label: ''
456
+ }));
418
457
  }