@theia/getting-started 1.75.0-next.21 → 1.75.0-next.28

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 (86) hide show
  1. package/lib/browser/getting-started-contribution.d.ts +30 -2
  2. package/lib/browser/getting-started-contribution.d.ts.map +1 -1
  3. package/lib/browser/getting-started-contribution.js +120 -0
  4. package/lib/browser/getting-started-contribution.js.map +1 -1
  5. package/lib/browser/getting-started-contribution.spec.d.ts +2 -0
  6. package/lib/browser/getting-started-contribution.spec.d.ts.map +1 -0
  7. package/lib/browser/getting-started-contribution.spec.js +152 -0
  8. package/lib/browser/getting-started-contribution.spec.js.map +1 -0
  9. package/lib/browser/getting-started-frontend-module.d.ts.map +1 -1
  10. package/lib/browser/getting-started-frontend-module.js +8 -0
  11. package/lib/browser/getting-started-frontend-module.js.map +1 -1
  12. package/lib/browser/getting-started-widget.d.ts +19 -0
  13. package/lib/browser/getting-started-widget.d.ts.map +1 -1
  14. package/lib/browser/getting-started-widget.js +57 -2
  15. package/lib/browser/getting-started-widget.js.map +1 -1
  16. package/lib/browser/getting-started-widget.spec.d.ts +2 -0
  17. package/lib/browser/getting-started-widget.spec.d.ts.map +1 -0
  18. package/lib/browser/getting-started-widget.spec.js +71 -0
  19. package/lib/browser/getting-started-widget.spec.js.map +1 -0
  20. package/lib/browser/walkthrough-card.d.ts +8 -0
  21. package/lib/browser/walkthrough-card.d.ts.map +1 -0
  22. package/lib/browser/walkthrough-card.js +40 -0
  23. package/lib/browser/walkthrough-card.js.map +1 -0
  24. package/lib/browser/walkthrough-card.spec.d.ts +2 -0
  25. package/lib/browser/walkthrough-card.spec.d.ts.map +1 -0
  26. package/lib/browser/walkthrough-card.spec.js +188 -0
  27. package/lib/browser/walkthrough-card.spec.js.map +1 -0
  28. package/lib/browser/walkthrough-detail.d.ts +19 -0
  29. package/lib/browser/walkthrough-detail.d.ts.map +1 -0
  30. package/lib/browser/walkthrough-detail.js +210 -0
  31. package/lib/browser/walkthrough-detail.js.map +1 -0
  32. package/lib/browser/walkthrough-detail.spec.d.ts +2 -0
  33. package/lib/browser/walkthrough-detail.spec.d.ts.map +1 -0
  34. package/lib/browser/walkthrough-detail.spec.js +409 -0
  35. package/lib/browser/walkthrough-detail.spec.js.map +1 -0
  36. package/lib/browser/walkthrough-icon.d.ts +10 -0
  37. package/lib/browser/walkthrough-icon.d.ts.map +1 -0
  38. package/lib/browser/walkthrough-icon.js +37 -0
  39. package/lib/browser/walkthrough-icon.js.map +1 -0
  40. package/lib/browser/walkthrough-section.d.ts +23 -0
  41. package/lib/browser/walkthrough-section.d.ts.map +1 -0
  42. package/lib/browser/walkthrough-section.js +65 -0
  43. package/lib/browser/walkthrough-section.js.map +1 -0
  44. package/lib/browser/walkthrough-section.spec.d.ts +2 -0
  45. package/lib/browser/walkthrough-section.spec.d.ts.map +1 -0
  46. package/lib/browser/walkthrough-section.spec.js +328 -0
  47. package/lib/browser/walkthrough-section.spec.js.map +1 -0
  48. package/lib/browser/walkthrough-service.d.ts +166 -0
  49. package/lib/browser/walkthrough-service.d.ts.map +1 -0
  50. package/lib/browser/walkthrough-service.js +505 -0
  51. package/lib/browser/walkthrough-service.js.map +1 -0
  52. package/lib/browser/walkthrough-service.spec.d.ts +2 -0
  53. package/lib/browser/walkthrough-service.spec.d.ts.map +1 -0
  54. package/lib/browser/walkthrough-service.spec.js +1026 -0
  55. package/lib/browser/walkthrough-service.spec.js.map +1 -0
  56. package/lib/common/getting-started-preferences.d.ts +1 -0
  57. package/lib/common/getting-started-preferences.d.ts.map +1 -1
  58. package/lib/common/getting-started-preferences.js +5 -0
  59. package/lib/common/getting-started-preferences.js.map +1 -1
  60. package/lib/common/walkthrough-commands.d.ts +11 -0
  61. package/lib/common/walkthrough-commands.d.ts.map +1 -0
  62. package/lib/common/walkthrough-commands.js +41 -0
  63. package/lib/common/walkthrough-commands.js.map +1 -0
  64. package/lib/common/walkthrough-types.d.ts +22 -0
  65. package/lib/common/walkthrough-types.d.ts.map +1 -0
  66. package/lib/common/walkthrough-types.js +18 -0
  67. package/lib/common/walkthrough-types.js.map +1 -0
  68. package/package.json +8 -7
  69. package/src/browser/getting-started-contribution.spec.ts +188 -0
  70. package/src/browser/getting-started-contribution.ts +139 -3
  71. package/src/browser/getting-started-frontend-module.ts +8 -0
  72. package/src/browser/getting-started-widget.spec.tsx +88 -0
  73. package/src/browser/getting-started-widget.tsx +70 -3
  74. package/src/browser/style/index.css +279 -0
  75. package/src/browser/walkthrough-card.spec.tsx +247 -0
  76. package/src/browser/walkthrough-card.tsx +62 -0
  77. package/src/browser/walkthrough-detail.spec.tsx +675 -0
  78. package/src/browser/walkthrough-detail.tsx +347 -0
  79. package/src/browser/walkthrough-icon.tsx +42 -0
  80. package/src/browser/walkthrough-section.spec.tsx +484 -0
  81. package/src/browser/walkthrough-section.tsx +119 -0
  82. package/src/browser/walkthrough-service.spec.ts +1279 -0
  83. package/src/browser/walkthrough-service.ts +565 -0
  84. package/src/common/getting-started-preferences.ts +6 -0
  85. package/src/common/walkthrough-commands.ts +37 -0
  86. package/src/common/walkthrough-types.ts +39 -0
@@ -0,0 +1,565 @@
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 { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify';
18
+ import { Emitter, Event } from '@theia/core/lib/common/event';
19
+ import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable';
20
+ import { StorageService } from '@theia/core/lib/browser/storage-service';
21
+ import { CommandRegistry } from '@theia/core/lib/common/command';
22
+ import { PreferenceService } from '@theia/core/lib/common/preferences';
23
+ import { ContextKeyService } from '@theia/core/lib/browser/context-key-service';
24
+ import { DeployedPlugin, PluginIdentifiers, PluginMetadata, WalkthroughContribution, WalkthroughStepContribution } from '@theia/plugin-ext/lib/common/plugin-protocol';
25
+
26
+ import { OpenerService, open } from '@theia/core/lib/browser/opener-service';
27
+ import { ILogger } from '@theia/core/lib/common/logger';
28
+ import { MessageService } from '@theia/core/lib/common/message-service';
29
+ import { nls } from '@theia/core/lib/common/nls';
30
+ import { URI } from '@theia/core/lib/common/uri';
31
+ import { Walkthrough, WalkthroughStep } from '../common/walkthrough-types';
32
+ import { GettingStartedPreferences } from '../common/getting-started-preferences';
33
+ import { WalkthroughCommands } from '../common/walkthrough-commands';
34
+
35
+ export const WalkthroughViewEventSource = Symbol('WalkthroughViewEventSource');
36
+ export interface WalkthroughViewEventSource {
37
+ readonly onDidExpandView: Event<string>;
38
+ }
39
+
40
+ export const WalkthroughPluginSupport = Symbol('WalkthroughPluginSupport');
41
+ export interface WalkthroughPluginSupport {
42
+ readonly plugins: PluginMetadata[];
43
+ getPlugin(id: PluginIdentifiers.UnversionedId): DeployedPlugin | undefined;
44
+ /** The plugins that are not loaded because the workspace is not trusted. */
45
+ readonly disabledByTrust: ReadonlySet<string>;
46
+ readonly onDidChangePlugins: Event<void>;
47
+ }
48
+
49
+ const WALKTHROUGH_PROGRESS_KEY = 'walkthrough-progress';
50
+ const ON_CONTEXT_EVENT_PREFIX = 'onContext:';
51
+
52
+ interface WalkthroughProgressState {
53
+ completedSteps: { [walkthroughId: string]: string[] };
54
+ }
55
+
56
+ @injectable()
57
+ export class WalkthroughService implements Disposable {
58
+
59
+ @inject(StorageService)
60
+ protected readonly storageService: StorageService;
61
+
62
+ @inject(CommandRegistry)
63
+ protected readonly commandRegistry: CommandRegistry;
64
+
65
+ @inject(PreferenceService)
66
+ protected readonly preferenceService: PreferenceService;
67
+
68
+ @inject(ContextKeyService)
69
+ protected readonly contextKeyService: ContextKeyService;
70
+
71
+ @inject(GettingStartedPreferences)
72
+ protected readonly gettingStartedPreferences: GettingStartedPreferences;
73
+
74
+ @inject(WalkthroughPluginSupport)
75
+ protected readonly pluginSupport: WalkthroughPluginSupport;
76
+
77
+ @inject(WalkthroughViewEventSource)
78
+ protected readonly viewEventSource: WalkthroughViewEventSource;
79
+
80
+ @inject(OpenerService)
81
+ protected readonly openerService: OpenerService;
82
+
83
+ @inject(MessageService)
84
+ protected readonly messageService: MessageService;
85
+
86
+ @inject(ILogger) @named('getting-started:WalkthroughService')
87
+ protected readonly logger: ILogger;
88
+
89
+ protected readonly walkthroughs = new Map<string, Walkthrough>();
90
+ /** The definition each walkthrough was registered from, to detect a changed contribution. */
91
+ protected readonly contributionSignatures = new Map<string, string>();
92
+ protected readonly toDispose = new DisposableCollection();
93
+
94
+ protected readonly onDidChangeWalkthroughsEmitter = new Emitter<void>();
95
+ readonly onDidChangeWalkthroughs: Event<void> = this.onDidChangeWalkthroughsEmitter.event;
96
+
97
+ protected readonly onDidChangeSelectionEmitter = new Emitter<void>();
98
+ readonly onDidChangeSelection: Event<void> = this.onDidChangeSelectionEmitter.event;
99
+
100
+ protected progressState: WalkthroughProgressState = { completedSteps: {} };
101
+ /** Resolves once the persisted progress has been read and the initial sync has run. */
102
+ protected progressReady: Promise<void> = Promise.resolve();
103
+ protected knownPluginIds: Set<string> = new Set();
104
+ protected pluginBaselineEstablished = false;
105
+
106
+ protected selectedWalkthroughId: string | undefined;
107
+ protected selectedStepId: string | undefined;
108
+
109
+ /** Cache of {@link getContextKeys}, invalidated whenever the set of walkthroughs changes. */
110
+ protected contextKeys: Set<string> | undefined;
111
+
112
+ @postConstruct()
113
+ protected init(): void {
114
+ this.toDispose.push(this.onDidChangeWalkthroughsEmitter);
115
+ this.toDispose.push(this.onDidChangeSelectionEmitter);
116
+ // Plugins are deployed while the progress is still being read. Registering a walkthrough before
117
+ // that would report its completed steps as pending, so every sync waits for the progress.
118
+ this.progressReady = this.loadProgress().then(() => {
119
+ this.syncWalkthroughsFromPlugins();
120
+ this.establishPluginBaseline();
121
+ });
122
+
123
+ this.toDispose.push(this.pluginSupport.onDidChangePlugins(() => this.handlePluginsChanged()));
124
+
125
+ this.toDispose.push(this.commandRegistry.onDidExecuteCommand(e => {
126
+ this.handleCompletionEvent(`onCommand:${e.commandId}`);
127
+ }));
128
+
129
+ this.toDispose.push(this.preferenceService.onPreferenceChanged(e => {
130
+ this.handleCompletionEvent(`onSettingChanged:${e.preferenceName}`);
131
+ }));
132
+
133
+ this.toDispose.push(this.viewEventSource.onDidExpandView(viewId => {
134
+ this.handleCompletionEvent(`onView:${viewId}`);
135
+ }));
136
+
137
+ this.toDispose.push(this.contextKeyService.onDidChange(event => {
138
+ if (event.affects(this.getContextKeys())) {
139
+ this.completeMatchingContextSteps();
140
+ this.handleVisibilityChanged();
141
+ }
142
+ }));
143
+ }
144
+
145
+ protected async handlePluginsChanged(): Promise<void> {
146
+ await this.progressReady;
147
+ const previousIds = this.knownPluginIds;
148
+ const baselineEstablished = this.pluginBaselineEstablished;
149
+ this.syncWalkthroughsFromPlugins();
150
+ this.establishPluginBaseline();
151
+ if (!baselineEstablished) {
152
+ // The plugins deployed while the application was starting are not new installations.
153
+ return;
154
+ }
155
+ for (const newId of this.knownPluginIds) {
156
+ if (!previousIds.has(newId)) {
157
+ this.handleCompletionEvent(`extensionInstalled:${newId}`);
158
+ this.handleExtensionInstalledAutoOpen(newId);
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Complete every pending step whose `onContext:` expression currently holds.
165
+ */
166
+ protected completeMatchingContextSteps(): void {
167
+ for (const [walkthroughId, walkthrough] of this.walkthroughs) {
168
+ for (const step of walkthrough.steps) {
169
+ if (step.isComplete || !step.completionEvents) {
170
+ continue;
171
+ }
172
+ for (const completionEvent of step.completionEvents) {
173
+ if (completionEvent.startsWith(ON_CONTEXT_EVENT_PREFIX) && this.contextKeyService.match(completionEvent.substring(ON_CONTEXT_EVENT_PREFIX.length))) {
174
+ this.markStepComplete(walkthroughId, step.id);
175
+ }
176
+ }
177
+ }
178
+ }
179
+ }
180
+
181
+ /**
182
+ * The context keys that the `when` clauses and the `onContext:` completion events of all registered
183
+ * walkthroughs depend on.
184
+ *
185
+ * The set is cached because context keys change very frequently, while walkthroughs rarely do.
186
+ */
187
+ protected getContextKeys(): Set<string> {
188
+ if (!this.contextKeys) {
189
+ const keys = new Set<string>();
190
+ const collect = (expression: string | undefined) => {
191
+ if (expression) {
192
+ this.contextKeyService.parseKeys(expression)?.forEach(key => keys.add(key));
193
+ }
194
+ };
195
+ for (const walkthrough of this.walkthroughs.values()) {
196
+ collect(walkthrough.when);
197
+ for (const step of walkthrough.steps) {
198
+ collect(step.when);
199
+ step.completionEvents
200
+ ?.filter(event => event.startsWith(ON_CONTEXT_EVENT_PREFIX))
201
+ .forEach(event => collect(event.substring(ON_CONTEXT_EVENT_PREFIX.length)));
202
+ }
203
+ }
204
+ this.contextKeys = keys;
205
+ }
206
+ return this.contextKeys;
207
+ }
208
+
209
+ /**
210
+ * Whether a `when` clause currently holds. A contribution without a `when` clause is always visible.
211
+ */
212
+ protected isVisible(when: string | undefined): boolean {
213
+ return !when || this.contextKeyService.match(when);
214
+ }
215
+
216
+ /**
217
+ * Restricts a walkthrough to the steps that are currently visible.
218
+ *
219
+ * Hidden steps also stay out of the progress, so that a walkthrough whose remaining steps do not apply to
220
+ * this platform or workspace can still be completed.
221
+ */
222
+ protected toVisibleWalkthrough(walkthrough: Walkthrough): Walkthrough {
223
+ const steps = walkthrough.steps.filter(step => this.isVisible(step.when));
224
+ return steps.length === walkthrough.steps.length ? walkthrough : { ...walkthrough, steps };
225
+ }
226
+
227
+ protected handleVisibilityChanged(): void {
228
+ if (this.selectedWalkthroughId !== undefined && !this.selectedWalkthrough) {
229
+ // The selected walkthrough is no longer visible, so the welcome view must not keep showing it.
230
+ this.clearSelection();
231
+ }
232
+ this.onDidChangeWalkthroughsEmitter.fire();
233
+ }
234
+
235
+ dispose(): void {
236
+ this.toDispose.dispose();
237
+ }
238
+
239
+ /**
240
+ * Remember the currently deployed plugins as the set that is not considered newly installed.
241
+ *
242
+ * Plugins are deployed asynchronously while the application starts, so the baseline can only be trusted
243
+ * once plugins have actually arrived. Without this, every plugin of a fresh session would look like a new
244
+ * installation and - with `workbench.welcomePage.walkthroughs.openOnInstall` enabled - open a walkthrough
245
+ * on startup.
246
+ */
247
+ protected establishPluginBaseline(): void {
248
+ this.knownPluginIds = new Set(this.pluginSupport.plugins.map(p => p.model.id));
249
+ if (this.knownPluginIds.size > 0) {
250
+ this.pluginBaselineEstablished = true;
251
+ }
252
+ }
253
+
254
+ protected async loadProgress(): Promise<void> {
255
+ this.progressState = await this.storageService.getData<WalkthroughProgressState>(
256
+ WALKTHROUGH_PROGRESS_KEY,
257
+ { completedSteps: {} }
258
+ );
259
+ }
260
+
261
+ protected async saveProgress(): Promise<void> {
262
+ await this.storageService.setData(WALKTHROUGH_PROGRESS_KEY, this.progressState);
263
+ }
264
+
265
+ protected syncWalkthroughsFromPlugins(): void {
266
+ const plugins = this.pluginSupport.plugins;
267
+ const seenIds = new Set<string>();
268
+
269
+ for (const pluginMeta of plugins) {
270
+ if (pluginMeta.outOfSync) {
271
+ // An uninstalled or disabled plugin stays loaded until the next reload, but its walkthroughs
272
+ // must not be offered any more.
273
+ continue;
274
+ }
275
+ const unversionedId = PluginIdentifiers.componentsToUnversionedId(pluginMeta.model);
276
+ if (this.pluginSupport.disabledByTrust.has(unversionedId)) {
277
+ // A plugin restricted by workspace trust contributes nothing, so there is nothing to walk through.
278
+ continue;
279
+ }
280
+ const deployed = this.pluginSupport.getPlugin(unversionedId);
281
+ if (!deployed?.contributes?.walkthroughs) {
282
+ continue;
283
+ }
284
+ for (const contribution of deployed.contributes.walkthroughs) {
285
+ const fullId = `${contribution.pluginId}.${contribution.id}`;
286
+ seenIds.add(fullId);
287
+ // Re-register when the definition changed, for instance because the plugin was updated.
288
+ const signature = JSON.stringify(contribution);
289
+ if (this.contributionSignatures.get(fullId) !== signature) {
290
+ this.contributionSignatures.set(fullId, signature);
291
+ this.registerWalkthrough(contribution);
292
+ }
293
+ }
294
+ }
295
+
296
+ let changed = false;
297
+ for (const id of this.walkthroughs.keys()) {
298
+ if (!seenIds.has(id)) {
299
+ this.walkthroughs.delete(id);
300
+ this.contributionSignatures.delete(id);
301
+ this.contextKeys = undefined;
302
+ changed = true;
303
+ }
304
+ }
305
+ if (changed) {
306
+ if (this.selectedWalkthroughId && !this.walkthroughs.has(this.selectedWalkthroughId)) {
307
+ // The selected walkthrough was contributed by a plugin that is no longer available.
308
+ this.clearSelection();
309
+ }
310
+ this.onDidChangeWalkthroughsEmitter.fire();
311
+ }
312
+ }
313
+
314
+ protected registerWalkthrough(contribution: WalkthroughContribution): void {
315
+ const fullId = `${contribution.pluginId}.${contribution.id}`;
316
+ const completedSteps = this.progressState.completedSteps[fullId] || [];
317
+
318
+ const steps: WalkthroughStep[] = contribution.steps.map(step => this.toWalkthroughStep(step, completedSteps));
319
+
320
+ const walkthrough: Walkthrough = {
321
+ id: fullId,
322
+ title: contribution.title,
323
+ description: contribution.description,
324
+ steps,
325
+ when: contribution.when,
326
+ icon: contribution.icon,
327
+ pluginId: contribution.pluginId,
328
+ pluginIcon: contribution.pluginIcon
329
+ };
330
+
331
+ this.walkthroughs.set(fullId, walkthrough);
332
+ this.contextKeys = undefined;
333
+ // A context key that is already set has to complete its steps right away; without this, a step keyed on a
334
+ // static context - `onContext:isLinux` for instance - would wait for a change that never comes.
335
+ this.completeMatchingContextSteps();
336
+ this.onDidChangeWalkthroughsEmitter.fire();
337
+ }
338
+
339
+ protected toWalkthroughStep(step: WalkthroughStepContribution, completedSteps: string[]): WalkthroughStep {
340
+ return {
341
+ id: step.id,
342
+ title: step.title,
343
+ description: step.description,
344
+ media: step.media,
345
+ completionEvents: step.completionEvents,
346
+ when: step.when,
347
+ isComplete: completedSteps.includes(step.id)
348
+ };
349
+ }
350
+
351
+ /**
352
+ * All walkthroughs whose `when` clause currently holds, restricted to their currently visible steps.
353
+ */
354
+ getWalkthroughs(): Walkthrough[] {
355
+ return Array.from(this.walkthroughs.values())
356
+ .filter(walkthrough => this.isVisible(walkthrough.when))
357
+ .map(walkthrough => this.toVisibleWalkthrough(walkthrough));
358
+ }
359
+
360
+ /**
361
+ * The walkthrough with the given id, or `undefined` if it is unknown or its `when` clause does not hold.
362
+ */
363
+ getWalkthrough(id: string): Walkthrough | undefined {
364
+ const walkthrough = this.walkthroughs.get(id);
365
+ return walkthrough && this.isVisible(walkthrough.when) ? this.toVisibleWalkthrough(walkthrough) : undefined;
366
+ }
367
+
368
+ async markStepComplete(walkthroughId: string, stepId: string): Promise<void> {
369
+ return this.setStepComplete(walkthroughId, stepId, true);
370
+ }
371
+
372
+ /**
373
+ * Take the completion mark off a step again, for example when it was completed by mistake.
374
+ */
375
+ async markStepIncomplete(walkthroughId: string, stepId: string): Promise<void> {
376
+ return this.setStepComplete(walkthroughId, stepId, false);
377
+ }
378
+
379
+ protected async setStepComplete(walkthroughId: string, stepId: string, isComplete: boolean): Promise<void> {
380
+ const walkthrough = this.walkthroughs.get(walkthroughId);
381
+ if (!walkthrough) {
382
+ return;
383
+ }
384
+
385
+ const stepIndex = walkthrough.steps.findIndex(s => s.id === stepId);
386
+ if (stepIndex === -1 || walkthrough.steps[stepIndex].isComplete === isComplete) {
387
+ return;
388
+ }
389
+
390
+ const updatedSteps = walkthrough.steps.map((s, i) =>
391
+ i === stepIndex ? { ...s, isComplete } : s
392
+ );
393
+ const updatedWalkthrough: Walkthrough = { ...walkthrough, steps: updatedSteps };
394
+ this.walkthroughs.set(walkthroughId, updatedWalkthrough);
395
+
396
+ const completedSteps = this.progressState.completedSteps[walkthroughId] ?? [];
397
+ this.progressState.completedSteps[walkthroughId] = isComplete
398
+ ? (completedSteps.includes(stepId) ? completedSteps : [...completedSteps, stepId])
399
+ : completedSteps.filter(id => id !== stepId);
400
+
401
+ await this.saveProgress();
402
+ this.onDidChangeWalkthroughsEmitter.fire();
403
+ }
404
+
405
+ /**
406
+ * Mark every step of the given walkthrough as complete, persisting the progress once.
407
+ */
408
+ async markAllStepsComplete(walkthroughId: string): Promise<void> {
409
+ const walkthrough = this.walkthroughs.get(walkthroughId);
410
+ // Only the steps that apply right now are completed; a hidden step may become relevant again later.
411
+ const visibleStepIds = new Set(this.getWalkthrough(walkthroughId)?.steps.map(step => step.id));
412
+ if (!walkthrough || walkthrough.steps.every(step => step.isComplete || !visibleStepIds.has(step.id))) {
413
+ return;
414
+ }
415
+
416
+ const updatedSteps = walkthrough.steps.map(step => step.isComplete || !visibleStepIds.has(step.id) ? step : { ...step, isComplete: true });
417
+ this.walkthroughs.set(walkthroughId, { ...walkthrough, steps: updatedSteps });
418
+ this.progressState.completedSteps[walkthroughId] = updatedSteps.filter(step => step.isComplete).map(step => step.id);
419
+
420
+ await this.saveProgress();
421
+ this.onDidChangeWalkthroughsEmitter.fire();
422
+ }
423
+
424
+ async resetProgress(walkthroughId: string): Promise<void> {
425
+ const walkthrough = this.walkthroughs.get(walkthroughId);
426
+ if (!walkthrough) {
427
+ return;
428
+ }
429
+
430
+ const updatedSteps = walkthrough.steps.map(s => ({ ...s, isComplete: false }));
431
+ const updatedWalkthrough: Walkthrough = { ...walkthrough, steps: updatedSteps };
432
+ this.walkthroughs.set(walkthroughId, updatedWalkthrough);
433
+
434
+ delete this.progressState.completedSteps[walkthroughId];
435
+ await this.saveProgress();
436
+ this.onDidChangeWalkthroughsEmitter.fire();
437
+ }
438
+
439
+ getStepProgress(walkthroughId: string): { completed: number; total: number } {
440
+ const walkthrough = this.getWalkthrough(walkthroughId);
441
+ if (!walkthrough) {
442
+ return { completed: 0, total: 0 };
443
+ }
444
+ const completed = walkthrough.steps.filter(s => s.isComplete).length;
445
+ return { completed, total: walkthrough.steps.length };
446
+ }
447
+
448
+ /**
449
+ * The walkthrough that is currently opened in the welcome view, if any.
450
+ * While a walkthrough is selected, the welcome view renders it instead of its regular content.
451
+ */
452
+ get selectedWalkthrough(): Walkthrough | undefined {
453
+ return this.selectedWalkthroughId === undefined ? undefined : this.getWalkthrough(this.selectedWalkthroughId);
454
+ }
455
+
456
+ /**
457
+ * The step of the {@link selectedWalkthrough} whose content is currently shown, if any.
458
+ */
459
+ get selectedStep(): WalkthroughStep | undefined {
460
+ const walkthrough = this.selectedWalkthrough;
461
+ if (!walkthrough || this.selectedStepId === undefined) {
462
+ return undefined;
463
+ }
464
+ return walkthrough.steps.find(step => step.id === this.selectedStepId);
465
+ }
466
+
467
+ /**
468
+ * Open the given walkthrough in the welcome view and preselect its first pending step.
469
+ * Does nothing if no walkthrough is registered under that id.
470
+ */
471
+ selectWalkthrough(walkthroughId: string): void {
472
+ const walkthrough = this.getWalkthrough(walkthroughId) ?? this.getWalkthrough(this.fromVSCodeWalkthroughId(walkthroughId));
473
+ if (!walkthrough) {
474
+ return;
475
+ }
476
+ this.selectedWalkthroughId = walkthrough.id;
477
+ this.selectedStepId = this.getFirstPendingStep(walkthrough)?.id;
478
+ this.onDidChangeSelectionEmitter.fire();
479
+ }
480
+
481
+ /**
482
+ * Show the content of the given step of the currently selected walkthrough.
483
+ */
484
+ selectStep(stepId: string): void {
485
+ const walkthrough = this.selectedWalkthrough;
486
+ if (!walkthrough || this.selectedStepId === stepId || !walkthrough.steps.some(step => step.id === stepId)) {
487
+ return;
488
+ }
489
+ this.selectedStepId = stepId;
490
+ this.onDidChangeSelectionEmitter.fire();
491
+ }
492
+
493
+ /**
494
+ * Close the currently selected walkthrough and return the welcome view to its regular content.
495
+ */
496
+ clearSelection(): void {
497
+ if (this.selectedWalkthroughId === undefined) {
498
+ return;
499
+ }
500
+ this.selectedWalkthroughId = undefined;
501
+ this.selectedStepId = undefined;
502
+ this.onDidChangeSelectionEmitter.fire();
503
+ }
504
+
505
+ protected getFirstPendingStep(walkthrough: Walkthrough): WalkthroughStep | undefined {
506
+ return walkthrough.steps.find(step => !step.isComplete) ?? walkthrough.steps[0];
507
+ }
508
+
509
+ /**
510
+ * VS Code refers to a walkthrough as `publisher.name#walkthroughId`, for example in the `command:` links of a
511
+ * step description, while the ids used here are fully dot-separated.
512
+ */
513
+ protected fromVSCodeWalkthroughId(walkthroughId: string): string {
514
+ return walkthroughId.replace('#', '.');
515
+ }
516
+
517
+ /**
518
+ * Handle a link click from a walkthrough step description.
519
+ * Fires the `onLink:{url}` completion event and opens the link.
520
+ *
521
+ * Walkthroughs commonly link to `command:` URIs that Theia does not implement. Such a link must not
522
+ * reject unhandled, so the failure is reported to the user instead.
523
+ */
524
+ async handleLinkClick(url: string): Promise<void> {
525
+ this.handleCompletionEvent(`onLink:${url}`);
526
+ try {
527
+ await open(this.openerService, new URI(this.normalizeLinkUrl(url)));
528
+ } catch (error) {
529
+ const reason = error instanceof Error ? error.message : String(error);
530
+ this.logger.warn(`Could not open the walkthrough link '${url}'.`, error);
531
+ this.messageService.error(nls.localize('theia/getting-started/walkthroughLinkFailed', "Could not open '{0}': {1}", url, reason));
532
+ }
533
+ }
534
+
535
+ /**
536
+ * VS Code walkthroughs use `command:toSide:<commandId>` to open the result of a command beside the walkthrough.
537
+ * Theia has no generic equivalent, so the command is executed as usual rather than failing to resolve.
538
+ */
539
+ protected normalizeLinkUrl(url: string): string {
540
+ const toSidePrefix = 'command:toSide:';
541
+ return url.startsWith(toSidePrefix) ? `command:${url.substring(toSidePrefix.length)}` : url;
542
+ }
543
+
544
+ protected handleCompletionEvent(event: string): void {
545
+ for (const [walkthroughId, walkthrough] of this.walkthroughs) {
546
+ for (const step of walkthrough.steps) {
547
+ if (!step.isComplete && step.completionEvents?.includes(event)) {
548
+ this.markStepComplete(walkthroughId, step.id);
549
+ }
550
+ }
551
+ }
552
+ }
553
+
554
+ protected handleExtensionInstalledAutoOpen(pluginId: string): void {
555
+ if (!this.gettingStartedPreferences['workbench.welcomePage.walkthroughs.openOnInstall']) {
556
+ return;
557
+ }
558
+ for (const walkthrough of this.getWalkthroughs()) {
559
+ if (walkthrough.pluginId === pluginId) {
560
+ this.commandRegistry.executeCommand(WalkthroughCommands.OPEN_WALKTHROUGH.id, walkthrough.id);
561
+ return;
562
+ }
563
+ }
564
+ }
565
+ }
@@ -41,11 +41,17 @@ export const GettingStartedPreferenceSchema: PreferenceSchema = {
41
41
  default: 'welcomePage',
42
42
  description: nls.localizeByDefault('Controls which editor is shown at startup, if none are restored from the previous session.')
43
43
  },
44
+ 'workbench.welcomePage.walkthroughs.openOnInstall': {
45
+ type: 'boolean',
46
+ default: true,
47
+ description: nls.localizeByDefault("When enabled, an extension's walkthrough will open upon install of the extension.")
48
+ },
44
49
  }
45
50
  };
46
51
 
47
52
  export interface GettingStartedConfiguration {
48
53
  'workbench.startupEditor': string;
54
+ 'workbench.welcomePage.walkthroughs.openOnInstall': boolean;
49
55
  }
50
56
 
51
57
  export const GettingStartedPreferenceContribution = Symbol('GettingStartedPreferenceContribution');
@@ -0,0 +1,37 @@
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
+ import { Command } from '@theia/core/lib/common/command';
17
+ import { nls } from '@theia/core/lib/common/nls';
18
+
19
+ export namespace WalkthroughCommands {
20
+ export const OPEN_WALKTHROUGH = Command.toDefaultLocalizedCommand({
21
+ id: 'walkthrough.open',
22
+ category: 'Help',
23
+ label: 'Open Walkthrough...'
24
+ });
25
+ /**
26
+ * VS Code's id for opening a walkthrough, registered as an alias of {@link OPEN_WALKTHROUGH}.
27
+ * Walkthroughs link to it from their step descriptions.
28
+ */
29
+ export const OPEN_WALKTHROUGH_VSCODE: Command = {
30
+ id: 'workbench.action.openWalkthrough'
31
+ };
32
+ export const RESET_WALKTHROUGH_PROGRESS = Command.toLocalizedCommand({
33
+ id: 'walkthrough.resetProgress',
34
+ category: 'Help',
35
+ label: 'Reset Walkthrough Progress'
36
+ }, 'theia/getting-started/resetWalkthroughProgress', nls.getDefaultKey('Help'));
37
+ }
@@ -0,0 +1,39 @@
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 { WalkthroughStepMedia } from '@theia/plugin-ext/lib/common/plugin-protocol';
18
+
19
+ export interface WalkthroughStep {
20
+ id: string;
21
+ title: string;
22
+ description: string;
23
+ media?: WalkthroughStepMedia;
24
+ completionEvents?: string[];
25
+ when?: string;
26
+ isComplete: boolean;
27
+ }
28
+
29
+ export interface Walkthrough {
30
+ id: string;
31
+ title: string;
32
+ description: string;
33
+ steps: WalkthroughStep[];
34
+ when?: string;
35
+ icon?: string;
36
+ pluginId: string;
37
+ /** Icon of the contributing extension, as a backend relative path. */
38
+ pluginIcon?: string;
39
+ }