diff --git a/.github/skills/component-fixtures/SKILL.md b/.github/skills/component-fixtures/SKILL.md new file mode 100644 index 00000000000..ec2df9d4e92 --- /dev/null +++ b/.github/skills/component-fixtures/SKILL.md @@ -0,0 +1,343 @@ +--- +name: component-fixtures +description: Use when creating or updating component fixtures for screenshot testing, or when designing UI components to be fixture-friendly. Covers fixture file structure, theming, service setup, CSS scoping, async rendering, and common pitfalls. +--- + +# Component Fixtures + +Component fixtures render isolated UI components for visual screenshot testing via the component explorer. Fixtures live in `src/vs/workbench/test/browser/componentFixtures/` and are auto-discovered by the Vite dev server using the glob `src/**/*.fixture.ts`. + +Use tools `mcp_component-exp_`* to list and screenshot fixtures. If you cannot see these tools, inform the user to them on. + +## Running Fixtures Locally + +1. Start the component explorer daemon: run the **Launch Component Explorer** task +2. Use the `mcp_component-exp_list_fixtures` tool to see all available fixtures and their URLs +3. Use the `mcp_component-exp_screenshot` tool to capture screenshots programmatically + +## File Structure + +Each fixture file exports a default `defineThemedFixtureGroup(...)`. The file must end with `.fixture.ts`. + +``` +src/vs/workbench/test/browser/componentFixtures/ + fixtureUtils.ts # Shared helpers (DO NOT import @vscode/component-explorer elsewhere) + myComponent.fixture.ts # Your fixture file +``` + +## Basic Pattern + +```typescript +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from './fixtureUtils.js'; + +export default defineThemedFixtureGroup({ + Default: defineComponentFixture({ render: renderMyComponent }), + AnotherVariant: defineComponentFixture({ render: renderMyComponent }), +}); + +function renderMyComponent({ container, disposableStore, theme }: ComponentFixtureContext): void { + container.style.width = '400px'; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: theme, + additionalServices: (reg) => { + // Register additional services the component needs + reg.define(IMyService, MyServiceImpl); + reg.defineInstance(IMockService, mockInstance); + }, + }); + + const widget = disposableStore.add( + instantiationService.createInstance(MyWidget, /* constructor args */) + ); + container.appendChild(widget.domNode); +} +``` + +Key points: +- **`defineThemedFixtureGroup`** automatically creates Dark and Light variants for each fixture +- **`defineComponentFixture`** wraps your render function with theme setup and shadow DOM isolation +- **`createEditorServices`** provides a `TestInstantiationService` with base editor services pre-registered +- Always register created widgets with `disposableStore.add(...)` to prevent leaks +- Pass `colorTheme: theme` to `createEditorServices` so theme colors render correctly + +## Utilities from fixtureUtils.ts + +| Export | Purpose | +|---|---| +| `defineComponentFixture` | Creates Dark/Light themed fixture variants from a render function | +| `defineThemedFixtureGroup` | Groups multiple themed fixtures into a named fixture group | +| `createEditorServices` | Creates `TestInstantiationService` with all base editor services | +| `registerWorkbenchServices` | Registers additional workbench services (context menu, label, etc.) | +| `createTextModel` | Creates a text model via `ModelService` for editor fixtures | +| `setupTheme` | Applies theme CSS to a container (called automatically by `defineComponentFixture`) | +| `darkTheme` / `lightTheme` | Pre-loaded `ColorThemeData` instances | + +**Important:** Only `fixtureUtils.ts` may import from `@vscode/component-explorer`. All fixture files must go through the helpers in `fixtureUtils.ts`. + +## CSS Scoping + +Fixtures render inside shadow DOM. The component-explorer automatically adopts the global VS Code stylesheets and theme CSS. + +### Matching production CSS selectors + +Many VS Code components have CSS rules scoped to deep ancestor selectors (e.g., `.interactive-session .interactive-input-part > .widget-container .my-element`). In fixtures, you must recreate the required ancestor DOM structure for these selectors to match: + +```typescript +function render({ container }: ComponentFixtureContext): void { + container.classList.add('interactive-session'); + + // Recreate ancestor structure that CSS selectors expect + const inputPart = dom.$('.interactive-input-part'); + const widgetContainer = dom.$('.widget-container'); + inputPart.appendChild(widgetContainer); + container.appendChild(inputPart); + + widgetContainer.appendChild(myWidget.domNode); +} +``` + +**Design recommendation for new components:** Avoid deeply nested CSS selectors that require specific ancestor elements. Use self-contained class names (e.g., `.my-widget .my-element` rather than `.parent-view .parent-part > .wrapper .my-element`). This makes components easier to fixture and reuse. + +## Services + +### Using createEditorServices + +`createEditorServices` pre-registers these services: `IAccessibilityService`, `IKeybindingService`, `IClipboardService`, `IOpenerService`, `INotificationService`, `IDialogService`, `IUndoRedoService`, `ILanguageService`, `IConfigurationService`, `IStorageService`, `IThemeService`, `IModelService`, `ICodeEditorService`, `IContextKeyService`, `ICommandService`, `ITelemetryService`, `IHoverService`, `IUserInteractionService`, and more. + +### Additional services + +Register extra services via `additionalServices`: + +```typescript +createEditorServices(disposableStore, { + additionalServices: (reg) => { + // Class-based (instantiated by DI): + reg.define(IMyService, MyServiceImpl); + // Instance-based (pre-constructed): + reg.defineInstance(IMyService, myMockInstance); + }, +}); +``` + +### Mocking services + +Use the `mock()` helper from `base/test/common/mock.js` to create mock service instances: + +```typescript +import { mock } from '../../../../base/test/common/mock.js'; + +const myService = new class extends mock() { + override someMethod(): string { return 'test'; } + override onSomeEvent = Event.None; +}; +reg.defineInstance(IMyService, myService); +``` + +For mock view models or data objects: +```typescript +const element = new class extends mock() { }(); +``` + +## Async Rendering + +The component explorer waits **2 animation frames** after the synchronous render function returns. For most components, this is sufficient. + +If your render function returns a `Promise`, the component explorer waits for the promise to resolve. + +### Pitfall: DOM reparenting causes flickering + +Avoid moving rendered widgets between DOM parents after initial render. This causes: +- Layout recalculation (the widget jumps as `position: absolute` coordinates become invalid) +- Focus loss (blur events can trigger hide logic in widgets like QuickInput) +- Screenshot instability (the component explorer may capture an intermediate layout state) + +**Bad pattern — reparenting a widget after async wait:** +```typescript +async function render({ container }: ComponentFixtureContext): Promise { + const host = document.createElement('div'); + container.appendChild(host); + // ... create widget inside host ... + await waitForWidget(); + container.appendChild(widget); // BAD: reparenting causes flicker + host.remove(); +} +``` + +**Better pattern — render in-place with the correct DOM structure from the start:** +```typescript +function render({ container }: ComponentFixtureContext): void { + // Set up the correct DOM structure first, then create the widget inside it + const widget = createWidget(container); + container.appendChild(widget.domNode); +} +``` + +If the component absolutely requires async setup (e.g., QuickInput which renders internally), minimize DOM manipulation after the widget appears by structuring the host container to match the final layout from the beginning. + +## Adapting Existing Components for Fixtures + +Existing components often need small changes to become fixturable. When writing a fixture reveals friction, fix the component — don't work around it in the fixture. Common adaptations: + +### Decouple CSS from ancestor context + +If a component's CSS only works inside a deeply nested selector like `.workbench .sidebar .my-view .my-widget`, refactor the CSS to be self-contained. Move the styles so they're scoped to the component's own root class: + +```css +/* Before: requires specific ancestors */ +.workbench .sidebar .my-view .my-widget .header { font-weight: bold; } + +/* After: self-contained */ +.my-widget .header { font-weight: bold; } +``` + +If the component shares styles with its parent (e.g., inheriting background color), use CSS custom properties rather than relying on ancestor selectors. + +### Extract hard-coded service dependencies + +If a component reaches into singletons or global state instead of using DI, refactor it to accept services through the constructor: + +```typescript +// Before: hard to mock in fixtures +class MyWidget { + private readonly config = getSomeGlobalConfig(); +} + +// After: injectable and testable +class MyWidget { + constructor(@IConfigurationService private readonly configService: IConfigurationService) { } +} +``` + +### Add options to control auto-focus and animation + +Components that auto-focus on creation or run animations cause flaky screenshots. Add an options parameter: + +```typescript +interface IMyWidgetOptions { + shouldAutoFocus?: boolean; +} +``` + +The fixture passes `shouldAutoFocus: false`. The production call site keeps the default behavior. + +### Expose internal state for "already completed" rendering + +Many components have lifecycle states (loading → active → completed). If the component can only reach the "completed" state through user interaction, add support for initializing directly into that state via constructor data: + +```typescript +// The fixture can pass pre-filled data to render the summary/completed state +// without simulating the full user interaction flow. +const carousel: IChatQuestionCarousel = { + questions, + allowSkip: true, + kind: 'questionCarousel', + isUsed: true, // Already completed + data: { 'q1': 'answer' }, // Pre-filled answers +}; +``` + +### Make DOM node accessible + +If a component builds its DOM internally and doesn't expose the root element, add a public `readonly domNode: HTMLElement` property so fixtures can append it to the container. + +## Writing Fixture-Friendly Components + +When designing new UI components, follow these practices to make them easy to fixture: + +### 1. Accept a container element in the constructor + +```typescript +// Good: container is passed in +class MyWidget { + constructor(container: HTMLElement, @IFoo foo: IFoo) { + this.domNode = dom.append(container, dom.$('.my-widget')); + } +} + +// Also good: widget creates its own domNode for the caller to place +class MyWidget { + readonly domNode: HTMLElement; + constructor(@IFoo foo: IFoo) { + this.domNode = dom.$('.my-widget'); + } +} +``` + +### 2. Use dependency injection for all services + +All external dependencies should come through DI so fixtures can provide test implementations: + +```typescript +// Good: services injected +constructor(@IThemeService private readonly themeService: IThemeService) { } + +// Bad: reaching into globals +constructor() { this.theme = getGlobalTheme(); } +``` + +### 3. Keep CSS selectors shallow + +```css +/* Good: self-contained, easy to fixture */ +.my-widget .my-header { ... } +.my-widget .my-list-item { ... } + +/* Bad: requires deep ancestor chain */ +.workbench .sidebar .my-view .my-widget .my-header { ... } +``` + +### 4. Avoid reading from layout/window services during construction + +Components that measure the window or read layout dimensions during construction are hard to fixture because the shadow DOM container has different dimensions than the workbench: + +```typescript +// Prefer: use CSS for sizing, or accept dimensions as parameters +container.style.width = '400px'; +container.style.height = '300px'; + +// Avoid: reading from layoutService during construction +const width = this.layoutService.mainContainerDimension.width; +``` + +### 5. Support disabling auto-focus in fixtures + +Auto-focus can interfere with screenshot stability. Provide options to disable it: + +```typescript +interface IMyWidgetOptions { + shouldAutoFocus?: boolean; // Fixtures pass false +} +``` + +### 6. Expose the DOM node + +The fixture needs to append the widget's DOM to the container. Expose it as a public `readonly domNode: HTMLElement`. + +## Multiple Fixture Variants + +Create variants to show different states of the same component: + +```typescript +export default defineThemedFixtureGroup({ + // Different data states + Empty: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { items: [] }) }), + WithItems: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { items: sampleItems }) }), + + // Different configurations + ReadOnly: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { readonly: true }) }), + Editable: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { readonly: false }) }), + + // Lifecycle states + Loading: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { state: 'loading' }) }), + Completed: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { state: 'done' }) }), +}); +``` + +## Learnings + +Update this section with insights from your fixture development experience! + +* Do not copy the component to the fixture and modify it there. Always adapt the original component to be fixture-friendly, then render it in the fixture. This ensures the fixture tests the real component code and lifecycle, rather than a modified version that may hide bugs. + +* **Don't recompose child widgets in fixtures.** Never manually instantiate and add a sub-widget (e.g., a toolbar content widget) that the parent component is supposed to create. Instead, configure the parent correctly (e.g., set the right editor option, register the right provider) so the child appears through the normal code path. Manually recomposing hides integration bugs and doesn't test the real widget lifecycle. diff --git a/.vscode/launch.json b/.vscode/launch.json index 04fc3906188..47d901042e3 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -648,9 +648,19 @@ } }, { - "name": "Component Explorer", + "name": "Component Explorer (Edge)", "type": "msedge", - "port": 9230, + "request": "launch", + "url": "http://localhost:5337/___explorer", + "preLaunchTask": "Launch Component Explorer", + "presentation": { + "group": "1_component_explorer", + "order": 4 + } + }, + { + "name": "Component Explorer (Chrome)", + "type": "chrome", "request": "launch", "url": "http://localhost:5337/___explorer", "preLaunchTask": "Launch Component Explorer", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index c330df2edec..8353bc02c75 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -375,9 +375,18 @@ { "label": "Launch Component Explorer", "type": "shell", - "command": "npx component-explorer serve -c ./test/componentFixtures/component-explorer.json", + "command": "npx component-explorer serve -c ./test/componentFixtures/component-explorer.json -vv", "isBackground": true, - "problemMatcher": [] + "problemMatcher": { + "owner": "component-explorer", + "fileLocation": "absolute", + "pattern": { + "regexp": "^\\s*at\\s+(.+?):(\\d+):(\\d+)\\s*$", + "file": 1, + "line": 2, + "column": 3 + } + } } ] } diff --git a/build/vite/package-lock.json b/build/vite/package-lock.json index 4179138e714..8d3f50df39b 100644 --- a/build/vite/package-lock.json +++ b/build/vite/package-lock.json @@ -8,8 +8,8 @@ "name": "@vscode/sample-source", "version": "0.0.0", "devDependencies": { - "@vscode/component-explorer": "^0.1.1-12", - "@vscode/component-explorer-vite-plugin": "^0.1.1-12", + "@vscode/component-explorer": "^0.1.1-16", + "@vscode/component-explorer-vite-plugin": "^0.1.1-16", "@vscode/rollup-plugin-esm-url": "^1.0.1-1", "rollup": "*", "vite": "npm:rolldown-vite@latest" @@ -683,20 +683,22 @@ "license": "MIT" }, "node_modules/@vscode/component-explorer": { - "version": "0.1.1-12", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.1.1-12.tgz", - "integrity": "sha512-qqbxbu3BvqWtwFdVsROLUSd1BiScCiUPP5n0sk0yV1WDATlAl6wQMX1QlmsZy3hag8iP/MXUEj5tSBjA1T7tFw==", + "version": "0.1.1-16", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.1.1-16.tgz", + "integrity": "sha512-is1RxdlNO5K1RSqWd5z8BN6gPrqEBZfjgUi3ZJbQj8Z4VqmqoJsNLIzBXOIlQJX+5mWgeNdOq3vxe0u15ZkAlA==", "dev": true, + "license": "MIT", "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0" } }, "node_modules/@vscode/component-explorer-vite-plugin": { - "version": "0.1.1-12", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer-vite-plugin/-/component-explorer-vite-plugin-0.1.1-12.tgz", - "integrity": "sha512-MG5ndoooX2X9PYto1WkNSwWKKmR5OJx3cBnUf7JHm8ERw+8RsZbLe+WS+hVOqnCVPxHy7t+0IYRFl7IC5cuwOQ==", + "version": "0.1.1-16", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer-vite-plugin/-/component-explorer-vite-plugin-0.1.1-16.tgz", + "integrity": "sha512-z2EqusWl49dUF3vNDgmJJJQXkv4ejeBH9AdFZUWOiGaMvjjFX6UV7oQ733b+vo5YFE8my9WaK7D691i2wZ47Fg==", "dev": true, + "license": "MIT", "dependencies": { "tinyglobby": "^0.2.0" }, diff --git a/build/vite/package.json b/build/vite/package.json index 245bf4fc800..5e5d59d1a16 100644 --- a/build/vite/package.json +++ b/build/vite/package.json @@ -9,8 +9,8 @@ "preview": "vite preview" }, "devDependencies": { - "@vscode/component-explorer": "^0.1.1-12", - "@vscode/component-explorer-vite-plugin": "^0.1.1-12", + "@vscode/component-explorer": "^0.1.1-16", + "@vscode/component-explorer-vite-plugin": "^0.1.1-16", "@vscode/rollup-plugin-esm-url": "^1.0.1-1", "rollup": "*", "vite": "npm:rolldown-vite@latest" diff --git a/package-lock.json b/package-lock.json index 587596d6666..d0ebb1204c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,8 +84,8 @@ "@types/yazl": "^2.4.2", "@typescript-eslint/utils": "^8.45.0", "@typescript/native-preview": "^7.0.0-dev.20260130", - "@vscode/component-explorer": "^0.1.1-12", - "@vscode/component-explorer-cli": "^0.1.1-8", + "@vscode/component-explorer": "^0.1.1-16", + "@vscode/component-explorer-cli": "^0.1.1-12", "@vscode/gulp-electron": "1.40.0", "@vscode/l10n-dev": "0.0.35", "@vscode/telemetry-extractor": "^1.10.2", @@ -3065,20 +3065,22 @@ "license": "CC-BY-4.0" }, "node_modules/@vscode/component-explorer": { - "version": "0.1.1-12", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.1.1-12.tgz", - "integrity": "sha512-qqbxbu3BvqWtwFdVsROLUSd1BiScCiUPP5n0sk0yV1WDATlAl6wQMX1QlmsZy3hag8iP/MXUEj5tSBjA1T7tFw==", + "version": "0.1.1-16", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.1.1-16.tgz", + "integrity": "sha512-is1RxdlNO5K1RSqWd5z8BN6gPrqEBZfjgUi3ZJbQj8Z4VqmqoJsNLIzBXOIlQJX+5mWgeNdOq3vxe0u15ZkAlA==", "dev": true, + "license": "MIT", "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0" } }, "node_modules/@vscode/component-explorer-cli": { - "version": "0.1.1-8", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer-cli/-/component-explorer-cli-0.1.1-8.tgz", - "integrity": "sha512-Sze4SdE6zlr5Mkd/RFLmLqSmEjsjq1f2pBp4a/S5u0TDS4matrkklb3LHym8dMbYC6UjWQuFOkYFZwXnGFxZqw==", + "version": "0.1.1-12", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer-cli/-/component-explorer-cli-0.1.1-12.tgz", + "integrity": "sha512-SaChUP94wkf1RaaJ/MnpQsxsr7pUpqQJq5Z9QLbrZuUqRil2TZEHwYLSqpQPqLgybNxZtrlMDivTjcCWXFTttg==", "dev": true, + "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", "clipanion": "^4.0.0-rc.4", diff --git a/package.json b/package.json index cb5b8b6a963..64ad8d3954f 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "perf": "node scripts/code-perf.js", "update-build-ts-version": "npm install -D typescript@next && npm install -D @typescript/native-preview && (cd build && npm run typecheck)", "install-local-component-explorer": "npm install ../vscode-packages/js-component-explorer/dist/vscode-component-explorer-0.1.0.tgz ../vscode-packages/js-component-explorer/dist/vscode-component-explorer-cli-0.1.0.tgz --no-save && cd build/vite && npm install ../../../vscode-packages/js-component-explorer/dist/vscode-component-explorer-vite-plugin-0.1.0.tgz --no-save", + "symlink-local-component-explorer": "npm install ../vscode-packages/js-component-explorer/packages/explorer ../vscode-packages/js-component-explorer/packages/cli --no-save && cd build/vite && npm install ../../../vscode-packages/js-component-explorer/packages/vite-plugin ../../../vscode-packages/js-component-explorer/packages/explorer --no-save", "install-latest-component-explorer": "npm install @vscode/component-explorer@next @vscode/component-explorer-cli@next && cd build/vite && npm install @vscode/component-explorer-vite-plugin@next && npm install @vscode/component-explorer@next" }, "dependencies": { @@ -152,8 +153,8 @@ "@types/yazl": "^2.4.2", "@typescript-eslint/utils": "^8.45.0", "@typescript/native-preview": "^7.0.0-dev.20260130", - "@vscode/component-explorer": "^0.1.1-12", - "@vscode/component-explorer-cli": "^0.1.1-8", + "@vscode/component-explorer": "^0.1.1-16", + "@vscode/component-explorer-cli": "^0.1.1-12", "@vscode/gulp-electron": "1.40.0", "@vscode/l10n-dev": "0.0.35", "@vscode/telemetry-extractor": "^1.10.2", diff --git a/src/vs/workbench/test/browser/componentFixtures/chatQuestionCarousel.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chatQuestionCarousel.fixture.ts new file mode 100644 index 00000000000..0cef5d23bd7 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/chatQuestionCarousel.fixture.ts @@ -0,0 +1,172 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { IMarkdownRendererService, MarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js'; +import { IChatQuestion, IChatQuestionCarousel } from '../../../contrib/chat/common/chatService/chatService.js'; +import { ChatQuestionCarouselPart, IChatQuestionCarouselOptions } from '../../../contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.js'; +import { IChatContentPartRenderContext } from '../../../contrib/chat/browser/widget/chatContentParts/chatContentParts.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from './fixtureUtils.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { Event } from '../../../../base/common/event.js'; +import { observableValue } from '../../../../base/common/observable.js'; +import { IChatRequestViewModel } from '../../../contrib/chat/common/model/chatViewModel.js'; +import '../../../contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css'; + +function createCarousel(questions: IChatQuestion[], allowSkip: boolean = true): IChatQuestionCarousel { + return { + questions, + allowSkip, + kind: 'questionCarousel', + }; +} + +function createMockContext(): IChatContentPartRenderContext { + return { + element: new class extends mock() { }(), + elementIndex: 0, + container: document.createElement('div'), + content: [], + contentIndex: 0, + editorPool: undefined!, + codeBlockStartIndex: 0, + treeStartIndex: 0, + diffEditorPool: undefined!, + codeBlockModelCollection: undefined!, + currentWidth: observableValue('currentWidth', 400), + onDidChangeVisibility: Event.None, + }; +} + +function createOptions(): IChatQuestionCarouselOptions { + return { + onSubmit: () => { }, + shouldAutoFocus: false, + }; +} + +function renderCarousel(context: ComponentFixtureContext, carousel: IChatQuestionCarousel): void { + const { container, disposableStore } = context; + + const instantiationService = createEditorServices(disposableStore, { + additionalServices: (reg) => { + reg.define(IMarkdownRendererService, MarkdownRendererService); + }, + }); + + const part = disposableStore.add( + instantiationService.createInstance( + ChatQuestionCarouselPart, + carousel, + createMockContext(), + createOptions(), + ) + ); + + container.style.width = '400px'; + container.style.padding = '8px'; + container.classList.add('interactive-session'); + + // The CSS uses `.interactive-session .interactive-input-part > .chat-question-carousel-widget-container` + // for most layout rules, so we need those wrapper elements. + const inputPart = dom.$('.interactive-input-part'); + const widgetContainer = dom.$('.chat-question-carousel-widget-container'); + inputPart.appendChild(widgetContainer); + container.appendChild(inputPart); + + widgetContainer.appendChild(part.domNode); +} + +// ============================================================================ +// Sample questions +// ============================================================================ + +const textQuestion: IChatQuestion = { + id: 'project-name', + type: 'text', + title: 'Project name', + message: 'What is the name of your project?', + defaultValue: 'my-project', +}; + +const singleSelectQuestion: IChatQuestion = { + id: 'language', + type: 'singleSelect', + title: 'Language', + message: 'Which language do you want to use?', + options: [ + { id: 'ts', label: 'TypeScript - Strongly typed JavaScript', value: 'typescript' }, + { id: 'js', label: 'JavaScript - Dynamic scripting language', value: 'javascript' }, + { id: 'py', label: 'Python - General purpose language', value: 'python' }, + { id: 'rs', label: 'Rust - Systems programming', value: 'rust' }, + ], + defaultValue: 'ts', +}; + +const multiSelectQuestion: IChatQuestion = { + id: 'features', + type: 'multiSelect', + title: 'Features', + message: 'Which features should be enabled?', + options: [ + { id: 'lint', label: 'Linting', value: 'linting' }, + { id: 'fmt', label: 'Formatting', value: 'formatting' }, + { id: 'test', label: 'Testing', value: 'testing' }, + { id: 'ci', label: 'CI/CD Pipeline', value: 'ci' }, + ], + defaultValue: ['lint', 'fmt'], +}; + +// ============================================================================ +// Fixtures +// ============================================================================ + +export default defineThemedFixtureGroup({ + SingleTextQuestion: defineComponentFixture({ + render: (context) => renderCarousel(context, createCarousel([textQuestion])), + }), + + SingleSelectQuestion: defineComponentFixture({ + render: (context) => renderCarousel(context, createCarousel([singleSelectQuestion])), + }), + + MultiSelectQuestion: defineComponentFixture({ + render: (context) => renderCarousel(context, createCarousel([multiSelectQuestion])), + }), + + MultipleQuestions: defineComponentFixture({ + render: (context) => renderCarousel(context, createCarousel([ + textQuestion, + singleSelectQuestion, + multiSelectQuestion, + ])), + }), + + NoSkip: defineComponentFixture({ + render: (context) => renderCarousel(context, createCarousel([singleSelectQuestion], false)), + }), + + SubmittedSummary: defineComponentFixture({ + render: (context) => { + const carousel = createCarousel([textQuestion, singleSelectQuestion, multiSelectQuestion]); + carousel.isUsed = true; + carousel.data = { + 'project-name': 'my-app', + 'language': { selectedValue: 'typescript', freeformValue: undefined }, + 'features': { selectedValues: ['linting', 'formatting'], freeformValue: undefined }, + }; + renderCarousel(context, carousel); + }, + }), + + SkippedSummary: defineComponentFixture({ + render: (context) => { + const carousel = createCarousel([textQuestion, singleSelectQuestion]); + carousel.isUsed = true; + carousel.data = {}; + renderCarousel(context, carousel); + }, + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/codeActionList.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/codeActionList.fixture.ts new file mode 100644 index 00000000000..76be7d9d682 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/codeActionList.fixture.ts @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { Event } from '../../../../base/common/event.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from './fixtureUtils.js'; +import { ActionList, ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; +import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; + +import '../../../../platform/actionWidget/browser/actionWidget.css'; +import '../../../../base/browser/ui/codicons/codiconStyles.js'; +import '../../../../editor/contrib/symbolIcons/browser/symbolIcons.js'; + +interface CodeActionFixtureOptions extends ComponentFixtureContext { + items: IActionListItem[]; + width?: string; +} + +function renderCodeActionList(options: CodeActionFixtureOptions): void { + const { container, disposableStore, theme } = options; + container.style.width = options.width ?? '300px'; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: theme, + additionalServices: (reg) => { + registerWorkbenchServices(reg); + reg.defineInstance(ILayoutService, new class extends mock() { + declare readonly _serviceBrand: undefined; + override get mainContainer() { return container; } + override get activeContainer() { return container; } + override get mainContainerDimension() { return { width: 300, height: 600 }; } + override get activeContainerDimension() { return { width: 300, height: 600 }; } + override readonly mainContainerOffset = { top: 0, quickPickTop: 0 }; + override readonly onDidLayoutMainContainer = Event.None; + override readonly onDidLayoutActiveContainer = Event.None; + override readonly onDidLayoutContainer = Event.None; + override readonly onDidChangeActiveContainer = Event.None; + override readonly onDidAddContainer = Event.None; + override get containers() { return [container]; } + override getContainer() { return container; } + override whenContainerStylesLoaded() { return undefined; } + }); + }, + }); + + const delegate: IActionListDelegate = { + onHide: () => { }, + onSelect: () => { }, + }; + + const anchor = container; + + const list = disposableStore.add(instantiationService.createInstance( + ActionList, + 'codeActionWidget', + false, + options.items, + delegate, + undefined, + undefined, + anchor, + )); + + // Render the list directly into the container instead of using context view + const wrapper = document.createElement('div'); + wrapper.classList.add('action-widget'); + wrapper.appendChild(list.domNode); + container.appendChild(wrapper); + + list.layout(0); + list.focus(); +} + +const quickFixItems: IActionListItem[] = [ + { kind: ActionListItemKind.Header, group: { title: 'Quick Fix' } }, + { kind: ActionListItemKind.Action, item: 'fix-import', label: 'Add missing import for \'useState\'', group: { title: 'Quick Fix', icon: Codicon.lightBulb } }, + { kind: ActionListItemKind.Action, item: 'fix-typo', label: 'Change spelling to \'initialCount\'', group: { title: 'Quick Fix', icon: Codicon.lightBulb } }, + { kind: ActionListItemKind.Action, item: 'fix-type', label: 'Add explicit type annotation', group: { title: 'Quick Fix', icon: Codicon.lightBulb } }, + { kind: ActionListItemKind.Header, group: { title: 'Extract', icon: Codicon.wrench } }, + { kind: ActionListItemKind.Action, item: 'extract-const', label: 'Extract to constant in enclosing scope', group: { title: 'Extract', icon: Codicon.wrench } }, + { kind: ActionListItemKind.Action, item: 'extract-fn', label: 'Extract to function in module scope', group: { title: 'Extract', icon: Codicon.wrench } }, + { kind: ActionListItemKind.Header, group: { title: 'Source Action', icon: Codicon.symbolFile } }, + { kind: ActionListItemKind.Action, item: 'organize-imports', label: 'Organize Imports', group: { title: 'Source Action', icon: Codicon.symbolFile } }, +]; + +const simpleFixes: IActionListItem[] = [ + { kind: ActionListItemKind.Action, item: 'fix-1', label: 'Convert to arrow function', group: { title: 'Quick Fix', icon: Codicon.lightBulb } }, + { kind: ActionListItemKind.Action, item: 'fix-2', label: 'Remove unused variable', group: { title: 'Quick Fix', icon: Codicon.lightBulb } }, + { kind: ActionListItemKind.Action, item: 'fix-3', label: 'Add \'await\' to async call', group: { title: 'Quick Fix', icon: Codicon.lightBulb } }, +]; + +export default defineThemedFixtureGroup({ + GroupedCodeActions: defineComponentFixture({ + render: (context) => renderCodeActionList({ ...context, items: quickFixItems }), + }), + SimpleQuickFixes: defineComponentFixture({ + render: (context) => renderCodeActionList({ ...context, items: simpleFixes }), + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/findWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/findWidget.fixture.ts new file mode 100644 index 00000000000..4ed036840b6 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/findWidget.fixture.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../base/common/uri.js'; +import { IContextViewProvider } from '../../../../base/browser/ui/contextview/contextview.js'; +import { ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup } from './fixtureUtils.js'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { FindReplaceState } from '../../../../editor/contrib/find/browser/findState.js'; +import { FindWidget, IFindController } from '../../../../editor/contrib/find/browser/findWidget.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; + +import '../../../../editor/contrib/find/browser/findWidget.css'; +import '../../../../base/browser/ui/codicons/codiconStyles.js'; + +const SAMPLE_CODE = `import { useState } from 'react'; + +function Counter({ initialCount }: { initialCount: number }) { + const [count, setCount] = useState(initialCount); + + return ( +
+

Count: {count}

+ + +
+ ); +} + +export default Counter; +`; + +interface FindFixtureOptions extends ComponentFixtureContext { + searchString?: string; + replaceString?: string; + showReplace?: boolean; + matchesCount?: number; + matchesPosition?: number; +} + +async function renderFindWidget(options: FindFixtureOptions): Promise { + const { container, disposableStore, theme } = options; + container.style.width = '600px'; + container.style.height = '350px'; + container.style.border = '1px solid var(--vscode-editorWidget-border)'; + + const instantiationService = createEditorServices(disposableStore, { colorTheme: theme }); + + const textModel = disposableStore.add(createTextModel( + instantiationService, + SAMPLE_CODE, + URI.parse('inmemory://find-fixture.tsx'), + 'typescript' + )); + + const editorWidgetOptions: ICodeEditorWidgetOptions = { + contributions: [] + }; + + const editor = disposableStore.add(instantiationService.createInstance( + CodeEditorWidget, + container, + { + automaticLayout: true, + minimap: { enabled: false }, + lineNumbers: 'on', + scrollBeyondLastLine: false, + fontSize: 14, + cursorBlinking: 'solid', + find: { addExtraSpaceOnTop: false }, + }, + editorWidgetOptions + )); + + editor.setModel(textModel); + editor.focus(); + + const state = disposableStore.add(new FindReplaceState()); + + const mockController: IFindController = { + replace: () => { }, + replaceAll: () => { }, + getGlobalBufferTerm: async () => '', + }; + + const mockContextViewProvider: IContextViewProvider = { + showContextView: () => { }, + hideContextView: () => { }, + layout: () => { }, + }; + + disposableStore.add(new FindWidget( + editor, + mockController, + state, + mockContextViewProvider, + instantiationService.get(IKeybindingService), + instantiationService.get(IContextKeyService), + instantiationService.get(IHoverService), + undefined, + undefined, + instantiationService.get(IConfigurationService), + instantiationService.get(IAccessibilityService), + )); + + state.change({ + searchString: options.searchString ?? 'count', + isRevealed: true, + isReplaceRevealed: options.showReplace ?? false, + replaceString: options.replaceString ?? '', + }, false); + + // Wait for the CSS transition (top: -64px → 0, 200ms linear) + await new Promise(resolve => setTimeout(resolve, 300)); +} + +export default defineThemedFixtureGroup({ + Find: defineComponentFixture({ + render: (context) => renderFindWidget({ ...context, searchString: 'count' }), + }), + FindAndReplace: defineComponentFixture({ + render: (context) => renderFindWidget({ ...context, searchString: 'count', replaceString: 'value', showReplace: true }), + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/inlineCompletionsExtras.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/inlineCompletionsExtras.fixture.ts new file mode 100644 index 00000000000..bbd8420665c --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/inlineCompletionsExtras.fixture.ts @@ -0,0 +1,306 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { constObservable, IObservableWithChange } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from './fixtureUtils.js'; +import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { observableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js'; +import { IEditorOptions } from '../../../../editor/common/config/editorOptions.js'; +import { Position } from '../../../../editor/common/core/position.js'; +import { Range } from '../../../../editor/common/core/range.js'; +import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; +import { InlineCompletionsController } from '../../../../editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.js'; +import '../../../../editor/contrib/inlineCompletions/browser/inlineCompletions.contribution.js'; +import { InlineCompletionsSource, InlineCompletionsState } from '../../../../editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.js'; +import { InlineEditItem } from '../../../../editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.js'; +import { TextModelValueReference } from '../../../../editor/contrib/inlineCompletions/browser/model/textModelValueReference.js'; +import { JumpToView } from '../../../../editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/jumpToView.js'; +import { IUserInteractionService, MockUserInteractionService } from '../../../../platform/userInteraction/browser/userInteractionService.js'; + +import '../../../../editor/contrib/inlineCompletions/browser/hintsWidget/inlineCompletionsHintsWidget.css'; +import '../../../../editor/contrib/inlineCompletions/browser/view/inlineEdits/view.css'; +import '../../../../base/browser/ui/codicons/codiconStyles.js'; + +const SAMPLE_CODE = `function fibonacci(n: number): number { + if (n <= 1) return n; + return fibonacci(n - 1) + fibonacci(n - 2); +} + +const result = fibonacci(10); +console.log(result); +`; + +const LONG_DISTANCE_CODE = `import { readFile, writeFile } from 'fs'; +import { join } from 'path'; + +interface Config { + inputDir: string; + outputDir: string; + verbose: boolean; +} + +function loadConfig(): Config { + return { + inputDir: './input', + outputDir: './output', + verbose: false, + }; +} + +function processLine(line: string): string { + return line.trim().toUpperCase(); +} + +function validateInput(data: string): boolean { + return data.length > 0 && data.length < 10000; +} + +async function processFile(config: Config, filename: string): Promise { + const inputPath = join(config.inputDir, filename); + const data = await readFile(inputPath, 'utf8'); + if (!validateInput(data)) { + throw new Error('Invalid input'); + } + const lines = data.split('\\n'); + const processed = lines.map(processLine); + const outputPath = join(config.outputDir, filename); + await writeFile(outputPath, processed.join('\\n')); + if (config.verbose) { + console.log(\`Processed \${filename}\`); + } +} + +async function main() { + const config = loadConfig(); + const files = ['a.txt', 'b.txt', 'c.txt']; + for (const file of files) { + await processFile(config, file); + } +} + +main(); +`; + +interface HintsToolbarOptions extends ComponentFixtureContext { + simulateHover?: boolean; +} + +const HINTS_CODE = `function greet(name: string): string { + return "Hello, " + name +} + +greet("World"); +`; + +async function renderHintsToolbar(options: HintsToolbarOptions): Promise { + const { container, disposableStore, theme } = options; + container.style.width = '500px'; + container.style.height = '180px'; + container.style.border = '1px solid var(--vscode-editorWidget-border)'; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: theme, + additionalServices: (reg) => { + registerWorkbenchServices(reg); + if (options.simulateHover) { + reg.defineInstance(IUserInteractionService, new MockUserInteractionService(true, true)); + } + }, + }); + + const textModel = disposableStore.add(createTextModel( + instantiationService, + HINTS_CODE, + URI.parse('inmemory://hints-toolbar.ts'), + 'typescript' + )); + + // Register an inline completion provider (not an inline edit) so the result is ghost text + const languageFeaturesService = instantiationService.get(ILanguageFeaturesService); + disposableStore.add(languageFeaturesService.inlineCompletionsProvider.register({ pattern: '**' }, { + provideInlineCompletions: () => ({ + items: [{ + insertText: ' + "!";', + range: new Range(2, 28, 2, 28), + }], + }), + disposeInlineCompletions: () => { }, + })); + + const editor = disposableStore.add(instantiationService.createInstance( + CodeEditorWidget, + container, + { + automaticLayout: true, + minimap: { enabled: false }, + lineNumbers: 'on', + scrollBeyondLastLine: false, + fontSize: 14, + cursorBlinking: 'solid', + inlineSuggest: { showToolbar: 'always' }, + }, + { contributions: EditorExtensionsRegistry.getEditorContributions() } satisfies ICodeEditorWidgetOptions + )); + + editor.setModel(textModel); + editor.setPosition({ lineNumber: 2, column: 28 }); + editor.focus(); + + const controller = InlineCompletionsController.get(editor); + controller?.model?.get()?.triggerExplicitly(); + + await new Promise(resolve => setTimeout(resolve, 100)); +} + +function renderJumpToHint({ container, disposableStore, theme }: ComponentFixtureContext): void { + container.style.width = '500px'; + container.style.height = '200px'; + container.style.border = '1px solid var(--vscode-editorWidget-border)'; + + const instantiationService = createEditorServices(disposableStore, { colorTheme: theme }); + + const textModel = disposableStore.add(createTextModel( + instantiationService, + SAMPLE_CODE, + URI.parse('inmemory://jump-to-hint.ts'), + 'typescript' + )); + + const editor = disposableStore.add(instantiationService.createInstance( + CodeEditorWidget, + container, + { + automaticLayout: true, + minimap: { enabled: false }, + lineNumbers: 'on', + scrollBeyondLastLine: false, + fontSize: 14, + cursorBlinking: 'solid', + }, + { contributions: [] } satisfies ICodeEditorWidgetOptions + )); + + editor.setModel(textModel); + editor.setPosition({ lineNumber: 1, column: 1 }); + editor.focus(); + + const editorObs = observableCodeEditor(editor); + disposableStore.add(instantiationService.createInstance( + JumpToView, + editorObs, + { style: 'label' }, + constObservable({ jumpToPosition: new Position(6, 18) }), + )); +} + +function createLongDistanceEditor(options: { + container: HTMLElement; + disposableStore: import('../../../../base/common/lifecycle.js').DisposableStore; + theme: import('./fixtureUtils.js').ComponentFixtureContext['theme']; + code: string; + cursorLine: number; + editRange: { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number }; + newText: string; + editorOptions?: IEditorOptions; +}): void { + const { container, disposableStore, theme } = options; + container.style.width = '600px'; + container.style.height = '500px'; + container.style.border = '1px solid var(--vscode-editorWidget-border)'; + + const instantiationService = createEditorServices(disposableStore, { colorTheme: theme }); + + const textModel = disposableStore.add(createTextModel( + instantiationService, + options.code, + URI.parse('inmemory://long-distance.ts'), + 'typescript' + )); + + instantiationService.stubInstance(InlineCompletionsSource, { + cancelUpdate: () => { }, + clear: () => { }, + clearOperationOnTextModelChange: constObservable(undefined) as IObservableWithChange, + clearSuggestWidgetInlineCompletions: () => { }, + dispose: () => { }, + fetch: async () => true, + inlineCompletions: constObservable(new InlineCompletionsState([ + InlineEditItem.createForTest( + TextModelValueReference.snapshot(textModel), + new Range( + options.editRange.startLineNumber, + options.editRange.startColumn, + options.editRange.endLineNumber, + options.editRange.endColumn + ), + options.newText + ) + ], undefined)), + loading: constObservable(false), + seedInlineCompletionsWithSuggestWidget: () => { }, + seedWithCompletion: () => { }, + suggestWidgetInlineCompletions: constObservable(InlineCompletionsState.createEmpty()), + }); + + const editorWidgetOptions: ICodeEditorWidgetOptions = { + contributions: EditorExtensionsRegistry.getEditorContributions() + }; + + const editor = disposableStore.add(instantiationService.createInstance( + CodeEditorWidget, + container, + { + automaticLayout: true, + minimap: { enabled: false }, + lineNumbers: 'on', + scrollBeyondLastLine: false, + fontSize: 14, + cursorBlinking: 'solid', + inlineSuggest: { + edits: { showLongDistanceHint: true }, + }, + ...options.editorOptions, + }, + editorWidgetOptions + )); + + editor.setModel(textModel); + editor.setPosition({ lineNumber: options.cursorLine, column: 1 }); + editor.focus(); + + const controller = InlineCompletionsController.get(editor); + controller?.model?.get(); +} + +export default defineThemedFixtureGroup({ + HintsToolbar: defineComponentFixture({ + render: (context) => renderHintsToolbar(context), + }), + HintsToolbarHovered: defineComponentFixture({ + render: (context) => renderHintsToolbar({ ...context, simulateHover: true }), + }), + JumpToHint: defineComponentFixture({ + render: renderJumpToHint, + }), + LongDistanceHint: defineComponentFixture({ + render: (context) => createLongDistanceEditor({ + ...context, + code: LONG_DISTANCE_CODE, + cursorLine: 1, + editRange: { startLineNumber: 28, startColumn: 1, endLineNumber: 35, endColumn: 100 }, + newText: `async function processFile(config: Config, filename: string): Promise { + const inputPath = join(config.inputDir, filename); + const outputPath = join(config.outputDir, filename); + const data = await readFile(inputPath, 'utf8'); + if (!validateInput(data)) { + throw new Error(\`Invalid input in \${filename}\`); + } + const processed = data.split('\\n').map(processLine).join('\\n'); + await writeFile(outputPath, processed);`, + }), + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/renameWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/renameWidget.fixture.ts new file mode 100644 index 00000000000..82416f4872c --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/renameWidget.fixture.ts @@ -0,0 +1,117 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup } from './fixtureUtils.js'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { RenameWidget } from '../../../../editor/contrib/rename/browser/renameWidget.js'; + +import '../../../../editor/contrib/rename/browser/renameWidget.css'; +import '../../../../base/browser/ui/codicons/codiconStyles.js'; + +const SAMPLE_CODE = `class UserService { + private _users: Map = new Map(); + + getUser(userId: string): User | undefined { + return this._users.get(userId); + } + + addUser(user: User): void { + this._users.set(user.id, user); + } +} +`; + +interface RenameFixtureOptions extends ComponentFixtureContext { + cursorLine: number; + cursorColumn: number; + currentName: string; + rangeStartColumn: number; + rangeEndColumn: number; +} + +function renderRenameWidget(options: RenameFixtureOptions): void { + const { container, disposableStore, theme } = options; + container.style.width = '500px'; + container.style.height = '280px'; + container.style.border = '1px solid var(--vscode-editorWidget-border)'; + + const instantiationService = createEditorServices(disposableStore, { colorTheme: theme }); + + const textModel = disposableStore.add(createTextModel( + instantiationService, + SAMPLE_CODE, + URI.parse('inmemory://rename-fixture.ts'), + 'typescript' + )); + + const editorWidgetOptions: ICodeEditorWidgetOptions = { + contributions: [] + }; + + const editor = disposableStore.add(instantiationService.createInstance( + CodeEditorWidget, + container, + { + automaticLayout: true, + minimap: { enabled: false }, + lineNumbers: 'on', + scrollBeyondLastLine: false, + fontSize: 14, + cursorBlinking: 'solid', + }, + editorWidgetOptions + )); + + editor.setModel(textModel); + editor.setPosition({ lineNumber: options.cursorLine, column: options.cursorColumn }); + + const renameWidget = instantiationService.createInstance( + RenameWidget, + editor, + ['editor.action.rename', 'editor.action.rename'], + ); + disposableStore.add(renameWidget); + + const cts = new CancellationTokenSource(); + disposableStore.add(cts); + + renameWidget.getInput( + { + startLineNumber: options.cursorLine, + startColumn: options.rangeStartColumn, + endLineNumber: options.cursorLine, + endColumn: options.rangeEndColumn, + }, + options.currentName, + false, + undefined, + cts + ); +} + +export default defineThemedFixtureGroup({ + RenameVariable: defineComponentFixture({ + render: (context) => renderRenameWidget({ + ...context, + cursorLine: 4, + cursorColumn: 2, + currentName: 'getUser', + rangeStartColumn: 2, + rangeEndColumn: 9, + }), + }), + RenameClass: defineComponentFixture({ + render: (context) => renderRenameWidget({ + ...context, + cursorLine: 1, + cursorColumn: 7, + currentName: 'UserService', + rangeStartColumn: 7, + rangeEndColumn: 18, + }), + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/suggestWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/suggestWidget.fixture.ts new file mode 100644 index 00000000000..623650a7a8d --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/suggestWidget.fixture.ts @@ -0,0 +1,170 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../base/common/uri.js'; +import { ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup } from './fixtureUtils.js'; +import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { EditorOption, IEditorOptions } from '../../../../editor/common/config/editorOptions.js'; +import { CompletionItemKind, CompletionList } from '../../../../editor/common/languages.js'; +import { SuggestController } from '../../../../editor/contrib/suggest/browser/suggestController.js'; +import { ISuggestMemoryService } from '../../../../editor/contrib/suggest/browser/suggestMemory.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { IMenuChangeEvent, IMenuService } from '../../../../platform/actions/common/actions.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { CompletionModel, LineContext } from '../../../../editor/contrib/suggest/browser/completionModel.js'; +import { CompletionItem } from '../../../../editor/contrib/suggest/browser/suggest.js'; +import { WordDistance } from '../../../../editor/contrib/suggest/browser/wordDistance.js'; +import { FuzzyScoreOptions } from '../../../../base/common/filters.js'; + +// CSS imports for the suggest widget +import '../../../../editor/contrib/suggest/browser/media/suggest.css'; +import '../../../../editor/contrib/symbolIcons/browser/symbolIcons.js'; +import '../../../../base/browser/ui/codicons/codiconStyles.js'; + +interface SuggestFixtureOptions extends ComponentFixtureContext { + code: string; + cursorLine: number; + cursorColumn: number; + completions: CompletionList; + width?: string; + height?: string; + editorOptions?: IEditorOptions; +} + +async function renderSuggestWidget(options: SuggestFixtureOptions): Promise { + const { container, disposableStore, theme } = options; + container.style.width = options.width ?? '500px'; + container.style.height = options.height ?? '300px'; + container.style.border = '1px solid var(--vscode-editorWidget-border)'; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: theme, + additionalServices: (reg) => { + reg.defineInstance(ISuggestMemoryService, new class extends mock() { + override memorize(): void { } + override select(): number { return 0; } + }); + reg.defineInstance(IMenuService, new class extends mock() { + override createMenu() { + return { onDidChange: new Emitter().event, getActions: () => [], dispose: () => { } }; + } + }); + }, + }); + + const textModel = disposableStore.add(createTextModel( + instantiationService, + options.code, + URI.parse('inmemory://suggest-fixture.ts'), + 'typescript' + )); + + const editorWidgetOptions: ICodeEditorWidgetOptions = { + contributions: EditorExtensionsRegistry.getEditorContributions() + }; + + const editor = disposableStore.add(instantiationService.createInstance( + CodeEditorWidget, + container, + { + automaticLayout: true, + minimap: { enabled: false }, + lineNumbers: 'on', + scrollBeyondLastLine: false, + fontSize: 14, + cursorBlinking: 'solid', + suggest: { + showIcons: true, + showStatusBar: true, + }, + ...options.editorOptions, + }, + editorWidgetOptions + )); + + editor.setModel(textModel); + const position = { lineNumber: options.cursorLine, column: options.cursorColumn }; + editor.setPosition(position); + editor.focus(); + + const controller = SuggestController.get(editor)!; + const widget = controller.widget.value; + + const completionList = options.completions; + const provider = { _debugDisplayName: 'suggestFixture', provideCompletionItems: () => completionList }; + const items = completionList.suggestions.map(s => new CompletionItem(position, s, completionList, provider)); + + const lineContent = textModel.getLineContent(position.lineNumber); + const leadingLineContent = lineContent.substring(0, position.column - 1); + + const completionModel = new CompletionModel( + items, + position.column, + new LineContext(leadingLineContent, 0), + WordDistance.None, + editor.getOption(EditorOption.suggest), + 'inline', + FuzzyScoreOptions.default, + undefined + ); + + widget.showSuggestions(completionModel, 0, false, false, false); +} + +const typescriptCompletions: CompletionList = { + suggestions: [ + { label: 'addEventListener', kind: CompletionItemKind.Method, insertText: 'addEventListener', range: { startLineNumber: 3, startColumn: 10, endLineNumber: 3, endColumn: 10 }, detail: '(method) addEventListener(type: string, listener: EventListener): void' }, + { label: 'appendChild', kind: CompletionItemKind.Method, insertText: 'appendChild', range: { startLineNumber: 3, startColumn: 10, endLineNumber: 3, endColumn: 10 }, detail: '(method) appendChild(node: Node): Node' }, + { label: 'attributes', kind: CompletionItemKind.Property, insertText: 'attributes', range: { startLineNumber: 3, startColumn: 10, endLineNumber: 3, endColumn: 10 } }, + { label: 'blur', kind: CompletionItemKind.Method, insertText: 'blur', range: { startLineNumber: 3, startColumn: 10, endLineNumber: 3, endColumn: 10 }, detail: '(method) blur(): void' }, + { label: 'childElementCount', kind: CompletionItemKind.Property, insertText: 'childElementCount', range: { startLineNumber: 3, startColumn: 10, endLineNumber: 3, endColumn: 10 } }, + { label: 'children', kind: CompletionItemKind.Property, insertText: 'children', range: { startLineNumber: 3, startColumn: 10, endLineNumber: 3, endColumn: 10 } }, + { label: 'classList', kind: CompletionItemKind.Property, insertText: 'classList', range: { startLineNumber: 3, startColumn: 10, endLineNumber: 3, endColumn: 10 } }, + { label: 'className', kind: CompletionItemKind.Property, insertText: 'className', range: { startLineNumber: 3, startColumn: 10, endLineNumber: 3, endColumn: 10 } }, + { label: 'click', kind: CompletionItemKind.Method, insertText: 'click', range: { startLineNumber: 3, startColumn: 10, endLineNumber: 3, endColumn: 10 }, detail: '(method) click(): void' }, + { label: 'cloneNode', kind: CompletionItemKind.Method, insertText: 'cloneNode', range: { startLineNumber: 3, startColumn: 10, endLineNumber: 3, endColumn: 10 }, detail: '(method) cloneNode(deep?: boolean): Node' }, + { label: 'closest', kind: CompletionItemKind.Method, insertText: 'closest', range: { startLineNumber: 3, startColumn: 10, endLineNumber: 3, endColumn: 10 }, detail: '(method) closest(selectors: string): Element | null' }, + { label: 'contains', kind: CompletionItemKind.Method, insertText: 'contains', range: { startLineNumber: 3, startColumn: 10, endLineNumber: 3, endColumn: 10 }, detail: '(method) contains(other: Node | null): boolean' }, + ], +}; + +const mixedKindCompletions: CompletionList = { + suggestions: [ + { label: 'MyClass', kind: CompletionItemKind.Class, insertText: 'MyClass', range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }, detail: 'class MyClass' }, + { label: 'myFunction', kind: CompletionItemKind.Function, insertText: 'myFunction', range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }, detail: 'function myFunction(): void' }, + { label: 'myVariable', kind: CompletionItemKind.Variable, insertText: 'myVariable', range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }, detail: 'const myVariable: string' }, + { label: 'IMyInterface', kind: CompletionItemKind.Interface, insertText: 'IMyInterface', range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }, detail: 'interface IMyInterface' }, + { label: 'MyEnum', kind: CompletionItemKind.Enum, insertText: 'MyEnum', range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }, detail: 'enum MyEnum' }, + { label: 'MY_CONSTANT', kind: CompletionItemKind.Constant, insertText: 'MY_CONSTANT', range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }, detail: 'const MY_CONSTANT = 42' }, + { label: 'myKeyword', kind: CompletionItemKind.Keyword, insertText: 'myKeyword', range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 } }, + { label: 'mySnippet', kind: CompletionItemKind.Snippet, insertText: 'mySnippet', range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }, detail: 'snippet' }, + ], +}; + +export default defineThemedFixtureGroup({ + MethodCompletions: defineComponentFixture({ + render: (context) => renderSuggestWidget({ + ...context, + code: `const element = document.getElementById('app'); +if (element) { + element. +}`, + cursorLine: 3, + cursorColumn: 10, + completions: typescriptCompletions, + }), + }), + + MixedKinds: defineComponentFixture({ + render: (context) => renderSuggestWidget({ + ...context, + code: '', + cursorLine: 1, + cursorColumn: 1, + completions: mixedKindCompletions, + }), + }), +});