@theia/plugin-ext 1.71.0-next.8 → 1.72.0-next.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 (98) hide show
  1. package/lib/common/plugin-api-rpc.d.ts +76 -0
  2. package/lib/common/plugin-api-rpc.d.ts.map +1 -1
  3. package/lib/common/plugin-api-rpc.js +9 -1
  4. package/lib/common/plugin-api-rpc.js.map +1 -1
  5. package/lib/hosted/browser/hosted-plugin.d.ts.map +1 -1
  6. package/lib/hosted/browser/hosted-plugin.js +13 -6
  7. package/lib/hosted/browser/hosted-plugin.js.map +1 -1
  8. package/lib/main/browser/main-context.d.ts.map +1 -1
  9. package/lib/main/browser/main-context.js +2 -6
  10. package/lib/main/browser/main-context.js.map +1 -1
  11. package/lib/main/browser/main-file-system-event-service.d.ts +10 -2
  12. package/lib/main/browser/main-file-system-event-service.d.ts.map +1 -1
  13. package/lib/main/browser/main-file-system-event-service.js +19 -2
  14. package/lib/main/browser/main-file-system-event-service.js.map +1 -1
  15. package/lib/main/browser/menus/menus-contribution-handler.d.ts.map +1 -1
  16. package/lib/main/browser/menus/menus-contribution-handler.js +2 -0
  17. package/lib/main/browser/menus/menus-contribution-handler.js.map +1 -1
  18. package/lib/main/browser/menus/plugin-menu-command-adapter.d.ts +1 -0
  19. package/lib/main/browser/menus/plugin-menu-command-adapter.d.ts.map +1 -1
  20. package/lib/main/browser/menus/plugin-menu-command-adapter.js +13 -1
  21. package/lib/main/browser/menus/plugin-menu-command-adapter.js.map +1 -1
  22. package/lib/main/browser/menus/vscode-theia-menu-mappings.d.ts +1 -1
  23. package/lib/main/browser/menus/vscode-theia-menu-mappings.d.ts.map +1 -1
  24. package/lib/main/browser/menus/vscode-theia-menu-mappings.js +9 -2
  25. package/lib/main/browser/menus/vscode-theia-menu-mappings.js.map +1 -1
  26. package/lib/main/browser/scm-main.d.ts +33 -2
  27. package/lib/main/browser/scm-main.d.ts.map +1 -1
  28. package/lib/main/browser/scm-main.js +237 -3
  29. package/lib/main/browser/scm-main.js.map +1 -1
  30. package/lib/main/browser/scm-main.spec.d.ts +2 -0
  31. package/lib/main/browser/scm-main.spec.d.ts.map +1 -0
  32. package/lib/main/browser/scm-main.spec.js +87 -0
  33. package/lib/main/browser/scm-main.spec.js.map +1 -0
  34. package/lib/main/browser/test-main.d.ts +3 -2
  35. package/lib/main/browser/test-main.d.ts.map +1 -1
  36. package/lib/main/browser/test-main.js +12 -1
  37. package/lib/main/browser/test-main.js.map +1 -1
  38. package/lib/plugin/file-system-event-service-ext-impl.d.ts +11 -5
  39. package/lib/plugin/file-system-event-service-ext-impl.d.ts.map +1 -1
  40. package/lib/plugin/file-system-event-service-ext-impl.js +28 -9
  41. package/lib/plugin/file-system-event-service-ext-impl.js.map +1 -1
  42. package/lib/plugin/plugin-context.js +4 -4
  43. package/lib/plugin/plugin-context.js.map +1 -1
  44. package/lib/plugin/scm.d.ts +8 -2
  45. package/lib/plugin/scm.d.ts.map +1 -1
  46. package/lib/plugin/scm.js +188 -5
  47. package/lib/plugin/scm.js.map +1 -1
  48. package/lib/plugin/scm.spec.d.ts +2 -0
  49. package/lib/plugin/scm.spec.d.ts.map +1 -0
  50. package/lib/plugin/scm.spec.js +461 -0
  51. package/lib/plugin/scm.spec.js.map +1 -0
  52. package/lib/plugin/terminal-ext.d.ts +13 -3
  53. package/lib/plugin/terminal-ext.d.ts.map +1 -1
  54. package/lib/plugin/terminal-ext.js +51 -10
  55. package/lib/plugin/terminal-ext.js.map +1 -1
  56. package/lib/plugin/terminal-ext.spec.d.ts +2 -0
  57. package/lib/plugin/terminal-ext.spec.d.ts.map +1 -0
  58. package/lib/plugin/terminal-ext.spec.js +285 -0
  59. package/lib/plugin/terminal-ext.spec.js.map +1 -0
  60. package/lib/plugin/test-item.d.ts.map +1 -1
  61. package/lib/plugin/test-item.js +8 -3
  62. package/lib/plugin/test-item.js.map +1 -1
  63. package/lib/plugin/tests.d.ts.map +1 -1
  64. package/lib/plugin/tests.js +15 -3
  65. package/lib/plugin/tests.js.map +1 -1
  66. package/lib/plugin/type-converters.d.ts +2 -2
  67. package/lib/plugin/type-converters.d.ts.map +1 -1
  68. package/lib/plugin/type-converters.js +3 -9
  69. package/lib/plugin/type-converters.js.map +1 -1
  70. package/lib/plugin/types-impl.d.ts +1 -1
  71. package/lib/plugin/types-impl.d.ts.map +1 -1
  72. package/lib/plugin/types-impl.js +1 -1
  73. package/lib/plugin/types-impl.js.map +1 -1
  74. package/lib/plugin/workspace.d.ts.map +1 -1
  75. package/lib/plugin/workspace.js +17 -3
  76. package/lib/plugin/workspace.js.map +1 -1
  77. package/package.json +39 -39
  78. package/src/common/plugin-api-rpc.ts +78 -0
  79. package/src/hosted/browser/hosted-plugin.ts +13 -6
  80. package/src/main/browser/main-context.ts +3 -7
  81. package/src/main/browser/main-file-system-event-service.ts +26 -6
  82. package/src/main/browser/menus/menus-contribution-handler.ts +2 -0
  83. package/src/main/browser/menus/plugin-menu-command-adapter.ts +15 -2
  84. package/src/main/browser/menus/vscode-theia-menu-mappings.ts +12 -3
  85. package/src/main/browser/scm-main.spec.ts +105 -0
  86. package/src/main/browser/scm-main.ts +272 -4
  87. package/src/main/browser/test-main.ts +13 -3
  88. package/src/plugin/file-system-event-service-ext-impl.ts +40 -14
  89. package/src/plugin/plugin-context.ts +7 -7
  90. package/src/plugin/scm.spec.ts +615 -0
  91. package/src/plugin/scm.ts +224 -6
  92. package/src/plugin/terminal-ext.spec.ts +350 -0
  93. package/src/plugin/terminal-ext.ts +58 -12
  94. package/src/plugin/test-item.ts +8 -3
  95. package/src/plugin/tests.ts +14 -3
  96. package/src/plugin/type-converters.ts +7 -13
  97. package/src/plugin/types-impl.ts +2 -2
  98. package/src/plugin/workspace.ts +17 -3
package/src/plugin/scm.ts CHANGED
@@ -28,16 +28,22 @@ import {
28
28
  ScmMain, ScmRawResource, ScmRawResourceGroup,
29
29
  ScmRawResourceSplice, ScmRawResourceSplices,
30
30
  SourceControlGroupFeatures,
31
- ScmActionButton
31
+ ScmActionButton,
32
+ ScmHistoryItemRefDto,
33
+ ScmHistoryItemDto,
34
+ ScmHistoryItemChangeDto,
35
+ ScmHistoryOptionsDto,
36
+ ScmHistoryItemRefsChangeEventDto
32
37
  } from '../common';
33
38
  import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable';
39
+ import { CancellationToken } from '@theia/core/lib/common/cancellation';
34
40
  import { CommandRegistryImpl } from '../plugin/command-registry';
35
41
  import { Splice } from '../common/arrays';
36
42
  import { UriComponents } from '../common/uri-components';
37
43
  import { Command } from '../common/plugin-api-rpc-model';
38
44
  import { RPCProtocol } from '../common/rpc-protocol';
39
45
  import { URI, ThemeIcon } from './types-impl';
40
- import { ScmCommandArg } from '../common/plugin-api-rpc';
46
+ import { ScmCommandArg, ScmHistoryItemCommandArg } from '../common/plugin-api-rpc';
41
47
  import { sep } from '@theia/core/lib/common/paths';
42
48
  import { PluginIconPath } from './plugin-icon-path';
43
49
  import { createAPIObject } from './plugin-context';
@@ -538,11 +544,54 @@ class ScmResourceGroupImpl implements theia.SourceControlResourceGroup {
538
544
  }
539
545
  }
540
546
 
547
+ function historyItemRefToDto(ref: theia.SourceControlHistoryItemRef): ScmHistoryItemRefDto {
548
+ return {
549
+ id: ref.id,
550
+ name: ref.name,
551
+ description: ref.description,
552
+ revision: ref.revision,
553
+ icon: ref.icon,
554
+ category: ref.category,
555
+ };
556
+ }
557
+
558
+ function historyItemToDto(item: theia.SourceControlHistoryItem): ScmHistoryItemDto {
559
+ return {
560
+ id: item.id,
561
+ parentIds: item.parentIds ? [...item.parentIds] : undefined,
562
+ subject: item.subject,
563
+ message: item.message,
564
+ author: item.author,
565
+ authorEmail: item.authorEmail,
566
+ authorIcon: item.authorIcon,
567
+ displayId: item.displayId,
568
+ timestamp: item.timestamp,
569
+ tooltip: item.tooltip,
570
+ statistics: item.statistics ? {
571
+ files: item.statistics.files,
572
+ insertions: item.statistics.insertions,
573
+ deletions: item.statistics.deletions,
574
+ } : undefined,
575
+ references: item.references ? item.references.map(historyItemRefToDto) : undefined,
576
+ };
577
+ }
578
+
579
+ function historyItemChangeToDto(change: theia.SourceControlHistoryItemChange): ScmHistoryItemChangeDto {
580
+ return {
581
+ uri: change.uri,
582
+ originalUri: change.originalUri,
583
+ modifiedUri: change.modifiedUri,
584
+ renameUri: change.renameUri,
585
+ };
586
+ }
587
+
541
588
  class SourceControlImpl implements theia.SourceControl {
542
589
 
543
590
  private static handlePool: number = 0;
544
591
  private groups: Map<GroupHandle, ScmResourceGroupImpl> = new Map<GroupHandle, ScmResourceGroupImpl>();
545
592
 
593
+ readonly apiObject: theia.SourceControl;
594
+
546
595
  get id(): string {
547
596
  return this._id;
548
597
  }
@@ -687,6 +736,53 @@ class SourceControlImpl implements theia.SourceControl {
687
736
  this.proxy.$updateSourceControl(this.handle, { contextValue });
688
737
  }
689
738
 
739
+ private _historyProvider: theia.SourceControlHistoryProvider | undefined = undefined;
740
+ private _historyProviderDisposables = new DisposableCollection();
741
+
742
+ readonly historyItems = new Map<string, theia.SourceControlHistoryItem>();
743
+ readonly historyItemRefs = new Map<string, theia.SourceControlHistoryItemRef>();
744
+
745
+ get historyProvider(): theia.SourceControlHistoryProvider | undefined {
746
+ return this._historyProvider;
747
+ }
748
+
749
+ set historyProvider(provider: theia.SourceControlHistoryProvider | undefined) {
750
+ this._historyProviderDisposables.dispose();
751
+ this._historyProviderDisposables = new DisposableCollection();
752
+ this._historyProvider = provider;
753
+
754
+ if (provider) {
755
+ this._historyProviderDisposables.push(
756
+ provider.onDidChangeCurrentHistoryItemRefs(() => {
757
+ this.proxy.$updateSourceControl(this.handle, {
758
+ hasHistoryProvider: true,
759
+ currentHistoryItemRef: provider.currentHistoryItemRef ? historyItemRefToDto(provider.currentHistoryItemRef) : undefined,
760
+ currentHistoryItemRemoteRef: provider.currentHistoryItemRemoteRef ? historyItemRefToDto(provider.currentHistoryItemRemoteRef) : undefined,
761
+ currentHistoryItemBaseRef: provider.currentHistoryItemBaseRef ? historyItemRefToDto(provider.currentHistoryItemBaseRef) : undefined,
762
+ });
763
+ this.proxy.$onDidChangeCurrentHistoryItemRefs(this.handle);
764
+ })
765
+ );
766
+ this._historyProviderDisposables.push(
767
+ provider.onDidChangeHistoryItemRefs(event => {
768
+ const dto: ScmHistoryItemRefsChangeEventDto = {
769
+ added: event.added.map(historyItemRefToDto),
770
+ removed: event.removed.map(historyItemRefToDto),
771
+ modified: event.modified.map(historyItemRefToDto),
772
+ };
773
+ this.proxy.$onDidChangeHistoryItemRefs(this.handle, dto);
774
+ })
775
+ );
776
+ }
777
+
778
+ this.proxy.$updateSourceControl(this.handle, {
779
+ hasHistoryProvider: !!provider,
780
+ currentHistoryItemRef: provider?.currentHistoryItemRef ? historyItemRefToDto(provider.currentHistoryItemRef) : undefined,
781
+ currentHistoryItemRemoteRef: provider?.currentHistoryItemRemoteRef ? historyItemRefToDto(provider.currentHistoryItemRemoteRef) : undefined,
782
+ currentHistoryItemBaseRef: provider?.currentHistoryItemBaseRef ? historyItemRefToDto(provider.currentHistoryItemBaseRef) : undefined,
783
+ });
784
+ }
785
+
690
786
  private readonly onDidDisposeEmitter = new Emitter<void>();
691
787
  readonly onDidDispose = this.onDidDisposeEmitter.event;
692
788
 
@@ -702,11 +798,13 @@ class SourceControlImpl implements theia.SourceControl {
702
798
  private _label: string,
703
799
  private _rootUri?: theia.Uri,
704
800
  _iconPath?: theia.IconPath,
801
+ _isHidden?: boolean,
705
802
  _parent?: SourceControlImpl
706
803
  ) {
707
804
  this.inputBox = new ScmInputBoxImpl(plugin, this.proxy, this.handle);
708
805
  this.proxy.$registerSourceControl(this.handle, _id, _label, _rootUri, _parent?.handle);
709
806
  this.onDidDisposeParent = _parent ? _parent.onDidDispose : Event.None;
807
+ this.apiObject = createAPIObject(this);
710
808
  }
711
809
 
712
810
  private createdResourceGroups = new Map<ScmResourceGroupImpl, Disposable>();
@@ -779,6 +877,14 @@ class SourceControlImpl implements theia.SourceControl {
779
877
  return this.groups.get(handle);
780
878
  }
781
879
 
880
+ getHistoryItem(id: string): theia.SourceControlHistoryItem | undefined {
881
+ return this.historyItems.get(id);
882
+ }
883
+
884
+ getHistoryItemRef(id: string): theia.SourceControlHistoryItemRef | undefined {
885
+ return this.historyItemRefs.get(id);
886
+ }
887
+
782
888
  setSelectionState(selected: boolean): void {
783
889
  this._selected = selected;
784
890
  this.onDidChangeSelectionEmitter.fire(selected);
@@ -788,6 +894,7 @@ class SourceControlImpl implements theia.SourceControl {
788
894
  this.acceptInputDisposables.dispose();
789
895
  this._statusBarDisposables.dispose();
790
896
  this._actionButtonDisposables.dispose();
897
+ this._historyProviderDisposables.dispose();
791
898
 
792
899
  this.groups.forEach(group => group.dispose());
793
900
  this.proxy.$unregisterSourceControl(this.handle);
@@ -813,6 +920,32 @@ export class ScmExtImpl implements ScmExt {
813
920
  constructor(rpc: RPCProtocol, private commands: CommandRegistryImpl) {
814
921
  this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.SCM_MAIN);
815
922
 
923
+ // Register history item arg processor before the generic ScmCommandArg processor
924
+ // so the more-specific guard matches first.
925
+ commands.registerArgumentProcessor({
926
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
927
+ processArgument: (arg: any) => {
928
+ if (!ScmHistoryItemCommandArg.is(arg) || arg.type !== 'historyItem') {
929
+ return arg;
930
+ }
931
+ const sourceControl = this.sourceControls.get(arg.sourceControlHandle);
932
+ const item = sourceControl?.getHistoryItem(arg.id);
933
+ return item ?? { id: arg.id };
934
+ }
935
+ });
936
+
937
+ commands.registerArgumentProcessor({
938
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
939
+ processArgument: (arg: any) => {
940
+ if (!ScmHistoryItemCommandArg.is(arg) || arg.type !== 'historyItemRef') {
941
+ return arg;
942
+ }
943
+ const sourceControl = this.sourceControls.get(arg.sourceControlHandle);
944
+ const ref = sourceControl?.getHistoryItemRef(arg.id);
945
+ return ref ?? { id: arg.id };
946
+ }
947
+ });
948
+
816
949
  commands.registerArgumentProcessor({
817
950
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
818
951
  processArgument: (arg: any) => {
@@ -824,7 +957,7 @@ export class ScmExtImpl implements ScmExt {
824
957
  return undefined;
825
958
  }
826
959
  if (typeof arg.resourceGroupHandle !== 'number') {
827
- return sourceControl;
960
+ return sourceControl.apiObject;
828
961
  }
829
962
  const resourceGroup = sourceControl.getResourceGroup(arg.resourceGroupHandle);
830
963
  if (typeof arg.resourceStateHandle !== 'number') {
@@ -836,17 +969,35 @@ export class ScmExtImpl implements ScmExt {
836
969
  }
837
970
 
838
971
  createSourceControl(extension: Plugin, id: string, label: string, rootUri: theia.Uri | undefined,
839
- iconPath?: theia.IconPath, parent?: theia.SourceControl): theia.SourceControl {
972
+ iconPath?: theia.IconPath, isHidden?: boolean, parent?: theia.SourceControl): theia.SourceControl {
840
973
  const handle = ScmExtImpl.handlePool++;
841
974
  const parentImpl = parent ? this.findSourceControlImpl(parent) : undefined;
842
- const sourceControl = new SourceControlImpl(extension, this.proxy, this.commands, id, label, rootUri, iconPath, parentImpl);
975
+ const sourceControl = new SourceControlImpl(extension, this.proxy, this.commands, id, label, rootUri, iconPath, isHidden, parentImpl);
843
976
  this.sourceControls.set(handle, sourceControl);
844
977
 
845
978
  const sourceControls = this.sourceControlsByExtension.get(extension.model.id) || [];
846
979
  sourceControls.push(sourceControl);
847
980
  this.sourceControlsByExtension.set(extension.model.id, sourceControls);
848
981
 
849
- return sourceControl;
982
+ // Clean up registries when the source control is disposed. Without this,
983
+ // disposed entries leak and findSourceControlImpl() may return a stale
984
+ // (disposed) instance as a parent for a later-created source control
985
+ // with the same id + rootUri (e.g. a worktree recreated after removal).
986
+ sourceControl.onDidDispose(() => {
987
+ this.sourceControls.delete(handle);
988
+ const list = this.sourceControlsByExtension.get(extension.model.id);
989
+ if (list) {
990
+ const index = list.indexOf(sourceControl);
991
+ if (index >= 0) {
992
+ list.splice(index, 1);
993
+ }
994
+ if (list.length === 0) {
995
+ this.sourceControlsByExtension.delete(extension.model.id);
996
+ }
997
+ }
998
+ });
999
+
1000
+ return sourceControl.apiObject;
850
1001
  }
851
1002
 
852
1003
  private findSourceControlImpl(apiObject: theia.SourceControl): SourceControlImpl | undefined {
@@ -920,6 +1071,73 @@ export class ScmExtImpl implements ScmExt {
920
1071
  return [result.message, result.type];
921
1072
  }
922
1073
 
1074
+ async $provideHistoryItemRefs(sourceControlHandle: number, historyItemRefs: string[] | undefined, token: CancellationToken): Promise<ScmHistoryItemRefDto[] | undefined> {
1075
+ const sourceControl = this.sourceControls.get(sourceControlHandle);
1076
+ if (!sourceControl || !sourceControl.historyProvider) {
1077
+ return undefined;
1078
+ }
1079
+ const result = await sourceControl.historyProvider.provideHistoryItemRefs(historyItemRefs, token);
1080
+ if (!result) {
1081
+ return undefined;
1082
+ }
1083
+ sourceControl.historyItemRefs.clear();
1084
+ for (const ref of result) {
1085
+ sourceControl.historyItemRefs.set(ref.id, ref);
1086
+ }
1087
+ return result.map(historyItemRefToDto);
1088
+ }
1089
+
1090
+ async $provideHistoryItems(sourceControlHandle: number, options: ScmHistoryOptionsDto, token: CancellationToken): Promise<ScmHistoryItemDto[] | undefined> {
1091
+ const sourceControl = this.sourceControls.get(sourceControlHandle);
1092
+ if (!sourceControl || !sourceControl.historyProvider) {
1093
+ return undefined;
1094
+ }
1095
+ const result = await sourceControl.historyProvider.provideHistoryItems(options, token);
1096
+ if (!result) {
1097
+ return undefined;
1098
+ }
1099
+ for (const item of result) {
1100
+ sourceControl.historyItems.set(item.id, item);
1101
+ }
1102
+ return result.map(historyItemToDto);
1103
+ }
1104
+
1105
+ async $provideHistoryItemChanges(
1106
+ sourceControlHandle: number, historyItemId: string,
1107
+ historyItemParentId: string | undefined, token: CancellationToken
1108
+ ): Promise<ScmHistoryItemChangeDto[] | undefined> {
1109
+ const sourceControl = this.sourceControls.get(sourceControlHandle);
1110
+ if (!sourceControl || !sourceControl.historyProvider) {
1111
+ return undefined;
1112
+ }
1113
+ const result = await sourceControl.historyProvider.provideHistoryItemChanges(historyItemId, historyItemParentId, token);
1114
+ if (!result) {
1115
+ return undefined;
1116
+ }
1117
+ return result.map(historyItemChangeToDto);
1118
+ }
1119
+
1120
+ async $resolveHistoryItem(sourceControlHandle: number, historyItemId: string, token: CancellationToken): Promise<ScmHistoryItemDto | undefined> {
1121
+ const sourceControl = this.sourceControls.get(sourceControlHandle);
1122
+ if (!sourceControl || !sourceControl.historyProvider) {
1123
+ return undefined;
1124
+ }
1125
+ const result = await sourceControl.historyProvider.resolveHistoryItem(historyItemId, token);
1126
+ if (!result) {
1127
+ return undefined;
1128
+ }
1129
+ return historyItemToDto(result);
1130
+ }
1131
+
1132
+ async $resolveHistoryItemRefsCommonAncestor(sourceControlHandle: number, historyItemRefs: string[], token: CancellationToken): Promise<string | undefined> {
1133
+ const sourceControl = this.sourceControls.get(sourceControlHandle);
1134
+ if (!sourceControl || !sourceControl.historyProvider) {
1135
+ return undefined;
1136
+ }
1137
+ const result = await sourceControl.historyProvider.resolveHistoryItemRefsCommonAncestor(historyItemRefs, token);
1138
+ return result ?? undefined;
1139
+ }
1140
+
923
1141
  $setSelectedSourceControl(selectedSourceControlHandle: number | undefined): Promise<void> {
924
1142
  if (selectedSourceControlHandle !== undefined) {
925
1143
  this.sourceControls.get(selectedSourceControlHandle)?.setSelectionState(true);
@@ -0,0 +1,350 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource 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 * as chai from 'chai';
18
+ import * as theia from '@theia/plugin';
19
+ import { TerminalServiceMain, Plugin, TerminalOptions } from '../common/plugin-api-rpc';
20
+ import { RPCProtocol, ProxyIdentifier } from '../common/rpc-protocol';
21
+ import { TerminalServiceExtImpl, TerminalExtImpl } from './terminal-ext';
22
+ import { TerminalExitReason } from './types-impl';
23
+
24
+ const expect = chai.expect;
25
+
26
+ /**
27
+ * Creates a mock RPCProtocol that returns the given proxy for TERMINAL_MAIN.
28
+ */
29
+ function createMockRpc(proxy: Partial<TerminalServiceMain>): RPCProtocol {
30
+ return {
31
+ getProxy<T>(_proxyId: ProxyIdentifier<T>): T {
32
+ return proxy as unknown as T;
33
+ },
34
+ set<T, R extends T>(_identifier: ProxyIdentifier<T>, instance: R): R {
35
+ return instance;
36
+ },
37
+ dispose(): void { }
38
+ } as RPCProtocol;
39
+ }
40
+
41
+ /**
42
+ * Creates a minimal mock Plugin object.
43
+ */
44
+ function createMockPlugin(): Plugin {
45
+ return {
46
+ pluginPath: '/test',
47
+ pluginFolder: '/test',
48
+ pluginUri: 'file:///test',
49
+ model: { id: 'test.plugin' } as Plugin['model'],
50
+ rawModel: {} as Plugin['rawModel'],
51
+ lifecycle: {} as Plugin['lifecycle'],
52
+ isUnderDevelopment: false
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Creates a stub TerminalServiceMain that records calls.
58
+ */
59
+ function createMockProxy(): TerminalServiceMain & { createdTerminals: { id: string; options: TerminalOptions }[] } {
60
+ const createdTerminals: { id: string; options: TerminalOptions }[] = [];
61
+ return {
62
+ createdTerminals,
63
+ $createTerminal(id: string, options: TerminalOptions): Promise<string> {
64
+ createdTerminals.push({ id, options });
65
+ return Promise.resolve(id);
66
+ },
67
+ $sendText(): void { },
68
+ $write(): void { },
69
+ $resize(): void { },
70
+ $show(): void { },
71
+ $hide(): void { },
72
+ $dispose(): void { },
73
+ $setName(): void { },
74
+ $writeByTerminalId(): void { },
75
+ $resizeByTerminalId(): void { },
76
+ $disposeByTerminalId(): void { },
77
+ $setNameByTerminalId(): void { },
78
+ $setEnvironmentVariableCollection(): void { },
79
+ $registerTerminalLinkProvider(): void { },
80
+ $unregisterTerminalLinkProvider(): void { },
81
+ $registerTerminalObserver(): void { },
82
+ $unregisterTerminalObserver(): void { },
83
+ } as unknown as TerminalServiceMain & { createdTerminals: { id: string; options: TerminalOptions }[] };
84
+ }
85
+
86
+ describe('TerminalServiceExtImpl', () => {
87
+ let proxy: ReturnType<typeof createMockProxy>;
88
+ let service: TerminalServiceExtImpl;
89
+ let plugin: Plugin;
90
+
91
+ beforeEach(() => {
92
+ proxy = createMockProxy();
93
+ const rpc = createMockRpc(proxy);
94
+ service = new TerminalServiceExtImpl(rpc);
95
+ plugin = createMockPlugin();
96
+ });
97
+
98
+ describe('terminals list', () => {
99
+ it('returns empty array initially', () => {
100
+ expect(service.terminals).to.deep.equal([]);
101
+ });
102
+
103
+ it('includes terminals after creation via $terminalCreated', () => {
104
+ service.$terminalCreated('t1', 'Terminal 1');
105
+ expect(service.terminals).to.have.length(1);
106
+ });
107
+
108
+ it('removes terminals after $terminalClosed', () => {
109
+ service.$terminalCreated('t1', 'Terminal 1');
110
+ service.$terminalClosed('t1', { code: 0, reason: TerminalExitReason.Process });
111
+ expect(service.terminals).to.have.length(0);
112
+ });
113
+ });
114
+
115
+ describe('API object identity', () => {
116
+ it('returns the raw TerminalExtImpl when no wrapper is provided', () => {
117
+ const terminal = service.createTerminal(plugin, 'Test Terminal');
118
+ expect(terminal).to.be.instanceOf(TerminalExtImpl);
119
+ });
120
+
121
+ it('returns the wrapped API object when a wrapper is provided', () => {
122
+ const wrapper = (t: TerminalExtImpl): theia.Terminal => ({ ...t, name: 'wrapped' } as unknown as theia.Terminal);
123
+ service.createTerminal(plugin, 'Test Terminal', undefined, undefined, wrapper);
124
+ const id = proxy.createdTerminals[0].id;
125
+ service.$terminalCreated(id, 'Test Terminal');
126
+ const terminal = service.terminals[0];
127
+ expect(terminal.name).to.equal('wrapped');
128
+ expect(terminal).to.not.be.instanceOf(TerminalExtImpl);
129
+ });
130
+
131
+ it('fires onDidOpenTerminal with the API object, not the raw terminal', () => {
132
+ const apiObject = { marker: 'api-object' } as unknown as theia.Terminal;
133
+ const wrapper = (_t: TerminalExtImpl): theia.Terminal => apiObject;
134
+ service.createTerminal(plugin, 'Test Terminal', undefined, undefined, wrapper);
135
+
136
+ const opened: theia.Terminal[] = [];
137
+ service.onDidOpenTerminal(t => opened.push(t));
138
+
139
+ // Get the ID from the proxy call
140
+ const id = proxy.createdTerminals[0].id;
141
+ service.$terminalCreated(id, 'Test Terminal');
142
+
143
+ expect(opened).to.have.length(1);
144
+ expect(opened[0]).to.equal(apiObject);
145
+ });
146
+
147
+ it('fires onDidCloseTerminal with the API object', () => {
148
+ const apiObject = { marker: 'api-object' } as unknown as theia.Terminal;
149
+ const wrapper = (_t: TerminalExtImpl): theia.Terminal => apiObject;
150
+ service.createTerminal(plugin, 'Test Terminal', undefined, undefined, wrapper);
151
+
152
+ const closed: theia.Terminal[] = [];
153
+ service.onDidCloseTerminal(t => closed.push(t));
154
+
155
+ const id = proxy.createdTerminals[0].id;
156
+ service.$terminalCreated(id, 'Test Terminal');
157
+ service.$terminalClosed(id, { code: 0, reason: TerminalExitReason.Process });
158
+
159
+ expect(closed).to.have.length(1);
160
+ expect(closed[0]).to.equal(apiObject);
161
+ });
162
+
163
+ it('fires onDidChangeTerminalState with the API object on interaction', () => {
164
+ const apiObject = { marker: 'api-object' } as unknown as theia.Terminal;
165
+ const wrapper = (_t: TerminalExtImpl): theia.Terminal => apiObject;
166
+ service.createTerminal(plugin, 'Test Terminal', undefined, undefined, wrapper);
167
+
168
+ const stateChanged: theia.Terminal[] = [];
169
+ service.onDidChangeTerminalState(t => stateChanged.push(t));
170
+
171
+ const id = proxy.createdTerminals[0].id;
172
+ service.$terminalCreated(id, 'Test Terminal');
173
+ service.$terminalOnInteraction(id);
174
+
175
+ expect(stateChanged).to.have.length(1);
176
+ expect(stateChanged[0]).to.equal(apiObject);
177
+ });
178
+
179
+ it('fires onDidChangeTerminalState with the API object on shell type change', () => {
180
+ const apiObject = { marker: 'api-object' } as unknown as theia.Terminal;
181
+ const wrapper = (_t: TerminalExtImpl): theia.Terminal => apiObject;
182
+ service.createTerminal(plugin, 'Test Terminal', undefined, undefined, wrapper);
183
+
184
+ const stateChanged: theia.Terminal[] = [];
185
+ service.onDidChangeTerminalState(t => stateChanged.push(t));
186
+
187
+ const id = proxy.createdTerminals[0].id;
188
+ service.$terminalCreated(id, 'Test Terminal');
189
+ service.$terminalShellTypeChanged(id, '/bin/zsh');
190
+
191
+ expect(stateChanged).to.have.length(1);
192
+ expect(stateChanged[0]).to.equal(apiObject);
193
+ });
194
+
195
+ it('returns the API object from the terminals list', () => {
196
+ const apiObject = { marker: 'api-object' } as unknown as theia.Terminal;
197
+ const wrapper = (_t: TerminalExtImpl): theia.Terminal => apiObject;
198
+ service.createTerminal(plugin, 'Test Terminal', undefined, undefined, wrapper);
199
+
200
+ const id = proxy.createdTerminals[0].id;
201
+ service.$terminalCreated(id, 'Test Terminal');
202
+
203
+ const terminals = service.terminals;
204
+ expect(terminals).to.have.length(1);
205
+ expect(terminals[0]).to.equal(apiObject);
206
+ });
207
+
208
+ it('returns the API object as activeTerminal', () => {
209
+ const apiObject = { marker: 'api-object' } as unknown as theia.Terminal;
210
+ const wrapper = (_t: TerminalExtImpl): theia.Terminal => apiObject;
211
+ service.createTerminal(plugin, 'Test Terminal', undefined, undefined, wrapper);
212
+
213
+ const id = proxy.createdTerminals[0].id;
214
+ service.$terminalCreated(id, 'Test Terminal');
215
+ service.$currentTerminalChanged(id);
216
+
217
+ expect(service.activeTerminal).to.equal(apiObject);
218
+ });
219
+
220
+ it('cleans up API object on terminal close', () => {
221
+ const apiObject = { marker: 'api-object' } as unknown as theia.Terminal;
222
+ const wrapper = (_t: TerminalExtImpl): theia.Terminal => apiObject;
223
+ service.createTerminal(plugin, 'Test Terminal', undefined, undefined, wrapper);
224
+
225
+ const id = proxy.createdTerminals[0].id;
226
+ service.$terminalCreated(id, 'Test Terminal');
227
+ service.$terminalClosed(id, { code: 0, reason: TerminalExitReason.Process });
228
+
229
+ expect(service.terminals).to.have.length(0);
230
+ });
231
+ });
232
+
233
+ describe('parentTerminal resolution', () => {
234
+ it('resolves parentTerminal from API proxy objects', () => {
235
+ const apiObject = { marker: 'parent-api' } as unknown as theia.Terminal;
236
+ const wrapper = (_t: TerminalExtImpl): theia.Terminal => apiObject;
237
+ service.createTerminal(plugin, 'Parent', undefined, undefined, wrapper);
238
+
239
+ const parentId = proxy.createdTerminals[0].id;
240
+ service.$terminalCreated(parentId, 'Parent');
241
+
242
+ // Create a child with parentTerminal set to the API object
243
+ service.createTerminal(plugin, {
244
+ name: 'Child',
245
+ location: { parentTerminal: apiObject }
246
+ } as theia.TerminalOptions);
247
+
248
+ expect(proxy.createdTerminals).to.have.length(2);
249
+ // The second createTerminal call should have passed the parent ID
250
+ // We verify it was called on the proxy (the parentId arg is the 3rd parameter)
251
+ });
252
+
253
+ it('resolves parentTerminal from raw terminal objects', () => {
254
+ const rawTerminal = service.createTerminal(plugin, 'Parent');
255
+
256
+ const parentId = proxy.createdTerminals[0].id;
257
+ service.$terminalCreated(parentId, 'Parent');
258
+
259
+ // Create a child with parentTerminal set to the raw terminal
260
+ service.createTerminal(plugin, {
261
+ name: 'Child',
262
+ location: { parentTerminal: rawTerminal }
263
+ } as theia.TerminalOptions);
264
+
265
+ expect(proxy.createdTerminals).to.have.length(2);
266
+ });
267
+ });
268
+
269
+ describe('events for terminals without wrapper', () => {
270
+ it('fires onDidOpenTerminal with the raw terminal when no wrapper is used', () => {
271
+ const opened: theia.Terminal[] = [];
272
+ service.onDidOpenTerminal(t => opened.push(t));
273
+
274
+ service.$terminalCreated('ext-t1', 'External Terminal');
275
+
276
+ expect(opened).to.have.length(1);
277
+ expect(opened[0]).to.be.instanceOf(TerminalExtImpl);
278
+ expect(opened[0].name).to.equal('External Terminal');
279
+ });
280
+
281
+ it('fires onDidCloseTerminal with the raw terminal when no wrapper is used', () => {
282
+ const closed: theia.Terminal[] = [];
283
+ service.onDidCloseTerminal(t => closed.push(t));
284
+
285
+ service.$terminalCreated('ext-t1', 'External Terminal');
286
+ service.$terminalClosed('ext-t1', { code: 0, reason: TerminalExitReason.Process });
287
+
288
+ expect(closed).to.have.length(1);
289
+ expect(closed[0]).to.be.instanceOf(TerminalExtImpl);
290
+ });
291
+ });
292
+
293
+ describe('shell change', () => {
294
+ it('fires onDidChangeShell when shell changes', async () => {
295
+ const shells: string[] = [];
296
+ service.onDidChangeShell(s => shells.push(s));
297
+
298
+ await service.$setShell('/bin/zsh');
299
+
300
+ expect(shells).to.deep.equal(['/bin/zsh']);
301
+ expect(service.defaultShell).to.equal('/bin/zsh');
302
+ });
303
+
304
+ it('does not fire onDidChangeShell when shell is the same', async () => {
305
+ const shells: string[] = [];
306
+ await service.$setShell('/bin/zsh');
307
+
308
+ service.onDidChangeShell(s => shells.push(s));
309
+ await service.$setShell('/bin/zsh');
310
+
311
+ expect(shells).to.deep.equal([]);
312
+ });
313
+ });
314
+
315
+ describe('$terminalNameChanged', () => {
316
+ it('updates the terminal name', () => {
317
+ service.$terminalCreated('t1', 'Old Name');
318
+ service.$terminalNameChanged('t1', 'New Name');
319
+
320
+ expect(service.terminals[0].name).to.equal('New Name');
321
+ });
322
+ });
323
+
324
+ describe('activeTerminal', () => {
325
+ it('is undefined initially', () => {
326
+ expect(service.activeTerminal).to.equal(undefined);
327
+ });
328
+
329
+ it('reflects the current active terminal', () => {
330
+ service.$terminalCreated('t1', 'Terminal 1');
331
+ service.$currentTerminalChanged('t1');
332
+
333
+ expect(service.activeTerminal).to.not.equal(undefined);
334
+ expect(service.activeTerminal!.name).to.equal('Terminal 1');
335
+ });
336
+
337
+ it('fires onDidChangeActiveTerminal', () => {
338
+ const changes: (theia.Terminal | undefined)[] = [];
339
+ service.onDidChangeActiveTerminal(t => changes.push(t));
340
+
341
+ service.$terminalCreated('t1', 'Terminal 1');
342
+ service.$currentTerminalChanged('t1');
343
+ service.$currentTerminalChanged(undefined);
344
+
345
+ expect(changes).to.have.length(2);
346
+ expect(changes[0]!.name).to.equal('Terminal 1');
347
+ expect(changes[1]).to.equal(undefined);
348
+ });
349
+ });
350
+ });