@theia/core 1.59.0-next.62 → 1.59.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 (125) hide show
  1. package/README.md +6 -6
  2. package/i18n/nls.cs.json +344 -23
  3. package/i18n/nls.de.json +344 -23
  4. package/i18n/nls.es.json +344 -23
  5. package/i18n/nls.fr.json +344 -23
  6. package/i18n/nls.hu.json +344 -23
  7. package/i18n/nls.it.json +344 -23
  8. package/i18n/nls.ja.json +344 -23
  9. package/i18n/nls.json +344 -23
  10. package/i18n/nls.ko.json +344 -23
  11. package/i18n/nls.pl.json +344 -23
  12. package/i18n/nls.pt-br.json +344 -23
  13. package/i18n/nls.ru.json +344 -23
  14. package/i18n/nls.tr.json +344 -23
  15. package/i18n/nls.zh-cn.json +344 -23
  16. package/i18n/nls.zh-tw.json +344 -23
  17. package/lib/browser/about-dialog.js +1 -1
  18. package/lib/browser/about-dialog.js.map +1 -1
  19. package/lib/browser/catalog.json +65 -5
  20. package/lib/browser/common-frontend-contribution.d.ts +2 -0
  21. package/lib/browser/common-frontend-contribution.d.ts.map +1 -1
  22. package/lib/browser/common-frontend-contribution.js +32 -0
  23. package/lib/browser/common-frontend-contribution.js.map +1 -1
  24. package/lib/browser/context-menu-renderer.d.ts +3 -3
  25. package/lib/browser/context-menu-renderer.d.ts.map +1 -1
  26. package/lib/browser/core-preferences.js +5 -5
  27. package/lib/browser/core-preferences.js.map +1 -1
  28. package/lib/browser/menu/browser-menu-plugin.d.ts +7 -0
  29. package/lib/browser/menu/browser-menu-plugin.d.ts.map +1 -1
  30. package/lib/browser/menu/browser-menu-plugin.js +46 -3
  31. package/lib/browser/menu/browser-menu-plugin.js.map +1 -1
  32. package/lib/browser/navigatable-types.d.ts.map +1 -1
  33. package/lib/browser/navigatable-types.js +1 -1
  34. package/lib/browser/navigatable-types.js.map +1 -1
  35. package/lib/browser/preload/i18n-preload-contribution.d.ts +4 -0
  36. package/lib/browser/preload/i18n-preload-contribution.d.ts.map +1 -1
  37. package/lib/browser/preload/i18n-preload-contribution.js +23 -1
  38. package/lib/browser/preload/i18n-preload-contribution.js.map +1 -1
  39. package/lib/browser/preload/preload-module.d.ts.map +1 -1
  40. package/lib/browser/preload/preload-module.js +5 -3
  41. package/lib/browser/preload/preload-module.js.map +1 -1
  42. package/lib/browser/preload/text-replacement-contribution.d.ts +37 -0
  43. package/lib/browser/preload/text-replacement-contribution.d.ts.map +1 -0
  44. package/lib/browser/preload/text-replacement-contribution.js +20 -0
  45. package/lib/browser/preload/text-replacement-contribution.js.map +1 -0
  46. package/lib/browser/shell/side-panel-handler.d.ts.map +1 -1
  47. package/lib/browser/shell/side-panel-handler.js +2 -1
  48. package/lib/browser/shell/side-panel-handler.js.map +1 -1
  49. package/lib/browser/shell/sidebar-bottom-menu-widget.d.ts.map +1 -1
  50. package/lib/browser/shell/sidebar-bottom-menu-widget.js +2 -1
  51. package/lib/browser/shell/sidebar-bottom-menu-widget.js.map +1 -1
  52. package/lib/browser/shell/sidebar-menu-widget.d.ts.map +1 -1
  53. package/lib/browser/shell/sidebar-menu-widget.js +1 -0
  54. package/lib/browser/shell/sidebar-menu-widget.js.map +1 -1
  55. package/lib/browser/shell/tab-bar-toolbar/tab-bar-toolbar.js +2 -2
  56. package/lib/browser/shell/tab-bar-toolbar/tab-bar-toolbar.js.map +1 -1
  57. package/lib/browser/shell/tab-bars.d.ts.map +1 -1
  58. package/lib/browser/shell/tab-bars.js +1 -0
  59. package/lib/browser/shell/tab-bars.js.map +1 -1
  60. package/lib/browser/tree/tree-widget.d.ts.map +1 -1
  61. package/lib/browser/tree/tree-widget.js +1 -0
  62. package/lib/browser/tree/tree-widget.js.map +1 -1
  63. package/lib/browser/view-container.d.ts.map +1 -1
  64. package/lib/browser/view-container.js +6 -2
  65. package/lib/browser/view-container.js.map +1 -1
  66. package/lib/browser/widgets/widget.d.ts.map +1 -1
  67. package/lib/browser/widgets/widget.js +4 -2
  68. package/lib/browser/widgets/widget.js.map +1 -1
  69. package/lib/common/array-utils.d.ts +2 -0
  70. package/lib/common/array-utils.d.ts.map +1 -1
  71. package/lib/common/array-utils.js +24 -0
  72. package/lib/common/array-utils.js.map +1 -1
  73. package/lib/common/content-replacer.d.ts +56 -0
  74. package/lib/common/content-replacer.d.ts.map +1 -0
  75. package/lib/common/content-replacer.js +139 -0
  76. package/lib/common/content-replacer.js.map +1 -0
  77. package/lib/common/content-replacer.spec.d.ts +2 -0
  78. package/lib/common/content-replacer.spec.d.ts.map +1 -0
  79. package/lib/common/content-replacer.spec.js +115 -0
  80. package/lib/common/content-replacer.spec.js.map +1 -0
  81. package/lib/common/i18n/localization.d.ts +2 -3
  82. package/lib/common/i18n/localization.d.ts.map +1 -1
  83. package/lib/common/i18n/localization.js +10 -3
  84. package/lib/common/i18n/localization.js.map +1 -1
  85. package/lib/common/menu/composite-menu-node.d.ts +2 -2
  86. package/lib/common/menu/composite-menu-node.d.ts.map +1 -1
  87. package/lib/common/menu/composite-menu-node.js +2 -0
  88. package/lib/common/menu/composite-menu-node.js.map +1 -1
  89. package/lib/common/menu/menu-model-registry.d.ts +22 -4
  90. package/lib/common/menu/menu-model-registry.d.ts.map +1 -1
  91. package/lib/common/menu/menu-model-registry.js +74 -17
  92. package/lib/common/menu/menu-model-registry.js.map +1 -1
  93. package/lib/common/menu/menu-types.d.ts +2 -1
  94. package/lib/common/menu/menu-types.d.ts.map +1 -1
  95. package/lib/common/menu/menu-types.js.map +1 -1
  96. package/lib/electron-browser/menu/electron-context-menu-renderer.d.ts.map +1 -1
  97. package/lib/electron-browser/menu/electron-context-menu-renderer.js +3 -1
  98. package/lib/electron-browser/menu/electron-context-menu-renderer.js.map +1 -1
  99. package/package.json +6 -6
  100. package/src/browser/about-dialog.tsx +1 -1
  101. package/src/browser/common-frontend-contribution.ts +30 -0
  102. package/src/browser/context-menu-renderer.ts +3 -3
  103. package/src/browser/core-preferences.ts +5 -5
  104. package/src/browser/menu/browser-menu-plugin.ts +55 -5
  105. package/src/browser/navigatable-types.ts +1 -1
  106. package/src/browser/preload/i18n-preload-contribution.ts +22 -2
  107. package/src/browser/preload/preload-module.ts +5 -3
  108. package/src/browser/preload/text-replacement-contribution.ts +53 -0
  109. package/src/browser/shell/side-panel-handler.ts +2 -1
  110. package/src/browser/shell/sidebar-bottom-menu-widget.tsx +2 -1
  111. package/src/browser/shell/sidebar-menu-widget.tsx +1 -0
  112. package/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx +2 -2
  113. package/src/browser/shell/tab-bars.ts +1 -0
  114. package/src/browser/tree/tree-widget.tsx +1 -0
  115. package/src/browser/view-container.ts +6 -2
  116. package/src/browser/widgets/widget.ts +4 -2
  117. package/src/common/array-utils.ts +25 -0
  118. package/src/common/content-replacer.spec.ts +124 -0
  119. package/src/common/content-replacer.ts +151 -0
  120. package/src/common/i18n/localization.ts +10 -4
  121. package/src/common/i18n/nls.metadata.json +34921 -32708
  122. package/src/common/menu/composite-menu-node.ts +4 -2
  123. package/src/common/menu/menu-model-registry.ts +84 -19
  124. package/src/common/menu/menu-types.ts +2 -1
  125. package/src/electron-browser/menu/electron-context-menu-renderer.ts +3 -1
@@ -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* getAffected<T extends Widget>(
45
+ export function getAffected<T extends Widget>(
46
46
  widgets: Iterable<T>,
47
47
  context: MaybeArray<URI>
48
48
  ): IterableIterator<[URI, T & NavigatableWidget]> {
@@ -17,8 +17,10 @@
17
17
  import { PreloadContribution } from './preloader';
18
18
  import { FrontendApplicationConfigProvider } from '../frontend-application-config-provider';
19
19
  import { nls } from '../../common/nls';
20
- import { inject, injectable } from 'inversify';
20
+ import { inject, injectable, named } from 'inversify';
21
21
  import { LocalizationServer } from '../../common/i18n/localization-server';
22
+ import { ContributionProvider } from '../../common';
23
+ import { TextReplacementContribution } from './text-replacement-contribution';
22
24
 
23
25
  @injectable()
24
26
  export class I18nPreloadContribution implements PreloadContribution {
@@ -26,6 +28,9 @@ export class I18nPreloadContribution implements PreloadContribution {
26
28
  @inject(LocalizationServer)
27
29
  protected readonly localizationServer: LocalizationServer;
28
30
 
31
+ @inject(ContributionProvider) @named(TextReplacementContribution)
32
+ protected readonly replacementContributions: ContributionProvider<TextReplacementContribution>;
33
+
29
34
  async initialize(): Promise<void> {
30
35
  const defaultLocale = FrontendApplicationConfigProvider.get().defaultLocale;
31
36
  if (defaultLocale && !nls.locale) {
@@ -33,8 +38,9 @@ export class I18nPreloadContribution implements PreloadContribution {
33
38
  locale: defaultLocale
34
39
  });
35
40
  }
41
+ let locale = nls.locale ?? nls.defaultLocale;
36
42
  if (nls.locale && nls.locale !== nls.defaultLocale) {
37
- const localization = await this.localizationServer.loadLocalization(nls.locale);
43
+ const localization = await this.localizationServer.loadLocalization(locale);
38
44
  if (localization.languagePack) {
39
45
  nls.localization = localization;
40
46
  } else {
@@ -43,8 +49,22 @@ export class I18nPreloadContribution implements PreloadContribution {
43
49
  Object.assign(nls, {
44
50
  locale: defaultLocale || undefined
45
51
  });
52
+ locale = defaultLocale;
46
53
  }
47
54
  }
55
+ const replacements = this.getReplacements(locale);
56
+ if (Object.keys(replacements).length > 0) {
57
+ nls.localization ??= { translations: {}, languageId: locale };
58
+ nls.localization.replacements = replacements;
59
+ }
60
+ }
61
+
62
+ protected getReplacements(locale: string): Record<string, string> {
63
+ const replacements: Record<string, string> = {};
64
+ for (const contribution of this.replacementContributions.getContributions()) {
65
+ Object.assign(replacements, contribution.getReplacement(locale));
66
+ }
67
+ return replacements;
48
68
  }
49
69
 
50
70
  }
@@ -21,19 +21,21 @@ import { I18nPreloadContribution } from './i18n-preload-contribution';
21
21
  import { OSPreloadContribution } from './os-preload-contribution';
22
22
  import { ThemePreloadContribution } from './theme-preload-contribution';
23
23
  import { LocalizationServer, LocalizationServerPath } from '../../common/i18n/localization-server';
24
- import { WebSocketConnectionProvider } from '../messaging/ws-connection-provider';
24
+ import { ServiceConnectionProvider } from '../messaging/service-connection-provider';
25
25
  import { OSBackendProvider, OSBackendProviderPath } from '../../common/os';
26
+ import { TextReplacementContribution } from './text-replacement-contribution';
26
27
 
27
28
  export default new ContainerModule(bind => {
28
29
  bind(Preloader).toSelf().inSingletonScope();
29
30
  bindContributionProvider(bind, PreloadContribution);
31
+ bindContributionProvider(bind, TextReplacementContribution);
30
32
 
31
33
  bind(LocalizationServer).toDynamicValue(ctx =>
32
- WebSocketConnectionProvider.createProxy<LocalizationServer>(ctx.container, LocalizationServerPath)
34
+ ServiceConnectionProvider.createProxy<LocalizationServer>(ctx.container, LocalizationServerPath)
33
35
  ).inSingletonScope();
34
36
 
35
37
  bind(OSBackendProvider).toDynamicValue(ctx =>
36
- WebSocketConnectionProvider.createProxy<OSBackendProvider>(ctx.container, OSBackendProviderPath)
38
+ ServiceConnectionProvider.createProxy<OSBackendProvider>(ctx.container, OSBackendProviderPath)
37
39
  ).inSingletonScope();
38
40
 
39
41
  bind(I18nPreloadContribution).toSelf().inSingletonScope();
@@ -0,0 +1,53 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2025 TypeFox 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
+ export const TextReplacementContribution = Symbol('TextReplacementContribution');
18
+
19
+ /**
20
+ * Enables adopters to override text in the application. All `TextReplacementContribution`s need to be bound in the `frontendPreload` scope of the package.json.
21
+ *
22
+ * @example Create a text replacement contribution
23
+ * ```typescript
24
+ * import { TextReplacementContribution } from '@theia/core/lib/browser/preload/text-replacement-contribution';
25
+ * export class TextSampleReplacementContribution implements TextReplacementContribution {
26
+ * getReplacement(locale: string): Record<string, string> {
27
+ * switch (locale) {
28
+ * case 'en': {
29
+ * return {
30
+ * 'About': 'About Theia',
31
+ * };
32
+ * }
33
+ * case 'de': {
34
+ * return {
35
+ * 'About': 'Über Theia',
36
+ * };
37
+ * }
38
+ * }
39
+ * return {};
40
+ * }
41
+ * }
42
+ * ```
43
+ */
44
+ export interface TextReplacementContribution {
45
+ /**
46
+ * This method returns a map of **default values** and their replacement values for the specified locale.
47
+ * **Do not** use the keys of the `nls.localization` call, but the English default values.
48
+ *
49
+ * @param locale The locale for which the replacement should be returned.
50
+ * @returns A map of default values and their replacement values.
51
+ */
52
+ getReplacement(locale: string): Record<string, string>;
53
+ }
@@ -240,7 +240,8 @@ export class SidePanelHandler {
240
240
  this.contextMenuRenderer.render({
241
241
  args: [title.owner],
242
242
  menuPath: SIDE_PANEL_TOOLBAR_CONTEXT_MENU,
243
- anchor: e
243
+ anchor: e,
244
+ context: e.currentTarget instanceof HTMLElement ? e.currentTarget : this.tabBar.node
244
245
  });
245
246
  }
246
247
 
@@ -32,7 +32,8 @@ export class SidebarBottomMenuWidget extends SidebarMenuWidget {
32
32
  anchor: {
33
33
  x: button.left + button.width,
34
34
  y: button.top + button.height,
35
- }
35
+ },
36
+ context: e.currentTarget
36
37
  });
37
38
  }
38
39
 
@@ -152,6 +152,7 @@ export class SidebarMenuWidget extends ReactWidget {
152
152
  x: button.left + button.width,
153
153
  y: button.top,
154
154
  },
155
+ context: e.currentTarget,
155
156
  onHide: () => {
156
157
  this.preservingContext = false;
157
158
  if (this.preservedContext) {
@@ -309,7 +309,7 @@ export class TabBarToolbar extends ReactWidget {
309
309
  menuPath: TAB_BAR_TOOLBAR_CONTEXT_MENU,
310
310
  args: [this.current],
311
311
  anchor,
312
- context: this.current?.node,
312
+ context: this.current?.node || this.node,
313
313
  onHide: () => toDisposeOnHide.dispose(),
314
314
  skipSingleRootNode: true,
315
315
  });
@@ -375,7 +375,7 @@ export class TabBarToolbar extends ReactWidget {
375
375
  menuPath,
376
376
  args: [this.current],
377
377
  anchor,
378
- context: this.current?.node,
378
+ context: this.current?.node || this.node,
379
379
  contextKeyService: contextMatcher,
380
380
  onHide: () => toDisposeOnHide.dispose()
381
381
  });
@@ -653,6 +653,7 @@ export class TabBarRenderer extends TabBar.Renderer {
653
653
  menuPath: this.contextMenuPath!,
654
654
  anchor: event,
655
655
  args: [event],
656
+ context: event.currentTarget,
656
657
  contextKeyService: contextKeyServiceOverlay,
657
658
  // We'd like to wait until the command triggered by the context menu has been run, but this should let it get through the preamble, at least.
658
659
  onHide: () => setTimeout(() => { if (this.selectionService) { this.selectionService.selection = oldSelection; } })
@@ -1362,6 +1362,7 @@ export class TreeWidget extends ReactWidget implements StatefulWidget {
1362
1362
  const args = this.toContextMenuArgs(node);
1363
1363
  setTimeout(() => this.contextMenuRenderer.render({
1364
1364
  menuPath: contextMenuPath,
1365
+ context: event.currentTarget,
1365
1366
  anchor: { x, y },
1366
1367
  args
1367
1368
  }), 10);
@@ -170,7 +170,11 @@ export class ViewContainer extends BaseWidget implements StatefulWidget, Applica
170
170
  if (event.button === 2 && every(this.containerLayout.iter(), part => !!part.isHidden)) {
171
171
  event.stopPropagation();
172
172
  event.preventDefault();
173
- contextMenuRenderer.render({ menuPath: this.contextMenuPath, anchor: event });
173
+ contextMenuRenderer.render({
174
+ menuPath: this.contextMenuPath,
175
+ anchor: event,
176
+ context: event.currentTarget instanceof HTMLElement ? event.currentTarget : this.node
177
+ });
174
178
  }
175
179
  }),
176
180
  commandRegistry.registerCommand({ id: this.globalHideCommandId }, {
@@ -436,7 +440,7 @@ export class ViewContainer extends BaseWidget implements StatefulWidget, Applica
436
440
  if (event.button === 2) {
437
441
  event.preventDefault();
438
442
  event.stopPropagation();
439
- this.contextMenuRenderer.render({ menuPath: this.contextMenuPath, anchor: event });
443
+ this.contextMenuRenderer.render({ menuPath: this.contextMenuPath, anchor: event, context: this.node });
440
444
  }
441
445
  }),
442
446
  newPart.onTitleChanged(() => this.refreshMenu(newPart)),
@@ -234,9 +234,11 @@ export class BaseWidget extends Widget implements PreviewableWidget {
234
234
  }
235
235
 
236
236
  override clearFlag(flag: Widget.Flag): void {
237
+ const wasVisible = this.isVisible;
237
238
  super.clearFlag(flag);
238
- if (flag === Widget.Flag.IsVisible) {
239
- this.handleVisiblityChanged(this.isVisible);
239
+ const isVisible = this.isVisible;
240
+ if (isVisible !== wasVisible) {
241
+ this.handleVisiblityChanged(isVisible);
240
242
  }
241
243
  }
242
244
  }
@@ -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
+ }
@@ -25,7 +25,8 @@ export interface AsyncLocalizationProvider {
25
25
  }
26
26
 
27
27
  export interface Localization extends LanguageInfo {
28
- translations: { [key: string]: string };
28
+ translations: Record<string, string>;
29
+ replacements?: Record<string, string>;
29
30
  }
30
31
 
31
32
  export interface LanguageInfo {
@@ -50,9 +51,14 @@ export namespace Localization {
50
51
  export function localize(localization: Localization | undefined, key: string, defaultValue: string, ...args: FormatType[]): string {
51
52
  let value = defaultValue;
52
53
  if (localization) {
53
- const translation = localization.translations[key];
54
- if (translation) {
55
- value = normalize(translation);
54
+ const replacement = localization.replacements?.[defaultValue];
55
+ if (typeof replacement === 'string') {
56
+ value = replacement;
57
+ } else {
58
+ const translation = localization.translations[key];
59
+ if (translation) {
60
+ value = normalize(translation);
61
+ }
56
62
  }
57
63
  }
58
64
  return format(value, args);