@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.
- package/README.md +44 -0
- package/lib/browser/getting-started-frontend-module.d.ts.map +1 -1
- package/lib/browser/getting-started-frontend-module.js +3 -0
- package/lib/browser/getting-started-frontend-module.js.map +1 -1
- package/lib/browser/getting-started-widget.d.ts +0 -7
- package/lib/browser/getting-started-widget.d.ts.map +1 -1
- package/lib/browser/getting-started-widget.js +1 -29
- package/lib/browser/getting-started-widget.js.map +1 -1
- package/lib/browser/walkthrough-detail.d.ts.map +1 -1
- package/lib/browser/walkthrough-detail.js +60 -1
- package/lib/browser/walkthrough-detail.js.map +1 -1
- package/lib/browser/walkthrough-detail.spec.js +119 -4
- package/lib/browser/walkthrough-detail.spec.js.map +1 -1
- package/lib/browser/walkthrough-section.d.ts +2 -2
- package/lib/browser/walkthrough-section.d.ts.map +1 -1
- package/lib/browser/walkthrough-section.js +7 -5
- package/lib/browser/walkthrough-section.js.map +1 -1
- package/lib/browser/walkthrough-section.spec.js +17 -1
- package/lib/browser/walkthrough-section.spec.js.map +1 -1
- package/lib/browser/walkthrough-service.d.ts +26 -5
- package/lib/browser/walkthrough-service.d.ts.map +1 -1
- package/lib/browser/walkthrough-service.js +77 -22
- package/lib/browser/walkthrough-service.js.map +1 -1
- package/lib/browser/walkthrough-service.spec.js +173 -57
- package/lib/browser/walkthrough-service.spec.js.map +1 -1
- package/lib/common/walkthrough-provider.d.ts +22 -0
- package/lib/common/walkthrough-provider.d.ts.map +1 -0
- package/lib/common/walkthrough-provider.js +27 -0
- package/lib/common/walkthrough-provider.js.map +1 -0
- package/lib/common/walkthrough-types.d.ts +18 -5
- package/lib/common/walkthrough-types.d.ts.map +1 -1
- package/package.json +8 -8
- package/src/browser/getting-started-frontend-module.ts +3 -0
- package/src/browser/getting-started-widget.tsx +31 -101
- package/src/browser/style/index.css +82 -36
- package/src/browser/walkthrough-detail.spec.tsx +194 -4
- package/src/browser/walkthrough-detail.tsx +79 -1
- package/src/browser/walkthrough-section.spec.tsx +29 -1
- package/src/browser/walkthrough-section.tsx +7 -5
- package/src/browser/walkthrough-service.spec.ts +207 -57
- package/src/browser/walkthrough-service.ts +82 -25
- package/src/common/walkthrough-provider.ts +38 -0
- 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) *
|
|
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
|
-
/*
|
|
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:
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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 <
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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'>
|