@theia/getting-started 1.76.0-next.4 → 1.76.0-next.42

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 (43) hide show
  1. package/README.md +44 -0
  2. package/lib/browser/getting-started-frontend-module.d.ts.map +1 -1
  3. package/lib/browser/getting-started-frontend-module.js +3 -0
  4. package/lib/browser/getting-started-frontend-module.js.map +1 -1
  5. package/lib/browser/getting-started-widget.d.ts +0 -7
  6. package/lib/browser/getting-started-widget.d.ts.map +1 -1
  7. package/lib/browser/getting-started-widget.js +1 -29
  8. package/lib/browser/getting-started-widget.js.map +1 -1
  9. package/lib/browser/walkthrough-detail.d.ts.map +1 -1
  10. package/lib/browser/walkthrough-detail.js +60 -1
  11. package/lib/browser/walkthrough-detail.js.map +1 -1
  12. package/lib/browser/walkthrough-detail.spec.js +119 -4
  13. package/lib/browser/walkthrough-detail.spec.js.map +1 -1
  14. package/lib/browser/walkthrough-section.d.ts +2 -2
  15. package/lib/browser/walkthrough-section.d.ts.map +1 -1
  16. package/lib/browser/walkthrough-section.js +7 -5
  17. package/lib/browser/walkthrough-section.js.map +1 -1
  18. package/lib/browser/walkthrough-section.spec.js +17 -1
  19. package/lib/browser/walkthrough-section.spec.js.map +1 -1
  20. package/lib/browser/walkthrough-service.d.ts +26 -5
  21. package/lib/browser/walkthrough-service.d.ts.map +1 -1
  22. package/lib/browser/walkthrough-service.js +77 -22
  23. package/lib/browser/walkthrough-service.js.map +1 -1
  24. package/lib/browser/walkthrough-service.spec.js +173 -57
  25. package/lib/browser/walkthrough-service.spec.js.map +1 -1
  26. package/lib/common/walkthrough-provider.d.ts +22 -0
  27. package/lib/common/walkthrough-provider.d.ts.map +1 -0
  28. package/lib/common/walkthrough-provider.js +27 -0
  29. package/lib/common/walkthrough-provider.js.map +1 -0
  30. package/lib/common/walkthrough-types.d.ts +18 -5
  31. package/lib/common/walkthrough-types.d.ts.map +1 -1
  32. package/package.json +8 -8
  33. package/src/browser/getting-started-frontend-module.ts +3 -0
  34. package/src/browser/getting-started-widget.tsx +31 -101
  35. package/src/browser/style/index.css +82 -36
  36. package/src/browser/walkthrough-detail.spec.tsx +194 -4
  37. package/src/browser/walkthrough-detail.tsx +79 -1
  38. package/src/browser/walkthrough-section.spec.tsx +29 -1
  39. package/src/browser/walkthrough-section.tsx +7 -5
  40. package/src/browser/walkthrough-service.spec.ts +207 -57
  41. package/src/browser/walkthrough-service.ts +82 -25
  42. package/src/common/walkthrough-provider.ts +38 -0
  43. package/src/common/walkthrough-types.ts +20 -5
@@ -53,8 +53,39 @@ body {
53
53
  height: 100%;
54
54
  }
55
55
 
56
+ /*
57
+ * Centred in a readable measure instead of stretching across the whole view: with the side bars collapsed the
58
+ * welcome page is as wide as the window, and full-width content drifts apart towards the edges.
59
+ */
56
60
  .gs-content-container {
57
61
  padding: 20px;
62
+ box-sizing: border-box;
63
+ width: 100%;
64
+ max-width: 1400px;
65
+ margin: 0 auto;
66
+ }
67
+
68
+ /*
69
+ * The welcome content below the header: the regular sections on the left, the walkthroughs on the right.
70
+ * Both columns keep a sensible minimum width and wrap onto each other in a narrow window.
71
+ */
72
+ .gs-columns {
73
+ display: flex;
74
+ flex-wrap: wrap;
75
+ align-items: flex-start;
76
+ gap: calc(var(--theia-ui-padding) * 8);
77
+ }
78
+
79
+ /* Even shares, so that the walkthroughs sit next to the sections rather than pushed against the edge. */
80
+ .gs-main-column,
81
+ .gs-side-column {
82
+ flex: 1 1 340px;
83
+ min-width: 0;
84
+ }
85
+
86
+ /* Without a walkthrough to offer, the column must not hold on to its share of the width. */
87
+ .gs-side-column:empty {
88
+ display: none;
58
89
  }
59
90
 
60
91
  .gs-content-container a {
@@ -116,46 +147,19 @@ body {
116
147
  align-items: center;
117
148
  }
118
149
 
119
- .gs-float {
120
- float: right;
121
- width: 50%;
122
- margin-top: 100px;
123
- }
124
-
125
- .gs-container.gs-aifeature-container {
126
- border: 1px solid var(--theia-focusBorder);
127
- padding: 15px;
128
- }
129
-
130
- .shadow-pulse {
131
- animation: shadowPulse 2s infinite ease-in-out;
132
- }
133
-
134
- @keyframes shadowPulse {
135
-
136
- 0%,
137
- 100% {
138
- box-shadow: 0 0 0 rgba(0, 0, 0, 0);
139
- }
140
-
141
- 50% {
142
- box-shadow: 0 0 15px rgba(0, 0, 0, 0.4);
143
- }
144
- }
145
-
146
150
  /* Walkthrough styles */
147
151
 
148
152
  /* The cards form a quiet single column: card-like blocks, but without borders. */
149
153
  .gs-walkthrough-cards {
150
154
  display: flex;
151
155
  flex-direction: column;
152
- gap: var(--theia-ui-padding);
153
- margin-top: var(--theia-ui-padding);
156
+ gap: calc(var(--theia-ui-padding) * 2);
157
+ margin-top: calc(var(--theia-ui-padding) * 2);
154
158
  max-width: 640px;
155
159
  }
156
160
 
157
161
  .gs-walkthrough-card {
158
- padding: calc(var(--theia-ui-padding) * 1.5) calc(var(--theia-ui-padding) * 2);
162
+ padding: calc(var(--theia-ui-padding) * 2.5);
159
163
  border-radius: calc(var(--theia-ui-padding) / 2);
160
164
  cursor: pointer;
161
165
  background-color: var(--theia-walkthrough-card-background);
@@ -203,25 +207,31 @@ body {
203
207
  object-fit: contain;
204
208
  }
205
209
 
206
- /* Kept to a single line so that every card stays the same height. */
210
+ /*
211
+ * Wraps, but no further than two lines, so that a description is readable in the narrow side column
212
+ * without any single card growing out of proportion.
213
+ */
207
214
  .gs-walkthrough-card-description {
208
215
  color: var(--theia-descriptionForeground);
209
216
  font-size: var(--theia-ui-font-size0);
210
217
  margin: 0;
218
+ display: -webkit-box;
219
+ -webkit-box-orient: vertical;
220
+ -webkit-line-clamp: 2;
221
+ line-clamp: 2;
211
222
  overflow: hidden;
212
- text-overflow: ellipsis;
213
- white-space: nowrap;
214
223
  }
215
224
 
216
225
  .gs-walkthrough-card-progress {
217
226
  display: flex;
218
227
  align-items: center;
219
228
  gap: var(--theia-ui-padding);
220
- margin-top: var(--theia-ui-padding);
229
+ margin-top: calc(var(--theia-ui-padding) * 2);
221
230
  }
222
231
 
232
+ /* Uses whatever width the card offers rather than a fixed one, which would leave a gap in a wide card. */
223
233
  .gs-walkthrough-progress-bar {
224
- flex: 0 1 180px;
234
+ flex: 1 1 auto;
225
235
  height: calc(var(--theia-ui-padding) / 3);
226
236
  background-color: var(--theia-walkthrough-progress-background);
227
237
  border-radius: calc(var(--theia-ui-padding) / 3);
@@ -242,6 +252,30 @@ body {
242
252
  white-space: nowrap;
243
253
  }
244
254
 
255
+ /*
256
+ * A link that stands alone in its paragraph is an action rather than a reference, so a step can offer it as a
257
+ * button. Links inside a sentence keep reading as links, which is what the `:only-child` restriction buys.
258
+ */
259
+ .gs-walkthrough-step-description p > a:only-child {
260
+ display: inline-block;
261
+ padding: calc(var(--theia-ui-padding) / 2) calc(var(--theia-ui-padding) * 2);
262
+ border: var(--theia-border-width) solid var(--theia-button-border);
263
+ border-radius: calc(var(--theia-ui-padding) / 2);
264
+ background-color: var(--theia-button-background);
265
+ color: var(--theia-button-foreground);
266
+ text-decoration: none;
267
+ cursor: pointer;
268
+ }
269
+
270
+ .gs-walkthrough-step-description p > a:only-child:hover {
271
+ background-color: var(--theia-button-hoverBackground);
272
+ text-decoration: none;
273
+ }
274
+
275
+ .gs-walkthrough-step-description p > a:only-child:focus {
276
+ outline: var(--theia-border-width) solid var(--theia-focusBorder);
277
+ }
278
+
245
279
  .gs-walkthrough-more {
246
280
  display: inline-block;
247
281
  margin-top: calc(var(--theia-ui-padding) / 2);
@@ -397,12 +431,24 @@ body {
397
431
  outline: var(--theia-border-width) dashed var(--theia-contrastActiveBorder);
398
432
  }
399
433
 
400
- .gs-walkthrough-media-image {
434
+ .gs-walkthrough-media-image,
435
+ .gs-walkthrough-media-svg {
401
436
  max-width: 100%;
402
437
  border-radius: calc(var(--theia-ui-padding) / 2);
403
438
  margin-top: var(--theia-ui-padding);
404
439
  }
405
440
 
441
+ .gs-walkthrough-media-svg svg {
442
+ max-width: 100%;
443
+ height: auto;
444
+ display: block;
445
+ }
446
+
447
+ .gs-walkthrough-media-svg a {
448
+ cursor: pointer;
449
+ outline: none;
450
+ }
451
+
406
452
  .gs-walkthrough-media-markdown {
407
453
  margin-top: var(--theia-ui-padding);
408
454
  }
@@ -358,7 +358,95 @@ describe('WalkthroughDetail', () => {
358
358
  }, 50);
359
359
  });
360
360
 
361
- it('should render SVG media for selected step', done => {
361
+ it('should render SVG media inline for selected step', done => {
362
+ const originalFetch = global.fetch;
363
+ const svgContent = '<svg viewBox="0 0 100 100"><rect width="100" height="100" fill="var(--vscode-foreground)" /></svg>';
364
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
365
+ (global as any).fetch = (url: string) => {
366
+ assert.ok(url.includes('graphic.svg'));
367
+ return Promise.resolve({ ok: true, text: () => Promise.resolve(svgContent) });
368
+ };
369
+
370
+ const selectedStep = createMockStep({
371
+ id: 's1',
372
+ media: { svg: '/path/to/graphic.svg', altText: 'SVG Graphic' }
373
+ });
374
+ const walkthrough = createMockWalkthrough({ steps: [selectedStep] });
375
+
376
+ root.render(
377
+ <WalkthroughDetail
378
+ logger={createMockLogger()}
379
+ themeService={createMockThemeService()}
380
+ walkthrough={walkthrough}
381
+ onStepSelect={() => { }}
382
+ onBack={() => { }}
383
+ selectedStep={selectedStep}
384
+ markdownRenderer={mockRenderer}
385
+ />
386
+ );
387
+
388
+ setTimeout(() => {
389
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
390
+ (global as any).fetch = originalFetch;
391
+ const svgContainer = container.querySelector('.gs-walkthrough-media-svg') as HTMLElement;
392
+ assert.ok(svgContainer, 'SVG container element should exist');
393
+ assert.strictEqual(svgContainer.getAttribute('role'), 'img', 'Role should be img');
394
+ assert.strictEqual(svgContainer.getAttribute('aria-label'), 'SVG Graphic', 'aria-label should be set');
395
+ const svgElement = svgContainer.querySelector('svg');
396
+ assert.ok(svgElement, 'Inline SVG element should exist');
397
+ assert.strictEqual(svgElement?.getAttribute('viewBox'), '0 0 100 100');
398
+ const rectElement = svgContainer.querySelector('rect');
399
+ assert.strictEqual(rectElement?.getAttribute('fill'), 'var(--vscode-foreground)');
400
+ done();
401
+ }, 50);
402
+ });
403
+
404
+ it('should handle command links clicked inside rendered SVG media', done => {
405
+ const originalFetch = global.fetch;
406
+ const svgContent = '<svg><a xlink:href="command:my.extension.command"><circle cx="50" cy="50" r="40" /></a></svg>';
407
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
408
+ (global as any).fetch = () => Promise.resolve({ ok: true, text: () => Promise.resolve(svgContent) });
409
+
410
+ let clickedUrl = '';
411
+ const selectedStep = createMockStep({
412
+ id: 's1',
413
+ media: { svg: 'sample.svg' }
414
+ });
415
+ const walkthrough = createMockWalkthrough({ steps: [selectedStep] });
416
+
417
+ root.render(
418
+ <WalkthroughDetail
419
+ logger={createMockLogger()}
420
+ themeService={createMockThemeService()}
421
+ walkthrough={walkthrough}
422
+ onStepSelect={() => { }}
423
+ onBack={() => { }}
424
+ selectedStep={selectedStep}
425
+ markdownRenderer={mockRenderer}
426
+ onLinkClick={url => { clickedUrl = url; }}
427
+ />
428
+ );
429
+
430
+ setTimeout(() => {
431
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
432
+ (global as any).fetch = originalFetch;
433
+ const circle = container.querySelector('.gs-walkthrough-media-svg circle') as HTMLElement;
434
+ assert.ok(circle, 'SVG circle should exist');
435
+ circle.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
436
+
437
+ setTimeout(() => {
438
+ assert.strictEqual(clickedUrl, 'command:my.extension.command', 'Link click should route command URI');
439
+ done();
440
+ }, 50);
441
+ }, 50);
442
+ });
443
+
444
+ it('should omit aria-label on SVG media when altText is not provided', done => {
445
+ const originalFetch = global.fetch;
446
+ const svgContent = '<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="50" /></svg>';
447
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
448
+ (global as any).fetch = () => Promise.resolve({ ok: true, text: () => Promise.resolve(svgContent) });
449
+
362
450
  const selectedStep = createMockStep({
363
451
  id: 's1',
364
452
  media: { svg: '/path/to/graphic.svg' }
@@ -378,13 +466,115 @@ describe('WalkthroughDetail', () => {
378
466
  );
379
467
 
380
468
  setTimeout(() => {
381
- const img = container.querySelector('.gs-walkthrough-media-image') as HTMLImageElement;
382
- assert.ok(img, 'SVG image element should exist');
383
- assert.strictEqual(img.getAttribute('src'), '/path/to/graphic.svg', 'SVG src should be set');
469
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
470
+ (global as any).fetch = originalFetch;
471
+ const svgContainer = container.querySelector('.gs-walkthrough-media-svg') as HTMLElement;
472
+ assert.ok(svgContainer, 'SVG container element should exist');
473
+ assert.strictEqual(svgContainer.getAttribute('role'), 'img');
474
+ assert.strictEqual(svgContainer.hasAttribute('aria-label'), false, 'aria-label attribute should be omitted');
475
+ done();
476
+ }, 50);
477
+ });
478
+
479
+ it('should render nothing when SVG media fetch fails', done => {
480
+ const originalFetch = global.fetch;
481
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
482
+ (global as any).fetch = () => Promise.resolve({ ok: false, text: () => Promise.resolve('') });
483
+
484
+ const selectedStep = createMockStep({
485
+ id: 's1',
486
+ media: { svg: '/path/to/missing.svg' }
487
+ });
488
+ const walkthrough = createMockWalkthrough({ steps: [selectedStep] });
489
+
490
+ root.render(
491
+ <WalkthroughDetail
492
+ logger={createMockLogger()}
493
+ themeService={createMockThemeService()}
494
+ walkthrough={walkthrough}
495
+ onStepSelect={() => { }}
496
+ onBack={() => { }}
497
+ selectedStep={selectedStep}
498
+ markdownRenderer={mockRenderer}
499
+ />
500
+ );
501
+
502
+ setTimeout(() => {
503
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
504
+ (global as any).fetch = originalFetch;
505
+ const svgContainer = container.querySelector('.gs-walkthrough-media-svg');
506
+ assert.ok(!svgContainer, 'No SVG container should be rendered on failure');
507
+ const roleImg = container.querySelector('[role="img"]');
508
+ assert.ok(!roleImg, 'No empty role=img should be left behind on failure');
384
509
  done();
385
510
  }, 50);
386
511
  });
387
512
 
513
+ it('should clear previous SVG media when switching to another SVG step while loading', done => {
514
+ const originalFetch = global.fetch;
515
+ let resolveSecondFetch: (value: { ok: boolean; text: () => Promise<string> }) => void;
516
+ const secondFetchPromise = new Promise<{ ok: boolean; text: () => Promise<string> }>(resolve => {
517
+ resolveSecondFetch = resolve;
518
+ });
519
+
520
+ const firstSvg = '<svg id="first"><circle cx="10" cy="10" r="10" /></svg>';
521
+ const secondSvg = '<svg id="second"><circle cx="20" cy="20" r="20" /></svg>';
522
+
523
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
524
+ (global as any).fetch = (url: string) => {
525
+ if (url.includes('first.svg')) {
526
+ return Promise.resolve({ ok: true, text: () => Promise.resolve(firstSvg) });
527
+ }
528
+ return secondFetchPromise;
529
+ };
530
+
531
+ const step1 = createMockStep({ id: 's1', media: { svg: 'first.svg' } });
532
+ const step2 = createMockStep({ id: 's2', media: { svg: 'second.svg' } });
533
+ const walkthrough = createMockWalkthrough({ steps: [step1, step2] });
534
+
535
+ root.render(
536
+ <WalkthroughDetail
537
+ logger={createMockLogger()}
538
+ themeService={createMockThemeService()}
539
+ walkthrough={walkthrough}
540
+ onStepSelect={() => { }}
541
+ onBack={() => { }}
542
+ selectedStep={step1}
543
+ markdownRenderer={mockRenderer}
544
+ />
545
+ );
546
+
547
+ setTimeout(() => {
548
+ assert.ok(container.querySelector('#first'), 'First SVG should be rendered');
549
+
550
+ root.render(
551
+ <WalkthroughDetail
552
+ logger={createMockLogger()}
553
+ themeService={createMockThemeService()}
554
+ walkthrough={walkthrough}
555
+ onStepSelect={() => { }}
556
+ onBack={() => { }}
557
+ selectedStep={step2}
558
+ markdownRenderer={mockRenderer}
559
+ />
560
+ );
561
+
562
+ setTimeout(() => {
563
+ assert.ok(!container.querySelector('#first'), 'First SVG should be cleared immediately upon step switch');
564
+ assert.ok(!container.querySelector('.gs-walkthrough-media-svg'), 'No SVG container should be visible while loading');
565
+
566
+ resolveSecondFetch({ ok: true, text: () => Promise.resolve(secondSvg) });
567
+
568
+ setTimeout(() => {
569
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
570
+ (global as any).fetch = originalFetch;
571
+ assert.ok(container.querySelector('#second'), 'Second SVG should be rendered after fetch resolves');
572
+ done();
573
+ }, 50);
574
+ }, 50);
575
+ }, 50);
576
+ });
577
+
388
578
  it('should turn single newlines in a description into Markdown hard breaks', done => {
389
579
  const renderedMarkdown: string[] = [];
390
580
  const recordingRenderer: MarkdownRenderer = {
@@ -14,6 +14,7 @@
14
14
  // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
15
  // *****************************************************************************
16
16
 
17
+ import * as DOMPurify from '@theia/core/shared/dompurify';
17
18
  import { codicon } from '@theia/core/lib/browser';
18
19
  import { MarkdownRenderer, MarkdownRenderOptions, MarkdownRenderResult } from '@theia/core/lib/browser/markdown-rendering/markdown-renderer';
19
20
  import { ThemeService } from '@theia/core/lib/browser/theming';
@@ -264,7 +265,7 @@ function renderMedia(
264
265
  return <WalkthroughMedia src={media.markdown} markdownRenderer={markdownRenderer} logger={logger} onLinkClick={onLinkClick} />;
265
266
  }
266
267
  if ('svg' in media) {
267
- return <WalkthroughMediaImage src={media.svg} altText={media.altText || ''} themeService={themeService} />;
268
+ return <WalkthroughMediaSvg src={media.svg} altText={media.altText || undefined} logger={logger} onLinkClick={onLinkClick} />;
268
269
  }
269
270
  if ('image' in media) {
270
271
  return <WalkthroughMediaImage src={media.image} altText={media.altText || ''} themeService={themeService} />;
@@ -345,3 +346,80 @@ function WalkthroughMedia(props: {
345
346
 
346
347
  return <div className='gs-walkthrough-media-markdown' ref={containerRef} />;
347
348
  }
349
+
350
+ function WalkthroughMediaSvg(props: {
351
+ src: string;
352
+ altText?: string;
353
+ logger: ILogger;
354
+ onLinkClick?: (url: string) => void;
355
+ }): React.ReactElement | undefined {
356
+ const [content, setContent] = React.useState<string | undefined>();
357
+ // eslint-disable-next-line no-null/no-null
358
+ const containerRef = React.useRef<HTMLDivElement>(null);
359
+ const onLinkClickRef = React.useRef(props.onLinkClick);
360
+ onLinkClickRef.current = props.onLinkClick;
361
+
362
+ React.useEffect(() => {
363
+ let cancelled = false;
364
+ setContent(undefined);
365
+ fetch(PluginSharedStyle.toExternalIconUrl(props.src))
366
+ .then(response => (!cancelled && response.ok ? response.text() : ''))
367
+ .then(text => {
368
+ if (cancelled) {
369
+ return;
370
+ }
371
+ if (text) {
372
+ const sanitized = DOMPurify.sanitize(text, {
373
+ USE_PROFILES: { svg: true, svgFilters: true },
374
+ ADD_TAGS: ['use', 'a'],
375
+ ADD_ATTR: ['xlink:href', 'href', 'target'],
376
+ ALLOW_UNKNOWN_PROTOCOLS: true
377
+ });
378
+ setContent(sanitized || undefined);
379
+ } else {
380
+ setContent(undefined);
381
+ }
382
+ })
383
+ .catch(error => {
384
+ if (!cancelled) {
385
+ setContent(undefined);
386
+ props.logger.warn(`Could not load the walkthrough media '${props.src}'.`, error);
387
+ }
388
+ });
389
+
390
+ return () => {
391
+ cancelled = true;
392
+ };
393
+ }, [props.src, props.logger]);
394
+
395
+ const handleClick = React.useCallback((event: React.MouseEvent) => {
396
+ const target = event.target as Element | null;
397
+ const anchor = target?.closest('a');
398
+ if (!anchor || !containerRef.current?.contains(anchor)) {
399
+ return;
400
+ }
401
+ const href = anchor.getAttribute('href') || anchor.getAttribute('xlink:href');
402
+ if (href) {
403
+ event.preventDefault();
404
+ event.stopPropagation();
405
+ onLinkClickRef.current?.(href);
406
+ }
407
+ }, []);
408
+
409
+ if (!content) {
410
+ return undefined;
411
+ }
412
+
413
+ return (
414
+ <div
415
+ className='gs-walkthrough-media-svg'
416
+ ref={containerRef}
417
+ role='img'
418
+ aria-label={props.altText || undefined}
419
+ onClick={handleClick}
420
+ // eslint-disable-next-line react/no-danger
421
+ dangerouslySetInnerHTML={{ __html: content }}
422
+ />
423
+ );
424
+ }
425
+
@@ -400,7 +400,9 @@ describe('WalkthroughSection', () => {
400
400
  }, 50);
401
401
  });
402
402
  it('should list at most WALKTHROUGH_LIST_LIMIT walkthroughs and delegate More... to the picker', done => {
403
- const walkthroughs = [1, 2, 3, 4, 5].map(i => createMockWalkthrough({ id: `wt${i}`, title: `WT ${i}` }));
403
+ // One more than fits, so that the assertions hold whatever the limit is.
404
+ const walkthroughs = Array.from({ length: WALKTHROUGH_LIST_LIMIT + 1 },
405
+ (_, i) => createMockWalkthrough({ id: `wt${i}`, title: `WT ${i}` }));
404
406
  const mockService = createMockWalkthroughService(walkthroughs);
405
407
  let showAllCalled = false;
406
408
 
@@ -459,6 +461,32 @@ describe('WalkthroughSection', () => {
459
461
  }, 50);
460
462
  });
461
463
 
464
+ it('should render nothing once every walkthrough is complete', done => {
465
+ const walkthroughs = [createMockWalkthrough({
466
+ id: 'done',
467
+ title: 'Done',
468
+ steps: [createMockStep({ id: 's1', isComplete: true })]
469
+ })];
470
+ const mockService = createMockWalkthroughService(walkthroughs);
471
+
472
+ root.render(
473
+ <WalkthroughSection
474
+ walkthroughService={mockService as unknown as WalkthroughService}
475
+ markdownRenderer={mockRenderer}
476
+ themeService={createMockThemeService()}
477
+ logger={createMockLogger()}
478
+ onShowAll={() => { }}
479
+ />
480
+ );
481
+
482
+ setTimeout(() => {
483
+ // Not even the heading: the section must not hold on to its place in the welcome page.
484
+ // eslint-disable-next-line no-null/no-null
485
+ assert.strictEqual(container.querySelector('.gs-section'), null, 'Should not render the section');
486
+ done();
487
+ }, 50);
488
+ });
489
+
462
490
  it('should not offer More... when every walkthrough is listed', done => {
463
491
  const walkthroughs = [1, 2].map(i => createMockWalkthrough({ id: `wt${i}`, title: `WT ${i}` }));
464
492
  const mockService = createMockWalkthroughService(walkthroughs);
@@ -35,8 +35,8 @@ export interface WalkthroughSectionProps {
35
35
  onShowAll?: () => void;
36
36
  }
37
37
 
38
- /** How many walkthroughs are listed on the welcome page. */
39
- export const WALKTHROUGH_LIST_LIMIT = 2;
38
+ /** How many walkthroughs are listed on the welcome page; the rest stay behind `More...`. */
39
+ export const WALKTHROUGH_LIST_LIMIT = 5;
40
40
 
41
41
  /**
42
42
  * Renders the list of contributed walkthroughs, or the currently selected walkthrough.
@@ -77,12 +77,14 @@ export function WalkthroughSection(props: WalkthroughSectionProps): React.ReactE
77
77
  }
78
78
 
79
79
  const walkthroughs = walkthroughService.getWalkthroughs();
80
- if (walkthroughs.length === 0) {
80
+ // A finished walkthrough has nothing left to offer here; it stays reachable through the
81
+ // `walkthrough.open` command. Once none is pending the section renders nothing at all, so that it does not
82
+ // hold on to its place in the welcome page for an empty heading.
83
+ const pending = walkthroughs.filter(walkthrough => walkthrough.steps.some(step => !step.isComplete));
84
+ if (pending.length === 0) {
81
85
  return <React.Fragment />;
82
86
  }
83
87
 
84
- // A finished walkthrough has nothing left to offer here; it stays reachable through `onShowAll`.
85
- const pending = walkthroughs.filter(walkthrough => walkthrough.steps.some(step => !step.isComplete));
86
88
  const listed = pending.slice(0, WALKTHROUGH_LIST_LIMIT);
87
89
  return (
88
90
  <div className='gs-section'>