@theia/plugin-ext 1.76.0-next.0 → 1.76.0-next.14

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 (35) hide show
  1. package/lib/main/browser/decorations/decorations-main.d.ts +1 -1
  2. package/lib/main/browser/decorations/decorations-main.d.ts.map +1 -1
  3. package/lib/main/browser/decorations/decorations-main.js +1 -1
  4. package/lib/main/browser/decorations/decorations-main.js.map +1 -1
  5. package/lib/main/browser/editors-and-documents-main.d.ts +7 -0
  6. package/lib/main/browser/editors-and-documents-main.d.ts.map +1 -1
  7. package/lib/main/browser/editors-and-documents-main.js +19 -9
  8. package/lib/main/browser/editors-and-documents-main.js.map +1 -1
  9. package/lib/main/browser/editors-and-documents-main.spec.d.ts +2 -0
  10. package/lib/main/browser/editors-and-documents-main.spec.d.ts.map +1 -0
  11. package/lib/main/browser/editors-and-documents-main.spec.js +117 -0
  12. package/lib/main/browser/editors-and-documents-main.spec.js.map +1 -0
  13. package/lib/main/browser/languages-main.d.ts +7 -0
  14. package/lib/main/browser/languages-main.d.ts.map +1 -1
  15. package/lib/main/browser/languages-main.js +31 -9
  16. package/lib/main/browser/languages-main.js.map +1 -1
  17. package/lib/main/browser/languages-main.spec.d.ts +2 -0
  18. package/lib/main/browser/languages-main.spec.d.ts.map +1 -0
  19. package/lib/main/browser/languages-main.spec.js +82 -0
  20. package/lib/main/browser/languages-main.spec.js.map +1 -0
  21. package/lib/plugin/decorations.d.ts.map +1 -1
  22. package/lib/plugin/decorations.js +6 -33
  23. package/lib/plugin/decorations.js.map +1 -1
  24. package/lib/plugin/decorations.spec.d.ts +2 -0
  25. package/lib/plugin/decorations.spec.d.ts.map +1 -0
  26. package/lib/plugin/decorations.spec.js +78 -0
  27. package/lib/plugin/decorations.spec.js.map +1 -0
  28. package/package.json +31 -31
  29. package/src/main/browser/decorations/decorations-main.ts +4 -4
  30. package/src/main/browser/editors-and-documents-main.spec.ts +140 -0
  31. package/src/main/browser/editors-and-documents-main.ts +19 -7
  32. package/src/main/browser/languages-main.spec.ts +101 -0
  33. package/src/main/browser/languages-main.ts +31 -9
  34. package/src/plugin/decorations.spec.ts +87 -0
  35. package/src/plugin/decorations.ts +6 -33
@@ -0,0 +1,140 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 JuliaHub, Inc. and others.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
18
+ let disableJSDOM = enableJSDOM();
19
+
20
+ import { expect } from 'chai';
21
+ import { Emitter, URI } from '@theia/core';
22
+ import { BaseWidget, Navigatable, Saveable, SaveableSource, Widget } from '@theia/core/lib/browser';
23
+ import { SaveableService } from '@theia/core/lib/browser/saveable-service';
24
+ import { EditorManager, EditorWidget } from '@theia/editor/lib/browser';
25
+ import { EditorsAndDocumentsMain } from './editors-and-documents-main';
26
+
27
+ disableJSDOM();
28
+
29
+ class TestSaveable implements Saveable {
30
+ dirty = true;
31
+ readonly onDirtyChanged = new Emitter<void>().event;
32
+ readonly onContentChanged = new Emitter<void>().event;
33
+ saveCount = 0;
34
+
35
+ async save(): Promise<void> {
36
+ this.saveCount++;
37
+ this.dirty = false;
38
+ }
39
+ }
40
+
41
+ /** Navigatable to the resource, but with nothing to save. */
42
+ class TestNavigatableWidget extends BaseWidget implements Navigatable {
43
+ constructor(private readonly uri: URI) {
44
+ super();
45
+ }
46
+
47
+ getResourceUri(): URI {
48
+ return this.uri;
49
+ }
50
+
51
+ createMoveToUri(): URI {
52
+ return this.uri;
53
+ }
54
+ }
55
+
56
+ /** Stands in for a `CustomEditorWidget`: saveable and navigatable, but not an `EditorWidget`. */
57
+ class TestSaveableWidget extends TestNavigatableWidget implements SaveableSource {
58
+ readonly saveable = new TestSaveable();
59
+ }
60
+
61
+ function createEditorsAndDocumentsMain(
62
+ editor: EditorWidget | undefined,
63
+ widgets: Widget[]
64
+ ): EditorsAndDocumentsMain {
65
+ // Bypass the constructor's RPC/container wiring: `save` and `saveAs` only touch the
66
+ // editor manager, the shell and the saveable service.
67
+ const main = Object.create(EditorsAndDocumentsMain.prototype) as EditorsAndDocumentsMain;
68
+ const fields = main as unknown as Record<string, unknown>;
69
+ fields.editorManager = { getByUri: async () => editor } as unknown as EditorManager;
70
+ fields.shell = { widgets };
71
+ fields.saveResourceService = new SaveableService();
72
+ return main;
73
+ }
74
+
75
+ describe('EditorsAndDocumentsMain#save', () => {
76
+
77
+ before(() => {
78
+ disableJSDOM = enableJSDOM();
79
+ });
80
+
81
+ after(() => {
82
+ disableJSDOM();
83
+ });
84
+
85
+ const uri = new URI('custom-editor:/some/resource?viewType=test');
86
+
87
+ it('saves a saveable widget that the editor manager does not know', async () => {
88
+ const widget = new TestSaveableWidget(uri);
89
+ const main = createEditorsAndDocumentsMain(undefined, [widget]);
90
+
91
+ const saved = await main.save(uri);
92
+
93
+ expect(widget.saveable.saveCount).to.equal(1);
94
+ expect(saved?.toString()).to.equal(uri.toString());
95
+ });
96
+
97
+ it('prefers the text editor when the editor manager has one', async () => {
98
+ const editor = new TestSaveableWidget(uri);
99
+ const custom = new TestSaveableWidget(uri);
100
+ const main = createEditorsAndDocumentsMain(editor as unknown as EditorWidget, [custom]);
101
+
102
+ await main.save(uri);
103
+
104
+ expect(editor.saveable.saveCount).to.equal(1);
105
+ expect(custom.saveable.saveCount).to.equal(0);
106
+ });
107
+
108
+ it('leaves widgets bound to another resource alone', async () => {
109
+ const other = new TestSaveableWidget(new URI('custom-editor:/other/resource'));
110
+ const main = createEditorsAndDocumentsMain(undefined, [other]);
111
+
112
+ const saved = await main.save(uri);
113
+
114
+ expect(other.saveable.saveCount).to.equal(0);
115
+ expect(saved).to.be.undefined;
116
+ });
117
+
118
+ it('ignores a widget that is navigatable to the resource but not saveable', async () => {
119
+ const main = createEditorsAndDocumentsMain(undefined, [new TestNavigatableWidget(uri)]);
120
+
121
+ expect(await main.save(uri)).to.be.undefined;
122
+ });
123
+
124
+ it('resolves saveAs through the same fallback', async () => {
125
+ const widget = new TestSaveableWidget(uri);
126
+ const main = createEditorsAndDocumentsMain(undefined, [widget]);
127
+ const saveAsTargets: Widget[] = [];
128
+ // `SaveableService.canSaveAs` is unconditionally false on the base class, so the
129
+ // service is stubbed to reach the `saveAs` branch at all.
130
+ (main as unknown as Record<string, unknown>).saveResourceService = {
131
+ canSaveAs: (candidate: Widget) => candidate === widget,
132
+ saveAs: async (target: Widget) => { saveAsTargets.push(target); return uri; }
133
+ };
134
+
135
+ const saved = await main.saveAs(uri);
136
+
137
+ expect(saveAsTargets).to.deep.equal([widget]);
138
+ expect(saved?.toString()).to.equal(uri.toString());
139
+ });
140
+ });
@@ -33,6 +33,7 @@ import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor';
33
33
  import { TextEditorMain } from './text-editor-main';
34
34
  import { DisposableCollection, Emitter, URI } from '@theia/core';
35
35
  import { EditorManager, EditorWidget } from '@theia/editor/lib/browser';
36
+ import { ApplicationShell, NavigatableWidget, Saveable, Widget } from '@theia/core/lib/browser';
36
37
  import { SaveableService } from '@theia/core/lib/browser/saveable-service';
37
38
  import { TabsMainImpl } from './tabs/tabs-main';
38
39
  import { NotebookCellEditorService, NotebookEditorWidgetService } from '@theia/notebook/lib/browser';
@@ -48,6 +49,7 @@ export class EditorsAndDocumentsMain implements Disposable {
48
49
 
49
50
  private readonly modelService: EditorModelService;
50
51
  private readonly editorManager: EditorManager;
52
+ private readonly shell: ApplicationShell;
51
53
  private readonly saveResourceService: SaveableService;
52
54
  private readonly encodingRegistry: EncodingRegistry;
53
55
 
@@ -69,6 +71,7 @@ export class EditorsAndDocumentsMain implements Disposable {
69
71
  this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.EDITORS_AND_DOCUMENTS_EXT);
70
72
 
71
73
  this.editorManager = container.get(EditorManager);
74
+ this.shell = container.get(ApplicationShell);
72
75
  this.modelService = container.get(EditorModelService);
73
76
  this.saveResourceService = container.get(SaveableService);
74
77
  this.encodingRegistry = container.get(EncodingRegistry);
@@ -183,22 +186,31 @@ export class EditorsAndDocumentsMain implements Disposable {
183
186
  }
184
187
 
185
188
  async save(uri: URI): Promise<URI | undefined> {
186
- const editor = await this.editorManager.getByUri(uri);
187
- if (!editor) {
189
+ const widget = await this.getSaveTarget(uri);
190
+ if (!widget) {
188
191
  return undefined;
189
192
  }
190
- return this.saveResourceService.save(editor);
193
+ return this.saveResourceService.save(widget);
191
194
  }
192
195
 
193
196
  async saveAs(uri: URI): Promise<URI | undefined> {
194
- const editor = await this.editorManager.getByUri(uri);
195
- if (!editor) {
197
+ const widget = await this.getSaveTarget(uri);
198
+ if (!widget) {
196
199
  return undefined;
197
200
  }
198
- if (!this.saveResourceService.canSaveAs(editor)) {
201
+ if (!this.saveResourceService.canSaveAs(widget)) {
199
202
  return undefined;
200
203
  }
201
- return this.saveResourceService.saveAs(editor);
204
+ return this.saveResourceService.saveAs(widget);
205
+ }
206
+
207
+ /**
208
+ * Resolve the widget holding `uri`, preferring a text editor. Custom editors and notebooks
209
+ * are not opened by the `EditorManager`, so they are only reachable through the shell.
210
+ */
211
+ protected async getSaveTarget(uri: URI): Promise<Widget | undefined> {
212
+ return await this.editorManager.getByUri(uri)
213
+ ?? this.shell.widgets.find(widget => Saveable.get(widget) && NavigatableWidget.getUri(widget)?.isEqual(uri));
202
214
  }
203
215
 
204
216
  saveAll(includeUntitled?: boolean): Promise<boolean> {
@@ -0,0 +1,101 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 JuliaHub, Inc. and others.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
18
+ // `languages-main` transitively pulls in Monaco and several frontend modules that touch `document`
19
+ // at module-load time.
20
+ const disableJSDOM = enableJSDOM();
21
+ import { expect } from 'chai';
22
+ import * as monaco from '@theia/monaco-editor-core';
23
+ import { mainWindow } from '@theia/monaco-editor-core/esm/vs/base/browser/window';
24
+ import { URI } from '@theia/core/lib/common/uri';
25
+ import { expectThrowsAsync } from '@theia/core/lib/common/test/expect';
26
+ import { CellEditType, CellUri } from '@theia/notebook/lib/common';
27
+ import { CellEditOperation } from '@theia/notebook/lib/browser/notebook-types';
28
+ import type { NotebookModel } from '@theia/notebook/lib/browser/view-model/notebook-model';
29
+ import { LanguagesMainImpl } from './languages-main';
30
+
31
+ after(() => disableJSDOM());
32
+
33
+ const notebookUri = new URI('file:///notebook.ipynb');
34
+ const cellHandle = 3;
35
+ const cellComponents = CellUri.generate(notebookUri, cellHandle).toComponents();
36
+
37
+ type FakeNotebookModel = Pick<NotebookModel, 'getCellIndexByHandle' | 'applyEdits'> & {
38
+ edits: CellEditOperation[][];
39
+ };
40
+
41
+ function createNotebookModel(handles: number[]): FakeNotebookModel {
42
+ const edits: CellEditOperation[][] = [];
43
+ return {
44
+ edits,
45
+ getCellIndexByHandle: handle => handles.indexOf(handle),
46
+ applyEdits: applied => { edits.push(applied); }
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Bypasses the constructor's RPC/container wiring.
52
+ */
53
+ function createLanguagesMain(notebook?: FakeNotebookModel): LanguagesMainImpl {
54
+ const languagesMain = Object.create(LanguagesMainImpl.prototype) as LanguagesMainImpl;
55
+ (languagesMain as unknown as Record<string, unknown>).notebookService = {
56
+ getNotebookEditorModel: (uri: URI) => uri.toString() === notebookUri.toString() ? notebook : undefined
57
+ };
58
+ return languagesMain;
59
+ }
60
+
61
+ describe('LanguagesMainImpl#$changeLanguage', () => {
62
+
63
+ before(() => {
64
+ // Resolving a language id boots Monaco's standalone services, whose theme service reaches for
65
+ // DOM APIs JSDOM does not provide. Monaco captures its window at module load.
66
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
67
+ (global as any).CSS ??= { escape: (value: string) => value };
68
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
69
+ (mainWindow as any).matchMedia ??= () => ({ matches: false, addEventListener: () => { }, removeEventListener: () => { } });
70
+ monaco.languages.register({ id: 'julia' });
71
+ });
72
+
73
+ it('applies a cell language edit for a notebook cell', async () => {
74
+ const notebook = createNotebookModel([1, 2, cellHandle]);
75
+ const languagesMain = createLanguagesMain(notebook);
76
+
77
+ await languagesMain.$changeLanguage(cellComponents, 'julia');
78
+
79
+ expect(notebook.edits).to.deep.equal([[{ editType: CellEditType.CellLanguage, index: 2, language: 'julia' }]]);
80
+ });
81
+
82
+ it('rejects for a cell of a notebook that is not open', async () => {
83
+ const languagesMain = createLanguagesMain();
84
+
85
+ await expectThrowsAsync(languagesMain.$changeLanguage(cellComponents, 'julia'), 'Invalid uri');
86
+ });
87
+
88
+ it('rejects for a cell handle the notebook does not know', async () => {
89
+ const languagesMain = createLanguagesMain(createNotebookModel([1, 2]));
90
+
91
+ await expectThrowsAsync(languagesMain.$changeLanguage(cellComponents, 'julia'), 'Invalid uri');
92
+ });
93
+
94
+ it('rejects an unknown language id before touching the notebook', async () => {
95
+ const notebook = createNotebookModel([cellHandle]);
96
+ const languagesMain = createLanguagesMain(notebook);
97
+
98
+ await expectThrowsAsync(languagesMain.$changeLanguage(cellComponents, 'no-such-language'), /Unknown language ID/);
99
+ expect(notebook.edits).to.be.empty;
100
+ });
101
+ });
@@ -88,6 +88,8 @@ import { CodeActionTriggerKind } from '../../plugin/types-impl';
88
88
  import { IReadonlyVSDataTransfer } from '@theia/monaco-editor-core/esm/vs/base/common/dataTransfer';
89
89
  import { FileUploadService } from '@theia/filesystem/lib/common/upload/file-upload';
90
90
  import { ILogger } from '@theia/core';
91
+ import { NotebookService } from '@theia/notebook/lib/browser';
92
+ import { CellEditType, CellUri } from '@theia/notebook/lib/common';
91
93
 
92
94
  @injectable()
93
95
  export class LanguagesMainImpl implements LanguagesMain, Disposable {
@@ -110,6 +112,9 @@ export class LanguagesMainImpl implements LanguagesMain, Disposable {
110
112
  @inject(FileUploadService)
111
113
  protected readonly fileUploadService: FileUploadService;
112
114
 
115
+ @inject(NotebookService)
116
+ protected readonly notebookService: NotebookService;
117
+
113
118
  @inject(ILogger) @named('plugin-ext:LanguagesMainImpl')
114
119
  protected readonly logger: ILogger;
115
120
 
@@ -129,18 +134,35 @@ export class LanguagesMainImpl implements LanguagesMain, Disposable {
129
134
  return Promise.resolve(monaco.languages.getLanguages().map(l => l.id));
130
135
  }
131
136
 
132
- $changeLanguage(resource: UriComponents, languageId: string): Promise<void> {
133
- const uri = monaco.Uri.revive(resource);
134
- const model = monaco.editor.getModel(uri);
135
- if (!model) {
136
- return Promise.reject(new Error('Invalid uri'));
137
+ async $changeLanguage(resource: UriComponents, languageId: string): Promise<void> {
138
+ if (!monaco.languages.getEncodedLanguageId(languageId)) {
139
+ throw new Error(`Unknown language ID: ${languageId}`);
137
140
  }
138
- const langId = monaco.languages.getEncodedLanguageId(languageId);
139
- if (!langId) {
140
- return Promise.reject(new Error(`Unknown language ID: ${languageId}`));
141
+ const cell = CellUri.parse(URI.fromComponents(resource));
142
+ if (cell) {
143
+ this.changeCellLanguage(cell.notebook, cell.handle, languageId);
144
+ return;
145
+ }
146
+ const model = monaco.editor.getModel(monaco.Uri.revive(resource));
147
+ if (!model) {
148
+ throw new Error('Invalid uri');
141
149
  }
142
150
  monaco.editor.setModelLanguage(model, languageId);
143
- return Promise.resolve(undefined);
151
+ }
152
+
153
+ /**
154
+ * The cell model owns a cell's language; kernel selection, serialization, and the cell editor all follow it.
155
+ */
156
+ protected changeCellLanguage(notebookUri: URI, handle: number, languageId: string): void {
157
+ const notebook = this.notebookService.getNotebookEditorModel(notebookUri);
158
+ if (!notebook) {
159
+ throw new Error('Invalid uri');
160
+ }
161
+ const index = notebook.getCellIndexByHandle(handle);
162
+ if (index < 0) {
163
+ throw new Error('Invalid uri');
164
+ }
165
+ notebook.applyEdits([{ editType: CellEditType.CellLanguage, index, language: languageId }], true);
144
166
  }
145
167
 
146
168
  protected register(handle: number, service: Disposable): void {
@@ -0,0 +1,87 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 Safi Seid-Ahmad, K2view and others.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { Emitter } from '@theia/core/lib/common/event';
18
+ import * as chai from 'chai';
19
+ import * as theia from '@theia/plugin';
20
+ import { PLUGIN_RPC_CONTEXT } from '../common/plugin-api-rpc';
21
+ import { ProxyIdentifier, RPCProtocol } from '../common/rpc-protocol';
22
+ import { DecorationsExtImpl } from './decorations';
23
+ import { URI } from './types-impl';
24
+
25
+ const expect = chai.expect;
26
+
27
+ describe('DecorationsExtImpl', () => {
28
+
29
+ let onDidChangeCalls: (URI[] | null)[];
30
+ let decorationsExt: DecorationsExtImpl;
31
+ let onDidChangeFileDecorationsEmitter: Emitter<theia.Uri | theia.Uri[] | undefined>;
32
+
33
+ beforeEach(() => {
34
+ onDidChangeCalls = [];
35
+ const decorationsMainMock = {
36
+ $registerDecorationProvider: async () => undefined,
37
+ $unregisterDecorationProvider: () => undefined,
38
+ $onDidChange: (handle: number, resources: URI[] | null) => {
39
+ onDidChangeCalls.push(resources);
40
+ }
41
+ };
42
+ const rpcMock = {
43
+ getProxy<T>(proxyId: ProxyIdentifier<T>): T {
44
+ return (proxyId.id === PLUGIN_RPC_CONTEXT.DECORATIONS_MAIN.id
45
+ ? decorationsMainMock
46
+ : new Proxy({}, { get: () => () => undefined })) as unknown as T;
47
+ },
48
+ set<T, R extends T>(_identifier: ProxyIdentifier<T>, instance: R): R {
49
+ return instance;
50
+ },
51
+ dispose(): void { }
52
+ } as RPCProtocol;
53
+
54
+ decorationsExt = new DecorationsExtImpl(rpcMock);
55
+ onDidChangeFileDecorationsEmitter = new Emitter<theia.Uri | theia.Uri[] | undefined>();
56
+ decorationsExt.registerFileDecorationProvider({
57
+ onDidChangeFileDecorations: onDidChangeFileDecorationsEmitter.event,
58
+ provideFileDecoration: () => undefined
59
+ }, { id: 'test.plugin', name: 'test' });
60
+ });
61
+
62
+ it('forwards small change events unchanged', () => {
63
+ const uris = [1, 2, 3].map(i => URI.parse(`file:///project/f${i}.ts`));
64
+ onDidChangeFileDecorationsEmitter.fire(uris);
65
+ expect(onDidChangeCalls).to.have.lengthOf(1);
66
+ expect(onDidChangeCalls[0]).to.have.lengthOf(3);
67
+ });
68
+
69
+ it('forwards undefined change events as a flush', () => {
70
+ onDidChangeFileDecorationsEmitter.fire(undefined);
71
+ expect(onDidChangeCalls).to.have.lengthOf(1);
72
+ // eslint-disable-next-line no-null/no-null
73
+ expect(onDidChangeCalls[0]).to.equal(null);
74
+ });
75
+
76
+ it('sends a flush instead of truncating events exceeding the max event size', () => {
77
+ const uris = [];
78
+ for (let i = 0; i < 300; i++) {
79
+ uris.push(URI.parse(`file:///project/folder${i % 30}/f${i}.ts`));
80
+ }
81
+ onDidChangeFileDecorationsEmitter.fire(uris);
82
+ expect(onDidChangeCalls).to.have.lengthOf(1);
83
+ // eslint-disable-next-line no-null/no-null
84
+ expect(onDidChangeCalls[0]).to.equal(null);
85
+ });
86
+
87
+ });
@@ -26,7 +26,6 @@ import {
26
26
  import { RPCProtocol } from '../common/rpc-protocol';
27
27
  import { Disposable, FileDecoration, URI } from './types-impl';
28
28
  import { CancellationToken } from '@theia/core/lib/common';
29
- import { dirname } from 'path';
30
29
  import { PluginLogger } from './logger';
31
30
 
32
31
  /*---------------------------------------------------------------------------------------------
@@ -70,24 +69,12 @@ export class DecorationsExtImpl implements DecorationsExt {
70
69
  return;
71
70
  }
72
71
 
73
- // too many resources per event. pick one resource per folder, starting
74
- // with parent folders
75
- const mapped = array.map(uri => ({ uri, rank: (uri.path.match(/\//g) || []).length }));
76
- const groups = groupBy(mapped, (a, b) => a.rank - b.rank);
77
- const picked: URI[] = [];
78
- outer: for (const uris of groups) {
79
- let lastDirname: string | undefined;
80
- for (const obj of uris) {
81
- const myDirname = dirname(obj.uri.path);
82
- if (lastDirname !== myDirname) {
83
- lastDirname = myDirname;
84
- if (picked.push(obj.uri) >= DecorationsExtImpl.maxEventSize) {
85
- break outer;
86
- }
87
- }
88
- }
89
- }
90
- this.proxy.$onDidChange(handle, picked);
72
+ // too many resources per event: send a flush instead, so that the renderer
73
+ // drops cached data for this provider and re-fetches the decorations it
74
+ // displays on demand. Truncating the event (as upstream VS Code does by
75
+ // picking one resource per folder) loses decorations for the dropped
76
+ // resources, see https://github.com/eclipse-theia/theia/issues/17507
77
+ this.proxy.$onDidChange(handle, null);
91
78
  });
92
79
 
93
80
  return new Disposable(() => {
@@ -95,20 +82,6 @@ export class DecorationsExtImpl implements DecorationsExt {
95
82
  this.proxy.$unregisterDecorationProvider(handle);
96
83
  this.providersMap.delete(handle);
97
84
  });
98
-
99
- function groupBy<T>(data: ReadonlyArray<T>, compareFn: (a: T, b: T) => number): T[][] {
100
- const result: T[][] = [];
101
- let currentGroup: T[] | undefined = undefined;
102
- for (const element of data.slice(0).sort(compareFn)) {
103
- if (!currentGroup || compareFn(currentGroup[0], element) !== 0) {
104
- currentGroup = [element];
105
- result.push(currentGroup);
106
- } else {
107
- currentGroup.push(element);
108
- }
109
- }
110
- return result;
111
- }
112
85
  }
113
86
 
114
87
  async $provideDecorations(handle: number, requests: DecorationRequest[], token: CancellationToken): Promise<DecorationReply> {