@theia/core 1.59.0-next.62 → 1.59.0-next.72
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/lib/browser/catalog.json +14 -3
- package/lib/browser/menu/browser-menu-plugin.d.ts +7 -0
- package/lib/browser/menu/browser-menu-plugin.d.ts.map +1 -1
- package/lib/browser/menu/browser-menu-plugin.js +46 -3
- package/lib/browser/menu/browser-menu-plugin.js.map +1 -1
- package/lib/browser/navigatable-types.d.ts.map +1 -1
- package/lib/browser/navigatable-types.js +1 -1
- package/lib/browser/navigatable-types.js.map +1 -1
- package/lib/common/array-utils.d.ts +2 -0
- package/lib/common/array-utils.d.ts.map +1 -1
- package/lib/common/array-utils.js +24 -0
- package/lib/common/array-utils.js.map +1 -1
- package/lib/common/content-replacer.d.ts +56 -0
- package/lib/common/content-replacer.d.ts.map +1 -0
- package/lib/common/content-replacer.js +139 -0
- package/lib/common/content-replacer.js.map +1 -0
- package/lib/common/content-replacer.spec.d.ts +2 -0
- package/lib/common/content-replacer.spec.d.ts.map +1 -0
- package/lib/common/content-replacer.spec.js +115 -0
- package/lib/common/content-replacer.spec.js.map +1 -0
- package/lib/common/menu/composite-menu-node.d.ts +2 -2
- package/lib/common/menu/composite-menu-node.d.ts.map +1 -1
- package/lib/common/menu/composite-menu-node.js +2 -0
- package/lib/common/menu/composite-menu-node.js.map +1 -1
- package/lib/common/menu/menu-model-registry.d.ts +22 -4
- package/lib/common/menu/menu-model-registry.d.ts.map +1 -1
- package/lib/common/menu/menu-model-registry.js +74 -17
- package/lib/common/menu/menu-model-registry.js.map +1 -1
- package/lib/common/menu/menu-types.d.ts +2 -1
- package/lib/common/menu/menu-types.d.ts.map +1 -1
- package/lib/common/menu/menu-types.js.map +1 -1
- package/package.json +4 -4
- package/src/browser/menu/browser-menu-plugin.ts +55 -5
- package/src/browser/navigatable-types.ts +1 -1
- package/src/common/array-utils.ts +25 -0
- package/src/common/content-replacer.spec.ts +124 -0
- package/src/common/content-replacer.ts +151 -0
- package/src/common/menu/composite-menu-node.ts +4 -2
- package/src/common/menu/menu-model-registry.ts +84 -19
- package/src/common/menu/menu-types.ts +2 -1
|
@@ -15,18 +15,20 @@
|
|
|
15
15
|
// *****************************************************************************
|
|
16
16
|
|
|
17
17
|
import { injectable, inject } from 'inversify';
|
|
18
|
-
import { MenuBar, Menu as MenuWidget, Widget } from '@phosphor/widgets';
|
|
18
|
+
import { Menu, MenuBar, Menu as MenuWidget, Widget } from '@phosphor/widgets';
|
|
19
19
|
import { CommandRegistry as PhosphorCommandRegistry } from '@phosphor/commands';
|
|
20
|
+
import { ElementExt } from '@phosphor/domutils';
|
|
20
21
|
import {
|
|
21
22
|
CommandRegistry, environment, DisposableCollection, Disposable,
|
|
22
|
-
MenuModelRegistry, MAIN_MENU_BAR, MenuPath, MenuNode, MenuCommandExecutor, CompoundMenuNode, CompoundMenuNodeRole, CommandMenuNode
|
|
23
|
+
MenuModelRegistry, MAIN_MENU_BAR, MenuPath, MenuNode, MenuCommandExecutor, CompoundMenuNode, CompoundMenuNodeRole, CommandMenuNode,
|
|
24
|
+
ArrayUtils
|
|
23
25
|
} from '../../common';
|
|
24
26
|
import { KeybindingRegistry } from '../keybinding';
|
|
25
27
|
import { FrontendApplication } from '../frontend-application';
|
|
26
28
|
import { FrontendApplicationContribution } from '../frontend-application-contribution';
|
|
27
29
|
import { ContextKeyService, ContextMatcher } from '../context-key-service';
|
|
28
30
|
import { ContextMenuContext } from './context-menu-context';
|
|
29
|
-
import { waitForRevealed } from '../widgets';
|
|
31
|
+
import { Message, waitForRevealed } from '../widgets';
|
|
30
32
|
import { ApplicationShell } from '../shell';
|
|
31
33
|
import { CorePreferences } from '../core-preferences';
|
|
32
34
|
import { PreferenceService } from '../preferences/preference-service';
|
|
@@ -82,8 +84,10 @@ export class BrowserMainMenuFactory implements MenuWidgetFactory {
|
|
|
82
84
|
this.keybindingRegistry.onKeybindingsChanged(() => {
|
|
83
85
|
this.showMenuBar(menuBar);
|
|
84
86
|
}),
|
|
85
|
-
this.menuProvider.onDidChange(
|
|
86
|
-
|
|
87
|
+
this.menuProvider.onDidChange(evt => {
|
|
88
|
+
if (ArrayUtils.startsWith(evt.path, MAIN_MENU_BAR)) {
|
|
89
|
+
this.showMenuBar(menuBar);
|
|
90
|
+
}
|
|
87
91
|
})
|
|
88
92
|
);
|
|
89
93
|
menuBar.disposed.connect(() => disposable.dispose());
|
|
@@ -156,6 +160,10 @@ export class BrowserMainMenuFactory implements MenuWidgetFactory {
|
|
|
156
160
|
|
|
157
161
|
}
|
|
158
162
|
|
|
163
|
+
export function isMenuElement(element: HTMLElement | null): boolean {
|
|
164
|
+
return !!element && element.className.includes('p-Menu');
|
|
165
|
+
}
|
|
166
|
+
|
|
159
167
|
export class DynamicMenuBarWidget extends MenuBarWidget {
|
|
160
168
|
|
|
161
169
|
/**
|
|
@@ -263,6 +271,48 @@ export class DynamicMenuWidget extends MenuWidget {
|
|
|
263
271
|
this.updateSubMenus(this, this.menu, this.options.commands);
|
|
264
272
|
}
|
|
265
273
|
|
|
274
|
+
protected override onAfterAttach(msg: Message): void {
|
|
275
|
+
super.onAfterAttach(msg);
|
|
276
|
+
this.node.ownerDocument.addEventListener('pointerdown', this, true);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
protected override onBeforeDetach(msg: Message): void {
|
|
280
|
+
this.node.ownerDocument.removeEventListener('pointerdown', this);
|
|
281
|
+
super.onAfterDetach(msg);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
override handleEvent(event: Event): void {
|
|
285
|
+
if (event.type === 'pointerdown') {
|
|
286
|
+
this.handlePointerDown(event as PointerEvent);
|
|
287
|
+
}
|
|
288
|
+
super.handleEvent(event);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
handlePointerDown(event: PointerEvent): void {
|
|
292
|
+
// this code is copied from the superclass because we cannot use the hit
|
|
293
|
+
// test from the "Private" implementation namespace
|
|
294
|
+
if (this['_parentMenu']) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// The mouse button which is pressed is irrelevant. If the press
|
|
299
|
+
// is not on a menu, the entire hierarchy is closed and the event
|
|
300
|
+
// is allowed to propagate. This allows other code to act on the
|
|
301
|
+
// event, such as focusing the clicked element.
|
|
302
|
+
if (!this.hitTestMenus(this, event.clientX, event.clientY)) {
|
|
303
|
+
this.close();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private hitTestMenus(menu: Menu, x: number, y: number): boolean {
|
|
308
|
+
for (let temp: Menu | null = menu; temp; temp = temp.childMenu) {
|
|
309
|
+
if (ElementExt.hitTest(temp.node, x, y)) {
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
|
|
266
316
|
public aboutToShow({ previousFocusedElement }: { previousFocusedElement: HTMLElement | undefined }): void {
|
|
267
317
|
this.preserveFocusedElement(previousFocusedElement);
|
|
268
318
|
this.clearItems();
|
|
@@ -42,7 +42,7 @@ export namespace NavigatableWidget {
|
|
|
42
42
|
export function is(arg: unknown): arg is NavigatableWidget {
|
|
43
43
|
return arg instanceof BaseWidget && Navigatable.is(arg);
|
|
44
44
|
}
|
|
45
|
-
export function
|
|
45
|
+
export function getAffected<T extends Widget>(
|
|
46
46
|
widgets: Iterable<T>,
|
|
47
47
|
context: MaybeArray<URI>
|
|
48
48
|
): IterableIterator<[URI, T & NavigatableWidget]> {
|
|
@@ -126,4 +126,29 @@ export namespace ArrayUtils {
|
|
|
126
126
|
}
|
|
127
127
|
return result;
|
|
128
128
|
}
|
|
129
|
+
|
|
130
|
+
export function shallowEqual<T>(left: readonly T[], right: readonly T[]): boolean {
|
|
131
|
+
if (left.length !== right.length) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
for (let i = 0; i < left.length; i++) {
|
|
135
|
+
if (left[i] !== right[i]) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function startsWith<T>(left: readonly T[], right: readonly T[]): boolean {
|
|
143
|
+
if (right.length > left.length) {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (let i = 0; i < right.length; i++) {
|
|
148
|
+
if (left[i] !== right[i]) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
129
154
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// *****************************************************************************
|
|
2
|
+
// Copyright (C) 2025 EclipseSource GmbH.
|
|
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 { expect } from 'chai';
|
|
18
|
+
import { ContentReplacer, Replacement } from './content-replacer';
|
|
19
|
+
|
|
20
|
+
describe('ContentReplacer', () => {
|
|
21
|
+
let contentReplacer: ContentReplacer;
|
|
22
|
+
|
|
23
|
+
before(() => {
|
|
24
|
+
contentReplacer = new ContentReplacer();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should replace content when oldContent matches exactly', () => {
|
|
28
|
+
const originalContent = 'Hello World!';
|
|
29
|
+
const replacements: Replacement[] = [
|
|
30
|
+
{ oldContent: 'World', newContent: 'Universe' }
|
|
31
|
+
];
|
|
32
|
+
const expectedContent = 'Hello Universe!';
|
|
33
|
+
const result = contentReplacer.applyReplacements(originalContent, replacements);
|
|
34
|
+
expect(result.updatedContent).to.equal(expectedContent);
|
|
35
|
+
expect(result.errors).to.be.empty;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should replace content when oldContent matches after trimming lines', () => {
|
|
39
|
+
const originalContent = 'Line one\n Line two \nLine three';
|
|
40
|
+
const replacements: Replacement[] = [
|
|
41
|
+
{ oldContent: 'Line two', newContent: 'Second Line' }
|
|
42
|
+
];
|
|
43
|
+
const expectedContent = 'Line one\n Second Line \nLine three';
|
|
44
|
+
const result = contentReplacer.applyReplacements(originalContent, replacements);
|
|
45
|
+
expect(result.updatedContent).to.equal(expectedContent);
|
|
46
|
+
expect(result.errors).to.be.empty;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should return an error when oldContent is not found', () => {
|
|
50
|
+
const originalContent = 'Sample content';
|
|
51
|
+
const replacements: Replacement[] = [
|
|
52
|
+
{ oldContent: 'Nonexistent', newContent: 'Replacement' }
|
|
53
|
+
];
|
|
54
|
+
const result = contentReplacer.applyReplacements(originalContent, replacements);
|
|
55
|
+
expect(result.updatedContent).to.equal(originalContent);
|
|
56
|
+
expect(result.errors).to.include('Content to replace not found: "Nonexistent"');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should return an error when oldContent has multiple occurrences', () => {
|
|
60
|
+
const originalContent = 'Repeat Repeat Repeat';
|
|
61
|
+
const replacements: Replacement[] = [
|
|
62
|
+
{ oldContent: 'Repeat', newContent: 'Once' }
|
|
63
|
+
];
|
|
64
|
+
const result = contentReplacer.applyReplacements(originalContent, replacements);
|
|
65
|
+
expect(result.updatedContent).to.equal(originalContent);
|
|
66
|
+
expect(result.errors).to.include('Multiple occurrences found for: "Repeat"');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should prepend newContent when oldContent is an empty string', () => {
|
|
70
|
+
const originalContent = 'Existing content';
|
|
71
|
+
const replacements: Replacement[] = [
|
|
72
|
+
{ oldContent: '', newContent: 'Prepended content\n' }
|
|
73
|
+
];
|
|
74
|
+
const expectedContent = 'Prepended content\nExisting content';
|
|
75
|
+
const result = contentReplacer.applyReplacements(originalContent, replacements);
|
|
76
|
+
expect(result.updatedContent).to.equal(expectedContent);
|
|
77
|
+
expect(result.errors).to.be.empty;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should handle multiple replacements correctly', () => {
|
|
81
|
+
const originalContent = 'Foo Bar Baz';
|
|
82
|
+
const replacements: Replacement[] = [
|
|
83
|
+
{ oldContent: 'Foo', newContent: 'FooModified' },
|
|
84
|
+
{ oldContent: 'Bar', newContent: 'BarModified' },
|
|
85
|
+
{ oldContent: 'Baz', newContent: 'BazModified' }
|
|
86
|
+
];
|
|
87
|
+
const expectedContent = 'FooModified BarModified BazModified';
|
|
88
|
+
const result = contentReplacer.applyReplacements(originalContent, replacements);
|
|
89
|
+
expect(result.updatedContent).to.equal(expectedContent);
|
|
90
|
+
expect(result.errors).to.be.empty;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should replace all occurrences when mutiple is true', () => {
|
|
94
|
+
const originalContent = 'Repeat Repeat Repeat';
|
|
95
|
+
const replacements: Replacement[] = [
|
|
96
|
+
{ oldContent: 'Repeat', newContent: 'Once', multiple: true }
|
|
97
|
+
];
|
|
98
|
+
const expectedContent = 'Once Once Once';
|
|
99
|
+
const result = contentReplacer.applyReplacements(originalContent, replacements);
|
|
100
|
+
expect(result.updatedContent).to.equal(expectedContent);
|
|
101
|
+
expect(result.errors).to.be.empty;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should return an error when mutiple is false and multiple occurrences are found', () => {
|
|
105
|
+
const originalContent = 'Repeat Repeat Repeat';
|
|
106
|
+
const replacements: Replacement[] = [
|
|
107
|
+
{ oldContent: 'Repeat', newContent: 'Once', multiple: false }
|
|
108
|
+
];
|
|
109
|
+
const result = contentReplacer.applyReplacements(originalContent, replacements);
|
|
110
|
+
expect(result.updatedContent).to.equal(originalContent);
|
|
111
|
+
expect(result.errors).to.include('Multiple occurrences found for: "Repeat"');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should return an error when conflicting replacements for the same oldContent are provided', () => {
|
|
115
|
+
const originalContent = 'Conflict test content';
|
|
116
|
+
const replacements: Replacement[] = [
|
|
117
|
+
{ oldContent: 'test', newContent: 'test1' },
|
|
118
|
+
{ oldContent: 'test', newContent: 'test2' }
|
|
119
|
+
];
|
|
120
|
+
const result = contentReplacer.applyReplacements(originalContent, replacements);
|
|
121
|
+
expect(result.updatedContent).to.equal(originalContent);
|
|
122
|
+
expect(result.errors).to.include('Conflicting replacement values for: "test"');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// *****************************************************************************
|
|
2
|
+
// Copyright (C) 2025 EclipseSource GmbH.
|
|
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
|
+
export interface Replacement {
|
|
18
|
+
oldContent: string;
|
|
19
|
+
newContent: string;
|
|
20
|
+
multiple?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class ContentReplacer {
|
|
24
|
+
/**
|
|
25
|
+
* Applies a list of replacements to the original content using a multi-step matching strategy.
|
|
26
|
+
* @param originalContent The original file content.
|
|
27
|
+
* @param replacements Array of Replacement objects.
|
|
28
|
+
* @param allowMultiple If true, all occurrences of each oldContent will be replaced. If false, an error is returned when multiple occurrences are found.
|
|
29
|
+
* @returns An object containing the updated content and any error messages.
|
|
30
|
+
*/
|
|
31
|
+
applyReplacements(originalContent: string, replacements: Replacement[]): { updatedContent: string, errors: string[] } {
|
|
32
|
+
let updatedContent = originalContent;
|
|
33
|
+
const errorMessages: string[] = [];
|
|
34
|
+
|
|
35
|
+
// Guard against conflicting replacements: if the same oldContent appears with different newContent, return with an error.
|
|
36
|
+
const conflictMap = new Map<string, string>();
|
|
37
|
+
for (const replacement of replacements) {
|
|
38
|
+
if (conflictMap.has(replacement.oldContent) && conflictMap.get(replacement.oldContent) !== replacement.newContent) {
|
|
39
|
+
return { updatedContent: originalContent, errors: [`Conflicting replacement values for: "${replacement.oldContent}"`] };
|
|
40
|
+
}
|
|
41
|
+
conflictMap.set(replacement.oldContent, replacement.newContent);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
replacements.forEach(({ oldContent, newContent, multiple }) => {
|
|
45
|
+
// If the old content is empty, prepend the new content to the beginning of the file (e.g. in new file)
|
|
46
|
+
if (oldContent === '') {
|
|
47
|
+
updatedContent = newContent + updatedContent;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let matchIndices = this.findExactMatches(updatedContent, oldContent);
|
|
52
|
+
|
|
53
|
+
if (matchIndices.length === 0) {
|
|
54
|
+
matchIndices = this.findLineTrimmedMatches(updatedContent, oldContent);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (matchIndices.length === 0) {
|
|
58
|
+
errorMessages.push(`Content to replace not found: "${oldContent}"`);
|
|
59
|
+
} else if (matchIndices.length > 1) {
|
|
60
|
+
if (multiple) {
|
|
61
|
+
updatedContent = this.replaceContentAll(updatedContent, oldContent, newContent);
|
|
62
|
+
} else {
|
|
63
|
+
errorMessages.push(`Multiple occurrences found for: "${oldContent}"`);
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
updatedContent = this.replaceContentOnce(updatedContent, oldContent, newContent);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return { updatedContent, errors: errorMessages };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Finds all exact matches of a substring within a string.
|
|
75
|
+
* @param content The content to search within.
|
|
76
|
+
* @param search The substring to search for.
|
|
77
|
+
* @returns An array of starting indices where the exact substring is found.
|
|
78
|
+
*/
|
|
79
|
+
private findExactMatches(content: string, search: string): number[] {
|
|
80
|
+
const indices: number[] = [];
|
|
81
|
+
let startIndex = 0;
|
|
82
|
+
|
|
83
|
+
while ((startIndex = content.indexOf(search, startIndex)) !== -1) {
|
|
84
|
+
indices.push(startIndex);
|
|
85
|
+
startIndex += search.length;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return indices;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Attempts to find matches by trimming whitespace from lines in the original content and the search string.
|
|
93
|
+
* @param content The original content.
|
|
94
|
+
* @param search The substring to search for, potentially with varying whitespace.
|
|
95
|
+
* @returns An array of starting indices where a trimmed match is found.
|
|
96
|
+
*/
|
|
97
|
+
private findLineTrimmedMatches(content: string, search: string): number[] {
|
|
98
|
+
const trimmedSearch = search.trim();
|
|
99
|
+
const lines = content.split('\n');
|
|
100
|
+
|
|
101
|
+
for (let i = 0; i < lines.length; i++) {
|
|
102
|
+
const trimmedLine = lines[i].trim();
|
|
103
|
+
if (trimmedLine === trimmedSearch) {
|
|
104
|
+
// Calculate the starting index of this line in the original content
|
|
105
|
+
const startIndex = this.getLineStartIndex(content, i);
|
|
106
|
+
return [startIndex];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Calculates the starting index of a specific line number in the content.
|
|
115
|
+
* @param content The original content.
|
|
116
|
+
* @param lineNumber The zero-based line number.
|
|
117
|
+
* @returns The starting index of the specified line.
|
|
118
|
+
*/
|
|
119
|
+
private getLineStartIndex(content: string, lineNumber: number): number {
|
|
120
|
+
const lines = content.split('\n');
|
|
121
|
+
let index = 0;
|
|
122
|
+
for (let i = 0; i < lineNumber; i++) {
|
|
123
|
+
index += lines[i].length + 1; // +1 for the newline character
|
|
124
|
+
}
|
|
125
|
+
return index;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Replaces the first occurrence of oldContent with newContent in the content.
|
|
130
|
+
* @param content The original content.
|
|
131
|
+
* @param oldContent The content to be replaced.
|
|
132
|
+
* @param newContent The content to replace with.
|
|
133
|
+
* @returns The content after replacement.
|
|
134
|
+
*/
|
|
135
|
+
private replaceContentOnce(content: string, oldContent: string, newContent: string): string {
|
|
136
|
+
const index = content.indexOf(oldContent);
|
|
137
|
+
if (index === -1) { return content; }
|
|
138
|
+
return content.substring(0, index) + newContent + content.substring(index + oldContent.length);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Replaces all occurrences of oldContent with newContent in the content.
|
|
143
|
+
* @param content The original content.
|
|
144
|
+
* @param oldContent The content to be replaced.
|
|
145
|
+
* @param newContent The content to replace with.
|
|
146
|
+
* @returns The content after all replacements.
|
|
147
|
+
*/
|
|
148
|
+
private replaceContentAll(content: string, oldContent: string, newContent: string): string {
|
|
149
|
+
return content.split(oldContent).join(newContent);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -54,11 +54,13 @@ export class CompositeMenuNode implements MutableCompoundMenuNode {
|
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
removeNode(id: string):
|
|
57
|
+
removeNode(id: string): boolean {
|
|
58
58
|
const idx = this._children.findIndex(n => n.id === id);
|
|
59
59
|
if (idx >= 0) {
|
|
60
60
|
this._children.splice(idx, 1);
|
|
61
|
+
return true;
|
|
61
62
|
}
|
|
63
|
+
return false;
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
updateOptions(options?: SubMenuOptions): void {
|
|
@@ -108,7 +110,7 @@ export class CompositeMenuNodeWrapper implements MutableCompoundMenuNode {
|
|
|
108
110
|
|
|
109
111
|
addNode(node: MenuNode): Disposable { return this.wrapped.addNode(node); }
|
|
110
112
|
|
|
111
|
-
removeNode(id: string):
|
|
113
|
+
removeNode(id: string): boolean { return this.wrapped.removeNode(id); }
|
|
112
114
|
|
|
113
115
|
updateOptions(options: SubMenuOptions): void { return this.wrapped.updateOptions(options); }
|
|
114
116
|
}
|
|
@@ -59,6 +59,29 @@ export interface MenuContribution {
|
|
|
59
59
|
registerMenus(menus: MenuModelRegistry): void;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
export enum ChangeKind {
|
|
63
|
+
ADDED,
|
|
64
|
+
REMOVED,
|
|
65
|
+
CHANGED,
|
|
66
|
+
LINKED
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface MenuChangedEvent {
|
|
70
|
+
kind: ChangeKind;
|
|
71
|
+
path: MenuPath
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface StructuralMenuChange extends MenuChangedEvent {
|
|
75
|
+
kind: ChangeKind.ADDED | ChangeKind.REMOVED | ChangeKind.LINKED;
|
|
76
|
+
affectedChildId: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export namespace StructuralMenuChange {
|
|
80
|
+
export function is(evt: MenuChangedEvent): evt is StructuralMenuChange {
|
|
81
|
+
return evt.kind !== ChangeKind.CHANGED;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
62
85
|
/**
|
|
63
86
|
* The MenuModelRegistry allows to register and unregister menus, submenus and actions
|
|
64
87
|
* via strings and {@link MenuAction}s without the need to access the underlying UI
|
|
@@ -69,9 +92,9 @@ export class MenuModelRegistry {
|
|
|
69
92
|
protected readonly root = new CompositeMenuNode('');
|
|
70
93
|
protected readonly independentSubmenus = new Map<string, MutableCompoundMenuNode>();
|
|
71
94
|
|
|
72
|
-
protected readonly onDidChangeEmitter = new Emitter<
|
|
95
|
+
protected readonly onDidChangeEmitter = new Emitter<MenuChangedEvent>();
|
|
73
96
|
|
|
74
|
-
get onDidChange(): Event<
|
|
97
|
+
get onDidChange(): Event<MenuChangedEvent> {
|
|
75
98
|
return this.onDidChangeEmitter.event;
|
|
76
99
|
}
|
|
77
100
|
|
|
@@ -108,8 +131,21 @@ export class MenuModelRegistry {
|
|
|
108
131
|
registerMenuNode(menuPath: MenuPath | string, menuNode: MenuNode, group?: string): Disposable {
|
|
109
132
|
const parent = this.getMenuNode(menuPath, group);
|
|
110
133
|
const disposable = parent.addNode(menuNode);
|
|
111
|
-
this.
|
|
112
|
-
|
|
134
|
+
const parentPath = this.getParentPath(menuPath, group);
|
|
135
|
+
this.fireChangeEvent({
|
|
136
|
+
kind: ChangeKind.ADDED,
|
|
137
|
+
path: parentPath,
|
|
138
|
+
affectedChildId: menuNode.id
|
|
139
|
+
});
|
|
140
|
+
return this.changeEventOnDispose(parentPath, menuNode.id, disposable);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
protected getParentPath(menuPath: MenuPath | string, group?: string): string[] {
|
|
144
|
+
if (typeof menuPath === 'string') {
|
|
145
|
+
return group ? [menuPath, group] : [menuPath];
|
|
146
|
+
} else {
|
|
147
|
+
return group ? menuPath.concat(group) : menuPath;
|
|
148
|
+
}
|
|
113
149
|
}
|
|
114
150
|
|
|
115
151
|
getMenuNode(menuPath: MenuPath | string, group?: string): MutableCompoundMenuNode {
|
|
@@ -152,11 +188,19 @@ export class MenuModelRegistry {
|
|
|
152
188
|
let disposable = Disposable.NULL;
|
|
153
189
|
if (!groupNode) {
|
|
154
190
|
groupNode = new CompositeMenuNode(menuId, label, options, parent);
|
|
155
|
-
disposable = this.changeEventOnDispose(parent.addNode(groupNode));
|
|
191
|
+
disposable = this.changeEventOnDispose(groupPath, menuId, parent.addNode(groupNode));
|
|
192
|
+
this.fireChangeEvent({
|
|
193
|
+
kind: ChangeKind.ADDED,
|
|
194
|
+
path: groupPath,
|
|
195
|
+
affectedChildId: menuId
|
|
196
|
+
});
|
|
156
197
|
} else {
|
|
198
|
+
this.fireChangeEvent({
|
|
199
|
+
kind: ChangeKind.CHANGED,
|
|
200
|
+
path: groupPath,
|
|
201
|
+
});
|
|
157
202
|
groupNode.updateOptions({ ...options, label });
|
|
158
203
|
}
|
|
159
|
-
this.fireChangeEvent();
|
|
160
204
|
return disposable;
|
|
161
205
|
}
|
|
162
206
|
|
|
@@ -165,12 +209,13 @@ export class MenuModelRegistry {
|
|
|
165
209
|
console.debug(`Independent submenu with path ${id} registered, but given ID already exists.`);
|
|
166
210
|
}
|
|
167
211
|
this.independentSubmenus.set(id, new CompositeMenuNode(id, label, options));
|
|
168
|
-
return this.changeEventOnDispose(Disposable.create(() => this.independentSubmenus.delete(id)));
|
|
212
|
+
return this.changeEventOnDispose([], id, Disposable.create(() => this.independentSubmenus.delete(id)));
|
|
169
213
|
}
|
|
170
214
|
|
|
171
215
|
linkSubmenu(parentPath: MenuPath | string, childId: string | MenuPath, options?: SubMenuOptions, group?: string): Disposable {
|
|
172
216
|
const child = this.getMenuNode(childId);
|
|
173
217
|
const parent = this.getMenuNode(parentPath, group);
|
|
218
|
+
const affectedPath = this.getParentPath(parentPath, group);
|
|
174
219
|
|
|
175
220
|
const isRecursive = (node: MenuNodeMetadata, childNode: MenuNodeMetadata): boolean => {
|
|
176
221
|
if (node.id === childNode.id) {
|
|
@@ -190,8 +235,13 @@ export class MenuModelRegistry {
|
|
|
190
235
|
|
|
191
236
|
const wrapper = new CompositeMenuNodeWrapper(child, parent, options);
|
|
192
237
|
const disposable = parent.addNode(wrapper);
|
|
193
|
-
this.fireChangeEvent(
|
|
194
|
-
|
|
238
|
+
this.fireChangeEvent({
|
|
239
|
+
kind: ChangeKind.LINKED,
|
|
240
|
+
path: affectedPath,
|
|
241
|
+
affectedChildId: child.id
|
|
242
|
+
|
|
243
|
+
});
|
|
244
|
+
return this.changeEventOnDispose(affectedPath, child.id, disposable);
|
|
195
245
|
}
|
|
196
246
|
|
|
197
247
|
/**
|
|
@@ -223,11 +273,14 @@ export class MenuModelRegistry {
|
|
|
223
273
|
if (menuPath) {
|
|
224
274
|
const parent = this.findGroup(menuPath);
|
|
225
275
|
parent.removeNode(id);
|
|
226
|
-
this.fireChangeEvent(
|
|
227
|
-
|
|
276
|
+
this.fireChangeEvent({
|
|
277
|
+
kind: ChangeKind.REMOVED,
|
|
278
|
+
path: menuPath,
|
|
279
|
+
affectedChildId: id
|
|
280
|
+
});
|
|
281
|
+
} else {
|
|
282
|
+
this.unregisterMenuNode(id);
|
|
228
283
|
}
|
|
229
|
-
|
|
230
|
-
this.unregisterMenuNode(id);
|
|
231
284
|
}
|
|
232
285
|
|
|
233
286
|
/**
|
|
@@ -236,16 +289,24 @@ export class MenuModelRegistry {
|
|
|
236
289
|
* @param id technical identifier of the `MenuNode`.
|
|
237
290
|
*/
|
|
238
291
|
unregisterMenuNode(id: string): void {
|
|
292
|
+
const parentPath: string[] = [];
|
|
239
293
|
const recurse = (root: MutableCompoundMenuNode) => {
|
|
240
294
|
root.children.forEach(node => {
|
|
241
295
|
if (CompoundMenuNode.isMutable(node)) {
|
|
242
|
-
node.removeNode(id)
|
|
296
|
+
if (node.removeNode(id)) {
|
|
297
|
+
this.fireChangeEvent({
|
|
298
|
+
kind: ChangeKind.REMOVED,
|
|
299
|
+
path: parentPath,
|
|
300
|
+
affectedChildId: id
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
parentPath.push(node.id);
|
|
243
304
|
recurse(node);
|
|
305
|
+
parentPath.pop();
|
|
244
306
|
}
|
|
245
307
|
});
|
|
246
308
|
};
|
|
247
309
|
recurse(this.root);
|
|
248
|
-
this.fireChangeEvent();
|
|
249
310
|
}
|
|
250
311
|
|
|
251
312
|
/**
|
|
@@ -339,16 +400,20 @@ export class MenuModelRegistry {
|
|
|
339
400
|
return true;
|
|
340
401
|
}
|
|
341
402
|
|
|
342
|
-
protected changeEventOnDispose(disposable: Disposable): Disposable {
|
|
403
|
+
protected changeEventOnDispose(path: MenuPath, id: string, disposable: Disposable): Disposable {
|
|
343
404
|
return Disposable.create(() => {
|
|
344
405
|
disposable.dispose();
|
|
345
|
-
this.fireChangeEvent(
|
|
406
|
+
this.fireChangeEvent({
|
|
407
|
+
path,
|
|
408
|
+
affectedChildId: id,
|
|
409
|
+
kind: ChangeKind.REMOVED
|
|
410
|
+
});
|
|
346
411
|
});
|
|
347
412
|
}
|
|
348
413
|
|
|
349
|
-
protected fireChangeEvent(): void {
|
|
414
|
+
protected fireChangeEvent<T extends MenuChangedEvent>(evt: T): void {
|
|
350
415
|
if (this.isReady) {
|
|
351
|
-
this.onDidChangeEmitter.fire();
|
|
416
|
+
this.onDidChangeEmitter.fire(evt);
|
|
352
417
|
}
|
|
353
418
|
}
|
|
354
419
|
|
|
@@ -139,8 +139,9 @@ export interface MutableCompoundMenuNode extends CompoundMenuNode {
|
|
|
139
139
|
* Removes the first node with the given id.
|
|
140
140
|
*
|
|
141
141
|
* @param id node id.
|
|
142
|
+
* @returns true if the id was present
|
|
142
143
|
*/
|
|
143
|
-
removeNode(id: string):
|
|
144
|
+
removeNode(id: string): boolean;
|
|
144
145
|
|
|
145
146
|
/**
|
|
146
147
|
* Fills any `undefined` fields with the values from the {@link options}.
|